JavaScript 中的数据双向绑定

JavaScript 中的数据双向绑定

关于数据双向绑定,绑定的基础就是监听属性的变化事件(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 为首的单向数据绑定,另一个是以 AngularVue 为主的双向数据绑定,两者主要有两点区别

  • 无需显示调用,例如 Vue 运用数据劫持加上发布订阅,直接可以通知变化并驱动视图,而比如 Angular 的脏检测或是 React 需要显示调用 setState
  • 可精确得知变化数据,例如上面的例子,我们劫持了属性的 setter,当属性值改变,我们可以精确获知变化的内容,因此在这部分不需要额外的 diff 操作,否则我们只知道数据发生了变化而不知道具体哪些数据变化了,这个时候需要大量 diff 来找出变化值,这是额外性能损耗

本质上,基于数据劫持的双向绑定离不开 ProxyObject.defineProperty 等方法对对象或者对象属性的劫持,我们要实现一个完整的双向绑定需要以下几个要点

  • 利用 ProxyObject.defineProperty 生成的 Observer 针对对象或者对象的属性进行劫持,在属性发生变化后通知订阅者
  • 解析器 Compile 解析模板中的 Directive(指令),收集指令所依赖的方法和数据,等待数据变化然后进行渲染
  • Watcher 属于 ObserverCompile 桥梁,它将接收到的 Observer 产生的数据变化,并根据 Compile 提供的指令进行视图渲染,使得数据变化促使视图变化

基于 Object.defineProperty 双向绑定

这里引用了 剖析 Vue 原理 && 实现双向绑定 MVVM 当中的部分内容,更为完整的实现可以见原文

我们仔细观察上面的示例,其实可以发现,里面是存在着一堆问题的,比如在上面的示例当中,我们只监听了一个属性,一个对象不可能只有一个属性,我们需要对对象的每个属性进行监听等等

我们可以参考 Vue 的实现方式,Vue 是采用数据劫持结合发布者订阅者模式的方式,通过 Object.defineProperty() 来劫持各个属性的 setter/getter,在数据变动时发布消息给订阅者,触发相应的监听回调,简单来说,主要有下面几个步骤

  1. 需要 observe 的数据对象进行递归遍历,包括子属性对象的属性,都加上 setter/getter,这样的话,给这个对象的某个值赋值,就会触发 setter,那么就能监听到了数据变化
  2. compile 解析模板指令,将模板中的变量替换成数据,然后初始化渲染页面视图,并将每个指令对应的节点绑定更新函数,添加监听数据的订阅者,一旦数据有变动,收到通知,更新视图
  3. Watcher 订阅者是 ObserverCompile 之间通信的桥梁,主要做的事情是
    • 在自身实例化时往属性订阅器(dep)里面添加自己
    • 自身必须有一个 update() 方法
    • 待属性变动 dep.notice() 通知时,能调用自身的 update() 方法,并触发 Compile 中绑定的回调,则功成身退
  4. MVVM 作为数据绑定的入口,整合 ObserverCompileWatcher 三者,通过 Observer 来监听自己的 model 数据变化,通过 Compile 来解析编译模板指令,最终利用 Watcher 搭起 ObserverCompile 之间的通信桥梁,所以最终便可以达到达到 数据变化 ==> 视图更新视图交互变化(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
// 首先实现一个订阅发布中心,即消息管理员(Dep),它负责储存订阅者和消息的分发,不管是订阅者还是发布者都需要依赖于它
const Vue = (function() {

let uid = 0

// 用于储存订阅者并发布消息
class Dep {

constructor() {
this.id = uid++ // 设置 id 用于区分新 Watcher 和只改变属性值后新产生的 Watcher
this.subs = [] // 储存订阅者的数组
}

// 触发 target 上的 Watcher 中的 addDep 方法,参数为 dep 的实例本身
depend() {
Dep.target.addDep(this)
}

// 添加订阅者
addSub(sub) {
this.subs.push(sub)
}

notify() {
// 通知所有的订阅者(Watcher)触发订阅者的相应逻辑处理
this.subs.forEach(sub => sub.update())
}
}

// 为 Dep 类设置一个静态属性,默认为 null,工作时指向当前的 Watcher
Dep.target = null

// 现在我们需要实现监听者(Observer),用于监听属性值的变化
// 监听者,监听对象属性值的变化
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: () => {
// 如果 Dep 类存在 target 属性,将其添加到 dep 实例的 subs 数组中
// target 指向一个 Watcher 实例,每个 Watcher 都是一个订阅者
// Watcher 实例在实例化过程中,会读取 data 中的某个属性,从而触发当前 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)
}


// 我们还需要实现一个订阅者(Watcher)
class Watcher {

constructor(vm, expOrFn, cb) {
this.depIds = {} // hash 储存订阅者的 id,避免重复的订阅者
this.vm = vm // 被订阅的数据一定来自于当前 Vue 实例
this.cb = cb // 当数据更新时想要做的事情
this.expOrFn = expOrFn // 被订阅的数据
this.val = this.get() // 维护更新之前的数据
}

// 对外暴露的接口,用于在订阅的数据被更新时,由订阅者管理员(Dep)调用
update() {
this.run()
}

addDep(dep) {
// 如果在 depIds 的 hash 中没有当前的 id,可以判断是新 Watcher,因此可以添加到 dep 的数组中储存
// 此判断是避免同 id 的 Watcher 被多次储存
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() {
// 当前订阅者(Watcher)读取被订阅数据的最新更新后的值时,通知订阅者管理员收集当前订阅者
Dep.target = this
const val = this.vm._data[this.expOrFn]
// 置空,用于下一个 Watcher 使用
Dep.target = null
console.log(Dep.target, 2)
return val
}
}


// 将上述方法挂载在 Vue 上
class Vue {

constructor(options = {}) {
// 简化了 $options 的处理
this.$options = options
// 简化了对 data 的处理
let data = (this._data = this.$options.data)
// 将所有 data 最外层属性代理到 Vue 实例上
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
// 首先获取 Array 原型
const oldMethod = Object.create(Array.prototype)
const newMethod = []

['push', 'pop', 'shift', 'unshift', 'splice', 'sort', 'reverse'].forEach(method => {
// 在 newMethod 上进行方法的重写
// 这里需要注意的是重写的方法是定义在 newMethod 的属性上而不是其原型属性(newMethod.__proto__ 没有改变)
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 实现的双向绑定

ProxyES2015 规范中被正式发布,它在目标对象之前架设一层拦截,外界对该对象的访问,都必须先通过这层拦截,因此提供了一种机制,可以对外界的访问进行过滤和改写,我们可以这样认为 ProxyObject.defineProperty 的全方位加强版,Proxy 直接可以劫持整个对象,并返回一个新对象,不管是操作便利程度还是底层功能上都远强于 Object.defineProperty

Proxy 可以直接监听数组的变化

当我们对数组进行操作(pushshiftsplice 等)时,会触发对应的方法名称和 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)
}

// push 数字
btn.addEventListener('click', function() {
newArr.push(6)
})

Proxy的优势

Proxy 有多种拦截方法,不限于 applyownKeysdeletePropertyhas 等等,是 Object.defineProperty 不具备的,Proxy 返回的是一个新对象,我们可以只操作新的对象达到目的,而 Object.defineProperty 只能遍历对象属性直接修改

评论

Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×