Vue 的数据劫持 + 发布订阅
Vue 的双向绑定策略基础是数据劫持,在 Vue2.0 中使用了 ES5 语法 Object.defineProperty,来劫持各个属性的 setter/getter,在数据变动时发布消息给订阅者(Wacther), 触发相应的监听回调。先来看一下这个 ES5 特性,我们可以通过 Object.defineProperty 这个方法,直接在一个对象上定义一个新的属性,或者修改已存在的属性,最终这个方法会返回该对象,如下为简单说明,对该特性不了解的同学可以查看《JavaScript 高级程序设计》的第六章,或者在线访问 MDN Web 文档: https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Object/defineProperty
var o = {};
var value = 1;
Object.defineProperty(o, 'a', {
get: function() { return value; },
set: function(newValue) { value = newValue; },
enumerable: true,
configurable: true
});
o.a; // 1
o.a = 2;
o.a; // 2
结合这一特定与发布订阅机制,可以实现完整的双向绑定。如下所示,Observer 数据监听器能够对数据对象的所有属性进行监听,如有变动可拿到最新值并通知订阅者,内部采用 Object.defineProperty 的 getter 和 setter 来实现 [3]。
Compile 指令解析器,它的作用对每个元素节点的指令进行扫描和解析,根据指令模板替换数据,以及绑定相应的更新函数。
Watcher 订阅者, 作为连接 Observer 和 Compile 的桥梁,能够订阅并收到每个属性变动的通知,执行指令绑定的相应回调函数。Dep 消息订阅器,内部维护了一个数组,用来收集订阅者(Watcher),数据变动触发 notify 函数,再调用订阅者的 update 方法。
当执行 new Vue() 时,Vue 就进入了初始化阶段,一方面会遍历 data 选项中的属性,用 Object.defineProperty 将它们转为 getter/setter,实现数据变化监听功能;另一方面,Vue 的指令编译器 Compile 对元素节点的指令进行扫描和解析,初始化视图,并订阅 Watcher 来更新视图, 此时 Wather 会将自己添加到消息订阅器中 (Dep), 初始化完毕。当数据发生变化时,Observer 中的 setter 方法被触发,setter 会立即调用 Dep.notify(),Dep 开始遍历所有的订阅者,并调用订阅者的 update 方法,订阅者收到通知后对视图进行相应的更新
使用 Object.defineProperty 这个特性存在一些明显的缺点,总结起来大概是下面两个:
- Object.defineProperty 无法监控到数组下标的变化,当监控数组数据对象的时候,实质上就是监控数组的地址,地址不变也就不会被监测到。为了解决这个问题,经过 Vue 内部处理后可以使用 push、pop、shift、unshift、splice、sort、reverse 来监听数组。
-
Object.defineProperty 只能劫持对象的属性, 因此我们需要对每个对象的每个属性进行遍历。Vue 2.x 里,是通过递归 + 遍历 data 对象来实现对数据的监控的。如果属性值也是对象,那么需要深度遍历,显然如果能劫持一个完整的对象是才是更好的选择。
由于只针对了以上八种方法进行了 hack 处理,所以其他数组的属性也是检测不到的,还是具有一定的局限性。Vue3.0 中使用了 ES6 语法 Proxy,用于取代 defineProperty,使用 Proxy 有以下两个优点:
- 可以劫持整个对象,并返回一个新对象。
-
有 13 种劫持操作。
既然 Proxy 能解决以上两个问题,而且 Proxy 作为 es6 的新属性在 Vue2.x 之前就有了,为什么 Vue2.x 不使用 Proxy 呢?一个很重要的原因就是,Proxy 是 ES6 提供的新特性,兼容性不好,并且是这个属性无法用 polyfill 来兼容。
Vue 的双向绑定策略成为当前考察前端人员技术功底的重点,我们以 Object.defineProperty 特性实现一个简单的双向绑定,实现最初的 hello everyone 效果。
<!DOCTYPE html>
<html lang="en">
<head>
<title>双向绑定最最最初级 demo</title>
<meta charset="UTF-8">
</head>
<body>
<div id="app">
<input type="text" id="txt">
<p id="show-txt"></p>
<button onClick="changeData()">更新数据</button>
</div>
</body>
<script>
var obj={}
Object.defineProperty(obj,'txt',{
get:function(){
return obj
},
set:function(newValue){
document.getElementById('txt').value = newValue
document.getElementById('show-txt').innerHTML = newValue
}
})
document.addEventListener('keyup',function(e){
obj.txt = e.target.value
})
changeData = function() {
obj.txt = 'hello world';
}
</script>
</html>
由于 Object.defineProperty 默认只能劫持值类型数据,对引用类型数据的内部修改无法劫持,需要重写覆盖原原型方法,以 Array 为例,如下可以支持到 7 种数组方法:
let arr = [];
let arrayMethod = Object.create(Array.prototype);
['push', 'pop', 'shift', 'unshift', 'splice', 'sort', 'reverse'].forEach(function (method) {
Object.defineProperty(arrayMethod, method, {
enumerable: true,
configurable: true,
value: function () {
let args = [...arguments]
Array.prototype[method].apply(this, args);
console.log(`operation: ${method}`);
}
})
});
arr.__proto__ = arrayMethod;
arr.push(1); // 劫持到了 push 方法
相对完整的仿 Vue 双向绑定实现,来自双向绑定数组源码: https://gitee.com/merico/Blog/tree/master/Object.defineProperty_Array
<!DOCTYPE html>
<html lang="en">
<head>
<title>双向绑定支持数组监听</title>
<meta charset="UTF-8">
</head>
<body>
<div id="app">
<div id='list'></div>
<input type="button" value="添加" onclick="btnAdd()" />
<input type="button" value="删除" onclick="btnDel()" />
</div>
</body>
<script>
// 数据源
let vm = {
list: [1, 2, 3, 4]
}
// 用于管理 watcher 的 Dep 对象
let Dep = function () {
this.list = [];
this.add = function (watcher) {
this.list.push(watcher)
};
this.notify = function (newValue) {
this.list.forEach(function (fn) {
fn(newValue)
})
}
};
// 模拟 compile, 通过对 Html 的解析生成一系列订阅者(watcher)
function renderList() {
let listContainer = document.querySelector('#list');
let contentList = '';
vm.list.forEach(function (item) {
contentList = contentList + `<div><h3>${item}</h3></div>`
})
listContainer.innerHTML = contentList;
}
// 将解析出来的 watcher 存入 Dep 中待用
let dep = new Dep();
dep.add(renderList)
// 核心方法
function initMVVM(vm) {
Object.keys(vm).forEach(function (key) {
let value = vm[key];
if (Array.isArray(value)) {
observeArray(vm, key)
}
})
}
function observeArray(vm, key) {
let arrayMethod = bindWatcherToArray();
vm[key].__proto__ = arrayMethod;
}
function bindWatcherToArray() {
let arrayMethod = Object.create(Array.prototype);
['push', 'pop', 'shift', 'unshift', 'splice', 'sort', 'reverse'].forEach(function (method) {
Object.defineProperty(arrayMethod, method, {
enumerable: true,
configurable: true,
value: function () {
let args = [...arguments]
Array.prototype[method].apply(this, args);
console.log(`operation: ${method}`)
dep.notify();
}
})
});
return arrayMethod
}
// 页面引用的方法
function btnAdd() {
vm.list.push(Math.random())
}
function btnDel() {
vm.list.pop()
}
// 初始化数据源
initMVVM(vm)
// 初始化页面
dep.notify();
</script>
</html>