核心内容均来自《Vuejs设计与实现》
引入
举一个简单的例子
const obj = { text: 'hello world' }
function effect() {
document.body.innerText = obj.text
}
以上代码,副作用函数中使用到了obj的值;如何使obj.text='其他'
设置时重新触发副作用函数?
实现响应式数据
前面的例子实际上就是在obj的值发生变化时,所依赖到的这个值的副作用函数,也就是上文的effect函数会依次更新。
要实现以上的需求就有两个问题:
问题1: 如何确定目标数据所依赖的副作用函数;
问题2: 如何在目标值修改的时候执行副作用函数;
const bucket = new Set()
const data = { text: 'hello world' }
const obj = new Proxy(data, {
get(target, key) {
bucket.add(effect)
return target[key]
},
set(target, key, newVal) {
target[key] = newVal
bucket.forEach(fn => fn())
return true
}
})
实际上就是在读取obj值的时候,在get中收集副作用函数,在设置obj的值时再去遍历容器中的副作用函数。
基于以上实现,引出以下使用方式:
function effect() {
document.body.innerText = obj.text
}
effect()
obj.text = 'hello vue3'
完整的响应式方案
针对以上实现,可以发现一些问题,例如:
-
以上的是直接通过获取副作用函数的名称去收集的副作用集合,不够灵活
-
基于以上实现,如果响应式对象新增一个属性,例如
obj.aa=123
, 此时会如何执行;由上图得知依然会执行effect函数,而此时的effect函数,并没有与data.aa的值相关联,这就是触发了不必要的执行。
问题1:
1.定义一个全局变量存储当前的副作用函数activeEffect
2.在公共的effect函数中将传入的副作用函数赋值给activeEffect并且执行当前函数
3.在之前定义的proxy内部使用全局变量activeEffect
新增activeEffect全局变量
let activeEffect
function effect(fn) {
activeEffect = fn
fn()
}
proxy内部处理
const obj = new Proxy(data, {
get(target, key) {
if (activeEffect) {
bucket.add(activeEffect)
}
return target[key]
},
set(target, key, newVal) {
target[key] = newVal
bucket.forEach(fn => fn())
return true
}
})
问题2:
引发该问题的根本原因是,响应式数据中被操作的目标字段obj.aa没有和副作用函数建立关系,如果每个被操作字段和对应的副作用函数都有明确的关系,那就可以确保准确执行函数。因此需要优化以上存储effect的数据结构。
现状:直接将effect函数放入set集合中
function effect() {
document.body.innerText = obj.text
}
思路:
- 确定副作用函数与哪些元素有关系,分别是代理对象obj,代理对象的字段名obj.text,以及副作用函数本身。
- 确定几个元素之间的关系,如下所示:
/* 图2 不同的副作用函数读取相同的key */
01 effect(function effectFn1() {
02 obj.text
03 })
04 effect(function effectFn2() {
05 obj.text
06 })
/* 图3 不同的key被相同的副作用函数读取 */
01 effect(function effectFn() {
02 obj.text1
03 obj.text2
04 })
/* 图4 不同的代理对象被不同的副作用函数读取 */
实际上是典型的树形结构,obj目标对象与他的key建立关系,而单独的key再与和他有关系的所有副作用函数建立联系。
由以上关系可以确定以下数据结构:
代码如下:
const bucket = new WeakMap()
const obj = new Proxy(data, {
get(target, key) {
track(target, key)
return target[key]
},
set(target, key, newVal) {
target[key] = newVal
trigger(target, key)
}
})
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) {
const depsMap = bucket.get(target)
if (!depsMap) return
const effects = depsMap.get(key)
effects && effects.forEach(fn => fn())
}
处理遗留的副作用函数
基于以上实现引入一个场景:
effect(() => {
document.body.innerText = obj.ok ? obj.text : 'not'
})
obj.ok=false
obj.text='123'
现状:当obj.ok初始为true,此时obj.text会执行,此时操作的具体key与effect函数的关系和上面的图3类似,effect注册的副作用函数在track操作时均会出现在obj.ok和obj.text的依赖列表中去;
影响:使obj.ok=false,触发set以后,副作用函数会执行一次,但是当前并不读取obj.text的值。然而此时obj.text的依赖列表中依然存在副作用函数。其实当前的副作用函数已经与obj.text没有关系了,理想情况下,执行obj.text='其他值',不触发以上的副作用函数,然而目前的现状是obj.text的set触发后,依然会使副作用函数执行。
以上就产生了一个遗留的副作用函数。
**处理方式:**在执行副作用函数之前,将当前所有依赖与其所依赖的集合,取消关联。在副作用函数执行后,又会重新建立(track操作)新的联系
实现思路:
- 重新定义effect函数内部的结构,定义一个存储所有与其相关的依赖列表的集合;
- 在每次执行effect内部的副作用函数之前,通过上面的集合删除当前副作用函数;
- 在每次读取数据时「track操作」, 将当前的key对应的依赖集合添加到当前的副作用函数中;
可以模拟一下上面例子:
代码实现如下:
let activeEffect
function effect(fn) {
const effectFn = () => {
// 调用 cleanup 函数完成清除工作
cleanup(effectFn) // 新增
activeEffect = effectFn
fn()
}
effectFn.deps = []
effectFn()
}
function cleanup(effectFn) {
// 遍历 effectFn.deps 数组
for (let i = 0; i < effectFn.deps.length; i++) {
// deps 是依赖集合
const deps = effectFn.deps[i]
// 将 effectFn 从依赖集合中移除
deps.delete(effectFn)
}
// 最后需要重置 effectFn.deps 数组
effectFn.deps.length = 0
}
/*track*/
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)
activeEffect.deps.push(deps);
}
如何适配嵌套的Effect的情况
基于以上实现引入一个场景:
let temp1, temp2
// effectFn1 嵌套了 effectFn2
effect(function effectFn1() {
console.log('effectFn1 执行')
effect(function effectFn2() {
console.log('effectFn2 执行')
temp2 = obj.bar
})
temp1 = obj.foo
})
按照正常情况下,以上代码应该建立的关系应如下所示,并且在修改obj.foo的值时,应该触发effectFn1与effectFn2的执行;当修改obj.bar时,触发effectFn2的执行。
而事实上,当触发obj.foo的时候,只有effectFn2执行。(理想状态时1,2)
原因:归咎于effect函数的实现,存储副作用函数是通过一个全局的变量;当发生嵌套关系时,内层的activeEffect会覆盖外层的activeEffect,响应式数据依赖收集的内容无论如何都会是最内层的(内层effect函数在obj.foo读取之前)。
let activeEffect
function effect(fn) {
const effectFn = () => {
// 调用 cleanup 函数完成清除工作
cleanup(effectFn) // 新增
activeEffect = effectFn
fn()
}
effectFn.deps = []
effectFn()
**解决:**为了解决这个问题,我们需要引入一个栈 effectStack,在副作用函数执行时,将当前副作用函数压入栈中,待副作用函数执行完毕后将其从栈中弹出,并始终让 activeEffect 指向栈顶的副作用函数。这样就能做到一个响应式数据只会收集直接读取其值的副作用函数,而不会出现互相影响的情况,如以下代码所示:
let activeEffect
const effectStack = []
function effect(fn) {
const effectFn = () => {
cleanup(effectFn)
activeEffect = effectFn
effectStack.push(effectFn)
fn()
effectStack.pop()
activeEffect = effectStack[effectStack.length - 1]
}
effectFn.deps = []
effectFn()
}
如下如所示:
调度执行
思考一个问题,在执行trigger函数时,如何去控制副作用函数的执行顺序以及执行次数,例如以下代码:
effect(() => {
console.log(obj.foo)
})
obj.foo++
console.log('结束了')
正常情况下执行的结果为:
如果想改变结果的执行顺序,将顺序改为「1,结束了,2」,即改变trigger函数的执行顺序。
方案:
实际上该功能深层含义其实是决定effect函数的执行状态,首先需要为effect函数提供一个配置的入口,用来让使用方去自己决定effect函数的执行机制。
- effect函数新增配置参数的入口
- effect函数内部将配置参数挂载在effect函数本身上
- trigger函数中执行effect函数时,判断配置对象中是否包含调度器,若有则执行调度器,没有直接执行函数即可
代码实现:
/* effect函数新增options*/
function effect(fn, options = {}) {
const effectFn = () => {
cleanup(effectFn)
activeEffect = effectFn
effectStack.push(effectFn)
fn()
effectStack.pop()
activeEffect = effectStack[effectStack.length - 1]
}
effectFn.options = options //***
effectFn.deps = []
effectFn()
}0
/* trigger函数分支执行*/
function trigger(target, key) {
const depsMap = bucket.get(target)
if (!depsMap) return
const effects = depsMap.get(key)
const effectsToRun = new Set()
effects && effects.forEach(effectFn => {
if (effectFn !== activeEffect) {
effectsToRun.add(effectFn)
}
})
effectsToRun.forEach(effectFn => {
if (effectFn.options.scheduler) {
effectFn.options.scheduler(effectFn)
} else {
effectFn()
}
})
}
执行顺序
基于以上内容,如果要实现开头的内容,就可以如下所示:
effect(
() => {
console.log(obj.foo)
},
{
scheduler(fn) {
setTimeout(fn)
}
}
)
obj.foo = '12'
console.log('结束了')
1,jieshul,12
以上将trigger触发的effect函数的执行加入任务队列中,因此优先执行同步代码,就可以实现开头的效果
执行次数
引入一个场景:
effect(() => {
console.log(obj.foo)
})
obj.foo++
obj.foo++
obj.foo++
输出://1,2,3,4
以上例子正常执行,但是2,3只是一个过渡的值,我们的目标时只想拿到最后一次执行的值,因此期望打印的只是1,4
,基于以上的调度器,可以得出以下思路:
- 利用调度器的特性,在每次执行时将其记录
- 将执行函数的操作放入微任务队列中,并在开始执行前设置标志
- 异步的代码执行完毕之后,初始标志
可以使用set集合(去重)存储effect函数,保证相同的effect只执行一次,代码实现如下:
// 定义一个任务队列
const jobQueue = new Set()
// 使用 Promise.resolve() 创建一个 promise 实例,我们用它将一个任务添加到微任务队列
const p = Promise.resolve()
// 一个标志代表是否正在刷新队列
let isFlushing = false
function flushJob() {
// 如果队列正在刷新,则什么都不做
if (isFlushing) return
// 设置为 true,代表正在刷新
isFlushing = true
// 在微任务队列中刷新 jobQueue 队列
p.then(() => {
jobQueue.forEach(job => job())
}).finally(() => {
// 结束后重置 isFlushing
isFlushing = false
})
}
//使用
effect(() => {
console.log(obj.foo)
}, {
scheduler(fn) {
// 每次调度时,将副作用函数添加到 jobQueue 队列中
jobQueue.add(fn)
// 调用 flushJob 刷新队列
flushJob()
}
})
obj.foo++
obj.foo++
obj.foo++
obj.foo++
如下图执行流程:
案例:计算属性的实现
基于以上,如果需要实现一个计算属性,需要有三个问题需要解决:
- 如何使effect函数懒执行
- 传递给effect的函数看作一个getter,并且返回计算值
- 如何支持缓存
懒执行&得到返回值
以上的effect函数都是立即执行的,要实现懒执行实际上十分容易,将执行结果缓存并返回即可;
实现方法:
- 通过option的配置对象配置lazy属性
- 根据lazy属性判断是立即执行的函数还是懒执行
function effect(fn, options = {}) {
const effectFn = () => {
cleanup(effectFn)
activeEffect = effectFn
effectStack.push(effectFn)
// 将 fn 的执行结果存储到 res 中
const res = fn()
effectStack.pop()
activeEffect = effectStack[effectStack.length - 1]
// 将 res 作为 effectFn 的返回值
return res
}
effectFn.options = options
effectFn.deps = []
// 只有非 lazy 的时候,才执行
if (!options.lazy) {
effectFn()
}
return effectFn
}
实现computed
定义一个 computed 函数,接收一个 getter 函数作为参数,我们把 getter 函数作为副作用函数,用它创建一个 lazy的 effect。computed 函数的执行会返回一个对象,该对象的value 属性是一个访问器属性,只有当读取 value 的值时,才会执行effectFn 并将其结果作为返回值返回。
function computed(getter) {
// 把 getter 作为副作用函数,创建一个 lazy 的 effect
const effectFn = effect(getter, {
lazy: true
})
const obj = {
// 当读取 value 时才执行 effectFn
get value() {
return effectFn()
}
}
return obj
}
实现缓存
目前实现的computed函数并不能支持缓存,例如以下代码,每一次读取都会触发effectFn函数的执行。
const sumRes = computed(() => obj.foo + obj.bar)
console.log(sumRes.value) // 3
console.log(sumRes.value) // 3
console.log(sumRes.value) // 3
实现方式:
新增一个value与dirty变量,分别用来缓存effectFn的值和重新计算的标志,利用调度器在trigger中执行的特性,在调度器执行时设置重新计算的标志。
当effectFn所依赖的响应式数据更新以后,会触发trigger,说明需要重新计算。
function computed(getter) {
let value
let dirty = true
const effectFn = effect(getter, {
lazy: true,
// 添加调度器,在调度器中将 dirty 重置为 true
scheduler() {
dirty = true
}
})
const obj = {
get value() {
if (dirty) {
value = effectFn()
dirty = false
}
return value
}
}
return obj
}