# Vue双向绑定原理

342 阅读15分钟

前言

这是第二遍写这篇文章了,之前写了两个多小时,好不容易写完了,第二天发现莫名其妙没保存。。。又重新写了一遍,真的是心态爆炸。

vue用了很久了,很方便,相对于之前自己通过innerHtml,append等方式去手动更改视图,在vue中只需要更改数据data则会自动更新视图,实在是方便多了。不过用了这么久,没去好好整理过vue双向绑定的原理,只大概知道是通过Object.defineProperty这个方法来实现的,这边花了一下午看了大佬们的文章,好好整理了一下。

此篇文章的大部分源码是来自于此处 ,我手动敲了一遍加入了自己的理解,整理了一下自己的思路,本人是个菜鸟,有些地方不一定理解的正确,请包涵。

Object.defineProperty

Object.defineProperty说明

这个方法就不具体介绍,可以点上述链接进行查看, 主要介绍这个方法中的get跟set,get就是在读取对象属性值的时候调用的方法,set就是在设置对象属性值的时候调用的方法,通过这两个方法,我们就可以实现数据劫持。

var Book = {}
var name = '';
Object.defineProperty(Book, 'name', {
  set: function (value) {
    name = value;
    console.log('你取了一个书名叫做' + value);
  },
  get: function () {
    return '《' + name + '》'
  }
})
//通过get跟set就可以对对象属性进行劫持,进行自定义的操作。
Book.name = 'vue权威指南';  // 你取了一个书名叫做vue权威指南
console.log(Book.name);  // 《vue权威指南》

MVVM

官方的解释:MVVM是Model-View-ViewModel的简写。它本质上就是MVC 的改进版。MVVM 就是将其中的View 的状态和行为抽象化,让我们将视图 UI 和业务逻辑分开。当然这些事 ViewModel 已经帮我们做了,它可以取出 Model 的数据同时帮忙处理 View 中由于需要展示内容而涉及的业务逻辑。

其实简单来说就是vue的双向绑定,数据的变化引起视图的变化,视图的变化也引起数据的更新。视图变化引起数据更新其实很简单,我们可以通过监听事件来进行数据更新。如

<input type="text" id="input"/>
document.getElementById("input").addEventListener("onchange",function(e){
    //监听input的onchange事件,更改其他地方的数据
    console.log(e)
})

重点是如何将数据的变化转换到视图的变化上去。根据上述的Object.defineProperty方法,我们可以实现数据劫持,那么在劫持数据后,我们其实可以在数据变更后手动更新视图。这样子我们可以实现一个最简单的双向绑定。

<span id="name"></span>

//在set中,当进行数据赋值的时候,将数据手动更新到视图上
var Book = {name:''}
Object.defineProperty(Book, 'name', {
  set: function (value) {
     document.getElementById("name").innerText=value
  },
  get: function () {
    return value
  }
})

发布-订阅者模式

上述的简单双向绑定有个很大的缺点,假设现在这个属性值需要在N个地方显示,那么就需要手动更新所有的innerText,这显然是不实际的,vue中使用了发布订阅者模式,将这种一对多的关系进行了处理。

<span id="name1"></span>
<span id="name2"></span>
<span id="name3"></span>
....

var Book = {name:''}
Object.defineProperty(Book, 'name', {
  set: function (value) {
     document.getElementById("name1").innerText=value
      document.getElementById("name2").innerText=value
      document.getElementById("name3").innerText=value
      ...
  },
  get: function () {
    return value
  }
})

这里有个发布订阅者模式的简单说明 -发布订阅者模式

发布---订阅模式又叫观察者模式,它定义了对象间的一种一对多的关系,让多个观察者对象同时监听某一个主题对象,当一个对象发生改变时,所有依赖于它的对象都将得到通知。

简单来说:发布订阅者模式类似于我们日常微博关注了一个博主。博主更新了动态,微博app就会通知我们所有的人。博主就是发布者,我们就是订阅者,而微博app则就是消息订阅器(用于完全解耦发布者跟订阅者的关系,上述简单说明中有提到)

原理实现

这段话摘自于原文

我们已经知道实现数据的双向绑定,首先要对数据进行劫持监听,所以我们需要设置一个监听器Observer,用来监听所有属性。如果属性发上变化了,就需要告诉订阅者Watcher看是否需要更新。因为订阅者是有很多个,所以我们需要有一个消息订阅器Dep来专门收集这些订阅者,然后在监听器Observer和订阅者Watcher之间进行统一管理的。接着,我们还需要有一个指令解析器Compile,对每个节点元素进行扫描和解析,将相关指令对应初始化成一个订阅者Watcher,并替换模板数据或者绑定相应的函数,此时当订阅者Watcher接收到相应属性的变化,就会执行对应的更新函数,从而更新视图。因此接下去我们执行以下3个步骤,实现数据的双向绑定:

1.实现一个监听器Observer,用来劫持并监听所有属性,如果有变动的,就通知订阅者。

2.实现一个订阅者Watcher,可以收到属性的变化通知并执行相应的函数,从而更新视图。

3.实现一个解析器Compile,可以扫描和解析每个节点的相关指令,并根据初始化模板数据以及初始化相应的订阅器。


  1. 实现监听器Observer

    监听器其实就是将所有数据通过Object.Property进行劫持,劫持以后可以读取到数据的变化,而调用set方法去通知订阅者。注意一点, 在vue中,data为一个对象,其内部所有的属性值没有类型限制,可能也是对象,所以需要递归才能将所有的值进行劫持。

//判断是否是对象,是的话对每个属性进行劫持
function observer(data){
    if(!data||typeof data!=='object'){
        return
    }
    Object.keys(data).forEach(function(key){
      defineReactive(data,key,data[key])  
    })
}

//数据劫持-将所有数据进行劫持
function defineReactive(data,key,value){
    //递归调用,如果属性值为对象则进行递归
   observer(data)
   Object.defineProperty(data, key, {
   set: function (newValue) {
    return newValue
   },
  get: function () {
    return value
  }
})
}
  1. 实现消息订阅器Dep

    消息订阅器的作用其实就是完全解耦发布者跟观察者。假设没有消息订阅器,当发布者内容更改后,我们通过调用观察者事件去通知观察者,这样子两者就耦合了,假设需要变更观察者的事件,那么发布者这边同样需要去更改,而通过消息订阅器在中间进行中转,可以解耦观察者跟订阅者。简单来说,消息订阅器就是个消息队列,先存入观察者,当数据变化时,一一通知队列中的观察者执行更新。这就是一对多的关系

//消息订阅器就是个队列
function Dep(){
    this.subs=[]
}

//定义消息订阅器的加入队列跟执行队列的方法,sub其实就是观察者
Dep.prototype={
    addSub: function(sub) {
        this.subs.push(sub);
    },
    notify: function() {
        this.subs.forEach(function(sub) {
            sub.update();
        });
    }
}

//更改defineReactive方法
 function defineReactive(data,key,value){
    observer(data)
    //为每个属性值定义一个消息订阅器
    var dep=new Dep()
    Object.defineProperty(data, key, {
    set: function (newValue) {
       //当属性值发生变化时,通知消息订阅器,执行队列中的观察者更新
        if(newValue===value){
            return
        }
        dep.notify()
        value=newValue
    },
    get: function () {
        //这里将观察者加入到消息订阅器队列中,watch就是观察者,后面会实现watch
        dep.addSub(watch)
        return value
    }
})
}
  1. 实现观察者Watch

当数据变化时,所有绑定了数据的观察者都会变化。我们以vue为例,数据变化引起视图变化,这些视图就是观察者。在上述实现消息订阅器的过程中,我们发现在get方法中,将观察者加入到消息订阅器中,说明只要观察者读取了这个属性值,就会将自身加入到订阅器队列中。需要注意的一点是:不能每次读取属性值就执行一次加入队列,这样子会存在无数重复的观察者在消息订阅器中,只需在初始化的时候,将自身加入队列即可。

function Watch(vm,exp,cb){
    //cb代表视图更新的方法,vm代表整个实例,exp代表属性名
    this.cb = cb;
    this.vm = vm;
    this.exp = exp;
    //在初始化watch时,会执行get方法
    //初始化的时候,读取下此属性,触发监听器的get,这样子就会将订阅者加入到订阅者管理器中
    //此举作用是用于 只有在第一次初始化的时候才能去将订阅者加入到订阅管理器中
    this.value = this.get();
}

//通过全局变量Dep.target来控制是否加入消息订阅器,保证了观察者只会被加入消息订阅器一次
Watcher.prototype = {
    update: function() {
        this.run();
    },
    run: function() {
        var value = this.vm.data[this.exp];
        var oldVal = this.value;
        if (value !== oldVal) {
            this.value = value;
            //cb为传入的方法
            this.cb.call(this.vm, value, oldVal);
        }
    },
    get: function() {
        Dep.target = this;  // 缓存自己
        var value = this.vm.data[this.exp]  // 强制执行监听器里的get函数,读取了对象的属性
        Dep.target = null;  // 释放自己
        return value;
    }
};


//更改defineReactive方法
 function defineReactive(data,key,value){
    observer(data)
     var dep=new Dep()
    Object.defineProperty(data, key, {
    set: function (newValue) {
         if(newValue===value){
            return
        }
        dep.notify()
        value=newValue
    },
    get: function () {
        //在new Watch的时候,执行了watch的get方法,将watch自身缓存到Dep.target中
        if(Dep.target){
          dep.addSub(Dep.target) 
        }
        return value
    }
})
}
  1. 实现selfVue

上面三步就完成了监听器-消息订阅器-观察者的模式。一个简单版的vue就出来了,我们将三者关联起来就可以实现简单的双向绑定。

function SelfVue (data, el, exp) {
    this.data = data;
    //将data进行数据劫持
    observe(data);
    // 初始化模板数据的值
    el.innerHTML = this.data[exp];  
    new Watcher(this, exp, function (value) {
        el.innerHTML = value;
    });
    return this;
}


<body>
    <h1 id="name">{{name}}</h1>
</body>
 
<script type="text/javascript">
    var ele = document.querySelector('#name');
    var selfVue = new SelfVue({
        name: 'hello world'
    }, ele, 'name');
 
    window.setTimeout(function () {
        console.log('name值改变了');
        selfVue.data.name = 'canfoo';
    }, 2000);
 
</script>

上述就实现了一个简单的数据双向绑定,不过还有个缺点,我们生成了selfVue实例时,将data绑定到了selfVue上,这样子我们更改数据就必须要selfVue.data.name来进行更改,这边同样可以通过数据劫持,将selfVue.data的属性值代理到selfVue上

  1. 实现数据代理
//更改selfVue,进行属性代理
function SelfVue (data, el, exp) {
    var self = this;
    this.data = data;
    
    //将data属性值绑定到selfVue本身上  
    Object.keys(data).forEach(function(key) {
        self.proxyKeys(key);  
    });
 
    observe(data);
    el.innerHTML = this.data[exp]; 
    new Watcher(this, exp, function (value) {
        el.innerHTML = value;
    });
    return this;
}

//简单来说 self.key读取时,就会返回self.data[key]
SelfVue.prototype = {
    proxyKeys: function (key) {
        var self = this;
        Object.defineProperty(this, key, {
            enumerable: false,
            configurable: true,
            get: function proxyGetter() {
                return self.data[key];
            },
            set: function proxySetter(newVal) {
                self.data[key] = newVal;
            }
        });
    }
}

这样子上述就可以通过selfVue.name来进行数据更新,并引起视图的更新了。不过上述的方法还是有个很大的问题,跟之前简单的双向绑定一样,如果有很多地方绑定了这个属性值,则需要读取所有的el,并生成许多实例。这不太现实,这边还有最后关键的一步,解析器来进行DOM 的解析。

  1. 实现解析器Complie

关于为何要创建DOM片段 fragment,fragment说明这篇文章进行了解释,因为在vue中,会有很多生成DOM节点的操作,如果每次都进行append,会十分消耗性能,所以将整个DOM树劫持到fragment中,等全部解析完后生成DOM树重新append回真实DOM中,节省性能消耗。

//(先判断"{{}}")
// el->"#name" ,vm->{el:;data:;}
    function Compile(elm){
        this.vm = elm;
        this.el = document.querySelector(elm.el);
        this.fragment = null;
        this.init();
    }
    Compile.prototype = {
        init:function(){
            if(this.el) {
                //将需要解析的DOM节点存入fragment片段里再进行处理
                this.fragment = this.nodeToFragment(this.el);
                
                //接下来遍历各个节点,对含有指定的节点特殊处理,先处理指令“{{}}”:
                this.compileElement(this.fragment);
                
                //绑定到el上
                this.el.appendChild(this.fragment);
            }else{
                console.log('DOM元素不存在');
            }
        },
        //创建代码片段
        nodeToFragment:function(el){
            var fragment = document.createDocumentFragment();
            var child = el.firstChild;
            //这里注意一点,fragment是剪切DOM的元素的,所以递归会将整个DOM剪切过来
            while(child){
                //将DOM元素移入fragment
                fragment.appendChild(child);
                child = el.firstChild;
            }
            return fragment;
        },
         //对所有子节点进行判断,1.初始化视图数据,2.绑定更新函数的订阅器
        compileElement:function(el){
            var childNodes = el.childNodes;
            var self = this;
            [].slice.call(childNodes).forEach(function(node){
                var reg = /\{\{(.*)\}\}/;//匹配" {{}} "
                var text = node.textContent;
                
                //判断" {{}} "
                if(self.isTextNode(node) && reg.test(text)) {
                    self.compileText(node,reg.exec(text)[1]);
                }
                // 递归遍历子节点
                if(node.childNodes && node.childNodes.length){
                    self.compileElement(node);
                }
            });
        },
        //当读取到{{}}的节点,进行值的初始化
        compileText:function(node,exp){
            var self = this;
            var initText = this.vm[exp];   //proxyKeys中代理访问self_vue.data.name1 -> self_vue.name1
            this.updateText(node,initText);//将初始化的数据初始化到视图中
            new Watcher(this.vm,exp,function(value){//{},name, // 生成订阅器并绑定更新函数
                self.updateText(node,value);
            })
        },
        updateText: function (node, value) {
            node.textContent = typeof value == 'undefined' ? '' : value;
        },
        isTextNode:function(node){
            return node.nodeType == 3;//文本节点
        }
    };

解析器完成,解析器解析所有的DOM元素,进行初始化值跟将更新DOM元素的方法绑定到watch中。假设现在页面中有{{name}} 的地方有10个,解析器都会全部解析到,并一一绑定到watch上。这里我们更新下selfVue方法方法,将解析器与监听器结合起来,并改造成传入option格式。

// function SelfVue(data,el,exp){  //first
    function SelfVue(options){
        var self = this;
    
         this.data = options.data;
        this.el = options.el;
    
        this.vm = this; //second
        console.log(this)
    
        Object.keys(this.data).forEach(function (key) {
            self.proxyKeys(key);//绑定代理属性
        });
    
        observers(this.data);
     
        //这样子解析器生成的时候会自动将观察者加入到消息订阅器中
        new Compile(this);
       options.mounted.call(this); // 所有事情处理好后执行mounted函数

    
        return this;
    }

至此一个简单的vue就完成了,主要就是用于数据的双向绑定。

<div id="app">
        <h2>{{title}}</h2>
        <input v-model="name">
        <h1>{{name}}</h1>
        <button v-on:click="clickMe">click me!</button>
</div>

var vue=new selfVue({
    {
        el: '#app',
        data: {
            title: 'hello world',
            name: 'canfoo'
        },
        methods: {
            clickMe: function () {
                this.title = 'hello world';
            }
        },
        mounted: function () {
            window.setTimeout(() => {
                this.title = '你好';
            }, 1000);
        }
    }
})

上述中使用了v-model指令,但是在上面解析器中并没有解析v-model指令。其实所有的指令解析都是在compileElement方法中实现的,遍历所有节点,然后判断节点是否包含某些指令,包含的话,根据不同指令绑定不同的DOM操作方法。这边是摘自于其他文章的v-model的指令判断

  Compile.prototype = {
      init:function(){
          if(this.el) {
              //将需要解析的DOM节点存入fragment片段里再进行处理
              this.fragment = this.nodeToFragment(this.el);
  
              //接下来遍历各个节点,对含有指定的节点特殊处理,先处理指令“{{}}”:
              this.compileElement(this.fragment);
  
              //绑定到el上
              this.el.appendChild(this.fragment);
          }else{
              console.log('DOM元素不存在');
          }
      },
      //创建代码片段
      nodeToFragment:function(el){
          var fragment = document.createDocumentFragment();
          var child = el.firstChild;
          while(child){//将DOM元素移入fragment
              fragment.appendChild(child);
              child = el.firstChild;
          }
          return fragment;
      },
      //对所有子节点进行判断,1.初始化视图数据,2.绑定更新函数的订阅器
      compileElement:function(el){
          var childNodes = el.childNodes;
          var self = this;
          [].slice.call(childNodes).forEach(function(node){
              var reg = /\{\{(.*)\}\}/;//匹配" {{}} "
              var text = node.textContent;
               /*      补充判断:     */
              if(self.isElementNode(node)){//元素节点判断
                  self.compile(node);
              }else if(self.isTextNode(node) && reg.test(text)) {
                  //文本节点判断 ,判断" {{}} "
                  self.compileText(node,reg.exec(text)[1]);
              }
  
              if(node.childNodes && node.childNodes.length){
                  // 递归遍历子节点
                  self.compileElement(node);
              }
          });
      },
      //初始化视图updateText和生成订阅器:
      compileText:function(node,exp){
          var self = this;
          var initText = this.vm[exp];   //代理访问self_vue.data.name1 -> self_vue.name1
          this.updateText(node,initText);//将初始化的数据初始化到视图中
          new Watcher(this.vm,exp,function(value){//{},name, // 生成订阅器并绑定更新函数
              self.updateText(node,value);
          })
      },
      updateText: function (node, value) {
          node.textContent = typeof value == 'undefined' ? '' : value;
      },
      compile:function(node){
          var nodeAttrs = node.attributes;
          var self = this;
          Array.prototype.forEach.call(nodeAttrs,function(attr){
              var attrName = attr.name;
              if(self.isDirective(attrName)){
                  //查到" v- "
                  var exp = attr.value;
                  var dir = attrName.substring(2);//" v-on/v-model "
                  if(self.isEventDirective(dir)){ // 事件指令
                      self.compileEvent(node,self.vm,exp,dir);
                  }else{
                      self.compileModel(node,self.vm,exp,dir);
                  }
                  node.removeAttribute(attrName);
              }
          })
      },
      compileEvent:function(node,vm,exp,dir) {
          //代码片段<><>,{data:;vm:;el:;},v-on="add",on:
          var eventType = dir.split(':')[1];//on
          var cb = vm.methods && vm.methods[exp];
  
          if(eventType && cb){
              node.addEventListener(eventType,cb.bind(vm),false);
          }
      },
      compileModel:function(node,vm,exp,dir){
          //代码片段<><>,{data:;vm:;el:;},v-on="addCounts",model:
          var self = this;
          var val = this.vm[exp];
          this.modelUpdater(node,val);
          new Watcher(this.vm,exp,function(value){
              self.modelUpdater(node,value);
          });
  
          node.addEventListener('input',function(e){
              var newValue = e.target.value;
              if(val === newValue){
                  return;
              }
              self.vm[exp] = newValue;
              val = newValue;
          })
      },
      modelUpdater:function(node,value){
          node.value = typeof value == 'undefined' ? '' : value;
      },
      isTextNode:function(node){
          return node.nodeType == 3;//文本节点
      },
      isElementNode:function(node){
          return node.nodeType == 1;//元素节点<p></p>
      },
      isDirective:function(attr){//查找自定义属性为:v- 的属性
          return attr.indexOf('v-') == 0;
      },
      isEventDirective:function(dir){ // 事件指令
          return dir.indexOf('on:') === 0
      }
  };
  

结语

上面的就是VUE双向绑定的简单实现了。

这边通过这个知识点的学习,就可以明白数据是如何去影响视图的。还有一个知识点,在vue中,是无法检测到对象新增属性的变化和数组长度的变化。VUE官方说明

<span>{{price}}</span>

data(){
    return {
       name:''
    }
}

prices=5 //并不会引起视图的变化

根据上述的vue双向绑定原理的实现,在初始化的时候,data传入的时候遍历所有属性进行劫持,不存在的属性并不会进行劫持,不存在对应的观察者,所以不会更新视图。

关于数组,数组不能通过arr[index]=val或arr.length=newval 来进行更改,关于这点,其实按照上述方式,arr[index]=val是可以监测到数组元素的变化。但是这边网上查了 文章说是因为性能问题所以没做这个。arr.length=newval会更改数组长度,无法进行监听变化。

补充说明

  1. data中的属性重新赋值,假设将空对象赋值成有属性的对象,或者空数组赋值成有数据的数组,这样子也是可以监听到属性变化的,上述源码中并没有实现这个功能,其实就是在set方法中加入判断,如果新的值为对象或数组,重新进行递归循环调用observe方法。
data(){
    return{
        book:{
        }
    }
},
method:{
    setBook(){
        this.book={
            name:'vue',
            price:'50'
        }
    }
}


Object.defineProperty(book,key,function({
     enumerable: false,
            configurable: true,
            get: function proxyGetter() {
                /*跟上述代码一样*/
            },
            set: function proxySetter(newVal) {
              if(typeof newVal==='object'||typeof newVal==='array'){
                  observe(newVal)
               }else{
                  /*跟上述代码一样*/
               }
            }
}))


  1. 数组的监听,可以使用push,pop,shift.unshifu,splice等方法,原理是vue对数组的操作方法进行了劫持,内部实现了数据监听。具体实现可看 vue数组拦截

  2. vue还提供了一个方法来进行数据属性的增加或者更改数组index的值,Vue.set(object, propertyName, value)或者Vue.set(vm.items, indexOfItem, newValue) ,原理简单来说就是对数据重新进行初始化的操作,重新进行劫持。vue.set原理