继续分析响应式的设计,一步步深入,会比上一篇难理解些
一个完善的响应式系统,需要考虑诸多细节,比如下面这个无限递归的例子
const data = { foo: 1 }
const obj = new Proxy(data, { /*...*/ })
effect(() => obj.foo++)
上面的这个代码会导致栈溢出
Uncaught RangeError: Maximum call stack size exceeded
实际上,把 obj.foo++ 这个自增操作分开来看,它相当于
effect(() => {
// 语句
obj.foo = obj.foo + 1
})
问题出现场景是这样的:首先读取 obj.foo 的值,触发 track 操作,将当前副作用函数收集到“桶”中,接着将其加 1 后再赋值给obj.foo,此时会触发 trigger 操作,即把“桶”中的副作用函数取出并执行。由于该副作用函数正在执行中,还没有执行完毕,就要开始下一次的执行。导致无限递归地调用自己,于是就产生了栈溢出。
这个问题要如何解决呢?
关键是副作用函数执行的时候,要避免trigger再次触发执行;这里可以发现,track跟trigger触发的都是同个activeEffect,那就可以基于此增加判断条件:如果 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 => {
// 如果 trigger 触发执行的副作用函数与当前正在执行的副作用函数相同,则不触发执行
if (effectFn !== activeEffect) { // 新增
effectsToRun.add(effectFn)
}
})
effectsToRun.forEach(effectFn => effectFn())
// effects && effects.forEach(effectFn => effectFn())
}
这样就可以修复问题了
可调度,指的是当 trigger 动作触发副作用函数重新执行时,有能力决定副作用函数执行的时机、次数以及方式。先来看下下面的代码
const data = { foo: 1 }
const obj = new Proxy(data, { /* ... */ })
effect(() => {
console.log(obj.foo)
})
obj.foo++
console.log('结束了')
这段代码的输出结果如下
1
2
'结束了'
现在假设需求有变,输出顺序需要调整为:
1
'结束了'
2
可以把语句 obj.foo++ 和语句 console.log('结束了') 位置互换。有什么办法在不调整代码的情况下实现需求呢? 这时就需要响应系统支持调度
可以为 effect 函数设计一个选项参数 options,允许用户指定调度器
effect(
() => {
console.log(obj.foo)
},
// options
{
// 调度器 scheduler 是一个函数
scheduler(fn) {
// ...
}
}
)
effect函数新增一个options参数,同时把options挂载在副作用函数上
function effect(fn, options = {}) {
const effectFn = () => {
cleanup(effectFn)
// 当调用 effect 注册副作用函数时,将副作用函数赋值给 activeEffect
activeEffect = effectFn
// 在调用副作用函数之前将当前副作用函数压栈
effectStack.push(effectFn)
fn()
// 在当前副作用函数执行完毕后,将当前副作用函数弹出栈,并把 activeEffect 还原为之前的值
effectStack.pop()
activeEffect = effectStack[effectStack.length - 1]
}
// 将 options 挂载到 effectFn 上
effectFn.options = options // 新增
// activeEffect.deps 用来存储所有与该副作用函数相关的依赖集合
effectFn.deps = []
// 执行副作用函数
effectFn()
}
有了调度函数,在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() // 新增
}
})
}
在trigger触发执行的时候,优先判断有没有调度器,如果存在,直接调用调度器函数,没有的话,直接执行副作用函数;
有了调度器,就可以实现前面的需求了
const data = { foo: 1 }
const obj = new Proxy(data, { /* ... */ })
effect(
() => {
console.log(obj.foo)
},
// options
{
// 调度器 scheduler 是一个函数
scheduler(fn) {
// 将副作用函数放到宏任务队列中执行
setTimeout(fn)
}
}
)
obj.foo++
console.log('结束了')
用setTimeout 开启一个宏任务来执行副作用函数 fn,这样就能实现期望的打印顺序了
另外调度器还可以控制它的执行次数,这个很重要,考虑下面例子
const data = { foo: 1 }
const obj = new Proxy(data, { /* ... */ })
effect(() => {
console.log(obj.foo)
})
obj.foo++
obj.foo++
它的输出如下
1
2
3
如果只关系最终结果,不关心过程,期望的打印结果是
1
3
也可以基于调度器来实现
// 定义一个任务队列
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++
任务队列jobQueue是一个Set数组,可以自动去重,在执行的时候,isFlushing来判断一个周期只执行一次,最终的执行是用p.then将函数添加到微任务队列,在队列内完成遍历执行
vue在连续多次修改响应数据,当只会触发一次更新,思路跟这个是相同的
现在设计实现的effect函数,都会立即执行传递给它的副作用函数,例如
effect(
// 这个函数会立即执行
() => {
console.log(obj.foo)
}
)
有些场景,我们不希望立即执行,而是在需要的时候才执行,可以在options中添加lazy属性来达到目的
effect(
// 指定了 lazy 选项,这个函数不会立即执行
() => {
console.log(obj.foo)
},
// options
{
lazy: true
}
)
相应的effect函数也要调整,当options.lazy为true时,不立即执行副作用函数,并且把副作用函数返回,由外部手动执行
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 = []
// 只有非 lazy 的时候,才执行
if (!options.lazy) { // 新增
// 执行副作用函数
effectFn()
}
// 将副作用函数作为返回值返回
return effectFn // 新增
}
然后就可以手动执行副作用函数了
const effectFn = effect(() => {
console.log(obj.foo)
}, { lazy: true })
// 手动执行副作用函数
effectFn()
单纯手动执行其实意义不大,不过如果把副作用函数看做一个getter,例如
const effectFn = effect(
// getter 返回 obj.foo 与 obj.bar 的和
() => obj.foo + obj.bar,
{ lazy: true }
)
在手动执行副作用函数时,可以拿到返回值
const effectFn = effect(
// getter 返回 obj.foo 与 obj.bar 的和
() => obj.foo + obj.bar,
{ lazy: true }
)
// value 是 getter 的返回值
const value = effectFn()
由于新增了返回值,需要再对effect函数做一些修改
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 = []
if (!options.lazy) {
effectFn()
}
return effectFn
}
effectFn把真正的副作用函数 fn 的执行结果,保存到 res 变量中,然后将其作为 effectFn 函数的返回值,这样接下来就可以实现计算属性computed了
function computed(getter) {
// 把 getter 作为副作用函数,创建一个 lazy 的 effect
const effectFn = effect(getter, {
lazy: true
})
const obj = {
// 当读取 value 时才执行 effectFn
get value() {
return effectFn()
}
}
return obj
}
computed 函数返回一个对象,该对象的 value 属性是一个访问器属性,只有当读取 value 的值时,才会执行 effectFn 并将其结果作为返回值返回
我们可以使用 computed 函数来创建一个计算属性
const data = { foo: 1, bar: 2 }
const obj = new Proxy(data, { /* ... */ })
const sumRes = computed(() => obj.foo + obj.bar)
console.log(sumRes.value) // 3
现在computed函数已经可以实现懒计算了,只有当真正读取sumRes.value 的值时,它才会进行计算并得到值,但是多次访问sumRes.value 的值,会导致effectFn的多次计算,所以computed方法还要继续优化,增加缓存功能
function computed(getter) {
// value 用来缓存上一次计算的值
let value
// dirty 标志,用来标识是否需要重新计算值,为 true 则意味着“脏”,需要计算
let dirty = true
const effectFn = effect(getter, {
lazy: true
})
const obj = {
get value() {
// 只有“脏”时才计算值,并将得到的值缓存到 value 中
if (dirty) {
value = effectFn()
// 将 dirty 设置为 false,下一次访问直接使用缓存到 value 中的值
dirty = false
}
return value
}
}
return obj
}
用value来缓存上一次计算的值,用dirty来表示是否需要重新计算,这样每次访问sumRes.value,拿到的都是缓存的值了
不过有个明显的漏洞,当修改obj.foo 或 obj.bar 的值的时候, sumRes.value 返回的值不会变,所以当obj.foo的值改变的时候,需要把dirty改成true,这个要如何实现呢?
可以利用之前结算的调度器来实现,代码如下
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
}
由于effect添加了scheduler调度器,在每次getter说依赖的响应式数据变化的时候,可以把dirty置为true,这样就可以得到预期的结果了
上面的设计趋于完美了,不过还有一个缺陷,当在另外一个effect读取计算属性,当修改obj.foo的值,不会触发副作用函数的重新执行
const sumRes = computed(() => obj.foo + obj.bar)
effect(() => {
// 在该副作用函数中读取 sumRes.value
console.log(sumRes.value)
})
// 修改 obj.foo 的值
obj.foo++
对于计算属性的 getter 函数来说,它里面访问的响应式数据只会把computed 内部的 effect 收集为依赖。外层的 effect 不会被内层 effect 中的响应式数据收集,这个怎么办?
其实方法不难,就是在读取计算属性的值时,手动调用 track 函数进行追踪;当响应式数据发生变化时,再手动调用 trigger 函数触发响应
function computed(getter) {
let value
let dirty = true
const effectFn = effect(getter, {
lazy: true,
scheduler() {
if (!dirty) {
dirty = true
// 当计算属性依赖的响应式数据变化时,手动调用 trigger 函数触发响应
trigger(obj, 'value')
}
}
})
const obj = {
get value() {
if (dirty) {
value = effectFn()
dirty = false
}
// 当读取 value 时,手动调用 track 函数进行追踪
track(obj, 'value')
return value
}
}
return obj
}
我们把计算属性返回的对象obj作为track函数的第一个参数,这样也就跟外层的effect函数建立关系了,在数据发生变化后,再手动调用trigger触发响应
watch其实就是一个响应式数据,数据一旦变化,有相应的回调
watch(obj, () => {
console.log('数据变了')
})
// 修改响应数据的值,会导致回调函数执行
obj.foo++
上面的watch方法,可以利用effect方法配合scheduler选项来实现
effect(() => {
console.log(obj.foo)
}, {
scheduler() {
// 当 obj.foo 的值变化时,会执行 scheduler 调度函数
}
})
在副作用函数中,访问响应式数据,就把函数跟响应式数据建立联系,再加上scheduler的回调,可以有最简单的watch函数的实现方式
// watch 函数接收两个参数,source 是响应式数据,cb 是回调函数
function watch(source, cb) {
effect(
// 触发读取操作,从而建立联系
() => source.foo,
{
scheduler() {
// 当数据变化时,调用回调函数 cb
cb()
}
}
)
}
上面函数可以正常工作,不过有个坑,硬编码了对source.foo的读取,如果非foo属性是无法触发watch的回调了,这里要怎么办?
其实很简单,遍历响应式数据的所有变量,让它跟副作用函数简历关联
function watch(source, cb) {
effect(
// 调用 traverse 递归地读取
() => traverse(source),
{
scheduler() {
// 当数据变化时,调用回调函数 cb
cb()
}
}
)
}
function 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
}
traverse会递归的读取所有的变量,从而实现任意属性都可以触发回调执行了,另外,扩大下watch的功能,既可以监听响应式对象,也可以是一个getter函数
watch(
// getter 函数
() => obj.foo,
// 回调函数
() => {
console.log('obj.foo 的值变了')
}
)
那watch方法要如何处理getter的场景呢?其实很简单,把getter当做特殊的traverse函数即可
function watch(source, cb) {
// 定义 getter
let getter
// 如果 source 是函数,说明用户传递的是 getter,所以直接把 source 赋值给 getter
if (typeof source === 'function') {
getter = source
} else {
// 否则按照原来的实现调用 traverse 递归地读取
getter = () => traverse(source)
}
effect(
// 执行 getter
() => getter(),
{
scheduler() {
cb()
}
}
)
}
通过判断source的类型,如果是getter类型,就在effect中直接执行,建立响应式联系;
另外,watch方法,还缺少旧值与新值的回调,这样要怎么处理?
利用effect函数的lazy选项,可以快速的实现
function watch(source, cb) {
let getter
if (typeof source === 'function') {
getter = source
} else {
getter = () => traverse(source)
}
// 定义旧值与新值
let oldValue, newValue
// 使用 effect 注册副作用函数时,开启 lazy 选项,并把返回值存储到 effectFn 中以便后续手动调用
const effectFn = effect(
() => getter(),
{
lazy: true,
scheduler() {
// 在 scheduler 中重新执行副作用函数,得到的是新值
newValue = effectFn()
// 将旧值和新值作为回调函数的参数
cb(newValue, oldValue)
// 更新旧值,不然下一次会得到错误的旧值
oldValue = newValue
}
}
)
// 手动调用副作用函数,拿到的值就是旧值
oldValue = effectFn()
}
由于是lazy加载,所以先主动调用下effectFn,拿到旧值,然后在每次的scheduler回调中,再去更新新值
watch正常是数据变成了,才会触发回调执行,不过有个immediate参数,来指定回调立即执行,这个要怎么实现?
watch(obj, () => {
console.log('变化了')
}, {
// 回调函数会在 watch 创建时立即执行一次
immediate: true
})
回调的执行是在scheduler函数中的,把scheduler函数封装下,再手动执行一次就可以了
function watch(source, cb, options = {}) {
let getter
if (typeof source === 'function') {
getter = source
} else {
getter = () => traverse(source)
}
let oldValue, newValue
// 提取 scheduler 调度函数为一个独立的 job 函数
const job = () => {
newValue = effectFn()
cb(newValue, oldValue)
oldValue = newValue
}
const effectFn = effect(
// 执行 getter
() => getter(),
{
lazy: true,
// 使用 job 函数作为调度器函数
scheduler: job
}
)
if (options.immediate) {
// 当 immediate 为 true 时立即执行 job,从而触发回调执行
job()
} else {
oldValue = effectFn()
}
}
另外,watch还有一个flush参数,可以指定回调在在一个微队列中,等待DOM更新结束后执行
watch(obj, () => {
console.log('变化了')
}, {
flush: 'post' // 还可以指定为 'pre' | 'sync'
})
其实现如下
function watch(source, cb, options = {}) {
let getter
if (typeof source === 'function') {
getter = source
} else {
getter = () => traverse(source)
}
let oldValue, newValue
const job = () => {
newValue = effectFn()
cb(newValue, oldValue)
oldValue = newValue
}
const effectFn = effect(
// 执行 getter
() => getter(),
{
lazy: true,
scheduler: () => {
// 在调度函数中判断 flush 是否为 'post',如果是,将其放到微
if (options.flush === 'post') {
const p = Promise.resolve()
p.then(job)
} else {
job()
}
}
}
)
if (options.immediate) {
job()
} else {
oldValue = effectFn()
}
}
指定为post的时候,job会被放在微队列中执行,实现了异步延迟执行
我们考虑一个竞态问题的场景
let finalData
watch(obj, async () => {
// 发送并等待网络请求
const res = await fetch('/path/to/request')
// 将请求结果赋值给 data
finalData = res
})
上面这段代码,可能会存在问题;
我们第一次修改 obj 对象的某个字段值,这会导致回调函数执行,同时发送了第一次请求 A。随着时间的推移,在请求 A 的结果返回之前,我们对 obj 对象的某个字段值进行了第二次修改,这会导致发送第二次请求 B。此时请求 A 和请求 B 都在进行中,如果请求B 先于请求 A 返回结果,就会导致最终 finalData 中存储的是 A 请求的结果,这样是不符合常规场景的
但由于请求 B 是后发送的,因此我们认为请求 B 返回的数据才是“最新”的,而请求 A 则应该被视为“过期”的,所以我们希望变量 finalData 存储的值应该是由请求B 返回的结果,而非请求 A 返回的结果
这个问题要如何处理呢?其实可以在callback增加第三个参数onInvalidate,它是一个函数,利用这个回调可以处理过期的副作用函数
watch(obj, async (newValue, oldValue, onInvalidate) => {
// 定义一个标志,代表当前副作用函数是否过期,默认为 false,代表没有过期
let expired = false
// 调用 onInvalidate() 函数注册一个过期回调
onInvalidate(() => {
// 当过期时,将 expired 设置为 true
expired = true
})
// 发送网络请求
const res = await fetch('/path/to/request')
// 只有当该副作用函数的执行没有过期时,才会执行后续操作。
if (!expired) {
finalData = res
}
})
那么onInvalidate是如何实现的呢?
其实很简单,在 watch 内部每次检测到变更后,在副作用函数重新执行之前,会先调用我们通过 onInvalidate 函数注册的过期回调,仅此而已
function watch(source, cb, options = {}) {
let getter
if (typeof source === 'function') {
getter = source
} else {
getter = () => traverse(source)
}
let oldValue, newValue
// cleanup 用来存储用户注册的过期回调
let cleanup
// 定义 onInvalidate 函数
function onInvalidate(fn) {
// 将过期回调存储到 cleanup 中
cleanup = fn
}
const job = () => {
newValue = effectFn()
// 在调用回调函数 cb 之前,先调用过期回调
if (cleanup) {
cleanup()
}
// 将 onInvalidate 作为回调函数的第三个参数,以便用户使用
cb(newValue, oldValue, onInvalidate)
oldValue = newValue
}
const effectFn = effect(
// 执行 getter
() => getter(),
{
lazy: true,
scheduler: () => {
if (options.flush === 'post') {
const p = Promise.resolve()
p.then(job)
} else {
job()
}
}
}
)
if (options.immediate) {
job()
} else {
oldValue = effectFn()
}
}
定义了 cleanup 变量,用来存储用户通过onInvalidate 函数注册的过期回调。在 job 函数内,每次执行回调函数 cb 之前,先检查是否存在过期回调,如果存在,则执行过期回调函数cleanup
watch(obj, async (newValue, oldValue, onInvalidate) => {
let expired = false
onInvalidate(() => {
expired = true
})
const res = await fetch('/path/to/request')
if (!expired) {
finalData = res
}
})
// 第一次修改
obj.foo++
setTimeout(() => {
// 200ms 后做第二次修改
obj.foo++
}, 200)
在 200ms 时第二次修改了 obj.foo 的值,会导致 watch 的回调函数再次执行。 watch 的回调函数第二次执行之前,会优先执行之前注册的过期回调,这会使得第一次执行的副作用函数内闭包的变量 expired 的值变为 true,即副作用函数的执行过期了。于是等请求 A 的结果返回时,其结果会被抛弃,从而避免了过期的副作用函数带来的影响
这个系列,主要是参考Vue.js设计与实现这个本书。响应式数据是Vue的核心概念,整体了解下来,确实有一定的难度,部分逻辑也是看了多遍才理解透,需要考虑很多的实际场景,看完后由衷的佩服框架的设计者,这是多么天才型的人才才可以如何优雅的处理好这一个个场景问题