深入Vue双向绑定原理

前言


本文主要比较Vue2.0Vue3.0双向绑定的原理,以及由二者不同的原理造成的一些差异,并对该差异产生的原因进行简单的分析。

  • Vue2.0双向绑定的主要实现原理是Object.defineProperty()方法
  • Vue3.0双向绑定的主要实现原理是ES6新增的Proxy()对象
    所以本文阐述的双向绑定的原理的区别简单来说就是上述两种方法对于劫持对象属性的不同。但是为了详细说清楚不同原理造成的差异,我们必须从源码说起。
    Vue双向绑定应用的设计模式是发布-订阅模式,由于设计模式能更好的说明不同类之间的意图,对于我们理解源码有很大的帮助(Ps:对于笔者是的) ,所以我们将从这个设计模式说起。
    发布-订阅模式在很多文章中都被认为成观察者模式,包括在《Javascript设计模式与开发实践》一书中,作者表示

    发布-订阅模式又叫观察者模式

经过笔者考察二者在是实现上还是有些区别,但是能确定发布-订阅模式观察者模式的一种变体。

Vue2.0双向绑定源码

观察者模式 VS 发布-订阅模式


观察者模式

它定义对象间的一种一对多的依赖关系,当一个对象的状态发生改变时,所有依赖于它的对象都将得到通知。

观察者模式.png

观察者模式中主要由两类对象组成,一种是发布者(主题),一种是观察者。

  • 发布者 – 为观察者提供注册功能,在需要的时候给观察者发送消息
  • 观察者 – 实现上需要监听发布者的改变

具体实现我们来看一下代码(TypeScript)

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
class Observer {
update() {
// do something
console.log("收到通知")
}
}

class Subject {
observerLists: Observer[] = []

publish() {
this.observerLists.forEach((observer) => {
observer.update()
})
}
trigger(observer: Observer) {
this.observerLists.push(observer)
}
}

const observer = new Observer()
const subject = new Subject()

subject.trigger(observer)

subject.publish() //收到通知

这就是最简单的观察者模式的实现方式,该模式主要含有两种类型的对象,而且这两种对象之间是“互相了解”的。

发布-订阅模式

事实上,发布-订阅模式观察者模式的意图是没有太大的区别的,都是为了监听一个对象的变化,并且在这个对象改变的时候通知另一个对象。但是现在两个对象之间是“互相了解”(耦合)的,那么为了解耦两种对象之间的关系,我们可以来看一下发布-订阅模式有什么新的改变呢?

发布-订阅模式.png

我们再来看一下具体代码的实现(TypeScript)

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
class Dep {
observerLists: Observer[] = []
publish() {
this.observerLists.forEach((observer) => {
observer.update()
})
}
trigger(observer: Observer) {
this.observerLists.push(observer)
}
}

class Observer {
update() {
// do something
console.log("收到通知")
}
}

class Subject {
deps: Dep[] = []
change() {
this.deps.forEach((dep) => dep.publish())
}
depend(dep: Dep) {
this.deps.push(dep)
}
}

const observer = new Observer()
const subject = new Subject()
const dep = new Dep()

subject.depend(dep) // 发布者关联消息中心
dep.trigger(observer) // 观察者关联消息中心
subject.change() // 收到通知

与观察者模式相比较,该模式增加了消息中心的对象来做消息的调度工作。
而我们一会要看的Vue源码,就是通过这种方式实现的。

Vue 双向绑定原理解析

Vue 2.0 VS 3.0 有哪些不同

  • 2.0 响应式数据都要提前data里面声明
  • 2.0 响应式数据对数组的效果不理想
  • 2.0 响应式数据需要对多级对象进行深度遍历影响性能

那造成2.0这些问题的原因是什么呢?

手写 Vue 双向绑定部分源码

发布-订阅模式.png

我们举个简单的双向绑定的例子:页面上存在input输入框以及一个p标签,我们要实现一个在输入框输入的内容会自动显示在p标签当中的功能。其实就是手写一个最普通的一个双向绑定。

  1. Observer作为发布者,用来做检测data数据改变的功能
  2. Dep作为消息中心,用来管理ObserverSubscriber之间的消息传递
  3. Subscriber作为观察者,数据有改变时被通知并执行update方法

代码的具体实现(html + ts)

建议您自己手写一边,最好是再用调试模式看一下执行过程。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="X-UA-Compatible" content="IE=edge" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
</head>
<body>
<input type="text" id="input" /><br />
<span id="p"></span>
<script src="./vue-ts.js"></script>
</body>
</html>
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
/**
* 为了更好的理解博客
* (https://juejin.cn/post/6844903601416978439#heading-10)
* 中所讲的Vue2.0双向绑定原理,所以自己再重新实现一下这个方法(ts)
*/
let uid = 0

class Dep {
id = uid++
subs: Subscriber[] = []
static target: Subscriber | null = null

addSub(sub: Subscriber): void {
this.subs.push(sub)
}

notify(): void {
this.subs.forEach((sub) => {
sub.update()
})
}

depend() {
if (Dep.target) {
Dep.target.addDep(this)
}
}
}

class Subscriber {
depIds: { [propName: string]: Dep } = {}
vm: Vue
cb: any
expOrFn: any
val: any

constructor(vm: Vue, expOrFn: any, cb: any) {
this.vm = vm
this.expOrFn = expOrFn
this.cb = cb
this.val = this.get()
}

update() {
this.run()
}

run() {
const val = this.get()
if (this.val !== val) {
this.val = val
this.cb.call(this.vm, val)
}
}

get() {
Dep.target = this
console.log(this.vm)
const val = this.vm.data[this.expOrFn]

Dep.target = null
return val
}

addDep(dep: Dep) {
if (!this.depIds.hasOwnProperty(dep.id)) {
dep.addSub(this)
this.depIds[dep.id] = dep
}
}
}

class Observer {
value: any
constructor(value: any) {
this.value = value
this.walk()
}
walk() {
Object.keys(this.value).forEach((key) => this.defineReactive(this.value, key, this.value[key]))
}
defineReactive(obj, key, val) {
const dep = new Dep()
Object.defineProperty(obj, key, {
enumerable: true,
configurable: true,
set(newValue) {
console.log(val)
if (val === newValue) return
val = newValue
observe(newValue)
dep.notify()
},
get() {
console.log(val)
if (Dep.target) {
dep.depend()
}
return val
},
})
}
}

const observe = (value) => {
if (!value || typeof value !== "object") return
return new Observer(value)
}

class Vue {
data: any
constructor(option: { data: any }) {
this.data = option.data
Object.keys(this.data).forEach((key) => this.proxy(key))
observe(this.data)
}
$watch(expOrFn: any, callback: any) {
new Subscriber(this, expOrFn, callback)
}
proxy(key: string) {
Object.defineProperty(this, key, {
get() {
return this.data[key]
},
set(newValue: any) {
this.data[key] = newValue
},
})
}
}

const demo: any = new Vue({
data: { text: "" },
})

const input = document.getElementById("input")
const p = document.getElementById("p")

input.addEventListener("input", (event: any) => {
demo.text = event.target.value
})

demo.$watch("text", (val: string) => (p.innerHTML = val))

理解源码之后我们来分析一下为什么存在上面我们说的三个问题

  • 2.0 响应式数据都要提前data里面声明;响应式数据是通过访问器属性(getter/setter)实现的,但是我们声明的时候声明的是对象的数据属性,是通过调用方法defineReactive()设置的响应式数据。假设我们的Vue实例化之后在代码中声明了一个属性,那么这个属性是没有调用过defineReactive()方法的。
  • 2.0 响应式数据对数组的效果不理想;响应式数据是通过访问器属性(getter/setter)实现的,当数组改变的时候无法检测到。最常见改变数组的方法:’push’, ‘pop’, ‘shift’, ‘unshift’, ‘splice’, ‘sort’, ‘reverse’(不包括改变数组的指向)
  • 2.0 响应式数据需要对多级对象进行深度遍历影响性能;2.0中为了能全面的对数据进行监听,所以要把多级对象进行深度遍历,为每个对象(PS:包括深度)的属性设置访问器属性

那么Proxy如何避免以上问题呢?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const data = {
title: "userInfo",
andy: {
name: "啦啦啦",
},
}

const newData = new Proxy(data, {
set() {
console.log("设置data的值")
return Reflect.set(...arguments)
},
})

newData.title = "infoMation" // 设置data的值

Proxy只需要给整个对象做劫持就可以,不需要为每个属性增加访问器属性

1
2
3
4
5
6
7
8
9
10
11
const data = [123, 123]

const newData = new Proxy(data, {
set() {
console.log("设置data的值")
return Reflect.set(...arguments)
},
})

newData.push(456) // 设置data的值
console.log(newData) // [123,123,234]

Proxy天生就可以劫持数组的改变

1
2
3
4
5
6
7
8
9
10
const newData = new Proxy(data, {
set() {
console.log("设置data的值")
return Reflect.set(...arguments)
},
})

newData.title = "infoMation" // 设置data的值
newData.andy.name = "吼吼吼" // 未打印
newData.andy = { name: "吼吼吼" } // 设置data的值

Proxy只能劫持代理对象的直系属性,多级属性改变也无法劫持,所以也需要做深层遍历劫持;
由于Proxy可以代理整个对象,所以相比直线深层遍历其实“不深”

文章参考来源

  1. Vue2.0双向绑定源码
  2. 发布-订阅模式和观察者模式真的不一样?
  3. 面试官: 实现双向绑定Proxy比defineproperty优劣如何?