前言:
本篇内容基于 Vue3响应式对象是如何实现的(2) 实现。
计算属性
Vue3的官方文档中,对于计算属性有这样的描述:
对于任何包含 响应式数据 的复杂逻辑,我们都应该使用 计算属性 。 计算属性只会在 相关响应式依赖发生改变时重新求值 。从上面的描述可以明确计算属性的需求, 计算属性计算的是响应式数据(满足描述1),且计算结果应当被缓存(满足描述2) 。让我们一个一个来实现,先使用 computed 创建一个计算属性。
function effect(fn) { // 副作用函数 const effectFn = () => { cleanup(effectFn) activeEffect = effectFn effectStack.push(effectFn) fn() effectStack.pop() activeEffect = effectStack[effectStack.length - 1] } effectFn.deps = [] effectFn() } ... const data = { foo: 1, bar: 2 } const obj = new Proxy(data, { // 响应式对象 get(target, key) { track(target, key) return target[key] }, set(target, key, newValue) { target[key] = newValue trigger(target, key) return true } }) ... const sumRes = computed(() => obj.foo + obj.bar) // (1) console.log(sumRes.value)
在(1)处,我们简单写了一个计算属性的功能,为了实现通过 sumRes.value 读取计算属性值功能,在实现计算属性时,需要返回一个对象,通过对象内的 get 触发副作用函数。
function computed(getter) { const effectFn = effect(getter) const obj = { get value() { return effectFn() } } return obj }
但这个函数显然是无法执行的,这是因为前面我们在实现 effect 时,需要直接执行副作用函数,不需要提供返回值。没有返回值, computed 自然无法获取到 effect 的执行结果。因此,当在计算属性中使用 effect 时,需要将副作用函数返回给计算属性,由计算属性决定何时执行,而不再由 effect 立即执行(即 懒执行 )。
为了实现这点,就需要向 effect 中添加一个开关 lazy ,考虑到我们可能将来还需要对 effect 配置其它特性,我们使用一个对象 options 来封装这个开关。
function effect(fn, options = {}) { const effectFn = () => { cleanup(effectFn) activeEffect = effectFn effectStack.push(effectFn) const res = fn() // (1) effectStack.pop() activeEffect = effectStack[effectStack.length - 1] return res // (2) } effectFn.deps = [] effectFn.options = options // (3) if (!options.lazy) { // (4) effectFn() } return effectFn // (5) }
我们在(4)处放置了 lazy 开关,不需要懒执行的副作用函数同样会自动执行。在(1)(2)(5)处返回了副作用函数的结果,供懒执行使用。同时在(3)处向下传递了 options ,保证在 effect 发生嵌套时,也使得副作用函数执行预期的行为。基于上述 effect 的修改,我们在 computed 中设置 lazy 开关。
function computed(getter) { const effectFn = effect(getter, { lazy: true }) const obj = { get value() { // (6) return effectFn() } } return obj } const sumRes = computed(() => obj.foo + obj.bar)
从上图中可以看出,我们已经实现了描述1,即使用计算属性进行响应式数据的计算,当响应式数据的值发生变化时,计算属性的值也会随之改变。但观察上文代码的(6)处,不难发现,无论什么情况下,只要读取 sumRes.value 的值,就会触发一次副作用函数,使其重新进行可能不必要的执行。所以接着,我们尝试实现描述2,缓存计算属性的结果。
先从最简单的入手,我们用一个变量 value 来缓存上次计算的值,并添加一个 dirty 开关,记录是否需要重新触发副作用函数。
function computed(getter) { let value let dirty = true const effectFn = effect(getter, { lazy: true }) const obj = { get value() { if(dirty) { value = effectFn() dirty = false } return value } } return obj }
修改之后,缓存的值就能生效了。但这样做产生了一个明显的BUG,当 dirty 的值被置为 false 时,无法再变为 true ,这也就意味着,无论响应式数据 obj.bar 与 obj.foo 如何变化,计算属性的值永远都只能是缓存的值 value ,如下图所示。
为了解决这个问题,我们需要一种方式,能够在 obj.bar 或 obj.foo 的值变化时,在获取 sumRes.value 之前,将 dirty 开关的值置为 true 。受前面懒加载的启发,我们尝试能不能通过配置 options 来实现这个功能。
const obj = new Proxy(data, { get(target, key) { track(target, key) return target[key] }, set(target, key, newValue) { target[key] = newValue trigger(target, key) return true } })
再来回忆一下响应式对象的整个流程,当响应式对象中的数据被修改时,执行了 trigger 去触发收集的副作用函数。而在计算属性中,我们不再需要自动的触发副作用函数。所以自然会想到,能否在这个地方将 dirty 置为 true 呢?按照这个思路,我们先对 trigger 进行修改。
function trigger(target, key) { const propsMap = objsMap.get(target) if(!propsMap) return const fns = propsMap.get(key) const otherFns = new Set() fns && fns.forEach(fn => { if(fn !== activeEffect) { otherFns.add(fn) } }) otherFns.forEach(fn => { if(fn.options.scheduler) { // (7) fn.options.scheduler() } else { fn() } }) }
按照前文的思路,我们在(7)处增加了一个判断,如果副作用函数 fn 的配置项 options 中含有 scheduler 函数,我们就执行 scheduler 而非副作用函数 fn 。我们称这里的 scheduler 为 调度器 。相应的,我们在计算属性中添加调度器。
function computed(getter) { let value let dirty = true const effectFn = effect(getter, { lazy: true, scheduler() { // (8) dirty = true } }) const obj = { get value() { if(dirty) { value = effectFn() dirty = false } return value } } return obj }
在(8)处我们添加了调度器。添加调度器后,让我们再来串一下整个流程,当 响应式数据被修改时,才会执行 trigger 函数 。由于我们传入了调度器,因此 trigger 函数在执行时不再触发副作用函数,转而执行调度器,此时 dirty 开关的值变为了 true 。当程序再往下执行时,由于 dirty 已经变为 true ,副作用函数就可以正常被手动触发。效果如下图所示。
到这里为止,计算属性在功能上已经实现完毕了,让我们尝试完善它。在Vue中,当计算属性中的响应式数据被修改时,计算属性值会同步更改,进而重新渲染,并在页面上更新。渲染函数也会执行 effect ,为了说明问题,让我们使用 effect 简单的模拟一下。
const sumRes = computed(() => obj.foo + obj.bar) effect(() => console.log('sumRes =', sumRes.value))
这里我们的预期是当 obj.foo 或 obj.bar 改变时, effect 会自动重新执行。这里存在的问题是,正常的 effect 嵌套可以被自动触发(这点我们在上一篇博客中已经实现了),但 sumRes 包含的 effect 仅会在被读取 value 时才会被 get 触发执行,这就导致响应式数据 obj.foo 与 obj.bar 无法收集到外部的 effect ,收集不到的副作用函数,自然无法被自动触发。
function computed(getter) { let value let dirty = true const effectFn = effect(getter, { lazy: true, scheduler() { dirty = true trigger(obj, 'value') // (9) } }) const obj = { get value() { if(dirty) { value = effectFn() dirty = false } track(obj, 'value') // (10) return value } } return obj }
在(10)处我们手动收集了副作用函数,并当响应式数据被修改时,触发它们。
使用微任务优化调度器
在设计调度器时,我们忽略了一个潜在的问题。
还是先来看一个例子:
effect(() => console.log(obj.foo)) for(let i = 0; i < 1e5; i++) { obj.foo++ }
随着响应式数据 obj.foo 被不停修改,副作用函数也被不断重复执行,在明显的延迟之后,控制台打印出了大量的数据。但在前端的实际开发中,我们往往只关心最终结果,拿到结果显示在页面上。在这种条件下,我们如何避免副作用函数被重复执行呢?
const jobQueue = new Set() // (11) const p = Promise.resolve() // (12) let isFlushing = false // (13) function flushJob() { // (14) if (isFlushing) return isFlushing = true p.then(() => { jobQueue.forEach(job => { job() }) }).finally(() => { isFlushing = false }) }
这里我们的思路是使用微任务队列来进行优化。(11)处我们定义了一个 Set 作为任务队列,(12)处我们定义了一个 Promise 来使用微任务。精彩的部分来了,我们的思路是将副作用函数放入任务队列中,由于任务队列是基于 Set 实现的,因此,重复的副作用函数仅会保留一个,这是第一点。接着,我们执行 flushJob() ,这里我们巧妙的设置了一个 isFlushing 开关,这个开关保证了被微任务包裹的任务队列在一次事件循环中只会执行一次,而执行的这一次会在宏任务完成之后触发任务队列中所有不重复的副作用函数,从而直接拿到最终结果,这是第二点。按照这个思路,我们在 effect 中添加调度器。
effect(() => { console.log(obj.foo) }, { scheduler(fn) { jobQueue.add(fn) flushJob() } })
需要注意的是,浏览器在执行微任务时,会把微任务队列中的所有微任务一口气做完。因此,微任务在执行时会阻塞页面的渲染。所以在实际使用时,要注意避免在微任务队列中放置过重的任务,以免引起卡顿。
到此这篇关于Vue3计算属性是如何实现的的文章就介绍到这了,更多相关Vue3计算属性 内容请搜索以前的文章或继续浏览下面的相关文章希望大家以后多多支持!