vue3源码-实现简易computed函数

我们知道vue的响应式API有这么一个函数computed,它接收一个有返回值的回调函数作为参数,返回一个只读的响应式的ref对象,该ref对象通过.value暴露回调函数的返回值。而且它还具有缓存数据的能力。那么vue是通过什么操作赋予computed函数这些能力的呢?让我们通过实现一个简易的computed函数来解释隐藏在这些能力背后的逻辑吧。(部分已经完成的响应式API在我前面发布的文章中可以找到)

构建ComputedRefImpl实例对象

ts 复制代码
export class ComputedRefImpl<T> {
  // 存储传入的回调函数的返回值
  private _value:<T>
  // dep是执行函数的集合Set
  dep?: Dep = undefined
  
  // 给实例对象做标记
  public readonly __v_isRef = true
  // 通过构造器传入的
  public readonly effect:ReactiveEffect<T>
  
  constructor(getter){
    // 将getter函数转化为ReactiveEffect实例
    this.effect = new ReactiveEffect(getter)
    // 通过给ReactiveEffect实例增加computed属性和RefImpl类进行区分
    this.effect.computed = this
  }
  
  get value() {
    // 收集依赖(使用的是和ref一样的方法):将执行函数收集到dep属性中,
    trackRefValue(this.dep)
    // 返回回调函数的返回值
    this._value = this.effect.run()
    return this._value
  }
}

ComputedRefImpl这个类是computed函数对外暴露数据的关键。从这里就可以看得出来computedref很类似,都是通过返回一个类ref实例,通过getset标记value方法,从而通过.value的形式暴露响应式数据。但是不同之处在ComputedRefImpl接收的是一个需要有返回值的getter函数。

computed函数

ts 复制代码
export function computed(getterOrOptions) {
  let getter
  
  // 判断参数是否为函数
  const onlyGetter = isFunction(getterOrOptions)
  if (onlyGetter) {
    // 只有在确认参数为函数的情况下才能赋值
    getter = getterOrOptions
  }
  
  // 生成 ComputedRefImpl 实例并返回
  const cRef = new ComputedRefImpl(getter)
  return cRef
}

const isFunction = (val: unknown): val is Function => {
  return typeof val === 'function'
}

computed函数中只做两件事,一个是确保传入的参数是function,另一个就是生成ComputedRefImpl实例对象并返回实例对象。此时实例对象就可以读取到值了,接下来就是构建响应性的环节了。

构建computed的响应性

根据之前的reactive函数和ref函数的响应性原理可以知道,响应性的核心在于收集依赖触发依赖 ,现在我们已经在set标记的value方法中收集过依赖了,接下来就是触发依赖的环节了。

ComputedRefImpl类中处理_dirtyscheduler

ts 复制代码
export class ComputedRefImpl<T> {
  
  ...

  // 当dirty为false时,表示需要触发依赖,为true时表示需要重新执行一次effect更新_value
  public _dirty: boolean = true

  constructor(getter) {
    // effect是传入的回调函数生成一个ReactiveEffect实例
    // 第二个参数传入的是代替原本的effect函数执行的scheduler
    this.effect = new ReactiveEffect(getter, () => {
      if (!this._dirty) {
        // 转换_dirty状态,表示数据需要更新了
        this._dirty = true
        // 触发依赖
        triggerRefValue(this)
      }
    })
    // 在生成的ReactiveEffect实例中添加新属性computed表示当前实例为ComputedRefImpl类型
    this.effect.computed = this
  }

  get value() {
    // 收集依赖,和ref函数同样的收集依赖方法
    trackRefValue(this)
    // 检查_dirty状态判断是否需要重新获取数据,默认是需要run一遍的
    if (this._dirty) {
      // 转换_dirty状态,表示数据更新完成了
      this._dirty = false
      // 重新运行一遍传入的回调函数更新数据
      this._value = this.effect.run()
    }
    return this._value
  }
}

这一步很关键,这里表明了computedref在收集和触发依赖上存在的不同之处。ref是在对实例对象value方法的get操作中收集依赖set操作中触发依赖 。而computed因为传入的是一个函数,不能进行set操作,因此它的触发依赖 同样需要在get中进行,因此我们需要一个标记来判断什么时候应该触发依赖,什么时候不能。这个标记就是_dirty,可以这么理解:当_dirtytrue的时候(这也是默认值),表示数据"有污点",需要更新,那么就会重新运行一遍回调函数并更新_value值,同时修改_dirtyfalsethis.effect.run()这里的运行会触发上面生成实例时传入的scheduler,当_dirtyfalse的时候,就可以触发依赖 了(这里的依赖是另一个effect,也就是使用到这个对象实例的值的effect函数)。

接下来就可以根据这里的思路去改造之前的ReactiveEffect类和triggerEffect函数了

修改ReactiveEffect

ts 复制代码
export type EffectScheduler = (...args: any[]) => any


/**
 * 响应性触发依赖时的执行类
 */
export class ReactiveEffect<T = any> {
	/**
	 * 存在该属性,则表示当前的 effect 为计算属性的 effect
	 */
	computed?: ComputedRefImpl<T>

	constructor(
		public fn: () => T,
		public scheduler: EffectScheduler | null = null
	) {}
	...
}

修改triggerEffect函数

ts 复制代码
/**
 * 触发指定依赖
 */
export function triggerEffect(effect: ReactiveEffect) {
  // 存在调度器就执行调度函数
  if (effect.scheduler) {
    effect.scheduler()
  } 
  // 否则直接执行 run 函数即可
  else {
    effect.run()
  }
}

到这里,computed的响应性就算完成了。但是我们还没有解释computed缓存能力的来源。接下来就开始实现computed的缓存性。

构建computed缓存能力

html 复制代码
<script>
  const { reactive, computed, effect } = Vue

  const obj = reactive({
    name: '张三'
  })

  const computedObj = computed(() => {
    console.log('计算属性执行计算');
    return '姓名:' + obj.name
  })

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

  setTimeout(() => {
    obj.name = '李四'
  }, 2000);
</script>

我们希望在effect函数中的computed.value可以只触发一次内部的回调函数(也就是将第一次计算的数据缓存),加上声明计算属性时时自动触发的一次,在控制台应该只会打印两次"计算属性执行计算"。

现在试着将这段代码运行起来,我们会发现它会无限循环打印,说明计算属性一直在执行计算,这是什么原因呢?

追踪计算属性的触发依赖

  1. track方法处增加断点,延迟两秒后进入断点,此时obj.name的赋值会触发计算属性依赖 ,在triggerEffects函数中(此时的effectComputedRefImpl实例对象的effect,也就是computed函数传入的回调函数)判断为ComputedRefImpl实例对象,则运行scheduler调度器函数。

  2. 进入到scheduler中,如果_dirtyfalse(此处满足条件)则触发ComputedRefImpl实例对象的依赖

  3. 再次进入triggerEffects函数(此时effectComputedRefImpl实例对象的dep,也就是触发实例对象get value方法的effect函数),由于effect函数内有多次调用ComputedRefImpl实例对象get value方法,导致收集依赖时将自身effect也收集到dep中。

  4. 依次触发依赖的第一个是非计算属性的effect函数,所以会直接执行run方法,而在effect函数中存在两次调用ComputedRefImpl实例对象get value方法(computedObj.value)

    ts 复制代码
    get value() {
      // 收集依赖,和ref函数同样的收集依赖方法
      trackRefValue(this)
      // 检查_dirty状态判断是否需要重新获取数据,默认是需要run一遍的
      if (this._dirty) {
        // 转换_dirty状态,表示数据更新完成了
        this._dirty = false
        // 重新运行一遍传入的回调函数更新数据
        this._value = this.effect.run()
      }
      return this._value
    }
    • 第一次调用:
      1. 进入computedget value
      2. 收集依赖
      3. 检查_dirty状态,可以执行this.effect.run()(运行原本的计算属性函数而非调度器)
      4. 获取最新值,返回
    • 第二次调用:
      1. 进入computedget value
      2. 收集依赖
      3. 检查_dirty状态,此时上一次get value中将_dirty赋值为false,不能再执行this.effect.run()
      4. 获取上一次的值,返回
  5. 从这里开始,进入triggerEffects函数的第二次循环,第二次循环的依赖是computedeffect,因此会被triggerEffect识别出来,运行effect.scheduler()。还记得第一步吗?第一步的开头就是执行effect.scheduler(),死循环就开始了。

解决死循环的方法

ts 复制代码
export function triggerEffects(dep: Dep) {
  // 通过 dep 获取执行函数组成的数组
  const effects = isArray(dep) ? dep : [...dep]

  // 先触发所有的计算属性依赖,再触发所有非计算属性依赖
  for (const effect of effects) {
    if (effect.computed) {
      triggerEffect(effect)
    }
  }
  for (const effect of effects) {
    if (!effect.computed) {
      triggerEffect(effect)
    }
  }
}

参考vue的源码中,triggerEffects触发依赖是按照先触发计算属性依赖,再触发非计算属性依赖的顺序,尝试修改成上述代码再跟踪一遍执行过程。

  1. 和上面的第一到第三步相同,在第二次进入triggerEffects之后才开始不同之处。

  2. 此时进入triggerEffects中如预想的一样先触发了计算属性依赖(也就是effect.scheduler()),但是_dirty此时为true(原因是在第一步中已经运行过一次调度器),因此这一次的调度器函数直接跳过。

  3. 此时进入triggerEffects的第二次循环,这一次触发非计算属性的effect函数,同样也是触发两次get value方法

    ts 复制代码
    get value() {
      // 收集依赖,和ref函数同样的收集依赖方法
      trackRefValue(this)
      // 检查_dirty状态判断是否需要重新获取数据,默认是需要run一遍的
      if (this._dirty) {
        // 转换_dirty状态,表示数据更新完成了
        this._dirty = false
        // 重新运行一遍传入的回调函数更新数据
        this._value = this.effect.run()
      }
      return this._value
    }
    • 第一次调用:
      1. 进入computedget value
      2. 收集依赖
      3. 检查_dirty状态,可以执行this.effect.run()(运行原本的计算属性函数而非调度器)
      4. 获取最新值,返回
    • 第二次调用:
      1. 进入computedget value
      2. 收集依赖
      3. 检查_dirty状态,此时上一次get value中将_dirty赋值为false,不能再执行this.effect.run()
      4. 获取上一次的值,返回
  4. 至此代码逻辑结束,死循环不再发生,而"计算属性执行计算"也只被打印了两次,成功构建缓存能力。

从这里可以看出缓存能力是基于_dirty状态控制的。当计算属性关联的响应式数据发生变化时,触发effect.scheduler(),在调度器中触发的依赖会先触发计算属性依赖effect.scheduler(),此时触发的依赖就会被跳过(因为上一次的状态还没有调整过来,需要get value的调整)。然后触发effect函数,进入两次get value,而get value同样只能执行第一次的effect.run()。也就是说在不触发effect.scheduler()的情况下无论多少次的get value都只会执行第一次,而effect.scheduler()的执行取决于computed函数中涉及到的响应性数据,因为它们建立了和computed的依赖关系。

总结

  1. 计算属性的实例,本质上是一个ComputedRefImpl的实例
  2. ComputedRefImpl中通过_dirty属性来控制run的执行和scheduler的触发
  3. 想要访问计算属性的值,必须通过.value因为它内部和ref一样是通过get value来进行实现的
  4. 每次.value时都会触发trackRefValue即:收集依赖
  5. 在依赖触发时,需要谨记,先触发computedeffect,再触发非computedeffect
相关推荐
熊的猫1 小时前
JS 中的类型 & 类型判断 & 类型转换
前端·javascript·vue.js·chrome·react.js·前端框架·node.js
mosen8682 小时前
Uniapp去除顶部导航栏-小程序、H5、APP适用
vue.js·微信小程序·小程序·uni-app·uniapp
别拿曾经看以后~3 小时前
【el-form】记一例好用的el-input输入框回车调接口和el-button按钮防重点击
javascript·vue.js·elementui
Gavin_9153 小时前
【JavaScript】模块化开发
前端·javascript·vue.js
Devil枫9 小时前
Vue 3 单元测试与E2E测试
前端·vue.js·单元测试
GIS程序媛—椰子10 小时前
【Vue 全家桶】6、vue-router 路由(更新中)
前端·vue.js
毕业设计制作和分享11 小时前
ssm《数据库系统原理》课程平台的设计与实现+vue
前端·数据库·vue.js·oracle·mybatis
程序媛小果11 小时前
基于java+SpringBoot+Vue的旅游管理系统设计与实现
java·vue.js·spring boot
从兄12 小时前
vue 使用docx-preview 预览替换文档内的特定变量
javascript·vue.js·ecmascript
凉辰12 小时前
设计模式 策略模式 场景Vue (技术提升)
vue.js·设计模式·策略模式