前言
Vue三要素:响应式、模版引擎和渲染。其中,响应式就是通过著名的双向绑定(Two-way data binding)实现的。今天我们就聊聊这个老掉牙的话题——Vue2是如何实现双向绑定的。
数据劫持
我以前写过一篇《Vue计算属性简析》,那里也提到过数据劫持。什么是数据劫持呢?说白了就是利用Object.defineProperty()来劫持对象属性的setter和getter操作,以期达到代理更复杂操作的目的。给个简单的例子——山寨Vue,快速回顾一下数据劫持:
class Vue {
constructor({data}) {
this.data = data();
Object.keys(this.data).forEach( this.proxy.bind(this) );
}
proxy (key) {
Object.defineProperty(this, key, {
get () {
return Reflect.get(this.data, key);
},
set (newVal) {
Reflect.set(this.data, key, newVal)
}
})
}
}
let vm = new Vue({
data: () => ( {
price: 5,
quantity: 2,
}),
});
console.log(Object.keys(vm)); // [ 'data' ]
console.log(vm.price, vm.quantity); // 5 2
如上所示,我们对Vue对象进行数据劫持,它本身并不拥有price或quantity域;但当调用vm.price
和 vm.quantity
的setter或getter操作时,会自动代理到vm.data
的price和quantity方法。现实开发中,我们也能在vue模版或方法里看到类似的调用。道理都是一样的,就是通过数据劫持来代理data
域操作。
<div>{{this.price}}</div>
或是
{
data: () => ( {
price: 5,
quantity: 2,
}),
computed: {
total () {
return this.price * this.quantity;
}
}
}
双向绑定
有了数据劫持的知识,我们进一步探索双向绑定的实现。
极简实现
上文我们通过Vue自身的数据劫持代理了私有域data
。下面我写了一个极简版的双向绑定示例。由于单一职责的设计原则,我又进一步劫持了vm.data
。原因很简单:除了data
, Vue还会代理methods
、computed
等方法,这些方法实现差异巨大,不适合全部耦合在Vue.proxy
里。这里的实现就是将price
和DOM的input
做双向绑定。
let vm = new Vue({
data: () => ( {
price: 5,
}),
});
Object.defineProperty(vm.data, 'price', {
get: function() {
return vm.data['price']
},
set: function(newVal) {
vm.data['price'] = newVal;
document.getElementById('input').value = newVal;
}
});
document.getElementById('input')
.addEventListener('keyup', function cb(e) {
vm.price = e.target.value;
})
上述代码仅仅是个示例,仅反映我们可以通过数据劫持实现双向绑定;但是并没什么学习价值,耦合严重,违反了开发闭合原则——DOM操作不应该放在set
方法里面。更大的问题是:只能监听一个属性。假如某个DOM绑定了this.price
和this.quantity
两个域,实现就会变得很复杂。
<span>total = {{price*quantity}}</span>
订阅发布
有什么改进法案呢?想想设计模式。
Vue2就将数据劫持和订阅发布模式结合在了一起。看这张图:
有些复杂,我们先拆解来看:
Dep(订阅发布中心):负责存储订阅者,并处理消息分发。
Observer(观察者):用于监听data属性变化,实现注册和消息通知
Watcher(订阅者):在Dep里注册自己的信息,当Dep分发消息后触发自身方法
Viewer(显示):DOM更新(方便起见,后文将用
console.log
代替)
山寨Vue
先看一下我github上的山寨Vue:
/* Step 1 */
let watcher = function () {
const total = this.price * this.quantity;
console.log(`total = ${total}`); // Viewer!!
};
/* Step 2 */
let vm = new Vue({
data: () => ( {
price: 5,
quantity: 2,
}),
});
/* Step 3 */
vm.$mount( watcher ); // total = 10
/* Step 4 */
vm.price = 100; // total = 200
vm.quantity = 100; // total = 10000
定义了一个
watcher
函数,用于模拟template里的数据绑定:——<span>total = {{price*quantity}}</span>
。初始化Vue对象
vm
将
watcher
函数挂载到vm
里,total
初始化成功分别修改
vm.price
和vm.quantity
,total
随之更新
山寨Vue的使用方法已经列在上面了,看一下类实现(proxy
见第一部分):
class Vue {
constructor({data}) {
this.data = data();
Object.keys(this.data).forEach( this.proxy.bind(this) );
new Observer(this.data);
}
$mount(watcher) {
Dep.target = watcher.bind(this);
watcher.call(this); // init and register
Dep.target = null;
}
proxy (key) { ... }
}
Observer
从上到下,我们就先说Observer吧。
class Observer {
constructor (data) {
Object.keys(data).forEach( Observer.defineReactive.bind(null, data) )
}
static defineReactive(obj, key) {
let val = Reflect.get(obj, key);
const dep = new Dep();
Object.defineProperty(obj, key, {
get () {
dep.depend();
return val;
},
set (newVal) {
val = newVal;
dep.notify();
}
})
}
}
Vue初始化后,将this.data
交由Observer.defineReactive
做数据劫持:
getter:返回数值,但重点是往Dep里注册绑定的依赖——Watcher
setter:在赋值后通知Dep分发消息至所有的订阅者——Watcher。
Dep
Dep的实现有点小技巧,首先定义了一个静态变量target
;当vue挂载watcher时,target
指向该方法(后面会继续展开)。如上图所示,depend
主要作用是将挂载了的watcher作为订阅者存储起来,并在notfiy
调用时,触发这些订阅者。
class Dep {
constructor() {
this.subscribers = [];
}
depend() {
if( Dep.target && !this.subscribers.includes(Dep.target) ){
this.subscribers.push(Dep.target);
}
}
notify() {
this.subscribers.forEach(sub => sub())
}
}
Dep.target = null;
Watcher
再看一下订阅者watcher:
let watcher = function () {
const total = this.price * this.quantity;
console.log(`total = ${total}`)
}
class Vue {
...
$mount( watcher ) {
Dep.target = watcher.bind(this);
watcher.call(this); // init and register
Dep.target = null;
}
}
vm.$mount(watcher);
这里重点还是在$mount
函数。我们先将Dep.target
指向watcher
,然后运行watcher.call(this)
初始化total
。这时候price和quantity的getter被调用。我们知道这两个域的getter已经被Observer劫持了,并会触发depend
方法。再回看一下depend
实现:
depend() {
if( Dep.target && !this.subscribers.includes(Dep.target) ){
this.subscribers.push(Dep.target);
}
}
这时候,watcher就通过Dep.target
添加到subscribers数组里了。至此,整个发布订阅模式被打通。
Publish
最后我们通过vm的setter方法,通知Dep,并调用所有订阅者watcher。
vm.price = 100; // total = 200
vm.quantity = 100; // total = 10000
class Dep {
...
notify() {
this.subscribers.forEach(sub => sub())
}
}
再来复盘一下消息流:
Observer劫持Vue.data
Vue挂载模版方法——watcher
调用watcher并初始化Viewer
由于数据劫持,watcher自动触发Vue getter,并调取Dep.depend
watcher通过Dep.target成功订阅Dep
触发Vue setter操作,setter将消息通知到Dep
Dep将消息发布至订阅者watcher
Viewer因watcher调用而更新
小结
这期我们利用数据劫持和订阅发布模式实现了一个山寨版的Vue,学习了Vue2双向绑定的设计思想。但是这个双向绑定还是存在一些漏洞的,尤雨溪也在今年宣布Vue3会重写双向绑定。至于新的实现又是什么,我们下次再聊。