本章目标:
计算属性的实现原理,如何实现 lazy ,缓存,解决嵌套问题?
watch 是如何实现监听对象变化,并调用回调函数的?监听对象和监听getter函数的区别?
立即执行的 watch 是如何实现的?如何决定回调执行时机?
……
4.8 计算属性 computed 与 lazy 上面实现的 effect 会立即执行传入的副作用函数,有时候我们并不希望立即执行,而是需要的时候才执行。例如计算属性。可以通过在 options 中配置 lazy 属性达到目的。
1 2 3 4 5 effect (() => { console .log (obj.foo ) }, { lazy : true })
在 effect 函数中就可以根据 lazy 属性确定是否执行副作用函数:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 function effect (fn, options={} ) { const effectFn = ( ) => { cleanup (effectFn) activeEffect = effectFn effectStack.push (effectFn) const res = fn () effectStack.pop () activeEffect = effectStack[effectStack.length - 1 ] return res } effectFn.options = options effectFn.deps = [] if (!options.lazy ) { effectFn () } return effectFn }
返回的 effectFn 该何时执行呢?手动执行拿到返回值。
1 2 3 4 const effectFn = effect ( () => obj.foo + obj.bar , {lazy : true }) const value = effectFn ()
现在已经实现了懒执行副作用函数,并且拿到了副作用函数的执行结果。接下来实现计算属性:
1 2 3 4 5 6 7 8 9 10 11 12 13 function computed (getter ) { const effectFn = effect (getter, { lazy : true , }) return { get value () { return effectFn () }, } }
实现原理:定义一个 computed 函数,接收 getter 作为参数。内部把 getter 作为副作用函数,创建了一个 lazy 的 effect。computed 函数的执行会返回一个对象,通过 value 访问器读取值。
1 2 3 const sumRes = computed (() => obj.foo + obj.bar )console .log ('sumRes' , sumRes.value )
上面只实现了计算属性的懒计算能力。多次访问会重复计算 sumRes 的值,无法做到对值的缓存。下面进行优化:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 function computed (getter ) { let value let dirty = true const effectFn = effect (getter, { lazy : true , }) return { get value () { if (dirty) { value = effectFn () dirty = false } return value }, } }
新增两个属性 value 和 dirty ,其中 value 用于缓存上一次计算值。dirty 用于标识是否需要重新计算。只用 dirty 为 true 才进行调用 effectFn 重新计算。现在测试是否符合:
1 2 3 4 5 6 const sumRes = computed (() => obj.foo + obj.bar )console .log ('sumRes' , sumRes.value ) obj.foo ++ console .log ('sumRes' , sumRes.value )
即使修改了 obj.foo 的值,也没重新计算。问题出在我们修改 obj.foo 值后 dirty 标识并没有发生改变。解决方法在 obj.foo 或 obj.bar 发生改变时,修改 dirty 值为 true 。我们该如何做呢?当然是使用上一节学习的 scheduler。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 function computed (getter ) { let value let dirty = true const effectFn = effect (getter, { lazy : true , scheduler ( ) { dirty = true }, }) const obj = { get value () { if (dirty) { value = effectFn () dirty = false } return value }, } return obj }
通过调度器 scheduler 函数,在 getter 函数中所依赖的响应数据发生改变时,通过调度器函数将 dirty 重置为 true。当下次访问 sumRes.value 时,会重新调用 effectFn 函数。 我们设计的计算属性已经趋于完美,但是存在一个缺陷:当另一个 effect 读取计算属性值时:
1 2 3 4 5 const sumRes = computed (() => obj.foo + obj.bar )effect (() => { console .log (sumRes.value ) }) obj.foo ++
在另一个 effect 读取 sumRes.value 时,即使修改 obj.foo 值,无法触发副作用重新执行。 经过分析发现,上述问题本质上就是 effect 嵌套问题。外层的 effect 不会被计算属性的内部的 effect 中的响应数据收集。解决办法:当读取计算属性的值时,手动调用 track 函数进行追踪;当计算属性依赖的响应式数据发生变化时,手动调用 trigger 函数触发响应:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 function computed (getter ) { let value let dirty = true const effectFn = effect (getter, { lazy : true , scheduler ( ) { if (!dirty) { dirty = true trigger (obj, 'value' ) } }, }) const obj = { get value () { if (dirty) { value = effectFn () dirty = false } track (obj, 'value' ) return value }, } return obj }
读取计算属性的 value 时,手动调用 track 函数,计算属性返回的对象 obj 作为 target 传递给 track 函数。当依赖数据变化时,手动调用 trigger 函数触发响应。
1 2 3 4 5 const sumRes = computed (() => obj.foo + obj.bar )effect (function effectFn ( ) { console .log (sumRes.value ) }) obj.foo ++
他们建立的联系:
1 2 3 01 computed (obj)02 └── value03 └── effectFn
以上,我们实现了 computed 的功能:
实现 effect 懒计算,通过 lazy 标识控制 effect 是否执行,将 effectFn 返回给用户决定执行时机。
缓存上一次计算值,在 computed 内部定义一个 value 和 dirty 标识,在数据为脏时才重新计算,否则直接读取缓存值。关联的响应式数据变化时,通过 scheduler 修改 dirty 的值为 true 触发重新执行。
最后我们还解决了嵌套导致外层 effect 无法和内层的响应数据关键的问题,通过手动调用 track 函数和 trigger 解决问题。
4.9 watch 实现原理
watch:所谓 watch 本质就是观测一个响应式数据,当数据发生变化时通知并执行响应的回调函数。
举个🌰,如何实现 watch 函数实现下面监听呢?
1 2 3 4 5 watch (obj, () => { console .log ('数据变了' ) }) obj.foo ++
聪明的你已经发现,通过 effect 配合 scheduler 可是轻松实现:
1 2 3 4 5 6 7 8 9 10 11 12 function watch (source, cb ) { effect ( () => source.foo , { scheduler ( ) { cb () }, } ) }
副作用的中通过 scheduler 调度回调函数,watch 实现利用这个点。但上面的实现存在一个问题,只能监听 foo 变化执行回调函数。所以我们需要一个通用的的读取操作:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 function watch (source, cb ) { effect ( () => traverse (source), { scheduler ( ) { cb () }, } ) } function traverse (value, seen = new Set () ) { if (typeof value !== 'object' || value === null || seen.has (value)) return seen.add (value) for (const k in value) { traverse (value[k], seen) } return value }
通过递归 traverse 读取对象的任意属性,从而任意属性发生变化时都能触发回调函数执行。在 Vue 中,watch 第一个参数不仅接收响应式数据,也可以接收一个 getter 函数。在 gettter 内部用户执行 watch 依赖哪些响应式数据,只用当前数据变化时,触发回调函数,例如:
1 2 3 4 5 6 7 watch ( () => obj.foo , () => { console .log ('数据变化了' ) } ) obj.foo ++
需要对 watch 进行修改,处理 source 为函数的情况:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 function watch (source, cb ) { let getter if (typeof source === 'function' ) { getter = source } else { getter = () => traverse (source) } effect ( () => getter (), { scheduler ( ) { cb () }, } ) } function traverse (value, seen = new Set () ) { }
对传入的 source 做一层判断,如果是函数,说明传递了 getter 函数,直接使用用户传入的 getter 函数即可。否则调用 traverse 函数递归读取。 上面还缺少一个功能,在回调时无法获取新旧值。如何解决这个问题呢?
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 function watch (source, cb ) { let getter if (typeof source === 'function' ) { getter = source } else { getter = () => traverse (source) } let oldValue, newValue const effectFn = effect ( () => getter (), { lazy : true , scheduler ( ) { newValue = effectFn () cb (newValue, oldValue) oldValue = newValue }, } ) oldValue = effectFn () } function traverse (value, seen = new Set () ) { }
核心修改是使用 lazy 选项创建一个懒执行的 effect。在最后手动调用 effectFn 函数得到的返回值是旧值,第一次执行得到的值。数据变化后触发 scheduler 调度函数执行时,会重新调用 effectFn 函数得到新值。这样就拿到了新值和旧值,将他们作为 cb 的参数传递出去。最后需要刷新旧值,将新旧赋给旧值即可。
4.10 立即执行的watch与回调执行时机 通过上一节学习,我们知道 watch 的本质是对 effect 的二次封装。本节我们继续学习 watch 的两个特性:
立即执行的回调函数,在 Vue.js 通过选项参数 immediate
指定是否需要立即执行。
回调函数执行时机,在 Vue.js 通过 flush
指定回调函数的执行时机。
首先来看立即执行的回调函数,默认情况下,一个 watch 的回调函数只会在响应式数据发生改变时才执行。回调函数的立即执行和后续执行本质上没有区别,只是 scheduler 调度函数执行时机不同,分别在初始化执行和变化时执行。那么我们对 watch 进行修改:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 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 (), { lazy : true , scheduler : job } ) if (options.immediate ) { job () } else { oldValue = effectFn () } }
实现思路很简单:将 scheduler 调度函数独立为 job 函数,在 scheduler 使用 job 函数作为调度器函数。另外再添加选项配置项 immediate 属性,在属性为 true 的时候,立即执行 job 函数,从而触发回调执行。
另外,通过 flush 选项参数来指定函数的执行时机。在 Vue.js 3 中:
1 2 3 4 5 watch (obj, () => { console .log ('---' , obj.foo ) }, { flush : 'pre' , })
flush 的值为 pre 表示组件更新前执行,暂时无法模拟,因为设计组件更新时机。
flush 的值为 post 时,调度函数需要将副作用函数放到一个微任务队列中,并等待 DOM 更新结束后执行。
flush 的值为 sync 时,同步执行,相当于直接调用 job 函数执行。
下面模拟实现 post 异步执行副作用函数:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 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 (), { lazy : true , scheduler : () => { if (options.flush == 'post' ) { const p = Promise .resolve () p.then (job) } else { job () } }, } ) if (options.immediate ) { job () } else { oldValue = effectFn () } }
4.11 过期的副作用 竞态问题通常是多线程或多进程编程被提及的问题。但是在一些情况下前端也会遇到竞态问题,例如下面的例子:
1 2 3 4 5 6 7 8 9 let finalDatawatch (obj, async () => { const res = await fetch ('xxxx' ) finalData = res })
这段代码,监听 obj 变化时发送网络请求,将请求结果赋值给 finalData。看起来没有问题,仔细思考会发现这段代码会发生竞态问题。 第一次请求 A 发送后,又重新发生请求B,因为网络原因,请求 B 先返回数据,请求 A 才返回数据,此时 finalData 是请求 A 产生的数据。理论上,请求 B 返回的数据是“最新”的,请求A 返回的数据就是过期数据,我们希望变量 finalData 存储的值是请求 B 返回的结果。 我们对上面问题做进一步总结,请求 A 是副作用函数第一次执行所产生的副作用,请求 B 是第二次执行产生的副作用。请求 B 发生后,请求 A 已经过期,应该把请求 A 产生的结果视为无效,这样就避免竞态问题产生。 总之,我们需要一种手段让副作用过期,在 Vue.js 中, watch 函数的回调函数接受第三个参数 onInvalidate 是一个函数,类似于事件监听器,可以使用它注册一个回调,这个回调函数会在当前副作用函数过期时执行:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 watch (obj, async (newValue, oldValue, onInvalidate) => { let expired = false onInvalidate (() => { expired = true }) const res = await fetch ('path/to/request' ) if (!expired) { finalData = res } })
Vue.js 如何实现 onInvalidate 的呢?在 watch 每次检测到变化后,在副作用函数重新执行前,会先调用 onInvalidate 函数注册的过期回调。那么我们对 watch 实现进行修改:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 function watch (source, cb, options={} ) { let getter if (typeof source === 'function' ) { getter = source } else { getter = () => traverse (source) } let cleanup function onInvalidate (fn ) { cleanup = fn } let oldValue, newValue const job = ( ) => { newValue = effectFn () if (cleanup) { cleanup () } cb (newValue, oldValue, onInvalidate) oldValue = newValue } const effectFn = effect ( () => 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 变量将用户定义的过期回调存储,在 job 函数中,每次回调 cb 之前先检查是否存在过期回调,存在执行回调函数 cleanup,最后把 onInvalidate 作为第三个 cb 参数返回给用户。
参考