Vue3 响应式(1)-基础原理

Vue 之所以能够实现声明式的 UI,是因为 Vue 通过响应式将数据和 UI 进行了绑定,当数据发生变化时 Vue 会自动调用相应的函数来重新渲染受到影响的 UI。本系列文章就来简单分析下 Vue 响应式系统的实现。本文先从响应式的基础原理开始,实现一个简单的响应式系统。

系统设计

设计目标

我们希望实现这样的系统:能够将某些变量包装成响应式的变量,当该变量的值发生变化时,依赖于该变量的函数能够重新执行以达到刷新状态的目的,例如重新渲染 UI。

具体地,我们需要实现以下函数:

  • reactive(): 包装响应式变量
  • effect(): 包装能够响应数据变化的副作用函数,使其能够在所涉及的响应式变量发生变化时重新执行

例如,对于以下代码,首先使用 reactive() 方法将 obj 包装成响应式对象,然后使用 effect() 方法包装渲染页面文字内容的函数 fn,使得当 data.text 发生变化时,能够自动调用 fn 来更新页面的文字内容。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
const obj = {
text: 'Hi'
}
const data = reactive(obj)

function fn() {
document.body.innerText = data.text
}

effect(fn)

setTiemout(() => {
data.text = 'Hello World'
}, 2000)

技术原理

对于以上的设计目标,我们可以通过 Proxy 对象来实现。整体的思路是这样:

  • 当调用 reactive(obj) 时,返回一个对原始数据 obj 的代理 data。并拦截它的 getset 操作。
  • 在拦截 get 时,记录读取该属性的函数,例如 fn 中读取了 data.text,则将 fn 函数收集起来
  • 在拦截 set 操作时,将上一步收集起来的函数重新执行
  • effect 方法则用来处理对副作用函数 fn 的标记管理

简而言之,对于响应式数据,当读取某个属性时收集依赖于该属性的副作用函数,当修改该属性的值时把之前收集的副作用函数重新执行。

编码实现

reactive

首先实现 reactive 方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
function reactive(obj) {
return new Proxy(obj, {
get(target, key) {
track(target, key) // 收集依赖函数
return Reflect.get(target, key)
},
set(target, key, value) {
const res = Reflect.set(target, key, value)
trigger(target, key) // 触发关联的依赖函数
return res
}
})
}

下面实现追踪和触发方法 tarcktrigger。注意这两个方法里使用到了全局变量 activeEffectbucket,其中

  • activeEffect 是用来标记当前由哪个副作用函数读取了响应式变量,它在 effect 方法里被设置
  • bucket 是一个哈希结构,用来关联响应式变量和与之相关的副作用函数
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
let activeEffect
const bucket = new WeakMap()

function track(target, key) {
if (!activeEffect) return
let depsMap = bucket.get(target)
if (!depsMap) {
bucket.set(target, (depsMap = new Map()))
}
let deps = depsMap.get(key)
if (!deps) {
depsMap.set(key, (deps = new Set()))
}
deps.add(activeEffect) // 将读取该属性的副作用函数收集到对应的集合中
}

function trigger(target, key) {
let depsMap = bucket.get(target)
if (!depsMap) return
let deps = depsMap.get(key)
if (!deps) return
deps.forEach(fn => fn()) // 把与之关联的副作用函数依次执行
}

effect

如上文所述 effect() 方法主要用来设置 activeEffect:

1
2
3
4
function effect(fn) {
activeEffect = fn
fn() // 立即调用副作用函数来设置初始值并收集依赖
}

注意到这里 bucket 使用了 WeakMap 数据结构主要是从性能出发考虑的,参考WeakMap

测试和小结

使用设计目标一节中代码进行测试可以发现,2 秒后页面的文本会自动更新。

See the Pen v1_basic by Hozen (@Hozen) on CodePen.

由于 Proxy 的拦截是同步进行的,所以代码的执行过程很容易最终和分析,具体如下:

  1. 首先包装响应式对象,对数据的读取和设置进行拦截。
  2. effect 函数包装的副作用函数中,由于读取了 data.text,并设置了 activeEffect。被代理对象 dataget 所拦截。将当前副作用函数收集到了集合 bucket[data].text 中。
  3. 当 2 秒后执行 data.text = 'Hello World' 时,被 set 拦截触发 trigger,将 bucket[data].text 集合中所有的副作用函数依次执行。页面的文本被更新。

参考资料

[1] vuejs/core: 🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.[EB/OL]. [2023-09-22]. https://github.com/vuejs/core.

[2] Vue.js - 渐进式 JavaScript 框架 | Vue.js[EB/OL]. [2023-09-22]. https://cn.vuejs.org/.

[3] 霍春阳. Vue.js设计与实现[M/OL]. 人民邮电出版社, 2022[2023-09-19]. https://book.douban.com/subject/35768338/.

[4] JavaScript | MDN[EB/OL]. 2023-04-10. https://developer.mozilla.org/zh-CN/docs/Web/JavaScript.