zoukankan      html  css  js  c++  java
  • Vue.js双向绑定原理

    Vue.js最核心的功能有两个,一个是响应式的数据绑定系统,另一个是组件系统。本文仅仅探究双向绑定是怎样实现的。先讲涉及的知识点,再用简化的代码实现一个简单的hello world示例。

    一、访问器属性

    访问器属性是对象中的一种特殊属性,它不能直接在对象中设置,而必须通过defineProperty()方法单独定义。

    <!DOCTYPE html>
    <html>
      <head>
        <meta charset="UTF-8">
        <title></title>
      </head>
      <body>
        <script>
          var obj = {};
          Object.defineProperty(obj, 'hello', {
            get: function() {
              console.log('get方法被调用了');
            },
            set: function(val) {
              console.log('set方法被调用了,参数是' + val);
            }
          });
          obj.hello; //get方法被调用了
          obj.hello = 'abc'; //set方法被调用了,参数是abc
        </script>
      </body>
    </html>
    View Code

    get和set方法内部的this都指向obj,这意味着get和set函数可以操作对象内部的值。另外,访问器属性的会“覆盖”同名的普通属性,因为访问器属性会被优先访问,与其同名的普通属性则会被忽略。

    二、极简的双向绑定实现

    <!DOCTYPE html>
    <html>
      <head>
        <meta charset="UTF-8">
        <title></title>
      </head>
      <body>
        <input type="text" id="a" />
        <span id="b"></span>
        <script>
          var obj = {};
          Object.defineProperty(obj, 'hello', {
            set: function(newval) {
              document.getElementById('a').value = newval;
              document.getElementById('b').innerHTML = newval
            }
          });
          document.addEventListener('keyup', function(e) {
            obj.hello = e.target.value;
          })
        </script>
      </body>
    </html>
    View Code

    此例实现的效果是:随着文本框输入文字的变化,span中会同步显示相同的内容。在js或者在控制台上显式的修改obj.hello的值,视图会相应的更新。这样就实现了model=>view以及view=>model的双向绑定。

    以上就是Vue实现双向绑定的基本原理。

    三、分解任务

    上述示例仅仅是为了说明原理,我们最终要实现的是:

    <div id="app">
      <input type="text" v-model="text">
      {{ text }}
    </div>
    
    var vm = new Vue({
      el:'#app',
      data:{
        text:'hello world'
      }
    })

    首先将该任务分成几个子任务:

    1、输入框以及文本节点与data中的数据绑定;

    2、输入框内容变化时,data中的数据同步变化,即view =>model的变化;

    3、data中的数据变化时,文本节点的内容同步变化,即model =>view的变化;

    要实现任务1,需要对DOM进行编译,这里有一个知识点:DocumentFragment。

    四、DocumentFragment

    DocumentFragment(文档片段)可以看做节点容器,它可以包含多个子节点,当我们将它插入到DOM中时,只有它的子节点会插入目标节点,所以把它看作一组节点的容器。使用DocumentFragment处理节点,速度和性能远远优于直接操作DOM。Vue进行编译时,就是将挂载目标的所有子节点劫持(真的是劫持,通过append方法,DOM中的节点会被自动删除)到DocumentFragment中,经过一番处理后,再将DocumentFragment整体返回插入挂载目标。

    <!DOCTYPE html>
    <html>
      <head>
        <meta charset="UTF-8">
        <title></title>
      </head>
      <body>
        <div id="app">
          <input type="text" id="a" />
          <span id="b"></span>
        </div>
        <script>
          var dom = nodeToFragment(document.getElementById('app'));
          console.log(dom);
    
          function nodeToFragment(node) {
            var flag = document.createDocumentFragment();
            var child;
            while(child == node.firstChild) {
              flag.appendChild(child); //劫持node的所有子节点
            }
            return flag;
          }
    
          document.getElementById('app').appendChild(dom); //返回到app中
        </script>
      </body>
    </html>
    View Code

    五、数据初始化绑定

    <!DOCTYPE html>
    <html lang="en">
      <head>
        <meta charset="UTF-8">
        <title>Two-way-data-binding</title>
      </head>
      <body>
        <div id="app">
          <input type="text" v-model="text"> {{ text }}
        </div>
    
        <script>
          function compile(node, vm) {
            var reg = /{{(.*)}}/;
            // 节点类型为元素
            if(node.nodeType === 1) {
              var attr = node.attributes;
              // 解析属性
              for(var i = 0; i < attr.length; i++) {
                if(attr[i].nodeName == 'v-model') {
                  var name = attr[i].nodeValue; // 获取 v-model 绑定的属性名
                  node.value = vm[name]; // 将 data 的值赋给该 node
                  node.removeAttribute('v-model');
                }
              };
            }
            // 节点类型为 text
            if(node.nodeType === 3) {
              if(reg.test(node.nodeValue)) {
                var name = RegExp.$1; // 获取匹配到的字符串
                name = name.trim();
                node.nodeValue = vm.data[name]; //将data的值赋给该node
              }
            }
          }
    
          function nodeToFragment(node, vm) {
            var flag = document.createDocumentFragment();
            var child;
            // 所有表达式必然会返回一个值,赋值表达式亦不例外
            // 理解了上面这一点,就能理解 while (child = node.firstChild) 这种用法
            // 其次,appendChild 方法有个隐蔽的地方,就是调用以后 child 会从原来 DOM 中移除
            // 所以,第二次循环时,node.firstChild 已经不再是之前的第一个子元素了
            while(child = node.firstChild) {
              compile(child, vm);
              flag.appendChild(child); // 将子节点劫持到文档片段中
            }
            return flag;
          }
    
          function Vue(options) {
            this.data = options.data;
            var id = options.el;
            var dom = nodeToFragment(document.getElementById(id), this);
            // 编译完成后,将 dom 返回到 app 中
            document.getElementById(id).appendChild(dom);
          }
          var vm = new Vue({
            el: 'app',
            data: {
              text: 'hello world'
            }
          });
        </script>
      </body>
    </html>
    View Code

    以上代码实现了任务一,我们可以看到,hello world已经呈现在输入框和文本节点中。

    六、响应式的数据绑定

    再来看任务2的是实现思路:当我们在输入框输入数据的时候,首先触发input事件或者keyup、change事件,在相应的事件处理程序中,我们获取输入框的value并赋值给vm实例的text属性。我们会利用defineProperty将data中的text设置为vm的访问器属性,因此给vm.text赋值就会触发set方法。在set方法中主要做两件事,第一是更新属性的值,第二留到任务3来说。

    <!DOCTYPE html>
    <html lang="en">
      <head>
        <meta charset="UTF-8">
        <title>Two-way-data-binding</title>
      </head>
      <body>
        <div id="app">
          <input type="text" v-model="text"> {{ text }}
        </div>
    
        <script>
          function observe(obj, vm) {
            Object.keys(obj).forEach(function(key) {
              defineReactive(vm, key, obj[key]);
            })
          }
    
          function defineReactive(obj, key, val) {
            Object.defineProperty(obj, key, {
              get: function() {
                return val
              },
              set: function(newVal) {
                if(newVal === val) return
                val = newVal;
                console.log(val); //方便看效果
              }
            });
          }
    
          function nodeToFragment(node, vm) {
            var flag = document.createDocumentFragment();
            var child;
            // 所有表达式必然会返回一个值,赋值表达式亦不例外
            // 理解了上面这一点,就能理解 while (child = node.firstChild) 这种用法
            // 其次,appendChild 方法有个隐蔽的地方,就是调用以后 child 会从原来 DOM 中移除
            // 所以,第二次循环时,node.firstChild 已经不再是之前的第一个子元素了
            while(child = node.firstChild) {
              compile(child, vm);
              flag.appendChild(child); // 将子节点劫持到文档片段中
            }
            return flag;
          }
    
          function compile(node, vm) {
            var reg = /{{(.*)}}/;
            // 节点类型为元素
            if(node.nodeType === 1) {
              var attr = node.attributes;
              // 解析属性
              for(var i = 0; i < attr.length; i++) {
                if(attr[i].nodeName == 'v-model') {
                  var name = attr[i].nodeValue; // 获取 v-model 绑定的属性名
                  node.addEventListener('input', function(e) {
                    // 给相应的 data 属性赋值,进而触发该属性的 set 方法
                    vm[name] = e.target.value;
                  });
                  node.value = vm[name]; // 将 data 的值赋给该 node
                  node.removeAttribute('v-model');
                }
              };
            }
            // 节点类型为 text
            if(node.nodeType === 3) {
              if(reg.test(node.nodeValue)) {
                var name = RegExp.$1; // 获取匹配到的字符串
                name = name.trim();
                node.nodeValue = vm[name]; //将data的值赋给该node
              }
            }
          }
    
          function Vue(options) {
            this.data = options.data;
            var data = this.data;
            observe(data, this);
            var id = options.el;
            var dom = nodeToFragment(document.getElementById(id), this);
            // 编译完成后,将 dom 返回到 app 中
            document.getElementById(id).appendChild(dom);
          }
          var vm = new Vue({
            el: 'app',
            data: {
              text: 'hello world'
            }
          });
        </script>
      </body>
    </html>
    View Code

    任务2也就完成了,text属性值会与输入框的内容同步变化(打开浏览器后台进行查看)。

    七、订阅/发布模式(subscribe&publish)

    text属性变化了,set方法触发了,但是文本节点的内容没有变化。如何让同样绑定到text的文本节点也同步变化呢?这里又有一个知识点:订阅发布模式。

    订阅发布模式(又称观察者模式)定义了一种一对多的关系,让多个观察者同时监听某一个主题对象,这个主题对象的状态发生改变时就会通知所有观察者对象。发布者发出通知 =>主题对象收到通知并推送给订阅者 =>订阅者执行相应操作。

    <!DOCTYPE html>
    <html lang="en">
      <head>
        <meta charset="UTF-8">
        <title>Two-way-data-binding</title>
      </head>
      <body>
        <script>
          //一个发布者publisher
          var pub = {
            publish: function() {
              dep.notify();
            }
          }
          
          //三个订阅者subscribers
          var sub1 = {
            update: function() {
              console.log(1)
            }
          };
          var sub2 = {
            update: function() {
              console.log(2)
            }
          };
          var sub3 = {
            update: function() {
              console.log(3)
            }
          };
          
          //一个主题对象
          function Dep() {
            this.subs = [sub1, sub2, sub3];
          }
          Dep.prototype.notify = function() {
            this.subs.forEach(function(sub) {
              sub.update();
            })
          }
          
          //发布者发布消息,主题对象执行notify方法,进而触发订阅者执行update方法
          var dep = new Dep();
          pub.publish(); //1,2,3
        </script>
      </body>
    </html>
    View Code

    之前提到的,当set方法触发后做的第二件事就是作为发布者发出通知:“我是属性text,我变了”。文本节点则是作为订阅者,在收到消息后执行相应的更新操作。

    八、双向绑定的实现

    回顾一下,每当 new 一个 Vue,主要做了两件事:第一个是监听数据:observe(data),第二个是编译 HTML:nodeToFragement(id)。

    在监听数据的过程中,会为data中的每一个属性生成一个主题对象dep。

    在编译HTML的过程中,会为每个与数据绑定相关的节点生成一个订阅者watcher,watcher会将自己添加到相应属性的dep中。

    我们已经实现:修改输入框内容 =>在事件回调函数中修改属性值 =>触发属性的set方法。接下来我们要实现的是:发出通知dep.notify() =>触发订阅者的update方法 =>更新视图。

    这里的关键逻辑时:如何将watcher添加到关联属性的dep中。

     function compile(node, vm) {
       var reg = /{{(.*)}}/;
       // 节点类型为元素
       if(node.nodeType === 1) {
         var attr = node.attributes;
         // 解析属性
         for(var i = 0; i < attr.length; i++) {
           if(attr[i].nodeName == 'v-model') {
             var name = attr[i].nodeValue; // 获取 v-model 绑定的属性名
             node.addEventListener('input', function(e) {
               // 给相应的 data 属性赋值,进而触发该属性的 set 方法
               vm[name] = e.target.value;
             });
             node.value = vm[name]; // 将 data 的值赋给该 node
             node.removeAttribute('v-model');
           }
         };
         new Watcher(vm, node, name, 'input');
       }
       // 节点类型为 text
       if(node.nodeType === 3) {
         if(reg.test(node.nodeValue)) {
           var name = RegExp.$1; // 获取匹配到的字符串
           name = name.trim();
           new Watcher(vm, node, name, 'text');
         }
       }
     }
    View Code

    在编译HTML过程中,为每个与data关联的节点生成一个watcher,watcher函数中发生了什么呢?

    function Watcher(vm, node, name, nodeType) {
      Dep.target = this;
      this.name = name;
      this.node = node;
      this.vm = vm;
      this.update();
      Dep.target = null;
    }
    Watcher.prototype = {
      update: function() {
        this.get();
        this.node.nodeValue = this.value;
      },
      // 获取 data 中的属性值
      get: function() {
        this.value = this.vm[this.name]; // 触发相应属性的 get
      }
    }
    View Code

    首先,将自己赋给了一个全局变量Dep.target;

    其次,执行了update方法,进而执行了get方法,get的方法读取了vm的访问器属性,从而触发了访问器属性的get方法,get方法中将该watcher添加到了对应访问器属性的dep中;

    接着,获取属性的值,然后更新视图。

    最后,将Dep.target设为空,因为它是全局变量,也是watcher与dep关联的唯一桥梁,任何时刻都必须保证Dep.target只有一个值。

    function defineReactive(obj, key, val) {
      var dep = new Dep();
      Object.defineProperty(obj, key, {
        get: function() {
          // 添加订阅者 watcher 到主题对象 Dep
          if(Dep.target) dep.addSub(Dep.target);
          return val;
        },
        set: function(newVal) {
          if(newVal === val) return
          val = newVal;
          // 作为发布者发出通知
          dep.notify();
        }
      });
    }
    
    function Dep() {
      this.subs = []
    }
    Dep.prototype = {
      addSub: function(sub) {
        this.subs.push(sub);
      },
      notify: function() {
        this.subs.forEach(function(sub) {
          sub.update();
        });
      }
    }
    View Code

    至此,hello world双向绑定就基本实现了。文本内容会随输入框内容同步变化,在控制器中修改vm.text的值,会同步反映到文本内容中。以下是完整代码:

    <!DOCTYPE html>
    <html lang="en">
      <head>
        <meta charset="UTF-8">
        <title>Two-way-data-binding</title>
      </head>
      <body>
    
        <div id="app">
          <input type="text" v-model="text"> {{ text }}
        </div>
    
        <script>
          function observe(obj, vm) {
            Object.keys(obj).forEach(function(key) {
              defineReactive(vm, key, obj[key]);
            })
          }
    
          function defineReactive(obj, key, val) {
            var dep = new Dep();
            Object.defineProperty(obj, key, {
              get: function() {
                // 添加订阅者 watcher 到主题对象 Dep
                if(Dep.target) dep.addSub(Dep.target);
                return val
              },
              set: function(newVal) {
                if(newVal === val) return
                val = newVal;
                // 作为发布者发出通知
                dep.notify();
              }
            });
          }
    
          function nodeToFragment(node, vm) {
            var flag = document.createDocumentFragment();
            var child;
            // 所有表达式必然会返回一个值,赋值表达式亦不例外
            // 理解了上面这一点,就能理解 while (child = node.firstChild) 这种用法
            // 其次,appendChild 方法有个隐蔽的地方,就是调用以后 child 会从原来 DOM 中移除
            // 所以,第二次循环时,node.firstChild 已经不再是之前的第一个子元素了
            while(child = node.firstChild) {
              compile(child, vm);
              flag.appendChild(child); // 将子节点劫持到文档片段中
            }
            return flag
          }
    
          function compile(node, vm) {
            var reg = /{{(.*)}}/;
            // 节点类型为元素
            if(node.nodeType === 1) {
              var attr = node.attributes;
              // 解析属性
              for(var i = 0; i < attr.length; i++) {
                if(attr[i].nodeName == 'v-model') {
                  var name = attr[i].nodeValue; // 获取 v-model 绑定的属性名
                  node.addEventListener('input', function(e) {
                    // 给相应的 data 属性赋值,进而触发该属性的 set 方法
                    vm[name] = e.target.value;
                  });
                  node.value = vm[name]; // 将 data 的值赋给该 node
                  node.removeAttribute('v-model');
                }
              };
              new Watcher(vm, node, name, 'input');
            }
            // 节点类型为 text
            if(node.nodeType === 3) {
              if(reg.test(node.nodeValue)) {
                var name = RegExp.$1; // 获取匹配到的字符串
                name = name.trim();
                new Watcher(vm, node, name, 'text');
              }
            }
          }
    
          function Watcher(vm, node, name, nodeType) {
            Dep.target = this;
            this.name = name;
            this.node = node;
            this.vm = vm;
            this.nodeType = nodeType;
            this.update();
            Dep.target = null;
          }
          Watcher.prototype = {
            update: function() {
              this.get();
              if(this.nodeType == 'text') {
                this.node.nodeValue = this.value;
              }
              if(this.nodeType == 'input') {
                this.node.value = this.value;
              }
            },
            // 获取 data 中的属性值
            get: function() {
              this.value = this.vm[this.name]; // 触发相应属性的 get
            }
          }
    
          function Dep() {
            this.subs = []
          }
          Dep.prototype = {
            addSub: function(sub) {
              this.subs.push(sub);
            },
            notify: function() {
              this.subs.forEach(function(sub) {
                sub.update();
              });
            }
          }
    
          function Vue(options) {
            this.data = options.data;
            var data = this.data;
            observe(data, this);
            var id = options.el;
            var dom = nodeToFragment(document.getElementById(id), this);
            // 编译完成后,将 dom 返回到 app 中
            document.getElementById(id).appendChild(dom);
          }
          var vm = new Vue({
            el: 'app',
            data: {
              text: 'hello world'
            }
          })
        </script>
      </body>
    </html>
    View Code

    参考文章1:https://github.com/DDFE/DDFE-blog/issues/7

    参考文章2:https://segmentfault.com/a/1190000006599500

    原文地址

  • 相关阅读:
    互斥锁的通俗理解
    U-Boot下分区信息查看
    《计算机组成原理》唐朔飞第二版_笔记
    《大话程序员》安晓辉_笔记
    C++ 类对象的初始化顺序
    FilterTerminal使用说明全总结
    sed -i 命令常用方法总结
    入园记录
    cookies,sessionStorage 和 localStorage区别
    优雅降级和渐进增强的理解:
  • 原文地址:https://www.cnblogs.com/fengxiongZz/p/8120337.html
Copyright © 2011-2022 走看看