一、副作用函数
指的是会产生副作用的函数,例如
01 function effect() {
02 document.body.innerText = 'hello vue3'
03 }
当 effect 函数执行时,它会设置 body 的文本内容,但除了 effect 函数之外的任何函数都可以读取或设置 body 的文本内容。也 就是说,effect 函数的执行会直接或间接影响其他函数的执行,这时 我们说 effect 函数产生了副作用。副作用很容易产生,例如一个函数修改了全局变量,这其实也是一个副作用,如下面的代码所示:
01 // 全局变量
02 let val = 1
03
04 function effect() {
05 val = 2 // 修改全局变量,产生副作用
06 }
二、响应式原理基本实现
拦截数据, 在读取数据的时候,把副作用函数effect放到一个桶中,在设置数据的时候,从桶中取出副作用函数,执行。
三、完善的响应系统
3.1 基本实现
上面我们硬编码了副作用函数的名字(effect),导致一旦副作用函数的名字不叫 effect,那么这段代码就不能正确地工作了。而我们希望的是,哪怕副作用函数是一个匿名函数,也能够被正确地收集到 “桶”中。为了实现这一点,我们需要提供一个用来注册副作用函数的机制,如以下代码所示:
02 let activeEffect // 用一个全局变量存储被注册的副作用函数
03 // effect 用于注册副作用函数 的 函数
04 function effect(fn) {
06 activeEffect = fn // 当调用 effect 注册副作用函数时,将副作用函数 fn 赋值给 activeEffect
08 fn() // 执行副作用函数
09 }
01 effect(
03 () => { // 一个匿名的副作用函数
04 document.body.innerText = obj.text // 触发get
05 }
06 )
01 const obj = new Proxy(data, {
02 get(target, key) {
03 // 将 activeEffect 中存储的副作用函数收集到“桶”中
04 if (activeEffect) { // 新增
05 bucket.add(activeEffect) // 新增
06 } // 新增
07 return target[key]
08 },
09 set(target, key, newVal) {
10 target[key] = newVal
11 bucket.forEach(fn => fn())
12 return true
13 }
14 })
3.2 第二版:副作用函数 与 被操作的目标字段建立联系
如上面的代码所示,由于副作用函数已经存储到了 activeEffect 中,所以在 get 拦截函数内应该把 activeEffect 收集到“桶”中,这样响应系统就不依赖副作用函数的名字了。
但如果我们再对这个系统稍加测试,例如在响应式数据 obj 上设置一个不存在的属性时:
01 effect(
02 // 匿名副作用函数
03 () => {
04 console.log('effect run') // 会打印 2 次
05 document.body.innerText = obj.text
06 }
07 )
08
09 setTimeout(() => {
10 // 副作用函数中并没有读取 notExist 属性的值
11 obj.notExist = 'hello vue3'
12 }, 1000)
读取的时候,无论读取哪个属性,都会收集到bucket中,同样的设置的时候,无论设置哪个属性,都会执行副作用函数。我们没有在副作用函数与被操作的目标字段之间建立明确的联系。
为了解决这个问题,我们需 要重新设计“桶”的数据结构。
01 effect(function effectFn() {
02 document.body.innerText = obj.text
03 })
仔细观察,在这段代码里有三种角色,
- 1)被操作(读取)的代理对象 obj;
- 2)被操作(读取)的字段名 text;
- 3)使用 effect 函数注册的副作用函数 effectFn。
01 target
02 └── key
03 └── effectFn
// 两个副作用同时读取同一个对象的同一个属性
01 effect(function effectFn1() {
02 obj.text
03 })
04 effect(function effectFn2() {
05 obj.text
06 })
01 target
02 └── text
03 └── effectFn1
04 └── effectFn2
01 effect(function effectFn() {
02 obj.text1
03 obj.text2
04 })
.....
如何实现
const bucket = new WeakMap() // 存储副作用函数的桶
const obj = new Proxy(data, {
get(target, key) {
if (!activeEffect) return target[key] // 没有 activeEffect,直接 return
let depsMap = bucket.get(target) // Map 类型:key -->effects
if (!depsMap) {
bucket.set(target, (depsMap = new Map()))
}
let deps = depsMap.get(key) // Set 类型, key 相关联的副作用函数:effects
if (!deps) {
depsMap.set(key, (deps = new Set())) // deps 不存在,新建一个 Set
}
deps.add(activeEffect) // 最后将当前激活的副作用函数添加到“桶”里
return target[key] // 返回属性值
},
set(target, key, newVal) {
target[key] = newVal // 设置属性值
const depsMap = bucket.get(target) // Map类型,它是 key --> effects
if (!depsMap) return
const effects = depsMap.get(key) // 根据 key 取得所有副作用函数 effects
effects && effects.forEach(fn => fn())
}
})
附: bucket 为什么用WeakMap呢? 简单来说,weekMap是弱引用,如果target没有被引用的话,说明用户侧不在需要target,垃圾回收器会回收,那么这种情况下用Map的话,target就一直不能被回收。导致内存溢出。
将这部分逻辑单独封装到一 个track函数中, 把触发副作用函数重新执行的逻辑封装到 trigger函数中:
const bucket = new WeakMap() // 存储副作用函数的桶
const obj = new Proxy(data, {
get(target, key) {
track(target, key) // 将副作用函数 activeEffect 添加到存储副作用函数的桶中
return target[key] // 返回属性值
},
set(target, key, newVal) {
target[key] = newVal // 设置属性值
trigger(target, key) // 把副作用函数从桶里取出并执行
}
})
// 在 get 拦截函数内调用 track 函数追踪变化
function track(target, key) {
if (!activeEffect) return // 没有 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) { // 在 set 拦截函数内调用 trigger 函数触发变化
const depsMap = bucket.get(target)
if (!depsMap) return
const effects = depsMap.get(key)
effects && effects.forEach(fn => fn())
}
四、 分支切换与 cleanup
分支切换的定义:
const data = { ok: true, text: 'hello world' }
const bucket = new WeakMap() // 存储副作用函数的桶
effect(function effectFn() {
console.log('执行中');
document.body.innerText = obj.ok ? obj.text : 'not' // 这里的三元表达式就是分支
})
图一
场景:data.ok 开始是true, 后面改成false。收集的结果始终是图一。所以data.ok后面改成false之后,在修改data.text,依然会触发副作用函数(包括遗留的)执行,如图一,每次都会触发 console.log('执行中');
在修改data.ok为false,理想情况结果是这样的,如下图二。造成上面的情况的原因是,data.ok 改为false的时候,没有重新收集副作用函数,还保留这上次的收集的副作用函数。导致页面上没有data.text了,修改data.text还能触发副作用函数执行。
图二
- 解决办法:当副作用函数执行的时候,重新收集依赖集合,要想把一个副作用函数从与他关联的依赖集合中删除,就必须知道哪些依赖集合中包含他。重新设计副作用函数。
let activeEffect;
function effect(fn) {
const effectFn = () => {
cleanup(effectFn) // 调用 cleanup 函数完成清除工作
// 当 effectFn 执行时,将其设置为当前激活的副作用函数
activeEffect = effectFn
fn()
}
effectFn.deps = [] // 存储所有与当前副作用函数相关联的依赖集合
effectFn() // 执行副作用函数
}
effectFn.deps 数组中的依赖集合是如何收集的呢?
function track(target, key) {
if (!activeEffect) return // 没有 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)
activeEffect.deps.push(deps) // 新增 将其添加到 activeEffect.deps 数组中
}
有了 effectFn.deps, 保存所有与当前副作用相关联的依赖集合,接下来就能删除了。
function cleanup(effectFn) {
for (let i = 0; i < effectFn.deps.length; i++) { // 遍历 effectFn.deps 数组
const deps = effectFn.deps[i] // deps 是依赖集合
deps.delete(effectFn) // 将 effectFn 从依赖集合中移除
}
effectFn.deps.length = 0 // 最后需要重置 effectFn.deps 数组
}
执行上面的,会发现无限循环。
function trigger(target, key) { // 在 set 拦截函数内调用 trigger 函数触发变化
const depsMap = bucket.get(target)
if (!depsMap) return
const effects = depsMap.get(key)
effects && effects.forEach(fn => fn()) // 问题出在这句代码
}
在trigger函数内部,我们遍历 effects 集合,它是一个 Set 集合,里面存储着副作用函数。当副作用函数执行时,会调用 cleanup 进行清除,实际上就是从 effects 集合中将当前执行的副作用函数剔除,但是副作用函数的执行会导致其重新被收集到集合 中,而此时对于 effects 集合的遍历仍在进行。这个行为可以用如下 简短的代码来表达:
const set = new Set([1])
set.forEach(item => {
set.delete(1)
set.add(1)
console.log('遍历中')
})
// 解决办法:
const set = new Set([1])
const newSet = new Set(set)
newSet.forEach(item => {
set.delete(1)
set.add(1)
console.log('遍历中')
})
function trigger(target, key) { // 在 set 拦截函数内调用 trigger 函数触发变化
const depsMap = bucket.get(target)
if (!depsMap) return
const effects = depsMap.get(key)
const effectsToRun = new Set(effects) // 新增
effectsToRun.forEach(effectFn => effectFn()) // 新增
// effects && effects.forEach(fn => fn()) // 问题出在这句代码
}
五、完整代码
const data = { ok: true, text: 'hello world' }
const bucket = new WeakMap() // 存储副作用函数的桶
let activeEffect;
const obj = new Proxy(data, {
get(target, key) {
track(target, key) // 将副作用函数 activeEffect 添加到存储副作用函数的桶中
return target[key] // 返回属性值
},
set(target, key, newVal) {
target[key] = newVal // 设置属性值
trigger(target, key) // 把副作用函数从桶里取出并执行
}
})
// 在 get 拦截函数内调用 track 函数追踪变化
function track(target, key) {
if (!activeEffect) return // 没有 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)
activeEffect.deps.push(deps) // 新增 // deps 就是一个与当前副作用函数存在联系的依赖集合 // 将其添加到 activeEffect.deps 数组中
}
function trigger(target, key) { // 在 set 拦截函数内调用 trigger 函数触发变化
const depsMap = bucket.get(target)
if (!depsMap) return
const effects = depsMap.get(key)
const effectsToRun = new Set(effects) // 新增
effectsToRun.forEach(effectFn => effectFn()) // 新增
// effects && effects.forEach(fn => fn()) // 问题出在这句代码
}
// function effect(fn) {
// activeEffect = fn;
// fn();
// }
function effect(fn) {
const effectFn = () => {
cleanup(effectFn) // 调用 cleanup 函数完成清除工作
// 当 effectFn 执行时,将其设置为当前激活的副作用函数
activeEffect = effectFn
fn()
}
effectFn.deps = [] // activeEffect.deps 用来存储所有与该副作用函数相关联的依赖集合
effectFn() // 执行副作用函数
}
function cleanup(effectFn) {
for (let i = 0; i < effectFn.deps.length; i++) { // 遍历 effectFn.deps 数组
const deps = effectFn.deps[i] // deps 是依赖集合
deps.delete(effectFn) // 将 effectFn 从依赖集合中移除
}
effectFn.deps.length = 0 // 最后需要重置 effectFn.deps 数组
}
effect(function effectFn() {
console.log('执行中');
document.body.innerText = obj.ok ? obj.text : 'not'
})
// const set = new Set([1])
// set.forEach(item => {
// set.delete(1)
// set.add(1)
// console.log('遍历中')
// })
// const set = new Set([1])
// const newSet = new Set(set)
// newSet.forEach(item => {
// set.delete(1)
// set.add(1)
// console.log('遍历中')
// }