4.1 响应式数据与副作用函数
副作用函数指的是会产生副作用的函数,如下面的代码所示
function effect() {
document.body.innerText = 'hello vue3'
}
当 effect 函数执行时,它会设置 body 的文本内容,但除了 effect 函数之外的任何函数都可以读取或设置 body 的文本内容。也就是说,effect 函数的执行会直接或间接影响其他函数的执行,这时,我们说 effect 函数产生了副作用。副作用很容易产生,例如一个函数修改了全局变量,这其实也是一个副作用,如下面代码所示:
// 全局变量
let val = 1
function effect() {
val = 2 // 修改全局变量,产生副作用
}
理解了什么是辅作用函数,再来说说什么是响应式数据。假设在一个副作用函数中读取了某个对象的属性:
const obj = { text: 'hello world' }
function effect() {
// effect 函数的执行会读取 obj.text
document.body.innerText = obj.text
}
这句代码修改了字段 obj.text 的值,我们希望当值发生变化后,副作用函数自动重新执行,如果能实现这个目标,那么对象 obj 就是响应式数据。但很明显,以上面的代码来看,还做不到这一点,因为 obj 是一个普通对象,当我们修改它的值时,除了值本身发生变化之外,不会有任何其他反应
4.2 响应式数据的基本实现
接着上文思考,如何才能让 obj 变成响应式数据呢?通过观察我们能发现两点线索:
- 当副作用函数 effect 执行时,会触发字段 obj.text 的读取操作
- 当修改 obj.text 的值时,会触发字段 obj.text 的设置操作
如果我们能拦截一个对象的读取和设置操作,事情就变得简单了,当读取字段 obj.text 时,我们可以把副作用函数 effect 储存到一个“桶”里
接着,当设置 obj.text 时,再把副作用函数 effect 从“桶”里取出来并执行即可。
现在问题的关键变成了我们如何才能拦截一个对象属性的读取和设置操作。在ES2015之前,只能通过 Object.defineProperty 函数实现。在ES2015+ 中,我们可以使用代理对象 Proxy 来实现,这也是Vue.js3所采用的方式。
接下来我们就根据如上思路,采用 Proxy 来实现:
// 存储副作用函数的桶
const bucket = new Set()
// 原始数据
const data = { text: 'hello world' }
// 对原始数据的代理
const obj = new Proxy(data, {
// 拦截读取操作
get(target, key) {
// 将副作用函数 effect 添加到存储副作用函数的桶中
bucket.add(effect)
// 返回属性值
return target[key]
},
// 拦截设置操作
set(target, key, newVal) {
// 设置属性值
target[key] = newVal
// 把副作用函数从桶中取出并执行
bucket.forEach(fn => fn())
// 返回 true 代表设置操作成功
return true
}
})
首先,我们创建了一个用于存储副作用函数的桶 bucket,它是 Set 类型。接着定义原始数据 data,obj 是原始数据的代理对象,我们分别设置了 get 和 set 拦截函数,用于拦截读取和设置操作。当读取属性时将副作用函数 effect 添加到桶里,即 bucket.add(effect),然后返回属性值;当设置属性值时先更新原始数据,再将副作用函数从桶中取出并重新执行,这样我们就实现了响应式数据。可以使用下面的代码来测试一下
// 副作用函数
function effect() {
document.body.innerText = obj.text
}
// 执行副作用函数,触发读取
effect()
// 1秒后修改响应式数据
setTimeout(() => {
obj.text = 'hello vue3'
})
在浏览器中运行上面这段代码,会得到期望的结果
但是目前的实现还存在很多缺陷,例如我们直接通过名字(effect)来获取副作用函数,这种硬编码的方式很不灵活。副作用函数的名字可以任意取,我们完全可以把副作用函数命名为 myEffect,甚至是一个匿名函数,因此我们要想办法去掉这种硬编码的机制。
4.3 设计一个完善的响应系统
从上一节的例子我们不难看出,一个响应系统的工作流程如下:
- 当读取操作发生时,将副作用函数收集到“桶”中
- 当设置操作发生时,从“桶”中取出副作用函数并执行
看上去简单,但需要处理的细节不少,上一节的实现中我,我们硬编码了副作用函数的名字 effect,导致一旦副作用函数的名字不叫 effect,那么这段代码就不能正确工作了。而我们希望的是,哪怕是一个匿名函数,也能被正确的收集到“桶”中,为了实现这一点,我们需要提供一个用来注册副作用函数的机制,如一下代码所示:
// 用一个全局变量存储被注册的副作用函数
let activeEffect
// effect 函数用于注册副作用函数
function effect(fn) {
// 当调用 effect 注册副作用函数时,将副作用函数 fn 赋值给 activeEffect
activeEffect = fn
// 执行副作用函数
fn()
}
可以看到,我们使用一个匿名的副作用函数作为 effect 函数的参数。当 effect 函数执行时,首先会把匿名的副作用函数 fn 赋值给 activeEffect。接着当执行被注册的匿名副作用函数fn,这将会触发响应式数据 obj.text 的读取操作,进而触发代理对象 Proxy 的 get拦截函数:
const obj = new proxy(data, {
get(target, key) {
// 将 activeEffect 中存储的副作用函数收集到“桶”中
if(activeEffect) {
bucket.add(activeEffect) // 新增
}
return target[key]
},
set(target, key, newVal) {
target[key] = newVal
bucket.forEach(fn => fn())
return true
}
})
如上面代码所示,由于副作用函数已经存储到 activeEffect 中,所以在 get 拦截函数内应该把 activeEffect 收集到 “桶” 中,这样响应系统就不依赖副作用函数的名字了。
但如果我们在对这个系统稍加测试,例如在响应式数据 obj 上设置一个不存在的属性时;
effect(
// 匿名副作用函数
() => {
console.log('effect run') // 会打印 2 次
document.body.innerText = obj.text
}
)
setTimeout(() => {
// 副作用函数中并没有读取 notExist 属性的值
obj.notExist = 'hello vue3'
})
可以看到,匿名副作用函数内部读取了字段 obj.text 的值,于是匿名作用函数与字段 obj.text 之间会建立响应联系。接着,我们开启了一个定时器,一秒钟后为对象 obj 添加新的 notExist 属性,我么你知道,在匿名副作用函数内并没有读取 obj.notExist 的值,所以理论上,字段 obj.notExist 并没有与副作用建立响应联系,因此,定时器内语句的执行不应该触发匿名副作用函数重新执行。但如果执行上面这段代码就会发现,定时器到时候,匿名副作用函数却重新执行了,这是不正确的。为了解决这个问题,我们需要重新设计“桶”的数据结构。
在上一节的例子中,我们使用一个 Set 数据结构作为存储副作用函数的“桶”。导致该问题的根本原因是,我们没有在副作用函数与被操作的目标字段之间建立明确的联系。例如当读取属性时,无论是哪一个属性,其实都一样,都会把副作用函数收集到“桶”里;当设置属性时,无论设置的是哪一个属性,也都会把“桶”里的副作用函数取出并执行。副作用函数与被操作的字段之间没有明确的联系。解决方法很简单,只需要在副作用函数与被操作的字段之间建立联系即可,这就需要我们重新设计“桶”的数据结构,而不能简单的使用一个 Set 类型的数据结构为“桶”了。
那应该设计怎样的数据结构呢?在回答这个问题之前,我们需要先仔细观察下面的代码
effect(function effectFn() {
document.body.innerText = obj.text
})
在这段代码中存在三个角色:
- 被操作(读取)的代理对象 obj
- 被操作(读取)的字段名 text
- 使用 effect 函数注册的副作用函数 effectFn
如果用 target 来表示一个代理对象所代理的原始对象,用 key 来表示被操作的字段名,用 effectFn 来表示被注册的副作用函数,那么可以为这三个角色建立如下关系
target
|____key
|____effectFn
这时一种树型结构,下面举几个例子来对其进行补充说明
如果有两个副作用函数同时读取同一个对象的属性值:
effect(function effectFn1(){
obj.text
})
effect(function effectFn2(){
obj.text
})
那么关系如下
target
|____text
|____effectFn1
|____effectFn2
如果有一个副作用函数中读取了同一个对象的不同属性:
effect(function effectFn(){
obj.text1
obj.text2
})
那么关系如下:
target
|____text1
|____effectFn1
|____text2
|____effectFn2
如果在不同的副作用函数中读取了不同对象的不同属性:
effect(function effectFn1(){
obj.text1
})
effect(function effectFn2(){
obj.text2
})
那么关系如下:
target1
|____text1
|____effectFn1
target2
|____text2
|____effectFn2
总之,这其实就是一个树型结构,这个联系建立起来之后,就可以解决前文提到的问题。那上面的例子来说,如果我们设置了 obj.text2 的值,就就会导致 effectFn2 函数重新执行,并不会导致 effectFn1 函数重新执行。
接下来我们尝试使用代码来实现这个新的“桶”,首先需要使用 WeakMap 代替 Set 作为桶的数据结构:
// 存储副作用函数的桶
const bucket = new WeakMap()
然后修改 get/set 拦截器代码:
const obj = new Proxy(data, {
// 拦截读取操作
get(target, key) {
// 没有 activeEffect 直接 return
if(!activeEffect) return
// 根据 target 从“桶”中取得 depsMap,它也是一个 Map 类型:key --> effects
let depsMap = bucket.get(target)
// 如果不存在 depsMap,那么新建一个 Map 并与 target 关联
if(!depsMap) {
bucket.set(target, (depsMap = new Map()))
}
// 再根据 key 从 depsMap 中取得 deps,它是一个 set 类型
// 里面存储着所有与当前 key 相关连的副作用函数:effects
let deps = depsMap.get(key)
// 如果 deps 不存在,同样新建一个 Set 并与 key 关联
if(!deps) {
depsMap.set(key, (deps = new Set()))
}
// 最后将当前激活的副作用函数添加到“桶”里
deps.add(activeEffect)
// 返回属性值
return target[key]
},
// 拦截设置操作
set(target, key, newVal) {
// 设置属性值
target[key] = newVal
// 根据 target 从桶中取得 depsMap,它是 key --> effects
const depsMap = bucket.get(target)
if(!depsMap) return
// 根据 key 取得所有副作用函数 effects
const effects = depsMap.get(key)
// 执行副作用函数
effects && effects.forEach(fn => fn())
}
})
从这段代码可以看出构建 数据结构的方式、我们分别使用了 WeakMap、Map和 Set:
- WeakMap由 target --> Map 构成;
- Map 由 key --> Set 构成。
其中 WeakMap 的键是原始对象 target,WeakMap的值时一个 Map 实例,而 Map 的键是原始对象 target 的 key,Map 的值是一个由副作用函数组成的 Set。它们关系如图
最后对上文中的代码做一些封装处理,当读取属性值时,我们直接在 get 拦截函数里编写把副作用函数收集到“桶”里的这部分逻辑,但更好的做法是将这部分逻辑单独封装到一个 track 函数中,函数的名字叫 track 是为了表达追踪的含义。同样把触发副作用函数重新执行的逻辑封装到 trigger 中
// 存储副作用函数的桶
const bucket = new WeakMap()
// 原始数据
const data = { text: 'hello world' }
// 对原始数据的代理
const obj = new Proxy(data, {
// 拦截读取操作
get(target, key) {
// 将副作用函数 activeEffect 添加到存储副作用函数的桶中
track(target, key)
// 返回属性值
return target[key]
},
// 拦截设置操作
set(target, key, newValue) {
// 设置属性值
target[key] = newValue
// 把副作用函数从“桶”中取出并执行
trigger(target, key)
}
})
// 用一个全局变量存储被注册的副作用函数
let activeEffect
// effect 函数用于注册副作用函数
function effect(fn) {
// 当调用 effect 注册副作用函数时,将副作用函数 fn 赋值给 activeEffect
activeEffect = fn
fn()
}
function track(target, key) {
// 没有 activeEffect,直接 return
if (!activeEffect) return;
// 根据 target 从 “桶” 中取得 depsMap,它也是一个 Map 类型:key --> effects
let depsMap = bucket.get(target);
// 如果不存在 depsMap,那么新建一个 Map 并与 target 关联
if (!depsMap) {
bucket.set(target, (depsMap = new Map()));
}
// 再根据 key 从 depsMap 中取得 deps,它是一个 set 类型,
// 里面存储着所有与当前 key 相关联的副作用函数:effects
let deps = depsMap.get(key);
if (!deps) {
depsMap.set(key, (deps = new Set()));
}
// 最后将激活的副作用函数添加到 “桶”里
deps.add(activeEffect);
}
function trigger(target, key) {
// 根据 target 从桶中取得 depsMap,它是 key --> effects
const depsMap = bucket.get(target)
if (!depsMap) return
// 再根据 key 取得所有副作用函数 effects
const effects = depsMap.get(key)
// 执行副作用函数
effects && effects.forEach(fn => fn())
}