Vue 3 响应式原理初探——从零开始实现响应式 API

响应式是指变量的值发生改变时,用到这个变量的其他变量的值会被重新计算。

在下面的例子中,sumnum1num2 的和。无论哪个加数被修改,都要重新计算 sum

1
2
3
4
5
let num1 = 2
let num2 = 3
let sum = num1 + num2

num1 = 4 // 自动执行 `sum = num1 + num2`

要在一个变量发生变化时,重新计算另一个变量的值,就必须知道哪些计算过程用到了这个变量,以及具体的计算过程,以便在变量发生变化时重新执行这些计算。

具体的计算过程可以用一个函数来表示。但是要知道变量被哪些函数所使用,就必须在变量的值被读取时,记住当前正在读取该变量的函数。可惜,JavaScript 并没有提供一种可以满足上述需求的机制。

访问器属性

庆幸的是,如果把变量换成对象的属性的话,情况就大不相同了。利用对象的 访问器属性 就可以实现上述需求。这就是为什么在 Vue 3 中,原始类型必须用 ref() 方法包装成对象类型。

访问器是对象的特殊方法,用关键字 getset 定义而不是 function

1
2
3
4
5
6
7
8
const obj = {
get value() {
return 'Hello World!'
},
set value(val) {
console.log(`The new value is '${val}'`)
}
}

get value() 方法是读取对象的 value 属性(实际上不存在)时会被调用的函数,称为 getter,它的返回值将被当作该属性的值。

1
console.log(obj.value) // Hello World!

set value() 方法是设置对象的 value 属性时会被调用的函数,称为 setter,它的参数是希望赋予该属性的值。

1
2
obj.value = 'JavaScript'
The new value is 'JavaScript'

实现 ref() 函数

为了把前面的例子中提到的变量包装成对象,需要定义一个包装函数:

1
2
3
4
5
6
7
8
9
10
11
function ref(val) {
return {
_value: val, // 用 `_value` 属性来保存实际值
get value() {
return this._value
},
set value(val) {
this._value = val
}
}
}

此处的 ref() 方法还不完善,它仅仅创建一个对象,并用 _value 属性来保存实际值,没有额外功能。

1
2
3
4
5
const num1 = ref(2)
const num2 = ref(3)
const sum = ref(0)

console.log(num1.value) // 2

getter 中追踪

利用 getter 很容易在属性的值被读取时执行一些操作,比如记住正在读取该属性的函数。为了做到这一点,必须要求每个函数在读取属性前,将自身的引用赋予一个全局变量。

1
2
3
4
5
6
7
let activeEffect = null

function fn() {
activeEffect = effect // 自身的引用赋予全局变量 `activeEffect`
sum.value = num1.value + num2.value // 求和
activeEffect = null
}

这样,当 getter 被调用时,就可以通过 activeEffect 得知当前是哪个函数正在读取属性。为了记住这个函数以及它正在操作的对象,定义一个追踪函数 track(obj),在 getter 中调用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
let activeEffect = null
let effects = []

function track(obj) {
const effect = activeEffect
if ( !effects.find(item => item.obj === obj && item.effect === effect) )
effects.push({ obj, effect })
}

function ref(val) {
return {
_value: val,
get value() {
track(this) // 追踪
return this._value
},
set value(val) {
this._value = val
}
}
}

追踪函数用一个对象来保存相关信息并推送到数组 effects 中(推送之前先检查是否已存在重复元素,不存在才推送)。

setter 中触发

在属性的值被读取时,已经将相关函数都记录下来。这些函数可能用该属性修改了一些变量。如果现在这个属性的值变了,自然就要重新调用这些函数,以更新相关变量的值。

定义一个触发函数 trigger(obj),在 setter 中调用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function trigger(obj) {
effects.forEach(item => {
if (item.obj === obj) item.effect() // 调用操作过该对象的函数
})
}

function ref(val) {
return {
_value: val,
get value() {
track(this) // 追踪
return this._value
},
set value(val) {
this._value = val
trigger(this) // 触发
}
}
}

至此,一组极其简陋的响应式 API 已经基本实现。

实现 watchEffect() 函数

要求每个函数在读取属性前,将自身的引用赋予一个全局变量,显然太繁琐。可以增加一个侦听函数负责此动作:

1
2
3
4
5
6
7
8
function watchEffect(fn) {
const effect = () => {
activeEffect = effect
fn()
activeEffect = null
}
effect()
}

watchEffect() 在调用目标函数 fn() 之前,先将目标函数 fn() 包裹在箭头函数 effect() 中。这个箭头函数在调用目标函数之前,会被赋予全局变量 activeEffect

完整代码

响应式 API 的完整代码如下:

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
let activeEffect = null
let effects = []

function track(obj) {
const effect = activeEffect
if ( !effects.find(item => item.obj === obj && item.effect === effect) )
effects.push({ obj, effect })
}

function trigger(obj) {
effects.forEach(item => {
if (item.obj === obj) item.effect()
})
}

function ref(val) {
return {
_value: val,
get value() {
track(this)
return this._value
},
set value(val) {
this._value = val
trigger(this)
}
}
}

function watchEffect(fn) {
const effect = () => {
activeEffect = effect
fn()
activeEffect = null
}
effect()
}

测试

定义 3 个响应式变量,用 watchEffect() 调用求和函数:

1
2
3
4
5
6
7
8
9
10
11
const num1 = ref(2)
const num2 = ref(3)
const sum = ref(0)

watchEffect(() => {
sum.value = num1.value + num2.value
})

num1 // Object { _value: 2, value: Getter & Setter }
num2 // Object { _value: 3, value: Getter & Setter }
sum // Object { _value: 5, value: Getter & Setter }

现在,修改其中一个加数的值:

1
2
3
4
num1.value = 4
num1 // Object { _value: 4, value: Getter & Setter }
num2 // Object { _value: 3, value: Getter & Setter }
sum // Object { _value: 7, value: Getter & Setter }

可以看到,sum 的值已经被重新计算。