Vue双向绑定原理

前言

今天面试远景能源问到了Vue的双向绑定原理,只是知道通过 Object.defineproperty 来操作的,具体流程还不清楚,整理下知识点,为接下来的面试积累经验!

何为双向绑定

MVVM架构

Vue是一种 MVVM(Model-View-ViewModel) 架构模式,因此在解释双向绑定之前,先来了解一下 MVVM 架构模式,如下图。它是将“数据模型数据双向绑定”的思想作为核心,因此在View和Model之间没有联系,通过ViewModel进行交互,而且Model和ViewModel之间的交互是双向的,因此视图的数据的变化会同时修改数据源,而数据源数据的变化也会立即反应到View上。
"MVVM"

单向绑定和双向绑定

解释完 MVVM 后,再来看单向绑定和双向绑定。

  • 单向绑定非常简单,就是把Model绑定到View,当我们用JavaScript代码更新Model时,View就会自动更新。
  • 有单向绑定,就有双向绑定。如果用户更新了View,Model的数据也自动被更新了,这种情况就是双向绑定。

Vue.js是采用数据劫持结合发布者-订阅者模式的方式,通过Object.defineProperty()来劫持各个属性的settergetter,在数据变动时发布消息给订阅者,触发相应的监听回调。

思路分析

  • 实现一个数据监听器Observer,能够对数据对象的所有属性进行监听,如有变动可拿到最新值并通知订阅者
  • 实现一个指令解析器Compile,对每个元素节点的指令进行扫描和解析,根据指令模板替换数据,以及绑定相应的更新函数
  • 实现一个Watcher,作为连接Observer和Compile的桥梁,能够订阅并收到每个属性变动的通知,执行指令绑定的相应回调函数,从而更新视图

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

  1. 输入框以及文本节点与data中的数据绑定。
  2. 输入框内容变化时,data中的数据同步变化。即view => model的变化。
  3. data中的数据变化时,文本节点的内容同步变化。即model => view的变化。

最终目标:

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

任务一:数据初始化绑定

function nodeToFragment(node, vm) {
  var fragment = document.createDocumentFragment(),
    child;
  while (child = node.firstChild) {
    compile(child, vm);
    // 将子节点劫持到文档片段中
    fragment.appendChild(child);
  }
  return fragment;
}

function compile(node, vm) {
  // 正则判断获取 {{ text }}
  var reg = /\{\{(.*)\}\}/;
  // 节点类型为元素 nodeType 为 1
  if (node.nodeType === 1) {
    var attr = node.attributes;
    // 解析属性
    for (var i = 0; i < attr.length; i++) {
      if (attr[i].nodeName == 'v-model') {
        // 获取v-model绑定的属性名
        var name = attr[i].nodeValue;
        // 将data的值赋给该node
        node.value = vm.data[name];
        node.removeAttribute('v-model');
      }
    };
  }
  // 节点类型为text nodeType 为 3
  if (node.nodeType === 3) {
    if (reg.test(node.nodeValue)) {
      // 获取匹配到的字符串
      var name = RegExp.$1;
      name = name.trim();
      // 将data的值赋给该node
      node.nodeValue = vm.data[name];
    }
  }
}

function Vue(options) {
  this.data = options.data;
  var dom = nodeToFragment(document.getElementById(options.el), this);
  // 编译完成后,将dom返回到app中
  document.getElementById(options.el).appendChild(dom);
}

var vm = new Vue({
  el: 'app',
  data: {
    text: 'hello world'
  }
});

效果如下:
task1_1
上述结果是input后紧跟text,并没有将text嵌入<div>标签内,如果是如下结构就会显示出错。

<div id="app">
  <input type="text" v-model="text">
  <div>{{ text }}</div>
</div>

效果如下:
task1_2
因此考虑到大括号内的text不是直接跟在input后,所有我们要对每个子节点判断其是否还有子节点,所以要递归调用compile方法。修改nodeToFragment方法如下就可以正常显示了。

function nodeToFragment(node, vm) {
  var fragment = document.createDocumentFragment(),
    child;
  while (child = node.firstChild) {
    // 判断子节点是否还有子节点
    if(child.hasChildNodes()){
      compile(child.firstChild, vm);
    }else{
      compile(child, vm);
    }
    // 将子节点劫持到文档片段中
    fragment.appendChild(child);
  }
  return fragment;
}

但是又出现了一个问题,如果文本节点不仅仅是text也无法正常显示,如下结构。

<div id="app">
  <input type="text" v-model="text">
  <div>内容为:{{ text }}</div>
</div>

你会发现“内容为:”几个字也没有了,所以修改compile函数中node.nodeValue赋值部分如下。

node.nodeValue = node.nodeValue.replace(reg, vm.data[name]);

效果如下:
task1_3
至此任务一就完成了!

任务二:响应式的数据绑定

任务二主要实现的是输入框内容变化时,data中的数据同步变化。当我们在输入框输入数据的时候,首先会触发input事件(或者keyupchange事件),然后事件程序就会将输入框中的value值赋给vm实例中的data.text。添加如下代码:

function observe(obj, vm) {
  // 遍历观察对象中的键
  Object.keys(obj).forEach((key) => {
    defineReactive(obj, key, obj[key]);
  })
}

// 将输入框中的 value 值赋给 vm 实例中的 data.text
function defineReactive(obj, key, value) {
  Object.defineProperty(obj, key, {
    get() {
      return value;
    },
    set(newVal) {
      if (newVal === value) {
        return;
      }
      value = newVal;
      // 检查是否成功
      console.log(value);
    }
  })
}

function compile(node, vm) {
  //省略...
    var name = attr[i].nodeValue;
    // 给节点绑定 input 事件
    node.addEventListener('input', (e) => {
      vm.data[name] = e.target.value;
    });
    // 将data的值赋给该node
    node.value = vm.data[name];
    node.removeAttribute('v-model');
  // 省略...
}

function Vue(options) {
  this.data = options.data;
  observe(this.data, this);
  // 省略...
}

注:代码中省略部分皆为之前代码中的不变动部分
效果如下:
task2
至此任务二也完成了!

任务三:发布订阅模式和双向绑定的实现

目前我们已经完成将vm实例中的data.text绑定到输入框和文本节点,并且修改输入框中的内容data.text也会随之变化,最后一步就是将data.text的变化同步到对应的文本节点中。

发布订阅模式

发布订阅模式(又称观察者模式)定义了一种一对多的关系,让多个观察者同时监听某一个主题对象,这个主题对象的状态发生改变时就会通知所有观察者对象。

发布者发出通知 => 主题对象收到通知并推送给订阅者 => 订阅者执行相应操作

// 一个发布者
var pub = {
  publish() {
    dep.notify();
  }
}

// 三个订阅者
var sub1 = {
  update() {
    console.log(1);
  }
}
var sub2 = {
  update() {
    console.log(2);
  }
}
var sub3 = {
  update() {
    console.log(3);
  }
}

// 一个主题对象
function Dep() {
  this.subs = [sub1, sub2, sub3];
}
Dep.prototype.notify = function() {
  this.subs.forEach((sub) => {
    sub.update();
  })
}
// 发布者发布消息,主题对象执行notify方法,进而触发订阅者的updata方法
var dep = new Dep();
pub.publish(); // 1, 2, 3

实现双向绑定

我们先来梳理一下双向绑定的大致流程。

初始数据绑定 => 输入框内容变化 => 触发事件函数 => 修改属性值 => 发出通知 dep.notify() => 触发订阅者的 update 方法 => 更新视图

Watcher订阅者作为ObserverCompile之间通信的桥梁,主要做的事情是:

  1. 在自身实例化时往属性订阅器(dep)里面添加自己
  2. 自身必须有一个update()方法
  3. 待属性变动dep.notice()通知时,能调用自身的update()方法。
function Watcher(vm, node, name = '', input = '') {
  Dep.target = this;
  this.name = name;
  this.node = node;
  this.input = input;
  this.vm = vm;
  this.update();
  Dep.target = null;
}

Watcher.prototype = {
  update() {
    this.get();
    this.node.nodeValue = this.value;
  },
  get() {
    // 将原本在 Compile 中的部分移到watcher中
    var reg = /\{\{(.*)\}\}/;
    if (reg.test(this.input)) {
      this.name = RegExp.$1.trim();
      this.value = this.input.replace(reg, this.vm.data[this.name]);
    }
  }
}

function Dep() {
  this.subs = [];
}

Dep.prototype = {
  addSub(sub) {
    this.subs.push(sub);
  },
  notify() {
    this.subs.forEach(function(sub) {
      sub.update();
    });
  }
};
  • 首先,将自己赋给了一个全局变量Dep.target
  • 其次,执行了update方法,进而执行了get方法,get的方法读取了vm的访问器属性,从而触发了访问器属性的get方法,get方法中将该 watcher添加到了对应访问器属性的dep中。
  • 再次,获取属性的值,然后更新视图。
  • 最后,将Dep.target设为空。因为它是全局变量,也是watcherdep关联的唯一桥梁,任何时刻都必须保证Dep.target只有一个值。

除了添加Watcher函数之外还要在defineReactivecompile中添加Watcher,修改如下:

function defineReactive(obj, key, value) {
  // 添加dep实例
  var dep = new Dep();
  Object.defineProperty(obj, key, {
  // 省略...
  value = newVal;
  // 发出通知
  dep.notify();
}

function compile(node, vm) {
  if (node.nodeType === 1) {
    // 省略...
    new Watcher(vm, node, name);
  }
  if (node.nodeType === 3) {
    new Watcher(vm, node, name, node.nodeValue);
  }
}

最终效果如下:
task3
至此三项任务全部完成!
参考:http://www.cnblogs.com/kidney/p/6052935.html