Vue3源码解读-computed实现原理


💡 [本系列Vue3源码解读文章基于3.3.4版本](https://github.com/vuejs/core/tree/v3.3.4) 欢迎关注公众号:《前端Talkking》

1、前言

在Vue3中,有一个effect函数,它用来注册副作用函数,同时它也允许指定一些选项参数options,例如指定scheduler调度器来控制副作用函数的执行时机和方式;也介绍了用来追踪和收集依赖的track函数,以及用来触发副作用函数重新执行的trigger函数。实际上,综合这些内容,我们就可以实现 Vue.js 中一个非常重要并且非常有特色的能力---计算属性 computed

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

javascript 复制代码
effect(
  () => {
    console.log("123")
  }
)

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

javascript 复制代码
effect(
  // 如果指定了lazy 选项,那么这个函数不会立即执行
  () => {
    console.log()
  },
  // options
  {
    lazy: true
  }
)

effect源码实现中,如果指定了options.lazy为true,则不立即执行副作用函数,而是将副作用函数effect作为返回值返回,如下面的代码所示:

javascript 复制代码
export function effect<T = any>(
  fn: () => T,
  options?: ReactiveEffectOptions
): ReactiveEffectRunner {
  // 省略部分代码

  // 如果不是延迟执行的,则立即执行一次副作用函数
  if (!options || !options.lazy) {
    _effect.run()
  }
  // 通过bind函数返回一个新的副作用函数
  const runner = _effect.run.bind(_effect) as ReactiveEffectRunner
  // 将副作用添加到新的副作用函数上
  runner.effect = _effect
  // 返回这个新的副作用函数
  return runner
}

上面的源码我们可以得知,计算属性computed实际上就是一个通过指定lazy属性而实现的懒执行的副作用函数

2、computed源码实现

2.1 computed签名实现

computed入口源码

javascript 复制代码
// 方式1
export function computed<T>(
  getter: ComputedGetter<T>,
  debugOptions?: DebuggerOptions
): ComputedRef<T>
// 方式2
export function computed<T>(
  options: WritableComputedOptions<T>,
  debugOptions?: DebuggerOptions
): WritableComputedRef<T>
// 方式3
export function computed<T>(
  getterOrOptions: ComputedGetter<T> | WritableComputedOptions<T>,
  debugOptions?: DebuggerOptions,
  isSSR = false
)

从以上源码实现中,我们可以看出computed计算属性有3种实现方式:

  • 方式1:只读的,接收一个getter,并返回ComputedRef类型的值;
  • 方式2:可读写,接收一个具有getset函数的options对象,并返回一个可写的ref对象;
  • 方式3:是方式1和方式2的结合,此时,既可以接收一个getter函数,又可以接受具有getset函数的 options对象。

第1种方式,我们可以这样使用计算属性:

javascript 复制代码
const count = ref(0)
// computed 接受一个 getter 函数
const plusTwo = computed(() => count.value + 2)

console.log(plusTwo.value) // 2
plusTwo.value++ // 错误

第2种方式,我们可以这样使用计算属性:

javascript 复制代码
const count = ref(2)
const plusTwo = computed({
  // computed 函数接受一个具有 get 和 set 函数的 options 对象
  get: () => count.value + 2,
  set: val => {
    count.value = val - 2
  }
})
plusTwo.value = 2
console.log(count.value) // 0

2.2 computed参数标准化处理

computed源码实现

javascript 复制代码
export function computed<T>(
  getterOrOptions: ComputedGetter<T> | WritableComputedOptions<T>,
  debugOptions?: DebuggerOptions,
  isSSR = false
) {
  let getter: ComputedGetter<T>
  let setter: ComputedSetter<T>
  // 入参是函数类型
  const onlyGetter = isFunction(getterOrOptions)
  if (onlyGetter) {
    getter = getterOrOptions
    setter = __DEV__
      ? () => {
          console.warn('Write operation failed: computed value is readonly')
        }
      : NOOP
  } else {
    // 入参是对象类型
    getter = getterOrOptions.get
    setter = getterOrOptions.set
  }

  const cRef = new ComputedRefImpl(getter, setter, onlyGetter || !setter, isSSR)
  // onTrack 和 onTrigger 仅开发模式下生效
  if (__DEV__ && debugOptions && !isSSR) {
    cRef.effect.onTrack = debugOptions.onTrack
    cRef.effect.onTrigger = debugOptions.onTrigger
  }

  return cRef as any
}

由于 computed函数有三种使用方法,因此在该函数中做了标准化处理:

  1. 如果传入的值getterOrOptionsgetter函数,则直接将传入的参数值赋值给getter函数,此时,计算属性是只读的,并且在开发环境中设置 setter 会报出警告;
  2. 如果传入的值getterOrOptions是对象(包含get、set方法),则将getset方法分别赋值给 computed 函数的gettersetter方法;
  3. 创建 ComputedRefImpl实例,该实例是一个ref对象,定义了getset两个方法,最后将该实例返回,因此计算属性取值和赋值的时候需要带上.value

2.3 ComputedRefImpl源码实现

ComputedRefImpl源码实现

javascript 复制代码
export class ComputedRefImpl<T> {
  public dep?: Dep = undefined

  private _value!: T
  public readonly effect: ReactiveEffect<T>

  public readonly __v_isRef = true
  public readonly [ReactiveFlags.IS_READONLY]: boolean = false
  // 数据是否更新的标识:缓存标识、脏数据标识,默认应该取值计算,所以是true
  public _dirty = true
  public _cacheable: boolean

  constructor(
    getter: ComputedGetter<T>,
    private readonly _setter: ComputedSetter<T>,
    isReadonly: boolean,
    isSSR: boolean
  ) {
    // 将用户的getter放到effect中,这样能对getter函数进行依赖收集,activeEffect会变为getter生成的effect
    // 传入scheduler调用函数,稍后 依赖的属性变化会调用此方法
    this.effect = new ReactiveEffect(getter, () => {
      // 稍后依赖属性变化会执行此调度函数
      if (!this._dirty) {
        // 2、依赖的值变化会更新dirty并触发更新
        this._dirty = true
        // 触发更新
        triggerRefValue(this)
      }
    })
    this.effect.computed = this
    this.effect.active = this._cacheable = !isSSR
    this[ReactiveFlags.IS_READONLY] = isReadonly
  }

  get value() {
    // 获取原始对象
    // the computed ref may get wrapped by other proxies e.g. readonly() #3376
    const self = toRaw(this)
    // 1、取值的时候进行依赖收集!!!
    trackRefValue(self)
    // 第一次是true,开关开启,说明是脏值,执行函数,然后关闭开关
    if (self._dirty || !self._cacheable) {
      self._dirty = false
      // 其实执行的是scheduler调度函数在其中触发更新triggerEffects,此处的run方法其实就是computed传入的匿名方法
      self._value = self.effect.run()!
    }
    return self._value
  }

  set value(newValue: T) {
    this._setter(newValue)
  }
}

在上面的源码中,拆解实现步骤如下:

  1. 定义了 _value私有变量用于缓存上一次计算的值,定义了 _dirty变量用于表示是否需要重新计算值,为true时表示需要重新计算,默认是true;
  2. 在构造函数中,定义了effect,ReactiveEffect第二个参数称为 scheduler调度器,当依赖属性的值发生变化时会触发该方法的执行;
  3. get方法中,读取值的时候使用 trackRefValue(self) 方法进行依赖收集,由于 _dirty默认是true,开关开启,先将开关关闭,然后执行 self.effect.run()方法后进行赋值,effect.run()方法实际上是computed传入的匿名方法 getter
  4. 当计算属性依赖的属性发生变化时,就会执行 scheduler调度器,即:
javascript 复制代码
() => {
  // 稍后依赖属性变化会执行此调度函数
  if (!this._dirty) {
    // 2、依赖的值变化会更新dirty并触发更新
    this._dirty = true
    // 触发更新
    triggerRefValue(this)
  }
}

此时,this._dirty是false,因此会先设置dirty的值为true,表示依赖的属性有更新,需要重新计算了,然后触发更新。触发更新会重新获取value值,此时dirty为true,因此重新执行effect.run方法(即computed传入的匿名函数),最后获取到了最新的值。

3、示例调试

下面我们用一个示例来熟悉一下computed的整个流程。

3.1 修改build命令

开启sourcemap支持,末尾增加:-s

javascript 复制代码
"build": "node scripts/build.js -s"

3.2 运行命令打包命令

javascript 复制代码
pnpm run build

执行完毕后会在 packages/vue/dist目录下生成打包后的文件,如下图所示:

3.3 添加demo示例

packages/vue/examples目录下新建computed目录以及computed.html文件,内容如下:

javascript 复制代码
<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>computed</title>
  <script src="../..dist/vue.global.js"></script>
</head>

<body>
  <div id="app"></div>
  <script>
    const { reactive, effect, computed } = Vue
    // 创建响应式数据
    const obj = reactive({
      name: 'Vue2'
    })
    // 计算属性 触发 obj.name 的 get 行为
    const computedObj = computed(() => {
      return '版本:' + obj.name
    })
    // effect 函数中 触发 计算属性的 get 行为
    effect(() => {
      document.querySelector('#app').innerHTML = computedObj.value
    })
    // 修改响应式数据的 name 值 触发 set 行为
    setTimeout(() => {
      obj.name = 'Vue3'
    }, 2000)
  </script>
</body>

</html>

首先computed接收了一个匿名函数getterOrOptions,它在底层传给了getter函数:

然后执行effect函数,执行赋值操作:document.querySelector('#app').innerHTML = computedObj.value ,触发到了computed的get value方法。在get方法内,先调用了 trackRefValue(self)方法进行依赖收集。此时_dirty变量默认是true,会执行effect.run()方法,而 effect.run()方法其实就是computed的匿名函数() => { return '版本:' + obj.name }

执行完毕后,页面渲染出:版本:Vue2

2秒后会触发obj的setter方法,它根据name属性获取到对应的effects,然后执行triggerEffects方法。由于此时执行的effect含有computed 属性,且存在scheduler,则会执行 effect.scheduler()方法。

在执行effect.scheduler()方法中,执行triggerRefValue(this)依赖触发。此时,scheduler为null,会执行 effect.run方法,也就是efffect传入的匿名函数。

之后执行document.querySelector('#app').innerHTML = computedObj.value赋值操作,再次触发 computedget value方法。

javascript 复制代码
get value() {
  // 获取原始对象
  // the computed ref may get wrapped by other proxies e.g. readonly() #3376
  const self = toRaw(this)
  // 1、取值的时候进行依赖收集!!!
  trackRefValue(self)
  // 第一次是true,开关开启,说明是脏值,执行函数,然后关闭开关
  if (self._dirty || !self._cacheable) {
    self._dirty = false
    // 其实执行的是scheduler调度函数在其中触发更新triggerEffects,此处的run方法其实就是computed传入的匿名方法
    self._value = self.effect.run()!
  }
  return self._value
}

接着执行self._value = self.effect.run()!,又再次执行computed传入的匿名函数() => { return '版本:' + obj.name }重新赋值:

执行完毕后,页面会呈现更新后的值,这样就是最新的值了。

4、总结

本文介绍了computed的实现流程,计算属性computed实际上是一个懒执行的副作用函数,我们通过lazy选项使得副作用函数可以 懒执行。被标记为懒执行的副作用函数可以通过手动方式让其执行。 利用这个特点,我们设计了计算属性,当读取计算属性的值时,只需要手动执行副作用函数即可。当计算属性依赖的响应式数据发生变化 时,会通过scheduler将 dirty 标记设置为true,代表"脏"。这样,下次读取计算属性的值时,我们会重新计算真正的值,从而完成了值的实时更新。

5、参考资料

[1] Vue官网

[2] Vuejs设计与实现

[3] Vue3源码

相关推荐
我认不到你5 分钟前
antd proFromSelect 懒加载+模糊查询
前端·javascript·react.js·typescript
集成显卡8 分钟前
axios平替!用浏览器自带的fetch处理AJAX(兼容表单/JSON/文件上传)
前端·ajax·json
焚琴煮鹤的熊熊野火17 分钟前
前端垂直居中的多种实现方式及应用分析
前端
我是苏苏37 分钟前
C# Main函数中调用异步方法
前端·javascript·c#
转角羊儿1 小时前
uni-app文章列表制作⑧
前端·javascript·uni-app
大G哥1 小时前
python 数据类型----可变数据类型
linux·服务器·开发语言·前端·python
hong_zc1 小时前
初始 html
前端·html
小小吱1 小时前
HTML动画
前端·html
糊涂涂是个小盆友2 小时前
前端 - 使用uniapp+vue搭建前端项目(app端)
前端·vue.js·uni-app
浮华似水2 小时前
Javascirpt时区——脱坑指南
前端