Vue 双向绑定的剖析

前言

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


学习过程

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

看了网上不少不少的教程和讲解,没怎么看明白,由于教程中的剖析,大多写出几个部分,这个部分作什么,感受这一部分耦合度较高,由于没有总体认知,因此看起来很难受。vue

后来本身思考了下,准备本身着手写一下,把代码一点一点拷贝下来,完成独立的功能,再进行拼接,而后总结概念和思路,最后触类旁通,若是读者看了这篇文章,也不妨跟着写写,更有收获。java

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

分解后的模块

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

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

总的一看,感受不是很难理解,一步一步来看。数组


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

何为观察者和订阅者

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

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

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

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

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 Observer {
  constructor(data) {
    this.data = data;
    Object.keys(data).forEach(key => {
      let value = data[key];
      let dep = new Dep();

      Object.defineProperty(data, key, {
        get() {
          Dep.target && dep.add(Dep.target);
          return value;
        },
        set(newVal) {
          if (newVal !== value) {
            value = newVal;
            dep.notify(newVal);
          }
        }
      });
    });
  }
}

// 订阅者生成器
class Watcher {
  constructor(data, key, cb) {
    this.data = data;
    this.cb = cb;
    this.key = key;

    Dep.target = this;
    this.value = data[key];
    Dep.target = null;
  }

  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;
                }
              });

              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;

          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;
      }
    });
  }
}
复制代码
相关文章
相关标签/搜索