08.计算属性computed和lazy的实现过程

82 阅读7分钟

在理解计算属性之前,我们需要先来聊聊关于懒执行的 effect,即 lazy 的 effect。这是什么意思呢?举个例子,现在我们所实现的 effect 函数会立即执行传递给它的副作用函数,例如:

effect(() => {
  console.log(obj.foo) //这个函数会立即执行
})

但在有些场景下,我们并不希望它立即执行,而是希望它在需要 的时候才执行,例如计算属性。这时我们可以通过在 options 中添加 lazy 属性来达到目的,如下

effect(
  () => {
    console.log(obj.foo)
  },
  {
    lazy:true
  }
)

lazy 选项和之前介绍的 scheduler 一样,它通过 options 选 项对象指定。有了它,我们就可以修改 effect 函数的实现逻辑了, 当 options.lazy 为 true 时,则不立即执行副作用函数

function effect(fn, options = {}) {
  const effectFn = () => {
   -------
  }
  // 将 options 挂在到 effectFn 上
  effectFn.options = options
  // activeEffect.deps 用来存储所有与该副作用函数相关的依赖集合
  effectFn.deps = []
  // 执行副作用函数
  if (!options.lazy) { //新增
    effectFn()
  }

  return effectFn //新增
}

上述,我们就实现了副作用函数不立即执行的功能,什么时候可以执行?我们将effectFn返回出来,我们就可以手动执行副作用函数

const effectFn = (() => {
  console.log(obj.foo)
},{lazy:true})
//手动执行副作用函数
effectFn()

如果只是手动执行,那并没什么意义,但我们把传递的effect函数看做一个getter,那这个getter函数可以返回任何值

const effectFn = effect(
  ()=> { obj.foo + obj.bar},
  {lazy:true}
)

这样我们在手动执行副作用函数时,就能够拿到其返回值:

const effectFn = effect(
  ()=> { obj.foo + obj.bar},
  {lazy:true}
)
// value是getter的返回值
const value = effectFn()

为了实现这个目标,我们需要再对 effect 函数做一些修改,如 以下代码所示:

function effect(fn, options = {}) {
  const effectFn = () => {

    const res = fn() //新增
  ]

    return res
  }
  // 将 options 挂在到 effectFn 上
  effectFn.options = options
  // activeEffect.deps 用来存储所有与该副作用函数相关的依赖集合
  effectFn.deps = []
  // 执行副作用函数
  if (!options.lazy) {
    effectFn()
  }

  return effectFn
}

通过新增的代码可以看到,传递给 effect 函数的参数 fn 才是真正的副作用函数,而 effectFn 是我们包装后的副作用函数。为了通 过 effectFn 得到真正的副作用函数 fn 的执行结果,我们需要将其 保存到 res 变量中,然后将其作为 effectFn 函数的返回值

现在我们已经能够实现懒执行的副作用函数,并且能够拿到副 用函数的执行结果了,接下来就可以实现计算属性了,如下所示:

function computed(getter) {
  const effectFn = effect(getter, {
    lazy: true,
  })
  const obj = {
    get value() {
      if (dirty) {
        return effectFn()
      }
    }
  }
  return obj
}

首先我们定义一个 computed 函数,它接收一个 getter 函数作 为参数,我们把 getter 函数作为副作用函数,用它创建一个 lazy 的 effect。computed 函数的执行会返回一个对象,该对象的 value 属性是一个访问器属性,只有当读取 value 的值时,才会执行 effectFn 并将其结果作为返回值返回。

我们可以使用 computed 函数来创建一个计算属性:

const data = {foo:1,bar:2}
const sumRes = computed(() => obj.foo + obj.bar)
console.log(sumRes.value) // 3

可以看到它能够正确地工作。不过现在我们实现的计算属性只做 到了懒计算,也就是说,只有当你真正读取 sumRes.value 的值时, 它才会进行计算并得到值。但是还做不到对值进行缓存,即假如我们 多次访问 sumRes.value 的值,会导致 effectFn 进行多次计算, 即使 obj.foo 和 obj.bar 的值本身并没有变化:

为了解决这个问题,就需要我们再实现computed函数时,添加对值进行缓存的功能

function computed(getter) {
  let value
  let dirty = true

  const effectFn = effect(getter, {
    lazy: true,
  })
  
  const obj = {
    // 只有脏才计算值,并将得到的缓存到value中
    get value() {
      if (dirty) {
        value = effectFn()
        dirty = false
      }
      return value
    }
  }

  return obj
}

我们新增了两个变量 value 和 dirty,其中 value 用来缓存上 一次计算的值,而 dirty 是一个标识,代表是否需要重新计算。当我 们通过 sumRes.value 访问值时,只有当 dirty 为 true 时才会调 用 effectFn 重新计算值,否则直接使用上一次缓存在 value 中的 值。这样无论我们访问多少次 sumRes.value,都只会在第一次访问 时进行真正的计算,后续访问都会直接读取缓存的 value 值。

如果此时我们修改 obj.foo 或 obj.bar 的值,再访问 sumRes.value 会发现访问到的值没有发 生变化:

 const data = { foo: 1, bar: 2 }
 const obj = new Proxy(data, { /* ... */ })

 const sumRes = computed(() => obj.foo + obj.bar)

 console.log(sumRes.value) // 3
 console.log(sumRes.value) // 3

 // 修改 obj.foo
 obj.foo++

 // 再次访问,得到的仍然是 3,但预期结果应该是 4
 console.log(sumRes.value) //

这是因为,当第一次访问 sumRes.value 的值后,变量 dirty 会设置为 false,代表不需要计算。即使我们修改了 obj.foo 的 值,但只要 dirty 的值为 false,就不会重新计算,所以导致我们得 到了错误的值。

解决办法很简单,当 obj.foo 或 obj.bar 的值发生变化时,只 要 dirty 的值重置为 true 就可以了。那么应该怎么做呢?这时就用到了上一篇的 scheduler 选项,如以下代码所示:

 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 函数中所依赖的响应式数据变化时执行,这样我们在 scheduler 函 数内将 dirty 重置为 true,当下一次访问 sumRes.value 时,就会 重新调用 effectFn 计算值,这样就能够得到预期的结果了。

现在,我们设计的计算属性已经趋于完美了,但还有一个缺陷, 它体现在当我们在另外一个 effect 中读取计算属性的值时:

const sumRes = computed(() => obj.foo + obj.bar)

 effect(() => {
 // 在该副作用函数中读取 sumRes.value
 console.log(sumRes.value)
 })

 // 修改 obj.foo 的值
 obj.foo++

如以上代码所示,sumRes 是一个计算属性,并且在另一个 effect 的副作用函数中读取了 sumRes.value 的值。如果此时修改 obj.foo 的值,我们期望副作用函数重新执行,就像我们在 Vue.js 的 模板中读取计算属性值的时候,一旦计算属性发生变化就会触发重新渲染一样。但是如果尝试运行上面这段代码,会发现修改 obj.foo 的 值并不会触发副作用函数的渲染,因此我们说这是一个缺陷。

分析问题的原因,我们发现,从本质上看这就是一个典型的 effect 嵌套。一个计算属性内部拥有自己的 effect,并且它是懒执 行的,只有当真正读取计算属性的值时才会执行。对于计算属性的 getter 函数来说,它里面访问的响应式数据只会把 computed 内部 的 effect 收集为依赖。而当把计算属性用于另外一个 effect 时, 就会发生 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(obj, 'value') //新增
      }
    }
  })
  
  const obj = {
    get value() {
      if (dirty) {
        value = effectFn()
        dirty = false
      }
      track(obj, 'value') //新增
      return value
    }
  }
 

如以上代码所示,当读取一个计算属性的 value 值时,我们手动 调用 track 函数,把计算属性返回的对象 obj 作为 target,同时作 为第一个参数传递给 track 函数。当计算属性所依赖的响应式数据变 化时,会执行调度器函数,在调度器函数内手动调用 trigger 函数触 发响应即可。这时,对于如下代码来说:

 effect(function effectFn() {
   console.log(sumRes.value)
 })

它会建立这样的联系:

computed(obj)
   └── value
        └── effectFn