双向数据绑定的原理和实现
SunShinewyf opened this issue · 2 comments
双向绑定的概念
所谓双向绑定,是指:用户在视图(View)层触发更改时能让数据模型(Model)检测到其更新并发生变化,同时数据模型(Model)层对数据的改变也能实时更新到视图层。也就是 MVVM 的核心概念,MVVM 的示意图如下:
双向绑定的三种实现方式
实现双向绑定有如下几种方式:
发布-订阅
这种方式是通过使用 get 和 set 的方式获取数据然后更新数据,其原理就是监听页面中某个具体元素的事件,然后将其最新的值手动 set 到 数据中,同时订阅 model 层的改变,然后触发页面的渲染更新,具体详见这里,具体的示意图如下所示:
这种方式虽然实现了双向绑定的功能,但是不能通过设置 model: vm.data = 'value' 的形式修改数据, 进而更新视图,存在一定的劣势。
脏检查
赃检查的主要原理是在将数据绑定到 View 的时候,就在监听器列表(scope 作用域中的监听队列 watchList) 中插入一条监听器,当触发 UI 事件或者 Ajax 请求时,就会触发脏检查($digest cycle), 在 $digest 流程中,将遍历每个数据变量的 watcher,比较它的新旧值。当新旧值不同时,触发 listener 函数,执行相关的更新逻辑。这个过程将会一直重复,直到所有数据指令的新旧值都相同为止。具体详见 这里,脏检查的原理示意图如下所示:
脏检查虽然可以达到实现双向绑定,但是当页面中绑定的 watcher 过多时,就会引发性能问题。所以 angular 在进行 $digest 检测时,会限制循环检查的次数最少2次,最多10次,防止无效的检查。
数据劫持
这种方式是利用 ES5 的 Object.defineProperty() 来劫持数据属性的 getter 和 setter, 在数据变动时触发订阅者,从而触发相应的监听回调。vue 也是使用的这种思路实现的双向绑定,下面来详细讲述一下这种方法的原理和实现。
原理
先上一张图:
如上图所示:数据劫持主要实现了如下几个功能:
- Observer 对数据的所有属性进行监听其 getter 和 setter
- Compile 是一个指令解析器,对 MVVM 实例的所有元素指令进行解析,并渲染成 model 中的绑定数据,当数据进行更新时,也能替换为更新后的值。
- Watcher 作为 Compile 和 Observer 的桥梁,能够订阅数据属性的更新,然后执行相应的监听回调
- Deps 用于存放监听器数组,主要用来保存 Watcher
- Updater 执行更新操作,针对不同的指令进行不同的更新操作,如 v-model, v-class, v-html 等类型的指令。
- MVVM 作为入口函数,整合以上所有的功能。
具体原理如下:
Observer 劫持了所有数据属性的 getter 和 setter,当数据发生改变时,就会通知 deps 中所有 watcher 的更新操作,进而触发页面的重新渲染,这是修改 Model 层从而引发 View 层的重新渲染。
在 Compile 中监听可输入元素的事件,然后将新值更新到 model 的数据中,这是修改 View 层触发的 Model 层的修改。
如何实现
下面分别从上图中涉及到的一些类来从代码层面介绍一下其实现细节:
MVVM 入口类
一般在 html 中,会实例化 MVVM 类,从而传递一些参数,如下所示:
<script>
let vm = new MVVM({
el: '#app',
data: {
message: { a: { b: 'hello world' } },
className: 'btn',
more: 'mvvm',
htmlStr: '<span style="color: #f00;">red</span>',
},
method: {
clickBtn: function (e) {
let strArr = ['one', 'two', 'three'];
this.message.a.b = strArr[parseInt(Math.random() * 3)]
}
}
})
</script>
这里面只是向 MVVM 传递了一些参数,包括 el(页面对应的元素),data( 修改的数据),method(要传递的方法),下面是 MVVM 的定义:
function MVVM(options) {
this.$el = options.el; //页面对应的元素
this.$data = options.data; //传入的数据
this.$method = options.method; //传入的方法
if (this.$el) {
//对所有数据进行劫持
new Observer(this.$data);
//将数据直接代理到实例中,无需通过vm.$data来操作
this.proxyData(this.$data);
new Compile(this.$el, this);
}
}
MVVM.prototype = {
proxyData: function(data) { //方法代理
Object.keys(data).forEach(key => {
Object.defineProperty(this, key, {
get() {
return data[key];
},
set(newValue) {
data[key] = newValue;
}
});
});
}
};
如代码所示,MVVM 的构造函数中只是执行了 Observer 类的实例化和 Compile 类的实例化,并且执行了原型方法中的 proxyData,该方法主要是做了一层数据的代理,也就是可以直接通过设置 vm.message.a.b 的形式进行 get 和 set 操作,相当于对 vm.$data.message.a.b 进行相应的操作。
Compile
Compile 主要是做了编译指令的工作,指令类型包括 v-html、v-class、v-model、v-on:click、{{}} 的多种形式。
function Compile(el, vm) {
this.el = this.isElementNode(el) ? el : document.querySelector(el);
this.vm = vm;
if (this.el) {
//将真实DOM移入内存 fragment 中
let fragment = this.node2Fragment(this.el);
this.compile(fragment);
//将编译后的 fragment 再次转化为 DOM 塞回到页面中
this.el.appendChild(fragment);
}
}
Compile 的构造函数中主要做了如下工作:
- 将 DOM 结构树转化为 fragment (文档碎片),之所以这样转化,主要是因为在编译指令中,必然涉及到将 data 里面的数据塞到 DOM 对应的指令元素中,也就涉及到对 DOM 的更新操作,如果直接修改原生 DOM,将产生频繁的页面重绘,导致页面性能降低,所以需要将 DOM 树存储到内存中。
//将 DOM 转化为 fragment
node2Fragment: function(el) {
let fragment = document.createDocumentFragment();
//每次获取DOM节点树中的第一个元素并移除,直到移除完毕为止
while (el.firstChild) {
fragment.appendChild(el.firstChild);
}
//返回一个文档碎片容器,存储DOM树的所有节点
return fragment;
},
- 对内存中的 fragment 进行编译和更新
compile: function(fragment) {
let childNodes = fragment.childNodes;
Array.from(childNodes).forEach(node => {
//是否是元素节点
if (this.isElementNode(node)) {
this.compileElement(node);
this.compile(node); //递归编译
} else {
//是否是文本节点
this.compileText(node);
}
});
},
//编译节点元素
compileElement: function(node) {
// 带v-model v-text
let attrs = node.attributes; // 取出当前节点的属性
Array.from(attrs).forEach(attr => {
let attrName = attr.name;
if (this.isDirective(attrName)) {
// 取到指令对应的值放到节点中
let expr = attr.value;
const attrArr = attrName.split('-');
//说明此时不是 v-model 的这种形式,而是 v-model-v-model
if (attrArr.length !== 2) {
return;
}
let type = attrArr[1]; //获取指令是哪种类型,比如v-model,v-text
//如果是事件指令
if (this.isEventDirective(type)) {
CompileUtil.eventHandler(node, this.vm, expr, type);
} else {
// 调用对应的编译方法 编译哪个节点,用数据替换掉表达式
CompileUtil[type](node, this.vm, expr);
}
}
});
},
//编译文本元素
compileText: function(node) {
let expr = node.textContent; // 取文本中的内容 todo:和 innerHTML 的区别
let reg = /\{\{([^}]+)\}\}/g; // 不能直接检测 {{}} 这种情况,还要考虑这种情况 {{a}} {{b}} {{c}}
if (reg.test(expr)) {
// 调用编译文本的方法 编译哪个节点,用数据替换掉表达式
CompileUtil['text'](node, this.vm, expr);
}
}
//指令处理集合
var CompileUtil = {
//model指令处理
model: function(node, vm, expr) {
let updateFn = Updater['modelUpdater'];
//实例化 Watcher
new Watcher(vm, expr, newValue => {
updateFn && updateFn(node, newValue);
});
//监听输入框的input事件,并将值回填到数据中
node.addEventListener('input', e => {
let newValue = e.target.value;
this.setVal(vm, expr, newValue);
});
updateFn && updateFn(node, this.getVal(vm, expr));
},
...代码省略
};
编译模块主要是针对 v-html、v-class、v-model、v-on:click、{{}} 这几种情况做分别编译处理。这里不详述,注释也写得比较清楚,直接戳文末的代码链接吧。
注:这里在 CompileUtil 里面实例化了一个 Watcher,主要是添加事件监听的绑定,这里之后再讲。
- 将更新后的 fragment 重新塞回到页面中
Observe
这个类主要是劫持数据所有属性的 setter 和 getter 方法,具体代码如下:
function Observer(data) {
this.data = data;
this.observe(this.data);
}
Observer.prototype = {
observe: function(data) {
if (!data || typeof data !== 'object') return;
//为每一个属性设置数据监听
Object.keys(data).forEach(key => {
this.defineReactive(data, key, data[key]);
this.observe(data[key]); //深度递归劫持属性
});
},
/**
* @param {data} 要监听的数据对象
* @param {key} 要监听的对象属性key值
* @param {value} 要监听的对象属性值
*/
defineReactive: function(data, key, value) {
let dep = new Dep();
let self = this;
//如果是该属性值是对象类型,则遍历
Object.defineProperty(data, key, {
enumerable: true,
configurable: false,
get: () => {
//由于需要在闭包内添加watcher,所有需要 Dep 定义一个全局 target 属性,暂存 watcher ,添加完移除
if (Dep.target) {
//如果为true,则说明是实例化 watcher 引起的,所以需要添加进消息订阅器中
dep.depend();
}
return value;
},
set: newVal => {
if (newVal === value) return;
value = newVal;
//对新值进行监听
self.observe(newVal);
//通知所有订阅者
dep.notify();
}
});
}
};
这里面在 Observer 的构造函数里面就执行了 observe 的遍历方法,遍历传进数据的所有属性,然后使用 ES5 的 defineProperty() 进行劫持。重点关注 get 和 set 的方法,有一个 Dep 的概念,我们先来看看 Dep 都干了啥
Dep
Dep 的作用是一个存储 watcher 的容器,代码如下:
let uid = 0;
//订阅类
function Dep() {
this.id = uid++;
this.subs = [];
}
Dep.prototype = {
addSub: function(sub) {
if (this.subs.indexOf(sub) === -1) {
//避免重复添加
this.subs.push(sub);
}
},
removeSub: function(sub) {
const index = this.subs.indexOf(sub);
if (index > -1) {
this.subs.splice(index, 1);
}
},
depend: function() {
Dep.target.addDep(this); //执行 watcher 的 addDep 方法
},
notify: function() {
this.subs.forEach(sub => {
sub.update(); //执行 watcher 的 update 方法
});
}
};
//Dep 类的全局属性 target,是一个 Watch 实例
Dep.target = null;
里面有一些对数组的添加、删除和通知的方法,比较简单,不详述。这里面的 Dep.target 是用来存储当前操作的 Watcher 的,是一个全局变量。
Watcher
Watcher 作为 Compile 和 Observer 的桥梁,是用来监听数据层的变化,并触发页面更新的开关。代码如下:
function Watcher(vm, expr, cb) {
this.depIds = {}; //存储deps订阅的依赖
this.vm = vm; //component 实例
this.cb = cb; //更新数据时的回调函数
this.expr = expr; //表达式还是function
this.value = this.get(vm, expr); //在实例化的时候获取老值
}
Watcher.prototype = {
//暴露给 Dep 类的方法,用于在订阅的数据更新时触发
update: function() {
const newValue = this.get(this.vm, this.expr); //获取到的新值
const oldValue = this.value; //获取到的旧值
if (newValue !== oldValue) {
//判断新旧值是否相等,不相等就执行回调
this.value = newValue;
this.cb(newValue);
}
},
addDep: function(dep) {
//检查depIds对象是否存在某个实例,避免去查找原型链上的属性
if (!this.depIds.hasOwnProperty(dep.id)) {
dep.addSub(this); //在 dep 存储 watcher 监听器
this.depIds[dep.id] = dep; //在 watcher 存储订阅者 dep
}
},
//获取data中的值,可能出现 hello.a.b的情况
getVal: function(vm, expr) {
expr = expr.split('.');
return expr.reduce((prev, next) => {
return prev[next];
}, vm.$data);
},
//获取值
get: function(vm, expr) {
Dep.target = this;
const val = this.getVal(vm, expr);
Dep.target = null;
return val;
}
};
原型中主要是定义了一些方法,比较简单,这里也不详述。
现在我们要串一下这些类是怎么关联起来的了。流程如下:
- Compile 在数据更新的时候,实例化了 Watcher 类
- Wathcer 类在实例化的时候,调用了自身的 get(),并将自身赋值给 Dep.target
- 在 Watcher 调用 get() 时,触发 Observer defineProperty 中的 get 劫持函数
- get 劫持函数触发了 Dep 的 depend(),因为此时 Dep.target 有值
- Dep.depend() 调用了 Watcher 的 addDep()
- addDep() 调用了 Dep 中的 addSub(),将自身作为参数传递
- addSub 中将 watcher 收集到数组容器中。
上面实现了 Watcher 的添加。
当数据改变时,页面如何改变的,也来串一下整体流程:
- Observer 的 set 劫持函数被触发
- set 劫持函数中调用了 dep.notify()
- notify() 中遍历执行了 dep 中所有 Watcher 的 update()
- update() 执行了 Watcher 被实例时传进的回调方法
- 页面数据重新渲染
当页面改变时,数据是如何改变的呢:
//model指令处理
model: function(node, vm, expr) {
let updateFn = Updater['modelUpdater'];
new Watcher(vm, expr, newValue => {
updateFn && updateFn(node, newValue);
});
//监听输入框的input事件,并将值回填到数据中
node.addEventListener('input', e => {
let newValue = e.target.value;
this.setVal(vm, expr, newValue);
});
updateFn && updateFn(node, this.getVal(vm, expr));
},
//设置值
setVal: function(vm, expr, value) {
expr = expr.split('.');
//将新值回填到数据中,并且回填到最后一个值,如:hello.a.b,就需要把值回填到b中
return expr.reduce((prev, next, index) => {
if (index === expr.length - 1) {
return (prev[next] = value);
}
return prev[next];
}, vm.$data);
}
这段代码是在 Compile 中,对页面元素进行事件监听,从而触发 Model 层的数据更新。
至此整个流程串起来了,一气呵成!
但是上面的处理还是没有考虑到当 data 是 数组的情况,所以还不是很完善,需要进一步加强,以上功能所有源码地址
发布-订阅、脏检查、数据劫持。请问第四种是?
@wy-Jay 是三种,写错了~