Vue框架相信大家肯定很熟悉,但是每当被问到Vue数据双向绑定原理的时候,大家可能都会脱口而出:Vue 内部使用了 Object.defineProperty() 来实现数据响应式,通过这个函数可以监听到 set 和 get 的事件。这样虽然一句话把大概原理概括了,但是其内部的实现原理还是值得我们去研究的。下面我就已浅显易懂的方式带你深度解析vue双向绑定原理。
我们都知道vue的设计框架是MVVM框架,何为MVVM?我们先来看一张图。话不多说,上图:
简单来说:双向绑定就是ViewModel负责将Model中的数据变化渲染到View中,View呢又将改变反馈到Model上,这就是数据的双向绑定。
下面我们就来自己实现一个小型的vue
新建目录外加一个html文件和一个JS文件,在html中引入这个JS
先上代码,下面的代码相信大家应该最熟悉不过了。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<div id="app">
<div>{{username}}</div>
<div>{{age}}</div>
<div v-text="sex"></div>
<button @click="handleclick">快点我!</button>
</div>
</body>
<script src="./myvue.js"></script>
<script>
var vm=new MyVue({
el:'app',
data:{
username:'健哥',
age:'18',
sex:'男'
},
methods: {
handlclick(){
console.log('被点击了')
}
},
})
</script>
</html>
现在我们来自己实现一下上面的写的MyVue
我们先引入自己的JS文件,并创建自己的MyVue。基本的constructor如以下代码:
class MyVue{
constructor(options){ //options为对象里面的配置项
this.$options=options; //接收所有的配置项,包括所有的值或者方法
this.$data=options.data; //接收配置项里面的值
this.$el=document.getElementById(options.el); //接收挂载点
this.observer(this.$data);
//数据劫持,通过此方法去劫持data里面的数据,然后进行一些操作。
}
}
下面我们来写自己定义的observer方法
这里我们对拿到的data进行一个forEach遍历,遍历里面所有的值,然后调用自己定义的defineRective方法。到这里你可能会问,为什么这个defineRective方法里面要传三个值,分别什么意思?这里我们只是将原本要传进Object.defineProperty()的三个值单独拿出来写在一个自己的方法里,方便后面进行后续进行添加getter和setter。三个值分别为: 对象,key值,配置项。
observer(data){
//这里我们要对传进来的data值进行判断,只有是对象形式才走下面的代码。
if(!data || typeof data!='object') return;
//获取到data身上的所有的key值进行遍历
Object.keys(data).forEach((key)=>{
//给data身上所有的属性添加getter和setter
this.defineRective(data,key,data[key]);
})
}
然后我们来写这个defineRective()方法
这里我们看到用了递归,我们又去调用了一下observer方法,并将value穿进去,是因为我们的data里面的属性和值不可能就一层,也有可能像data:{a:{b:‘b’,c:‘c’}}这样的形式,意思就是深拷贝,那么我们在对劫持到的数据做操作的时候就得考虑到这一点。方法里面调用了Object对象的Object.defineProperty方法。
Object.defineProperty() 方法会直接在一个对象上定义一个新属性,或者修改一个对象的现有属性, 并返回这个对象。
defineRective(data,key,value){
//递归 检测data的属性的值是否还是一个对象,如果是在进行遍历
this.observer(value);
//添加getter和setter方法
Object.defineProperty(data,key,{
get(){
//访问
//直接拿到这个值return出去
return value;
},
set(newvalue){
//设置
if(newvalue===value) return;
//如果值相同则不更新值,如果不相同则设置新值
value=newvalue;
}
})
}
这样简单的数据劫持就做好了,我们来看一下结果吧
我们在html里打印一下这个vm对象
var vm=new MyVue({
el:'app',
data:{
username:'健哥',
age:'18',
sex:'男'
},
methods: {
handlclick(){
console.log('被点击了')
}
},
})
console.log(vm) //打印一下
控制台输出结果如图:
$data里面就是我们传进去的值,证明我们数据劫持到了。
到这里你可能有疑问,那这只是在$data里的啊,我们的对象实例身上没有这个属性啊,下面我们就来将属性挂载到对象身上。
这里我们在oberver遍历的时候在里面定义一个ProxyData方法
ProxyData(key){
Object.defineProperty(this,key,{
get(){
//访问
return this.$data[key];
//之前的对象我们就可以用this去表示,当我们访问的时候直接返回这个值
},
set(newvalue){
//设置
this.$data[key]=newvalue;
}
})
}
控制台输出结果:
我们看到vm对象的实例身上已经有了我们设置的值
原本页面效果如图:
现在我们数据也劫持到了,下一步就是把页面中挂载的点下面的配置项都给编译成我们传入的值。
为了阅读方便,我们新建一个compile.js文件,专门去用作编译,并在myvue.js的constructor中去new一个我们的compile对象
我们先来写compile.js的constructor,代码如图:
class Compile{
constructor(el,vm){
this.$el=el;
this.$vm=vm;
if(this.$el){
//1、获取app下面的所有节点,this.$Fragment为获取到的挂载点下的所有节点
this.$Fragment=this.getNode(this.$el);
//2、进行编译
this.compile(this.$Fragment)
//3、将编译好的节点插入到挂载点中
this.$el.appendChild(this.$Fragment)
}
}
}
其实我们只需要做两步,1、获取节点,2、编译
下面我们来写getNode方法
getNode(root){
//创建文档碎片
var frag=document.createDocumentFragment();
var child;
while(child=root.firstChild){
//将节点保存到了JS内存当中,这样页面上就不会有这个节点了
frag.appendChild(child)
}
return frag;
}
这里我们采用文档碎片的方式,通过while将节点插入到文档碎片当中,文档碎片其实就是js内存对象。到这里我们是不是应该想到虚拟dom?这里我们就顺带提一嘴,具体的大家可以去网上查阅一下相关资料。
什么是虚拟dom?
虚拟dom就是真实的JS对象,我们操作内存中的JS对象的速度远比我们直接操作DOM的速度要快。
为什么不操作真实的DOM对象?
直接操作真实的DOM会让页面发生回流重绘,页面性能降低。
回到我们的代码当中,这时候我们可以打印一下看看frag到底是什么
打印结果如图:
这里我们看到已经获取到挂载点下面的子节点了
下面我们来写compile方法,对拿到的属性的值进行编译
这里面我们要遍历所有子节点,然后判断节点类型,如注释所示,如果是文本节点,那么调用我们下面的compileText()方法
//遍历所有的子节点
compile(fragment){
//遍历所有的子节点
Array.from(fragment.childNodes).forEach((node)=>{
//判断当前节点是文本节点还是元素节点
//判断是否是文本节点,并且文本节点中必须要有{{内容}}
if(node.nodeType===3 && /\{\{(.+)\}\}/.test(node.textContent)){
//操作文本节点方法
this.compileText(node)
}else if(node.nodeType===1){ //判断是否是元素节点
}
//如果子节点下面还有子节点,那么就进行递归,遍历所有的子节点
if(node.childNodes&&node.childNodes.length>0){
this.compile(node)
}
})
}
下面我们来写compileText方法,在这个方法中我们顺带把更新操作做一下
其中update方法传入的值分别表示元素、MyVue实例、{{属性}},简单来说就是将节点的textContent用我们写的值传入进去。
compileText(node){
this.update(node,this.$vm,RegExp.$1);
}
update(node,vm,exp){
node.textContent=vm[exp];
}
现在我们来看一下页面效果:
看,内容已被替换成我们写的值了~~
细心的你们肯定也发现一个问题,我们写的有个指令,v-text还没实现,现在我们就来看看指令这块内容。
指令
还记得上面我们判断传入的节点类型的时候还有个else-if还没用到嘛,我们现在就要在那个else-if下面写我们后面的逻辑。
再上compile方法的代码:
其实我们就是在else-if下面做了一些操作,先拿到当前这个节点的所有属性,然后遍历所有属性,再进行判断是否是指令还是事件。
1:如果是指令,且是v-text指令,这里我们就实现了v-text指令,那么我们就将节点的文本内容当前指令后面对应的内容,也就是v-text="sex"中的sex。
2:如果是事件,那么我们就先绑定this到实例身上,然后拿到vm实例身上的方法,还有方法的类型,比如是点击事件还是输入事件等。然后再将方法和类型绑定到当前节点中。完事~
compile(fragment){
//遍历所有的子节点
Array.from(fragment.childNodes).forEach((node)=>{
//判断当前节点是文本节点还是元素节点
//判断是否是文本节点,并且文本节点中必须要有{{内容}}
if(node.nodeType===3 && /\{\{(.+)\}\}/.test(node.textContent)){
//操作文本节点方法
this.compileText(node)
}else if(node.nodeType===1){ //判断是否是元素节点
//获取当前节点的所有属性
var attrs=node.attributes;
Array.from(attrs).forEach(attr=>{
var key = attr.name; //拿到元素身上的属性 如v-text @
var value = attr.value; //拿到元素身上属性的值 如sex handleclick
//判断是否是指令
if(key.indexOf('v-')===0){
//判断是否是v-text指令,如果是,则把属性上对应的值给节点内容
if(key.substring(2)==='text'){
node.textContent=value
}
}
//判断是否是事件
else if (key.indexOf('@')===0){
//事件处理
//拿到vm实例上的方法取名fn
var fn = this.$vm.$options.methods[value];
//拿到方法的类型,点击或者输入类型
var dir = key.substr(1);
//将事件绑定到该节点上
node.addEventListener(dir, fn.bind(this.$vm));
}
})
}
//如果子节点下面还有子节点,那么就进行递归,遍历所有的子节点
if(node.childNodes&&node.childNodes.length>0){
this.compile(node)
}
})
}
页面效果如图:
点击前:
点击后:
"被点击了"几个字在控制台就被打印出来了~
这样编译部分我们就做好了,但是我们目前只实现了v-text和事件,其他的指令大家可以参照我的代码自己去实现一下。好了下面我们来看一下第三部分,也是vue中最重要的一部分~
我们先解释一下什么是依赖?
我们在原本的页面上写的{{username}},还有{{age}},分别被我们的渲染后的内容所依赖。换句话说就是,“健哥”,“18”这些内容就是依赖,他们分别要去依赖{{username}},{{age}}。了解了这个之后我们再看原理。
原理:在页面第一次渲染的时候我们就要去收集这些依赖,然后监听我们收集到的东西是否发生改变,如果发生改变,那我们就去通知视图发生改变。
下面我们再新建一个文件,取名为Depend.js。这个文件主要用于去做监听
在写这个文件之前,我们得对前面的文件进行改造。首先我们要知道在哪里收集依赖是最好的。莫过于myvue.js中的defineRective方法下面的get()中去收集,你每次访问的时候都收集起来。然后我们写个Dep的类,如下:
addDep方法将收集的依赖都存放在deps数组中,然后定义一个message方法用来反馈状态的变化
class Dep{
constructor(){
//存放所有的依赖
this.deps=[];
}
addDep(rely){
this.deps.push(rely);
}
message(){
//通知状态
console.log('变化')
}
}
然后这个类在哪里收集呢,上面我们说到过,最好就在get的时候就去收集,所以我们改造myvue.js中的defineRective方法,如下:
defineRective(data,key,value){
//省略
//这里去构建Dep的实例
var dep=new Dep();
//添加getter和setter方法
Object.defineProperty(data,key,{
get(){
//访问
return value;
},
//省略
}
}
那么问题来了,我们到底要怎么收集,这里我们就需要再新建一个Watcher类,这里我写在了Depend.js中。
class Watcher{
constructor(){
Dep.target=this;
}
}
然后我们再改写get()方法。
这里我判断Dep.target是否存在,如果存在那么我们就将他存放到依赖的数组中。这个Dep.target其实就是Watcher。
get(){
//收集依赖
Dep.target && dep.addDep(Dep.target)
//访问
return value;
},
那么问题又来了,我们什么时候调用Watcher?因为只有Watcher进行实例化里面的constructor才会执行。我们最好的选择就是在compile.js文件中的update方法中去实例化他,因为不管是第一次还是现在还是以后的数据变化,update都要去执行。那么我们来改写update方法。
我们在第四个参数写一个回调,将变化的内容替换掉原本节点的内容。
update(node, vm, exp) {
node.textContent = vm[exp];
new Watcher(node,vm,exp,(value)=>{
node.textContent = vm[exp];
})
}
这样我们可以继续来写Watcher了,我们先把所有的东西都获取到,然后再做this.$vm[this.$exp]来触发get(),最后我们在把原本的Dep.target清空,不然会无限累加。然后我们在这里面写一个update方法来让cb回调触发,根据上面实例化传过来的value对数据进行更新。
class Watcher{
constructor(node,vm,exp,cb){
//先把所有的值都获取到
this.$vm=vm;
this.$exp=exp;
this.cb=cb;
Dep.target=this;
//这一步是在做get的触发
this.$vm[this.$exp];
Dep.target=null;
}
update(){
this.cb.call(this.$vm,this.$vm[this.$exp])
}
}
但是这个方法需要每次更新的时候进行调用,也就是我们在Set设置新数据的时候需要去调用一下,这里我们改写set方法(),在Set方法里去调用一下dep的message方法,然后在message方法里去掉我们的Watcher中的update()
set(newvalue){
//设置
if(newvalue===value) return;
value=newvalue;
//当设置的时候我们只需要做一次更新即可
dep.message();
}
我们接着改写message方法
message(){
//通知状态
this.deps.forEach((item)=>{
item.update() //在这里调用的Watcher中的update方法
})
}
好了现在我们就都写完了,去看一下结果吧
初始效果:
控制台修改后效果:
我们也可以在点击的时候进行修改,比如这样:
var vm=new MyVue({
el:'app',
data:{
username:'健哥',
age:'18',
sex:'男'
},
methods: {
handlclick(){
this.username='张三'
}
},
})
myvue.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<div id="app">
<div>{{username}}</div>
<div>{{age}}</div>
<div v-text="sex"></div>
<button @click="handlclick">快点我!</button>
</div>
</body>
<script src="./myvue.js"></script>
<script src="./compile.js"></script>
<script src="./Depend.js"></script>
<script>
var vm=new MyVue({
el:'app',
data:{
username:'健哥',
age:'18',
sex:'男'
},
methods: {
handlclick(){
this.username='张三'
}
},
})
</script>
</html>
myvue.js
class MyVue{
constructor(options){ //options为对象里面的配置项
//接收所有的配置项
this.$options=options;
//接收配置项里面的值
this.$data=options.data;
//接收挂载点
this.$el=document.getElementById(options.el);
//数据劫持
this.observer(this.$data);
//进行编译
new Compile(this.$el,this);
}
observer(data){
//这里我们要对传进来的data值进行判断,只有是对象形式才走下面的代码。
if(!data || typeof data!='object') return;
//获取到data身上的所有的key值进行遍历
Object.keys(data).forEach((key)=>{
//给data身上所有的属性添加getter和setter方法
this.defineRective(data,key,data[key]);
//将data身上的所有的属性到实例身上
this.ProxyData(key);
})
}
ProxyData(key){
Object.defineProperty(this,key,{
get(){
//访问
return this.$data[key];
},
set(newvalue){
this.$data[key]=newvalue;
}
})
}
defineRective(data,key,value){
//递归 检测data的属性的值是否还是一个对象,如果是在进行遍历
this.observer(value);
var dep=new Dep();
//添加getter和setter方法
Object.defineProperty(data,key,{
get(){
//收集依赖
Dep.target && dep.addDep(Dep.target)
//访问
return value;
},
set(newvalue){
//设置
if(newvalue===value) return;
value=newvalue;
//当设置的时候我们只需要做一次更新即可
dep.message();
}
})
}
}
compile.js
class Compile {
constructor(el, vm) {
this.$el = el;
this.$vm = vm;
if (this.$el) {
//1、获取app下面的所有节点,this.$Fragment为获取到的挂载点下的所有节点
this.$Fragment = this.getNode(this.$el);
//2、进行编译
this.compile(this.$Fragment)
//3、将编译好的节点插入到挂载点中
this.$el.appendChild(this.$Fragment)
}
}
getNode(root) {
//创建文档碎片
var frag = document.createDocumentFragment();
var child;
while (child = root.firstChild) {
//将节点保存到了JS内存当中,这样页面上就不会有这个节点了
frag.appendChild(child)
}
return frag;
}
compile(fragment) {
//遍历所有的子节点
Array.from(fragment.childNodes).forEach((node) => {
//判断当前节点是文本节点还是元素节点
//判断是否是文本节点,并且文本节点中必须要有{{内容}}
if (node.nodeType === 3 && /\{\{(.+)\}\}/.test(node.textContent)) {
//操作文本节点方法
this.compileText(node)
} else if (node.nodeType === 1) { //判断是否是元素节点
//获取当前节点的所有属性
var attrs = node.attributes;
Array.from(attrs).forEach(attr => {
var key = attr.name; //拿到元素身上的属性 如v-text @
var value = attr.value; //拿到元素身上属性的值 如sex handleclick
//判断是否是指令
if (key.indexOf('v-') === 0) {
//判断是否是v-text指令,如果是,则把属性上对应的值给节点内容
if (key.substring(2) === 'text') {
node.textContent = value
}
}
//判断是否是事件
else if (key.indexOf('@') === 0) {
//事件处理
//拿到vm实例上的方法取名fn
var fn = this.$vm.$options.methods[value];
//拿到方法的类型,点击或者输入类型
var dir = key.substr(1);
//将事件绑定到该节点上
node.addEventListener(dir, fn.bind(this.$vm));
}
})
}
//如果子节点下面还有子节点,那么就进行递归,遍历所有的子节点
if (node.childNodes && node.childNodes.length > 0) {
this.compile(node)
}
})
}
compileText(node) {
this.update(node, this.$vm, RegExp.$1);
}
update(node, vm, exp) {
node.textContent = vm[exp];
new Watcher(node,vm,exp,(value)=>{
node.textContent = vm[exp];
})
}
}
Depend.js
class Dep{
constructor(){
//存放所有的依赖
this.deps=[];
}
addDep(rely){
this.deps.push(rely);
}
message(){
//通知状态
this.deps.forEach((item)=>{
item.update()
})
}
}
class Watcher{
constructor(node,vm,exp,cb){
this.$vm=vm;
this.$exp=exp;
this.cb=cb;
Dep.target=this;
//这一步是在做get的触发
this.$vm[this.$exp];
Dep.target=null;
}
update(){
this.cb.call(this.$vm,this.$vm[this.$exp])
}
}
纯原创,代码纯手写,各位看官要是觉得写的详细,麻烦动动小手点个赞呗,后面带你了解更多前端的知识~~