Vue双向绑定原理
前言
今天面试远景能源问到了Vue的双向绑定原理,只是知道通过 Object.defineproperty
来操作的,具体流程还不清楚,整理下知识点,为接下来的面试积累经验!
何为双向绑定
MVVM架构
Vue是一种 MVVM(Model-View-ViewModel)
架构模式,因此在解释双向绑定之前,先来了解一下 MVVM
架构模式,如下图。它是将“数据模型数据双向绑定”的思想作为核心,因此在View和Model之间没有联系,通过ViewModel进行交互,而且Model和ViewModel之间的交互是双向的,因此视图的数据的变化会同时修改数据源,而数据源数据的变化也会立即反应到View上。
单向绑定和双向绑定
解释完 MVVM
后,再来看单向绑定和双向绑定。
- 单向绑定非常简单,就是把Model绑定到View,当我们用JavaScript代码更新Model时,View就会自动更新。
- 有单向绑定,就有双向绑定。如果用户更新了View,Model的数据也自动被更新了,这种情况就是双向绑定。
Vue.js是采用数据劫持结合发布者-订阅者模式的方式,通过Object.defineProperty()
来劫持各个属性的setter
,getter
,在数据变动时发布消息给订阅者,触发相应的监听回调。
思路分析
- 实现一个数据监听器
Observer
,能够对数据对象的所有属性进行监听,如有变动可拿到最新值并通知订阅者 - 实现一个指令解析器
Compile
,对每个元素节点的指令进行扫描和解析,根据指令模板替换数据,以及绑定相应的更新函数 - 实现一个
Watcher
,作为连接Observer和Compile的桥梁,能够订阅并收到每个属性变动的通知,执行指令绑定的相应回调函数,从而更新视图
首先将该任务分成几个子任务:
- 输入框以及文本节点与
data
中的数据绑定。 - 输入框内容变化时,
data
中的数据同步变化。即view => model
的变化。 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'
}
});
效果如下:
上述结果是input
后紧跟text
,并没有将text
嵌入<div>
标签内,如果是如下结构就会显示出错。
<div id="app">
<input type="text" v-model="text">
<div>{{ text }}</div>
</div>
效果如下:
因此考虑到大括号内的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]);
效果如下:
至此任务一就完成了!
任务二:响应式的数据绑定
任务二主要实现的是输入框内容变化时,data
中的数据同步变化。当我们在输入框输入数据的时候,首先会触发input
事件(或者keyup
、change
事件),然后事件程序就会将输入框中的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);
// 省略...
}
注:代码中省略部分皆为之前代码中的不变动部分
效果如下:
至此任务二也完成了!
任务三:发布订阅模式和双向绑定的实现
目前我们已经完成将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
订阅者作为Observer
和Compile
之间通信的桥梁,主要做的事情是:
- 在自身实例化时往属性订阅器(dep)里面添加自己
- 自身必须有一个
update()
方法 - 待属性变动
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
设为空。因为它是全局变量,也是watcher
与dep
关联的唯一桥梁,任何时刻都必须保证Dep.target
只有一个值。
除了添加Watcher
函数之外还要在defineReactive
和compile
中添加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);
}
}
最终效果如下:
至此三项任务全部完成!
参考:http://www.cnblogs.com/kidney/p/6052935.html