上一篇我们改善和解决了Vue3
的几点不足,本篇文章我们来看看computed/watch
的实现原理。
调度执行
在讲解computed/watch
实现原理之前,先了解调度执行。
可调度是响应式系统的一个特性,所谓可调度指的是当trigger
触发的副作用函数重新执行时我们有能力控制它的执行时机,次数以及方式。
执行时机
提供以下测试例子:
effect(() => {
console.log(data.version) // 3
})
console.log('start')
data.version++
console.log('end')
默认的打印顺序如下:
3
start
4
end
现在我们希望打印顺序如下:
3
start
end
4
我们很容易就想到只有当trigger
动作触发的副作用函数是异步任务才会有上述的结果,那么我们可以给effect
多传一个参数options
对象,使用它的属性scheduler
作为调度器去决定副作用函数执行的时机,其中,scheduler
是一个函数,接收副作用函数为参数,在trigger
动作触发执行副作用函数时,我们判断是否存在options.scheduler
,如果存在,则执行该调度器,否则直接执行副作用函数,上代码:
const effect = (fn, options) => {
const effectFn = () => {
// 将当前的副作用函数赋给activeEffect
activeEffect = effectFn;
activeEffect.options = options // 新增
effectStack.push(effectFn)
// 清除上一次所有对象属性对该依赖的收集
cleanUp();
// 执行副作用函数
fn();
effectStack.pop() // 当前依赖执行完后则弹出
activeEffect = effectStack[effectStack.length - 1] // 总是让activeEffect指向栈顶
};
effectFn();
};
trigger
函数如下:
const trigger = (target, key) => {
const deps = bucket.get(target)?.get?.(key);
const depsToRun = new Set() // 这一句是避免无限循环
deps && deps.forEach(fn => {
if (fn !== activeEffect) { // `trigger`触发执行的副作用函数和当前执行的函数如果是同一个,就不触发执行
depsToRun.add(fn)
}
})
depsToRun.forEach((fn) => {
if (fn?.options?.schedule) { // 新增
fn.options.schedule(fn)
} else {
fn() // 原先默认方式
}
});
};
那么我们的测试例子就可以修改成以下:
effect(() => {
console.log(data.version) // 3
},{
scheduler(fn) {
setTimeout(fn, 0)
}
})
console.log('start')
data.version++
console.log('end')
这样便可以决定副作用的执行时机了,得到期待的结果了。
执行次数
提供以下测试例子:
// 这里data.version默认为3
effect(() => {
console.log(data.version)
}
data.version++
data.version++
以上例子会打印3, 4, 5
,但是我们知道,4
在这里只是一个过渡状态,可以不必执行这一次的副作用函数,确切地说,无论data.version
修改了多少次,我们希望一个任务周期之内只执行一次(聪明的你是不是想到Vue
中也有类似的处理了),那么,如何设计呢?
- 将副作用函数放在
Set
队列里,利用Set
数据结构去重 - 开启一个异步任务,利用事件循环的执行机制:
- 当前代码执行过程中,如果遇到微任务(
Promise
),就把它放进微任务队列,如果遇到宏任务(setTimeout/setInterval
),就放到宏任务队列, - 执行完毕之后,查看微任务队列是否为空,不为空则拿出来一个执行,执行过程过程中也会遇到新的宏任务和微任务,同样按照上述方式处理,直至微任务队列为空
- 查看宏任务队列是否为空,不为空则取出来一个执行,重复1),2)
- 当前代码执行过程中,如果遇到微任务(
等当前任务的代码执行完毕后,开始执行该异步任务,而我们的副作用函数队列正是在该任务中执行的,于是
const callback = new Set() // 存放副作用函数
const p = Promise.resolve()
let isFlushing = false // 可以保证只创建了一个微任务:即意思是无论调用多少次 flushJob 函数,在一个周期内都只会执行一次
const flushCallback = () => {
if (isFlushing) return
isFlushing = true
p.then(() => { // 创建一个微任务:这一部分代码会在同步代码执行完后执行
callback.forEach(fn => fn())
}).finally(() => {
isFlushing = false
callback.clear()
})
}
测试如下:
effect(() => {
console.log('effect')
document.body.innerHTML = `${data.name}${data.version} `
}, {
scheduler(fn) {
callback.add(fn)
flushCallback() // 刷新队列
}
})
data.version++
data.version++
data.name = 'react'
分析上述代码:
isFlushing
最开始为false
- 第一次
data.version++
时,副作用函数添加到callback
,执行flushCallback
,将isFlushing
为true
,开启了一个微任务,但是由于是异步任务,这部分代码会在同步代码执行完毕后执行 - 第二次
data.version++
时,同样执行上述操作(添加副作用函数到callback
和执行flushCallback
),不同的是,由于callback
是Set
结构,具有去重能力,副作用函数实际上只被添加了一次,isFlushing
为true
,保证了一个任务周期内flushCallback
的异步代码只会被执行一次。 - 当所有的同步代码执行完毕后,开始执行
flushCallback
的异步代码,callback
里面的副作用函数执行完毕,最后重置isFlushing
为false
这样我们便可以决定副作用函数的执行次数了。代码查看
computed
有了上述铺垫,现在我们可以开始学习computed
原理了。
lazy
提供以下测试例子:
// 副作用函数会立即执行
effect(() => {
console.log(1)
})
如果希望副作用延迟执行可以怎么做呢?给effect
传入选项options.lazy
对副作用函数的执行进行控制,并返回包装好的副作用函数。
const effect = (fn, options) => {
const effectFn = () => {
// 将当前的副作用函数赋给activeEffect
activeEffect = effectFn;
activeEffect.options = options
effectStack.push(effectFn)
// 清除上一次所有对象属性对该依赖的收集
cleanUp();
// 执行副作用函数
fn();
effectStack.pop() // 当前依赖执行完后则弹出
activeEffect = effectStack[effectStack.length - 1] // 总是让activeEffect指向栈顶
};
if (!options.lazy) { // 新增
effectFn();
}
return effectFn
};
然后就可以这样使用
const effectFn = effect(() => {
console.log()
}, {
lazy: true
})
effectFn()
我们来看Vue3
中的computed用法
const computedObj = computed(getter)
// 读取
computedObj.value
那么,我们传给effect
函数的fn
可不可以也是一个getter
呢?答案显然可以的。并且让我们包装后的副作用函数执行返回getter
的值,那么effect
修改如下:
const effect = (fn, options) => {
const effectFn = () => {
// 将当前的副作用函数赋给activeEffect
activeEffect = effectFn;
activeEffect.options = options
effectStack.push(effectFn)
// 清除上一次所有对象属性对该依赖的收集
cleanUp();
// 执行副作用函数
const res = fn(); // 新增
effectStack.pop() // 当前依赖执行完后则弹出
activeEffect = effectStack[effectStack.length - 1] // 总是让activeEffect指向栈顶
return res // 新增
};
if (!options.lazy) {
effectFn();
}
return effectFn
};
测试如下:
const effectFn = effect(() => data.name + data.version, {
lazy: true
})
const value = effectFn()
console.log(value) // vue3
据上我们可以实现一个简单的computed
:
const computed = (getter) => {
const effectFn = effect(() => data.name + data.version,
{
lazy: true
}
)
const obj = {
get value() {
return effectFn()
}
}
return obj
}
使用:
const computedObj = computed(() => data.name + data.version, {
lazy: true
})
console.log(computedObj.value)
console.log(computedObj.value)
发现computed
虽然做到了懒加载,但是并没有做到缓存,每次调用computedObj.value
都会重新执行副作用函数,因此我们引入dirty
来标识是否已经计算过了
const computed = (getter) => {
const effectFn = effect(getter, {
lazy: true
})
let dirty = true
let value = effectFn()
return {
get value() {
if (dirty) {
value = effectFn()
dirty = false
}
return value
}
}
}
这个时候computed
有缓存的效果了,但是又发现了一个问题:修改data.name
或者data.version
,computed
还是旧值,原因很简单,读取了value
的值后,dirty
总是为false
,因此computed
实际上只计算了一次,所以我们在修改data.name
或者data.version
时,应该设置dirty
为true
,以重新计算,那么在哪里设置呢?聪明的你已经想到了,就是上文的sceduler
方法,上代码:
const computed = (getter) => {
let dirty = true
let value
const effectFn = effect(getter, {
lazy: true,
scheduler(fn) { // 新增
dirty = true
}
})
return {
get value() {
if (dirty) {
value = effectFn()
dirty = false
}
return value
}
}
}
上面代码还有一个缺陷,提供以下测试例子: 在另一个effect
里面使用computedObj
const computedObj = computed(() => {
return data.name + data.version
})
effect(function fn() {
console.log(computedObj.value)
})
按照期待,当computedObj
依赖的data.name
或者data.version
发生改变时,fn
会重新执行,然而事实并没有,其实这也可以看成嵌套的effect
,data.name
或者data.version
只收集了内层effect
,那么外层effect
只能靠computedObj
自己收集了,于是利用前面封装的track
和trigger
有:
const computed = (getter) => {
let dirty = true
let value
const effectFn = effect(getter, {
lazy: true,
scheduler(fn) {
if (dirty) return
dirty = true
// 触发依赖
trigger(obj, 'value') // 新增
}
})
const obj = {
get value() {
if (dirty) {
value = effectFn()
dirty = false
}
track(obj, 'value') // 新增
return value
}
}
return obj
}
watch
初步实现
首先来看watch
的用法:
watch(data, () => {})
当data
发生改变时,执行回调函数。首先我们想一下,这个回调函数是不是可以是传给effect
的副作用函数,也可以是scheduler
函数,然后这句话我们再来解读这句话。
- 要想
data
的任意key
对应的值发生改变时触发回调函数,那么就必须再effect
里面遍历读取了整个对象 - 既然
effect
的副作用函数已经用于遍历读取data
了,那么回调函数只能由scheduler
充当了。 由此初步实现一个watch
const watch = (source, cb) => {
effect(
// 调用 traverse 递归地读取
() => traverse(source),
{
scheduler() {
// 当数据变化时,调用回调函数 cb
cb();
},
}
);
};
const traverse = (value, seen = new Set()) => {
// 如果要读取的数据是原始值,或者已经被读取过了,那么什么都不做
if (typeof value !== "object" || value === null || seen.has(value)) return;
// 将数据添加到 seen 中,代表遍历地读取过了,避免循环引用引起的死循环
seen.add(value);
// 暂时不考虑数组等其他结构
// 假设 value 就是一个对象,使用 for...in 读取对象的每一个值,并递归地调用 traverse 进行处理
for (const k in value) {
traverse(value[k], seen);
}
return value;
};
watch
除了可以观测响应式对象,也可以观测getter
, 如
watch(()=> data.name, () => {
console.log('数据已经发生改变')
})
所以我们先对watch
接收的第一个函数进行判断
const watch = (source, cb) => {
let getter = typeof source === 'function' ? source : () => traverse(source)
effect(
getter,
{
scheduler() {
// 当数据变化时,调用回调函数 cb
cb();
},
}
);
};
新值和旧值
watch
的一个特性是可以在回调函数里拿到新值和旧值,那么为了实现这个功能,我们利用上面的lazy
选项,实现如下:
const watch = (source, cb) => {
let getter = typeof source === 'function' ? source : () => traverse(source)
let oldValue, newValue
// 为了拿到值,使用懒执行的方式
const effectFn = effect(
getter,
{
lazy: true,
scheduler() {
// 当数据变化时,调用回调函数 cb
newValue = effectFn()
cb(newValue, oldValue);
oldValue = newValue // 回调函数执行完毕后记得把newValue赋给oldValue
},
}
);
// 因为本来第一次会自动执行effectFn,但是由于使用了懒加载,所以这里需要手动执行,而第一次的结果会被当成旧值
oldValue = effectFn()
};
上面会有一个问题,当第一个参数是对象时,新旧值是一样的,原因可以使用以下例子说明:
因此我们要做出改善:
const watch = (source, cb) => {
let getter = typeof source === 'function' ? source : () => traverse(source)
let oldValue, newValue
// 为了拿到值,使用懒执行的方式
const effectFn = effect(
() => getter(),
{
lazy: true,
scheduler() {
// 当数据变化时,调用回调函数 cb
newValue = effectFn()
cb(newValue, oldValue);
oldValue = {...newValue} // 回调函数执行完毕后记得把newValue赋给oldValue
},
}
);
// 因为本来第一次会自动执行effectFn,但是由于使用了懒加载,所以这里需要手动执行,而第一次的结果会被当成旧值
oldValue = {...effectFn()}
console.log(oldValue)
};
立即执行的watch和回调执行的时机
立即执行
上述wacth
初始化时并不会执行回调函数,那么我们希望它立即执行回调函数可以怎么做呢?其实立即执行回调函数和后面数据发生执行的回调函数本质上并没有什么不同,因此我们可以把 scheduler
调度函数封装为一个通用函数,分别在初始化和变更时执行它,并通过传给watch
第3个参数进行控制
const watch = (source, cb, options) => {
let getter = typeof source === 'function' ? source : () => traverse(source)
let oldValue, newValue
const job = () => {
// 当数据变化时,调用回调函数 cb
newValue = effectFn()
cb(newValue, oldValue);
oldValue = {...newValue} // 回调函数执行完毕后记得把newValue赋给oldValue
}
// 为了拿到值,使用懒执行的方式
const effectFn = effect(
() => getter(),
{
lazy: true,
scheduler: job
}
);
// 因为本来第一次会自动执行effectFn,但是由于使用了懒加载,所以这里需要手动执行,而第一次的结果会被当成旧值
oldValue = {...effectFn()}
if (options.immidiately) {
job()
}
};
执行时机
这实际上跟我们前面讲解调度执行一致,我们通过参数flush
在scheduler
里面去控制job
的执行,当 flush
的值为 post
时,代表调度函数需要将副作用函数放到一个微任务队列中,并等待 DOM 更新结束后再执行,那么:
// 为了拿到值,使用懒执行的方式
const effectFn = effect(
() => getter(),
{
lazy: true,
scheduler: () => {
if (options.flush === 'post') { // flush的值还可以是'pre'等,根据不同的值去控制job的执行时机
const p = Promise.resolve()
p.then(job)
} else {
job()
}
}
}
);
过期的副作用
假设我们提供以下测试例子:
let finalRes
watch(data, async () => {
finalRes = await fetch('/xxx')
})
分析上面测试例子:
- 假设
data
第一次发生改变时,发送请求A
- 在请求
A
还没有返回结果之前data
再次发生改变,于是发送第2次请求B
- 请求
A
在400ms
后返回结果,而请求B
在200ms
后返回结果 - 那么最后
finalRes
的值是A
返回的结果,这明显与我们的期望不符
所以我们要解决的问题便是如何将上一次的副作用结果判断为无效,实际上我自己想了很久也没有想出来,于是我们直接上代码:
const watch = (source, cb, options = {}) => {
let getter = typeof source === 'function' ? source : () => traverse(source)
let oldValue, newValue
let cleanUp
const onValidate = (fn) => { // 新增
cleanUp = fn
}
const job = () => {
if (cleanUp) { // 新增
cleanUp()
}
// 当数据变化时,调用回调函数 cb
newValue = effectFn()
// 将onValidate作为第3个参数传给回调函数
cb(newValue, oldValue, onValidate);
oldValue = {...newValue} // 回调函数执行完毕后记得把newValue赋给oldValue
}
// 为了拿到值,使用懒执行的方式
const effectFn = effect(
() => getter(),
{
lazy: true,
scheduler: () => {
if (options.flush === 'post') { // flush的值还可以是'pre'等,根据不同的值去控制job的执行时机
const p = Promise.resolve()
p.then(job)
} else {
job()
}
}
}
);
if (options.immediate) {
job()
} else {
// 因为本来第一次会自动执行effectFn,但是由于使用了懒加载,所以这里需要手动执行,而第一次的结果会被当成旧值
oldValue = {...effectFn()}
}
};
// 模拟请求
let finalRes
let count = 2
watch(data, async (newVal, oldVal, onValidate) => {
let expired = false
let date = new Date().valueOf()
onValidate(() => {
console.log(date, '-------') // 这个date应该是上一次的回调函数执行的date,与下面的date是不同的值
expired = true
})
const res = await getRes(count)
if (!expired) {
finalRes = res
}
console.log('发生改变:', finalRes, res, expired, date)
})
const getRes = (times) => {
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve(times)
}, times * 1000)
})
}
data.version = count
count = 1
setTimeout(() => data.version = count, 0)
首先我是利用count
的值来区分2次不同的请求,其中包括控制getRes
返回的时机,返回值为count
,并且在onValidate
打印date
以区分是哪个请求
然后我们就来分析上面的代码:
- 第一次修改
data.version
会执行回调函数(我们把第一次执行的回调函数设为fn1
),fn1
执行onValidate
把入参函数赋给cleanUp
- 第2次修改
data.version
,由于cleanUp
保存着fn1
传进来的函数,这个函数在fn1
中是闭包的形式,所以它执行后修改的是fn1
中的expired
,把expired
设置为true
,然后执行回调函数(设为fn2
) 1000ms
后,fn2
返回结果,因为expired
仍为false
,所以finalRes
为fn2
返回的结果2000ms
后,fn1
返回结果,因为expired
为true
,放弃该结果,所以finalRes
仍为fn2
返回的结果
这样就可以解决过期的副作用函数了,总结来说,思路是这样的
- 本次会给下一次的回调函数注册一个函数,用于控制本次的状态
- 本次先执行上一次注册的函数,再执行真正的回调函数
本篇文章就到这里啦终于结束了