关于数据双向绑定,绑定的基础就是监听属性的变化事件(propertyChange
),在现在比较流行的一些框架当中的解决方法一般有以下几种
Knockout/Backbone
(发布/订阅模式),简单来说就是另外开发一套 API
,但使用起来却不得不使用这套 API
来操作 viewModel
,导致上手复杂、代码繁琐
Angular
(脏检查机制),特点是直接使用原生 JavaScript
来操作 viewModel
,但脏检查机制随之带来的就是性能问题
Vue
(数据劫持,也就是 Object.defineProperty
),会把定义的 viewModel
对象(即 data
函数返回的对象)中所有的(除某些前缀开头的)成员替换为属性,这样既可以使用原生 JavaScript
操作对象,又可以主动触发 propertyChange
事件,效率虽高,但也有一些限制,见后文
另外的几种方式
Object.observe
,谷歌对于简化双向绑定机制的尝试,在 Chrome 49
中引入,然而由于性能等问题,并没有被其他各大浏览器及 ES
标准所接受,所以在后续版本当中移除了该方法的实现
Proxy
,是 ES6
加入的新特性,用于对某些基本操作定义其自定义行为,类似于其他语言中的面向切面编程,它的其中一个作用就是用于(部分)替代 Object.observe
以实现双向绑定
基于数据劫持实现的双向绑定
数据劫持比较好理解,通常我们利用 Object.defineProperty
劫持对象的访问器,在属性值发生变化时我们可以获取变化,从而进行进一步操作
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22
| const data = { name: '', }
Object.keys(data).forEach(function (key) { Object.defineProperty(data, key, { enumerable: true, configurable: true, get: function () { console.log(`get`) }, set: function (newValue) { console.log(`set`) console.log(newValue) }, }) })
data.name = 'new name'
|
数据劫持的优势以及实现思路
目前业界分为两个大的流派,一个是以 React
为首的单向数据绑定,另一个是以 Angular
、Vue
为主的双向数据绑定,两者主要有两点区别
- 无需显示调用,例如
Vue
运用数据劫持加上发布订阅,直接可以通知变化并驱动视图,而比如 Angular
的脏检测或是 React
需要显示调用 setState
- 可精确得知变化数据,例如上面的例子,我们劫持了属性的
setter
,当属性值改变,我们可以精确获知变化的内容,因此在这部分不需要额外的 diff
操作,否则我们只知道数据发生了变化而不知道具体哪些数据变化了,这个时候需要大量 diff
来找出变化值,这是额外性能损耗
本质上,基于数据劫持的双向绑定离不开 Proxy
与 Object.defineProperty
等方法对对象或者对象属性的劫持,我们要实现一个完整的双向绑定需要以下几个要点
- 利用
Proxy
或 Object.defineProperty
生成的 Observer
针对对象或者对象的属性进行劫持,在属性发生变化后通知订阅者
- 解析器
Compile
解析模板中的 Directive
(指令),收集指令所依赖的方法和数据,等待数据变化然后进行渲染
Watcher
属于 Observer
和 Compile
桥梁,它将接收到的 Observer
产生的数据变化,并根据 Compile
提供的指令进行视图渲染,使得数据变化促使视图变化
基于 Object.defineProperty 双向绑定
这里引用了 剖析 Vue 原理 && 实现双向绑定 MVVM 当中的部分内容,更为完整的实现可以见原文
我们仔细观察上面的示例,其实可以发现,里面是存在着一堆问题的,比如在上面的示例当中,我们只监听了一个属性,一个对象不可能只有一个属性,我们需要对对象的每个属性进行监听等等
我们可以参考 Vue
的实现方式,Vue
是采用数据劫持结合发布者订阅者模式的方式,通过 Object.defineProperty()
来劫持各个属性的 setter/getter
,在数据变动时发布消息给订阅者,触发相应的监听回调,简单来说,主要有下面几个步骤
- 需要
observe
的数据对象进行递归遍历,包括子属性对象的属性,都加上 setter/getter
,这样的话,给这个对象的某个值赋值,就会触发 setter
,那么就能监听到了数据变化
compile
解析模板指令,将模板中的变量替换成数据,然后初始化渲染页面视图,并将每个指令对应的节点绑定更新函数,添加监听数据的订阅者,一旦数据有变动,收到通知,更新视图
Watcher
订阅者是 Observer
和 Compile
之间通信的桥梁,主要做的事情是
- 在自身实例化时往属性订阅器(
dep
)里面添加自己
- 自身必须有一个
update()
方法
- 待属性变动
dep.notice()
通知时,能调用自身的 update()
方法,并触发 Compile
中绑定的回调,则功成身退
MVVM
作为数据绑定的入口,整合 Observer
、Compile
和 Watcher
三者,通过 Observer
来监听自己的 model
数据变化,通过 Compile
来解析编译模板指令,最终利用 Watcher
搭起 Observer
和 Compile
之间的通信桥梁,所以最终便可以达到达到 数据变化 ==> 视图更新
和 视图交互变化(input) ==> 数据 model 变更
的双向绑定效果
下面是一个完成的例子
1 2 3 4 5
| <main> <p>请输入:</p> <input type="text" id="input"> <p id="p"></p> </main>
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189
| const Vue = (function() {
let uid = 0
class Dep {
constructor() { this.id = uid++ this.subs = [] }
depend() { Dep.target.addDep(this) }
addSub(sub) { this.subs.push(sub) }
notify() { this.subs.forEach(sub => sub.update()) } }
Dep.target = null class Observer {
constructor(value) { this.value = value this.walk(value) }
walk(value) { Object.keys(value).forEach(key => this.convert(key, value[key])) }
convert(key, val) { defineReactive(this.value, key, val) }
}
function defineReactive(obj, key, val) {
const dep = new Dep()
let chlidOb = observe(val) Object.defineProperty(obj, key, { enumerable: true, configurable: true, get: () => { if (Dep.target) { dep.depend() } return val }, set: newVal => { if (val === newVal) return val = newVal chlidOb = observe(newVal) dep.notify() }, }) }
function observe(value) { if (!value || typeof value !== 'object') { return } return new Observer(value) }
class Watcher {
constructor(vm, expOrFn, cb) { this.depIds = {} this.vm = vm this.cb = cb this.expOrFn = expOrFn this.val = this.get() }
update() { this.run() }
addDep(dep) { if (!this.depIds.hasOwnProperty(dep.id)) { dep.addSub(this) this.depIds[dep.id] = dep } }
run() { const val = this.get() console.log(val) if (val !== this.val) { this.val = val this.cb.call(this.vm, val) } }
get() { Dep.target = this const val = this.vm._data[this.expOrFn] Dep.target = null console.log(Dep.target, 2) return val } }
class Vue {
constructor(options = {}) { this.$options = options let data = (this._data = this.$options.data) Object.keys(data).forEach(key => this._proxy(key)) observe(data) }
$watch(expOrFn, cb) { new Watcher(this, expOrFn, cb) }
_proxy(key) { Object.defineProperty(this, key, { configurable: true, enumerable: true, get: () => this._data[key], set: val => { this._data[key] = val }, }) }
}
return Vue })()
let demo = new Vue({ data: { text: '', }, })
const p = document.getElementById('p') const input = document.getElementById('input')
input.addEventListener('keyup', function(e) { demo.text = e.target.value });
demo.$watch('text', str => p.innerHTML = str)
|
Object.defineProperty 的缺陷
其实我们升级版的双向绑定依然存在漏洞,比如我们将属性值改为数组
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32
| let demo = new Vue({ data: { list: [1], }, })
const list = document.getElementById('list') const btn = document.getElementById('btn')
btn.addEventListener('click', function () { demo.list.push(1) })
const render = arr => { const fragment = document.createDocumentFragment() for (let i = 0; i < arr.length; i++) { const li = document.createElement('li') li.textContent = arr[i] fragment.appendChild(li) } list.appendChild(fragment) }
demo.$watch('list', list => render(list))
setTimeout( function () { alert(demo.list) }, 5000, )
|
是的,Object.defineProperty
的第一个缺陷,无法监听数组变化,然而 Vue
的文档提到了 Vue
是可以检测到数组变化的,至于它是如何实现的,其实简单来说,这里就是重写了原来的方法
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21
| const oldMethod = Object.create(Array.prototype) const newMethod = []
['push', 'pop', 'shift', 'unshift', 'splice', 'sort', 'reverse'].forEach(method => { newMethod[method] = function () { console.log(`监听到数组的变化`) return oldMethod[method].apply(this, arguments) } })
let list = [1, 2]
list.__proto__ = newMethod list.push(3)
let list2 = [1, 2] list2.push(3)
|
由于只针对了特定几种方法进行了 hack
,所以其他数组的属性也是检测不到的,我们应该注意到在上文中的实现里,我们多次用遍历方法遍历对象的属性,这就引出了 Object.defineProperty
的第二个缺陷,只能劫持对象的属性,因此我们需要对每个对象的每个属性进行遍历,如果属性值也是对象那么需要深度遍历,显然能劫持一个完整的对象是需要消耗不少性能的
1
| Object.keys(value).forEach(key => this.convert(key, value[key]))
|
Proxy 实现的双向绑定
Proxy
在 ES2015
规范中被正式发布,它在目标对象之前架设一层拦截,外界对该对象的访问,都必须先通过这层拦截,因此提供了一种机制,可以对外界的访问进行过滤和改写,我们可以这样认为 Proxy
是 Object.defineProperty
的全方位加强版,Proxy
直接可以劫持整个对象,并返回一个新对象,不管是操作便利程度还是底层功能上都远强于 Object.defineProperty
Proxy 可以直接监听数组的变化
当我们对数组进行操作(push
、shift
、splice
等)时,会触发对应的方法名称和 length
的变化,下面是一个实例
1 2 3 4 5
| <main> <ul id="list"> </ul> <button type="button" name="button" id="btn">添加列表项</button> </main>
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52
| const list = document.getElementById('list') const btn = document.getElementById('btn')
const Render = {
init: function(arr) { const fragment = document.createDocumentFragment() for (let i = 0; i < arr.length; i++) { const li = document.createElement('li') li.textContent = arr[i] fragment.appendChild(li) } list.appendChild(fragment) },
change: function(val) { const li = document.createElement('li') li.textContent = val list.appendChild(li) }, };
const arr = [1, 2, 3, 4]
const newArr = new Proxy(arr, { get: function(target, key, receiver) { console.log(key) return Reflect.get(target, key, receiver) }, set: function(target, key, value, receiver) { console.log(target, key, value, receiver) if (key !== 'length') { Render.change(value) } return Reflect.set(target, key, value, receiver) }, })
window.onload = function() { Render.init(arr) }
btn.addEventListener('click', function() { newArr.push(6) })
|
Proxy的优势
Proxy
有多种拦截方法,不限于 apply
、ownKeys
、deleteProperty
、has
等等,是 Object.defineProperty
不具备的,Proxy
返回的是一个新对象,我们可以只操作新的对象达到目的,而 Object.defineProperty
只能遍历对象属性直接修改