美文网首页
Vue 双向绑定的剖析

Vue 双向绑定的剖析

作者: 卡布奇诺的秘密_Me | 来源:发表于2019-06-27 18:23 被阅读0次

    前言

    本文章纯为学习中的经验总结,并非教程,若有疑问欢迎讨论。


    学习过程

    最近准备研究了一下Vue的一些原理的实现方法,首先来了解一下双向绑定的实现原理。

    看了网上很多很多的教程和讲解,没怎么看明白,因为教程中的剖析,大多写出几个部分,这个部分做什么,感觉这一部分耦合度较高,因为没有整体认知,所以看起来很难受。

    后来自己思考了下,准备自己着手写一下,把代码一点一点拷贝下来,完成独立的功能,再进行拼接,然后总结概念和思路,最后举一反三,如果读者看了这篇文章,也不妨跟着写写,更有收获。

    另外以下代码使用到 es6 的语法 class 声明语法,可以先去复习下,以免看起来难以理解。

    分解后的模块

    将双向绑定分解后有以下实现过程:

    • 实现观察者对数据的观察
    • 实现订阅者对数据的订阅
    • 实现对HTML模板的解析和渲染
    • 实现上述的关联,即双向绑定

    总的一看,感觉不是很难理解,一步一步来看。


    实现观察者对数据的观察、实现订阅者对数据的订阅

    何为观察者和订阅者

    首先理解观察者和订阅者的关系,先举个生活中的例子:

    有一个食品仓库里面放着肉、蔬菜、奶制品等不同类型的食物,他们都有着自己的数目。
    有一天,A 被任命为仓库管理员,拿到了物品数目清单,B 和 C 两人被任命管理相应的食物:B - 肉,C - 蔬菜
    与此同时,B、C 两人向咨询 A 并拿到了各自负责的食物的数目,并且对 A 说:如果我负责的数目改变了,马上告诉我。
    在这以后,A 在管理仓库的时候,就开始注意肉和蔬菜的变化,肉的增减就给 B 发短信,蔬菜的增减就给 C 发短信

    上面的例子中,A 就是观察者,而 B、C 就是订阅者,下面分析下:

    观察者,顾名思义去观察,代码中,就是是去观察某个对象,假设有对象如下:

    var data = {
      name: '张三',
      age: 20
    };
    

    那么观察者就是观察这个 data ,而订阅者是订阅这个对象的某个属性,比如 data.name ,当 data.name 有变动时,观察者就会告诉订阅者,你订阅的数据更新了。

    实现

    假设我们有个数据 data ,那么接下来的步骤是:

    • 生成观察者
    • 生成订阅者
    • 让订阅者去订阅数据以便观察者通知

    生成观察者

    对象的观察,其核心是使用 Object.defineProperty() 对字段进行数据劫持,我称这个类为:\color{red}{观察者生成器}

    代码如下:

    // 订阅者生成器
    class Observer {
      constructor(data) {
        this.data = data;
        Object.keys(data).forEach(key => {
          let value = data[key];
    
          Object.defineProperty(data, key, {
            get() {
              console.log('get value', value);
              return value;
            },
            set(newVal) {
              if (newVal !== value) {
                console.log('set value', newVal);
                value = newVal;
              }
            }
          });
        });
      }
    }
    

    代码很简单,就是使用 Object.defineProperty() 对数据进行了劫持,当然了,这并不是完整的代码,后面会根据实现一步步增加代码,先试试效果吧。

    数据的读取触发 get,设置触发 set

    生成订阅者

    订阅者的功能,也很简单,这个类相似的,我称之为:\color{red}{订阅者生成器}

    代码如下:

    // 订阅者生成器
    class Watcher {
      constructor(data, key, cb) {
        this.data = data;
        this.cb = cb;
        this.key = key;
        this.value = data[key];
      }
    
      update(newVal) {
        let oldVal = this.value;
        if (newVal !== oldVal) {
          this.value = newVal;
          this.cb(newVal, oldVal);
        }
      }
    }
    

    代码也很简单,构造函数接收 data、订阅的 key、回调函数,存在一个 update 的方法,当值不同时进行回调的更新。

    另外这个工具暂时只能订阅一个属性,实际中,订阅者会订阅 N 个属性,这里只是供学习一下。

    当然这也并不是完整的代码,后面订阅者和观察者会有一些互动。

    让订阅者去订阅数据以便观察者通知

    生成好了观察者和订阅者,两者互动起来,那么我们再回到那个生活中的例子:

    有一个食品仓库里面放着肉、蔬菜、奶制品等不同类型的食物,他们都有着自己的数目。
    有一天,A 被任命为仓库管理员,拿到了物品数目清单,B 和 C 两人被任命管理相应的食物:B - 肉,C - 蔬菜
    与此同时,B、C 两人向咨询 A 并拿到了各自负责的食物的数目,并且对 A 说:如果我负责的数目改变了,马上告诉我。
    在这以后,A 在管理仓库的时候,就开始注意肉和蔬菜的变化,肉的增减就给 B 发短信,蔬菜的增减就给 C 发短信

    这个例子只是列举了两个订阅者分别订阅了对象的一个属性的简单情况,实际上,可能有X个订阅人订阅了对象的Y个属性,比如在例子中,5 个人对 A 说我订阅了某某某、某某某,A 表示你们等一下,我记不住,所以 A 想了一个办法。

    假设仓库的食物种类为 6,那么 A 买了 6 个笔记本,一一对应每个食物,以便记录谁订阅了这个食物。
    然后有一个办公室,A 坐在办公室里,一次只能进一个人。
    然后,就开始等着别人进来,别人说我订阅了某某某,A 就记载那个食物对应的本子上,XX
    最后本子大概就是这样的:
    蔬菜笔记本:B、C、D、F、J
    猪肉笔记本:B、C、J、K
    牛肉笔记本:D、F、J、P

    上面是举例,所以在代码中,我们也是类似的实现:

    • 笔记本 -> 订阅库
    • 办公室 -> 当前要订阅的人的一个标识

    订阅库来记录每个订阅者,既然有订阅库,那么自然就有\color{red}{订阅库生成器}

    代码如下:

    // 订阅库生成器
    class Dep {
      constructor() {
        this.subs = [];
      }
    
      add(sub) {
        this.subs.push(sub);
      }
    
      notify(newVal) {
        this.subs.forEach(sub => {
          sub.update(newVal);
        });
      }
    }
    

    订阅库生成器的代码很简单,即是一个数组,add 方法往库里添加订阅者,而 notify 去通知这个库里面的所有订阅者而,即调用订阅的者 update 方法。

    而另外一个关键 - 办公室,简单一点,其实就是一个变量,默认为 null,如果有人进来了,就 = 这个订阅者,离开了,就还原为 null,简单一点,挂载到订阅库生成器上面吧:

    // 其实也可以:let target = null;
    Dep.target = null;
    

    好的,现在工具已经齐全,那么来更新一下观察者生成器的代码了,看完代码看下方的解释:

    // 订阅者生成器
    class Observer {
      constructor(data) {
        this.data = data;
        Object.keys(data).forEach(key => {
          let value = data[key];
          let dep = new Dep(); // add1
    
          Object.defineProperty(data, key, {
            get() {
              Dep.target && dep.add(Dep.target); // add2
              return value;
            },
            set(newVal) {
              if (newVal !== value) {
                value = newVal;
                dep.notify(newVal); // add3
              }
            }
          });
        });
      }
    }
    

    添加的3行代码意义为:

    • add1:生成订阅库(为这个食物类别买个笔记本)
    • add2:如果当前指向了订阅者并要订阅这个,就加入订阅库。(如果办公室有人并且告诉A说要订阅这个,就把这个人写到笔记本上)
    • add3:通知订阅库里的订阅者们

    完成了对观察者生成器的改造,同样的的,要对订阅者生成器进行更新:

    // 订阅者生成器
    class Watcher {
      constructor(data, key, cb) {
        this.data = data;
        this.cb = cb;
        this.key = key;
    
        Dep.target = this; // add1
        this.value = data[key];
        Dep.target = null; // add2
      }
    
      update(newVal) {
        let oldVal = this.value;
        if (newVal !== oldVal) {
          this.value = newVal;
          this.cb(newVal, oldVal);
        }
      }
    }
    
    • add1:指向当前的订阅者(进入办公室)
    • add2:销毁指向(离开办公室)

    add1 和 add2 中间的赋值操作,能够触发 data 属性的 get() 方法,进而将该订阅者加入到该属性的订阅库中。

    至此,完成了所有工具的开发,总结一下:

    • 观察者生长期:Observer
    • 订阅者生成器:Watcher
    • 订阅库生成器:Dep
    • 订阅者的指向:Dep.target

    现在来试一下手动订阅数据吧:

    // 四个工具
    class Observer {}
    class Watcher {}
    class Dep {}
    Dep.target = null;
    
    // 数据
    var data = {
      name: '张三',
      age: 20
    }
    
    // 观察该对象
    new Observer(data);
    
    // 生成两个订阅者
    new Watcher(data, 'name', function(newVal) {
      console.log('A的更新操作,name的新值为:', newVal);
    });
    new Watcher(data, 'age', function(newVal) {
      console.log('B的更新操作,age的新值为:', newVal);
    });
    
    data.name = '李四'; // A的更新操作,name的新值为: 李四
    data.age =  30; // B的更新操作,age的新值为: 30
    

    实现对HTML模板的解析和渲染

    实现了前面的功能,再回过头来,实现对HTML模板的解析和渲染,既然要解析HTML,那么需要一个HTML文档吧,仿照vue,假设文档片段为:

    <div id="app">
      <input v-model="name" />
      <h1>{{ name }}</h1>
      <h1>{{ age }}</h1>
      <button v-on:click="addAge">过年了</button>
      <button v-on:click="changeName">我叫李四</button>
    </div>
    
    <script>
    new MyVue({
      el: '#app',
    
      data: {
        name: '张三',
        age: 20
      },
    
      methods: {
        addAge() {
          console.log(this);
          // this.age++;
        },
        changeName() {
          this.name = '李四';
        }
      }
    });
    </script>
    

    其中,Compile 被称之为:\color{red}{模板解析器},这里直接就解析了,语法和 vue 相似,但 vue 的 class vue 并非模板解析器,其包含 Compile,类似这种的:

    class Vue {
      constructor(config) {
        // some code ...
    
        new Compile(config);
      }
    }
    

    所以,也写一个框架的入口,起名为:MyVue:

    // MyVue
    class MyVue {
      constructor({ el, data, methods }) {
        this.$el = el;
        this.$data = data;
        this.$methods = methods;
    
        new Compile(this);
      }
    }
    

    就简单做解析节点的操作,这样初始化之后,就能显示页面中的 data 数据了。

    现在来 Compile 的代码如下:

    // 片段解析器
    class Compile {
      constructor(vm) {
        this.vm = vm;
    
        let el = document.querySelector(this.vm.$el);
        let fragment = document.createDocumentFragment();
    
        if (el) {
          while (el.firstChild) {
            fragment.appendChild(el.firstChild);
          }
    
          // 编译片段
          this.compileElement(fragment);
    
          el.appendChild(fragment);
        } else {
          console.log('挂载元素不存在!');
        }
      }
    
      compileElement(el) {
        for (let node of el.childNodes) {
          /*
            node.nodeType
            1:元素节点
            3:文本节点
          */
          if (node.nodeType === 1) {
            for (let attr of node.attributes) {
              let { name: attrName, value: exp } = attr;
    
              // v- 代表存在指令
              if (attrName.indexOf('v-') === 0) {
                /*
                  <div v-xxx=""> 元素上,可以用很多指令,这里仅做学习,所以不判断太多了
                  on    事件绑定
                  model 表单绑定
                */
                let [dir, value] = attrName.substring(2).split(':');
                if (dir === 'on') {
                  // 取 vm.methods 相应的含税,进行绑定
                  let fn = this.vm.$methods[exp];
                  fn && node.addEventListener(value, fn.bind(this.vm), false);
                } else if (dir === 'model') {
                  // 取 vm.data 进行 input 的赋值,并且在 input 的时候更新 vm.data 上的值
                  let value = this.vm.$data[exp];
                  node.value = typeof value === 'undefined' ? '' : value;
    
                  node.addEventListener('input', e => {
                    if (e.target.value !== value) {
                      this.vm.$data[exp] = e.target.value;
                    }
                  });
                }
              }
            }
          } else if (node.nodeType === 3) {
            let reg = /\{\{(.*)\}\}/;
            if (reg.test(node.textContent)) {
              // 这里文本里也许会有多个 {{}} ,{{}} 内或许会有表达式,这里简单处理,就取一个值
              let exp = reg.exec(node.textContent)[1].trim();
              let value = this.vm.$data[exp];
    
              node.textContent = typeof value === 'undefined' ? '' : value;
            }
          }
    
          if (node.childNodes && node.childNodes.length) {
            this.compileElement(node);
          }
        }
      }
    }
    

    代码看似很长,实际上很好理解。

    • 构造函数传入 vm, el 即挂载的dom节点,this.fragment 为临时创建的片段。
    • 将 el 的节点全部移入 fragment 进行编译,编译之后又移回

    主要说明下 compileElement 这个方法。

    compileElement

    此操作对 fragment 进行子节点遍历,每个子节点进行如下操作:

      1. 如果是元素节点,那么获取这个元素的属性值一一匹配 v- 指令,不同的指令则进行不同的操作
      1. 如果是文本节点,那么获取这个文本内容匹配 {{ }} 语法,得到使用的属性,为这个属性创建一个订阅者。
      1. 如果节点存在子节点,那么再次使用 compileElement 方法。

    而在上述整个代码里,其实节点的操作已经很清楚了,也有注释,看一下代码:

    • myvue.js
    class Observer {}
    class Watcher {}
    class Dep {}
    Dep.target = null;
    class Compile {}
    class MyVue {}
    
    • index.html
    <!DOCTYPE html>
    <html lang="en">
      <body>
        <div id="app">
          <input v-model="name" />
          <h1>{{ name }}</h1>
          <h1>{{ age }}</h1>
          <button v-on:click="addAge">过年了,大一岁</button>
          <button v-on:click="changeName">我叫李四</button>
        </div>
    
        <script src="./myvue.js"></script>
        <script>
          new MyVue({
            el: '#app',
    
            data: {
              name: '张三',
              age: 20
            },
    
            methods: {
              addAge() {
                this.age++;
              },
              changeName() {
                this.name = '李四';
              }
            }
          });
        </script>
      </body>
    </html>
    

    浏览器执行后:

    Good

    实现双向绑定

    让数据被观察

    改写 MyVue 的代码,让其数据可观察:

    class MyVue {
      constructor({ el, data, methods }) {
        let obs = new Observer(data);
    
        this.$el = el;
        this.$data = obs.data;
        this.$methods = methods;
    
        Object.keys(this.$data).forEach(i => {
          this.proxyKeys(i);
        });
    
        new Compile(this);
      }
    
      proxyKeys(key) {
        let _this = this;
        Object.defineProperty(_this, key, {
          enumerable: false,
          configurable: true,
          get() {
            return _this.$data[key];
          },
          set(newVal) {
            _this.$data[key] = newVal;
          }
        });
      }
    }
    

    这个修改比较多,但是也没什么复杂的,主要是让 this.$data 被观察者观察,然后加了一个数据代理,因为 vue 里面的 data 是 this.xxx 而不是 this.data.xxx,也为了让 methods 可以直接使用 this.xxx = xxx 。

    其实 methods 也需要代理一下,在 methods 调用 methods 也需要直接 this.xxx(),只是这里比较懒,就不加了。

    让数据被订阅

    首先,要理解谁是订阅者,订阅者的特性是:\color{red}{使用数据,并且根据数据来进行自身的状态改变}

    所以,由上面的模板解析器的代码可以看出,使用数据的是各个dom节点,而且节点的状态需要由数据来改变,所以,dom 节点的每一个使用数据的属性,就是一个订阅者,例如:

    <div v-if="" v-on:="" v-:bind="" ></div>
    

    所以上面元素,就有三个订阅者,不过其实他们都是一个 Node 节点,可以理解成一个订阅者,但是我们前面写的订阅者生成器只支持一个订阅者订阅有一个元素,所以,就将属性当做订阅者吧,有兴趣可以修改订阅者生成器,实现一个订阅者订阅多个属性,这里只是学习。

    确定了订阅者是谁,所以现在就修改 Compile 的代码如下:

    class Compile {
      constructor(vm) {
        this.vm = vm;
    
        let el = document.querySelector(this.vm.$el);
        let fragment = document.createDocumentFragment();
    
        if (el) {
          while (el.firstChild) {
            fragment.appendChild(el.firstChild);
          }
    
          // 编译片段
          this.compileElement(fragment);
    
          el.appendChild(fragment);
        } else {
          console.log('挂载元素不存在!');
        }
      }
    
      compileElement(el) {
        for (let node of el.childNodes) {
          /*
            node.nodeType
            1:元素节点
            3:文本节点
          */
          if (node.nodeType === 1) {
            for (let attr of node.attributes) {
              let { name: attrName, value: exp } = attr;
    
              // v- 代表存在指令
              if (attrName.indexOf('v-') === 0) {
                /*
                  <div v-xxx=""> 元素上,可以用很多指令,这里仅做学习,所以不判断太多了
                  on    事件绑定
                  model 表单绑定
                */
                let [dir, value] = attrName.substring(2).split(':');
                if (dir === 'on') {
                  // 取 vm.methods 相应的含税,进行绑定
                  let fn = this.vm.$methods[exp];
                  fn && node.addEventListener(value, fn.bind(this.vm), false);
                } else if (dir === 'model') {
                  // 取 vm.data 进行 input 的赋值,并且在 input 的时候更新 vm.data 上的值
                  let value = this.vm.$data[exp];
                  node.value = typeof value === 'undefined' ? '' : value;
    
                  node.addEventListener('input', e => {
                    if (e.target.value !== value) {
                      this.vm.$data[exp] = e.target.value;
                    }
                  });
    
                  // add 1
                  new Watcher(this.vm.$data, exp, newVal => {
                    node.value = typeof newVal === 'undefined' ? '' : newVal;
                  });
                }
              }
            }
          } else if (node.nodeType === 3) {
            let reg = /\{\{(.*)\}\}/;
            if (reg.test(node.textContent)) {
              // 这里文本里也许会有多个 {{}} ,{{}} 内或许会有表达式,这里简单处理,就取一个值
              let exp = reg.exec(node.textContent)[1].trim();
              let value = this.vm.$data[exp];
    
              node.textContent = typeof value === 'undefined' ? '' : value;
    
              // add 2
              new Watcher(this.vm.$data, exp, newVal => {
                node.textContent = typeof newVal === 'undefined' ? '' : newVal;
              });
            }
          }
    
          if (node.childNodes && node.childNodes.length) {
            this.compileElement(node);
          }
        }
      }
    }
    
    • add 1:为 v-model 绑定事件,如果修改了绑定值,则立即更改 input.value
    • add 2:为文本元素绑定事件,如果修改了绑定值,则立即更新 node. textContent

    至此,已完成双向绑定的操作,这里附上所有的代码,以供学习:

    • index.html
    <!DOCTYPE html>
    <html lang="en">
      <body>
        <div id="app">
          <input v-model="name" />
          <h1>{{ name }}</h1>
          <h1>{{ age }}</h1>
          <button v-on:click="addAge">过年了,大一岁</button>
          <button v-on:click="changeName">我叫李四</button>
        </div>
    
        <script src="./myvue.js"></script>
        <script>
          var vm = new MyVue({
            el: '#app',
    
            data: {
              name: '张三',
              age: 20
            },
    
            methods: {
              addAge() {
                this.age++;
              },
              changeName() {
                this.name = '李四';
              }
            }
          });
        </script>
      </body>
    </html>
    
    • myvue.js
    // 订阅者生成器
    class Observer {
      constructor(data) {
        this.data = data;
        Object.keys(data).forEach(key => {
          let value = data[key];
          let dep = new Dep(); // add1
    
          Object.defineProperty(data, key, {
            get() {
              Dep.target && dep.add(Dep.target); // add2
              return value;
            },
            set(newVal) {
              if (newVal !== value) {
                value = newVal;
                dep.notify(newVal); // add3
              }
            }
          });
        });
      }
    }
    
    // 订阅者生成器
    class Watcher {
      constructor(data, key, cb) {
        this.data = data;
        this.cb = cb;
        this.key = key;
    
        Dep.target = this; // add1
        this.value = data[key];
        Dep.target = null; // add2
      }
    
      update(newVal) {
        let oldVal = this.value;
        if (newVal !== oldVal) {
          this.value = newVal;
          this.cb(newVal, oldVal);
        }
      }
    }
    
    // 订阅库生成器
    class Dep {
      constructor() {
        this.subs = [];
      }
    
      add(sub) {
        this.subs.push(sub);
      }
    
      notify(newVal) {
        this.subs.forEach(sub => {
          sub.update(newVal);
        });
      }
    }
    Dep.target = null;
    
    // 片段解析器
    class Compile {
      constructor(vm) {
        this.vm = vm;
    
        let el = document.querySelector(this.vm.$el);
        let fragment = document.createDocumentFragment();
    
        if (el) {
          while (el.firstChild) {
            fragment.appendChild(el.firstChild);
          }
    
          // 编译片段
          this.compileElement(fragment);
    
          el.appendChild(fragment);
        } else {
          console.log('挂载元素不存在!');
        }
      }
    
      compileElement(el) {
        for (let node of el.childNodes) {
          /*
            node.nodeType
            1:元素节点
            3:文本节点
          */
          if (node.nodeType === 1) {
            for (let attr of node.attributes) {
              let { name: attrName, value: exp } = attr;
    
              // v- 代表存在指令
              if (attrName.indexOf('v-') === 0) {
                /*
                  <div v-xxx=""> 元素上,可以用很多指令,这里仅做学习,所以不判断太多了
                  on    事件绑定
                  model 表单绑定
                */
                let [dir, value] = attrName.substring(2).split(':');
                if (dir === 'on') {
                  // 取 vm.methods 相应的含税,进行绑定
                  let fn = this.vm.$methods[exp];
                  fn && node.addEventListener(value, fn.bind(this.vm), false);
                } else if (dir === 'model') {
                  // 取 vm.data 进行 input 的赋值,并且在 input 的时候更新 vm.data 上的值
                  let value = this.vm.$data[exp];
                  node.value = typeof value === 'undefined' ? '' : value;
    
                  node.addEventListener('input', e => {
                    if (e.target.value !== value) {
                      this.vm.$data[exp] = e.target.value;
                    }
                  });
    
                  // add 1
                  new Watcher(this.vm.$data, exp, newVal => {
                    node.value = typeof newVal === 'undefined' ? '' : newVal;
                  });
                }
              }
            }
          } else if (node.nodeType === 3) {
            let reg = /\{\{(.*)\}\}/;
            if (reg.test(node.textContent)) {
              // 这里文本里也许会有多个 {{}} ,{{}} 内或许会有表达式,这里简单处理,就取一个值
              let exp = reg.exec(node.textContent)[1].trim();
              let value = this.vm.$data[exp];
    
              node.textContent = typeof value === 'undefined' ? '' : value;
    
              // add 2
              new Watcher(this.vm.$data, exp, newVal => {
                node.textContent = typeof newVal === 'undefined' ? '' : newVal;
              });
            }
          }
    
          if (node.childNodes && node.childNodes.length) {
            this.compileElement(node);
          }
        }
      }
    }
    
    class MyVue {
      constructor({ el, data, methods }) {
        let obs = new Observer(data);
    
        this.$el = el;
        this.$data = obs.data;
        this.$methods = methods;
    
        Object.keys(this.$data).forEach(i => {
          this.proxyKeys(i);
        });
    
        new Compile(this);
      }
    
      proxyKeys(key) {
        let _this = this;
        Object.defineProperty(_this, key, {
          enumerable: false,
          configurable: true,
          get() {
            return _this.$data[key];
          },
          set(newVal) {
            _this.$data[key] = newVal;
          }
        });
      }
    }
    

    相关文章

      网友评论

          本文标题:Vue 双向绑定的剖析

          本文链接:https://www.haomeiwen.com/subject/czgccctx.html