【vue源码系列4】computed是如何实现,值改变的时候重新计算的?

306 阅读4分钟

computed 你可能不知道的一些特性

computed大家应该都用过,大概是下面这样子的。

        const { reactive, computed } = Vue

        const person = reactive({
            firstname: "zhang",
            lastname: "xx"
        })

        const personInfo = computed(() => {
            return person.firstname + person.lastname
        })

好,那么问题来了。

1. computed只能接受函数吗?

2. computed返回的值是什么类型,可以修改吗?

3. computed是怎么实现,数据变化,重新计算的?

如果你对这些答案不是很确定,那么这篇文章都会从源码的角度告诉你答案。

effect侦听器回顾

在我们自己动手开始实现computed功能之前,我们先回顾一下,上一讲中的effect侦听器。

在上一篇数据响应式中,我们创建了一个effect侦听器,并实现了数据的监听功能,当触发person.name的set事件时,会重新触发console.log打印。

这部分如果有不清楚的,建议先看这里

文章代码可以看这里选择对应的分支即可

    function effect(fn,options = {}) {
        const effectFn = () => {
          try {
            activeEffect = effectFn
            return fn()
          } catch (error) {
            activeEffect = null
          }
        }
        if (!options.lazy) {
          effectFn()
        }
        effectFn.scheduler = options.scheduler
        return effectFn
      }
    
      effect(() => {
        console.log('effect person', person.name)
      })

      setTimeout(() => {
        person.name = 'setTimeout world'
      }, 2000)

在函数的定义中,我们可以看出来,effect函数除了fn还接收一个options参数。

该参数中有两个值,一个lazy,一个scheduler。

lazy很好理解,就是第一次是否执行effectFn。跟watch中的immediate是一个意思。

scheduler可以理解为调度器,会优先执行,在触发依赖的时候会判断,优先执行scheduler

   effect(() => {
    console.log('effect person', person.name)
  }, {
    scheduler: () => {
      console.log('scheduler')
    }
  })

  setTimeout(() => {
    person.name = 'setTimeout world'
  }, 2000)
  
  // 2s 后输出scheduler

为了跟源码保持一致,我们对effect做一点改造。

export class ReactiveEffect<T = any> {
  deps: Dep[] = []
  computed?: ComputedRefImpl<T>
  // 如果当前是自己,则延迟清理
  private deferStop?: boolean
  // 监听停止
  onStop?: () => void
  // 是否停止
  active = true
  constructor(
    public fn: () => T,
    public scheduler: EffectScheduler | null = null
  ) {}
  run() {
    if (!this.active) {
      return this.fn()
    }
    try {
      activeEffect = this
      return this.fn()
    } finally {
      if (this.deferStop) {
        this.stop()
      }
    }
  }
  stop() {
    if (this.active) {
      cleanupEffect(this)
      if (this.onStop) {
        this.onStop()
      }
      this.active = false
    }
  }
}

主要就是run方法,内部的实现跟effect是一样的。

核心就是两行代码

      activeEffect = this
      return this.fn()

回顾到这,你肯定知道了,可以用effect来监听呀。

是不是呢?我们来看看源码是如何实现的。

手写一个computed

首先,从使用方法上,我们很容易可以分析出来,computed是一个函数,并返回一个值。

那这个option是什么格式呢?

我们可以看一下官网的说明。

参数可以接收函数,跟对象,返回值是一个只读的ref的对象。

image.png

有了这两点,就可以去写我们的函数了。

    function computed(options) {
        let getter
        let setter
        if(typeof options === 'function') {
            getter = options
            setter = () =>  console.warn('哥们是只读的')
        } else {
            getter = options.getter
            setter = options.setter
        }
        // 返回的ref,跟之前一样,也用类创建
        const cRef = new ComputedRefImpl(getter, setter)
        return cRef
    }
    

ReactiveEffect其实就是之前的effet函数。

 export class ComputedRefImpl<T> {
  public readonly effect: ReactiveEffect<T>
  public _dirty = true
  private _value!: T
  constructor(getter, private readonly _setter, isReadonly: boolean) {
    this.effect = new ReactiveEffect(getter, () => {
      // 判断当前脏的状态,如果为 false,表示需要《触发依赖》
      if (!this._dirty) {
        // 将脏置为 true,表示
        this._dirty = true
        triggerRefValue(this)
      }
    })
    this.effect.computed = this
  }

  get value() {
    // 收集依赖
    trackRefValue(this)
    if (this._dirty) {
      this._dirty = false
      this._value = this.effect.run()
    }
    return this._value
  }
  set value(newValue) {
    this._setter(newValue)
  }
}

这个代码也不复杂。

首先是加一个_dirty的变量来控制,当第一次触发get的时候,执行一遍run,也就是computed里面的计算函数。

当第二次访问的时候,因为_dirty已经改为false了,所以不会重新计算。

当computed里面的值发生变化的时候,则走到scheduler里面的逻辑,触发一次triggerRefValue,重新执行一遍计算。

到这里,看起来没有什么问题了,我们写个demo试一下。

    const { reactive, effect, ref, computed } = Vue
  
    const obj = reactive({
      name: '张三'
    })

    // C1
    const computedObj = computed(() => {
      console.log('computed')
      return '姓名:' + obj.name
    })

    // e1
    effect(() => {
      document.querySelector('#app').innerHTML = computedObj.value
    })

    setTimeout(() => {
      obj.name = '李四'
    }, 2000)

我们来看看结果,执行了两次计算,是符合预期的。

我们再试试访问两次,看有没有什么不同。

因为从代码中我们可以看到,由于_dirty的控制,computed多次访问是不会触发重新执行的,只有改变值才会重新执行。

我们把e1改成如下

  effect(() => {
      document.querySelector('#app').innerHTML = computedObj.value
      document.querySelector('#app').innerHTML = computedObj.value
  })

image.png

预期应该是计算三次对吧,但是实际上会一直执行计算。

image.png

这个问题解释起来比较复杂,下一篇我们再聊。

总结

computed的实现,主要是通过effect来监听数值变化,通过_dirty变量来控制是否需要重新计算。