计算属性是如何被Vue实现的

2022-09-27 19:25:19 浏览数 (1)

写在前边

无论是面试过程还是日常业务开发,相信大多数前端开发者对于 Vue 的应用已经熟能生巧了。

今天我们就来聊聊 Vue 中的 Computed 是如何被实现的。

文章会告别枯燥的源码,从用法到原理层层拨丝与你一起来看看在 Vue 中 Computed 是如何被实现的。

前置知识

首先,文章中的源码思路是基于最新稳定的 Vue@3.2.37 版本进行解读的。

其次,Computed 相关原理需要一些 Effect 相关的原理。如果你不是很清楚 Effect 是什么,推荐你优先阅读我的这篇 Vue3中的响应式是如何被JavaScript实现的。

当然,在文章中也会针对于一些额外的知识点稍微进行基础的讲解。

用法分析

首先,我们先来聊聊 computed 的一些用法特性。

懒计算

关于 Computed 大家都了解 Computed 是作为懒计算的,比如:

代码语言:javascript复制
<script setup lang="ts">
import { computed, reactive } from "vue";

const firstName = reactive({ name: "wang" });
const lastName = reactive({ name: "haoyu" });

const fullName = computed(() => {
  console.log("generator fullname.");
  return firstName.name   "."   lastName.name;
});
</script>

<template>
  <p>Hello</p>
  <p>My Name is wang.haoyu</p>
</template>

上述的一段代码,在我们打开页面时虽然我们定义了名为 fullName 的 computed 计算属性。

但是由于我们并没有在模板或者逻辑中使用它,所以它是不会进行任何计算的。换言之,fullName 中的 console.log('generator fullname') 是不会被执行的。

当然,如果我们使用到了定义的 computed 比如:

代码语言:javascript复制
<template>
  <p>Hello</p>
  <p>My Name is {{ fullName }}</p>
</template>

此时打开页面后会计算依赖属性,浏览器会输出:

代码语言:javascript复制
App.vue:8 generator fullname.

缓存

同时,Computed 最大的特点还是它具有缓存性质。

对于依赖的值如果未发生变化,那么 Computed 是不会重新进行计算的。比如:

代码语言:javascript复制
<script setup lang="ts">
import { computed, reactive } from "vue";

const firstName = reactive({ name: "wang" });
const lastName = reactive({ name: "haoyu" });

const fullName = computed(() => {
  console.log("generator fullname.");
  return firstName.name   "."   lastName.name;
});
</script>

<template>
  <p>Hello</p>
  <p>My Name is {{ fullName }}</p>
  <p>My Name is {{ fullName }}</p>
  <p>My Name is {{ fullName }}</p>
</template>

上述的代码中,即使我在模板中调用多次 fullName ,fullName 中的计算逻辑也仅仅只会执行一次。

也就是浏览器仅会打印一次 generator fullname.

只有当计算属性(fullName)中依赖的响应式数据 发生改变时,计算属性才会重新执行从而计算出最新的值。

支持任意值

大多数小伙伴利用 Computed 时,无非是使用了它的计算以及缓存两个特点。

因为 Computed 的原理导致,其实它是可以缓存任意值得,比如说你可以返回一个函数:

代码语言:javascript复制
<script setup lang="ts">
import { computed, ref } from "vue";

const nickName = ref("19Qingfeng");

const computedName = computed(() => {
  return (first: string, last: string) => {
    return first   "."   last   "-"   "nickName"   nickName.value;
  };
});
</script>

<template>
  <p>Hello</p>
  <p>My Name is {{ computedName("wang", "haoyu") }}</p>
</template>

此时,页面初始化时会正常渲染 My Name is wang.haoyu-nickNamewang,理论上你可以在 computed 中返回一切值它都会帮你进行缓存。

当然有以下两种情况需要大家额外留意:

代码语言:javascript复制
// 情况1
const computedName = computed(() => {
  return (first: string, last: string) => {
    return first   "."   last   "-"   "nickName"   nickName.value;
  };
});

// 情况下2
const computedName = computed(() => {
  const _nickName = nickName.value
  return (first: string, last: string) => {
    return first   "."   last   "-"   "nickName"   _nickName;
  };
});

虽然说情况1和情况2在页面上展示的效果是完全一致的,但是他们内部的原理是完全不同的。

在视图中依赖了 computedName 变量时:

  • 情况1下,如果 nickName 发生改变。computedName 并不会重新计算,是由于模板中的 Effect 直接依赖了 nickName ,由于 nickName 变化导致模板直接渲染。
  • 情况2下,如果 nickName 发生改变。模板和 nickName 并没有任何直接依赖关系,是由于computed 中依赖了 nickName,nickName 发生改变后导致 computed 会重新执行从而驱动视图更新。

简单来说,当 nickName 发生变化时,情况2会导致 computed 重新计算而情况1则不会。

上述提到的 Effect 意味副作用,简单来说在 Vue 中所有的响应式数据变化都会导致 Effect 执行。而 Effect 执行后会导致页面进行重新渲染。

如果有兴趣了解 Vue 中的响应式和 Effect 的同学可以移步这片文章。

值的设置

上述,我们提到的 Computed 基本上都是基于值的获取不涉及为 computed 重新赋值。

其实关于 computed 的设置我相信大家也都是耳熟能详了,我们在之前的写法:

代码语言:javascript复制
const fullName = computed(() => {
  return firstName.name   "."   lastName.name;
});

// 相当于
const fullName = computed({
  get() {
    return firstName.name   "."   lastName.name;
  },
  set() {},
});

当然 computed 也支持通过 value 属性结合 setter 来重新设置值,比如:

代码语言:javascript复制
<script setup lang="ts">
import { computed, ref, reactive } from "vue";

const firstName = reactive({ name: "wang" });
const lastName = reactive({ name: "haoyu" });

let nickName = ref("");

// 相当于
const fullName = computed({
  get() {
    return firstName.name   "."   lastName.name;
  },
  set(value: string) {
    nickName.value = value;
  },
});

// 为 computed 重新赋值,进入 setter
fullName.value = "19Qingfeng";
</script>

<template>
  <p>Hello</p>
  <p>My Name is {{ fullName }}</p>
  <p>{{ nickName }}</p>
</template>

get/set 的用法我就不过于累赘了,这次基础用法不是特别熟悉 Vue 的小伙伴可以翻阅下官方文档。

原理实现

上边我们大概聊了聊 Computed 用法上的一些特点,这里我们简单归纳一下。

Vue 中实现的 computed 需要缓存懒计算、以及它本身收集它内部依赖的响应式数据,当响应式发生改变时 computed 会重新计算当前内部缓存的值从而更新缓存值。

了解了这些基础特点后,我们在控制台来打印一下 computed 来看看它是什么东西:

代码语言:javascript复制
// ... 省略上文中的代码
console.log(fullName)

我们可以清楚的看到,所谓的 computed 对象是一个类的实例对象。

当然,稍后我们会详细来实现一下它。我们先来看看所谓实例上的一些属性代表的含义:

  • dep 上边我们提到过,一个 computed 本质上需要进行依赖收集。也就说当 computed 发生变化时(重新计算),需要通知模板上依赖该 Computed 的对象进行重新渲染。 所以这里的 dep 正是存储哪些 Effect 依赖了该 computed。
  • effect 同时我们说到过,除了 computed 发生改变时依赖的 computed 页面需要重新渲染,另一个有一个重要的点:计算属性中依赖的响应式数据发生改变时,该 computed 就会进行重新计算。 所以不难想到,简单来说一个 computed 也是一个 effect ,它同样对于它内部使用到的响应式数据进行依赖收集。
  • _value 上边我们提到了,computed 是具有缓存的特点的。那么每次变化后计算的值一定是需要存储的,这里的 _value 就是 computed 存储缓存值的地方。
  • _dirty 正如它的名字那般,这个属性代表的意思是脏的。当我们每次访问 computed 时,正是通过 _dirty 来判断本次 computed 是否需要重新计算,如果不需要则直接返回 _value 属性即可。
  • __v_isReadonly 这个属性表示该 computed 是否可写,通常情况下如果一个 computed 仅具有 getter 函数(或者仅传入一个函数时)那么它既是仅具有 getter 。那么,此时该 computed 是不可写的因为它并不具有 setter 。反之,get/set 都具有时该属性为 false。

上述的属性就是一个 Computed 中我们需要关心的属性,大概了解了各个属性代表的含义接下来就让我们一起来看看 computed 是如何被 Vue 实现的。

首先,我们来看看源代码中的 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 传入
    getter = getterOrOptions
    // 同时 setter 会默认在开发环境下赋为一个禁止修改的 log 函数
    setter = __DEV__
      ? () => {
          console.warn('Write operation failed: computed value is readonly')
        }
      : NOOP
  } else {
    // 其他情况就直接将传入的 getter 和 setting 进行赋值就可以了
    getter = getterOrOptions.get
    setter = getterOrOptions.set
  }
  // 通过传入的 getter 和 setter 进行初始化
  const cRef = new ComputedRefImpl(getter, setter, onlyGetter || !setter, isSSR)

  if (__DEV__ &amp;&amp; debugOptions &amp;&amp; !isSSR) {
    cRef.effect.onTrack = debugOptions.onTrack
    cRef.effect.onTrigger = debugOptions.onTrigger
  }

  return cRef as any
}

我们可以看到,computed 函数将传入的参数进行格式化后本质上是调用了ComputedRefImpl这个类进行实例 computed 实例对象。

接下来我们重点来看看所谓的 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

  public _dirty = true
  public _cacheable: boolean

  constructor(
    getter: ComputedGetter<T>,
    private readonly _setter: ComputedSetter<T>,
    isReadonly: boolean,
    isSSR: boolean
  ) {
    this.effect = new ReactiveEffect(getter, () => {
      if (!this._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)
    trackRefValue(self)
    if (self._dirty || !self._cacheable) {
      self._dirty = false
      self._value = self.effect.run()!
    }
    return self._value
  }

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

上述是 ComputedRefImpl 的所有源代码,所谓的 computed 对象本质上就是 ComputedRefImpl 的实例对象。

首先,我们可以看到 ComputedRefImpl 上拥有 get value 和 set value 两个属性。

说一点题外话,关于 class 上的 get/set(访问器属性) 在编译后是会添加到类的原型上而非作为实例属性。具体你可以查看这里。

同时,我们也提到过所谓的 computed 自身就是一个 Effect,对应 ComputedRefImpl 中的构造函数在初始化时候会为实例上挂载一个:

代码语言:javascript复制
this.effect = new ReactiveEffect(getter, () => {
    if (!this._dirty) { 
        this._dirty = true 
        // triggerRefValue 你可以先忽略这个逻辑,避免混淆我们稍后再来了解它
        triggerRefValue(this)
    } 
})
  • 初始化 computed 时,会调用 new ReactiveEffect 同时传入两个参数。
  • Effct 中的第一参数表示当前 Effect 进行依赖收集的函数,当 Effect 执行时会将当前函数中的所有响应式数据和当前 Effect 进行关联(依赖收集)。这里我们的 computed 自然是依赖 getter 函数中的数据,自然我们需要将第一个函数传入对应的 getter 从而在将当前 computed 对应的 effct 和 getter 函数中的数据进行关联。
  • Effect 中第二个函数为一个 scheduler ,不传入第二个参数时当当前 Effect 依赖的响应式数据发生变化后 Effect 中传入的第一个函数会立即执行。当传入第二个函数时,当第一个参数中依赖的响应式数据变化并不会执行传入的 getter 而是回执行对应的第二个参数 scheduler。
  • 在 scheduler 中,我们进行了处理。**当 computed 中 getter 里的响应式数据变化时会执行对应的 scheduler(派发更新) 将 _dirty 的值将它变为脏的也就是 true**。

Effect 我已经在前置文章 Vue3中的响应式是如何被JavaScript实现的 中介绍过它的实现,有兴趣深入了解的同学可以移步查阅。

同理,当我们首次访问该计算属性时。不难想到需要计算当前计算属性中的值:

代码语言:javascript复制
  get value() {
    const self = toRaw(this)
    trackRefValue(self)
    // 上述的代码你可以暂时忽略
    if (self._dirty || !self._cacheable) {
      self._dirty = false
      self._value = self.effect.run()!
    }
    return self._value
  }

getter 中做的事情也非常简单,当 this._dirty 为 false 时。访问 computed 的 value 时,会调用self.effect.run() 会执行当前 Effect 中的传入的函数(Effect 中第一个参数)。

同时获取返回值保存进入 self._value 中,也就是说当我们访问 computed 的 value 时,会触发 getter 中的逻辑。

getter 中首先会根据 self._dirty 判断当前 computed 是否需要重新计算,这一层起到了缓存的作用

之后,如果 self._dirty 为 true 时,会直接返回 self._value。反之,如果为 false 则会重新调用 self.effct.run() 重新计算 self._value 的值同时对于该 computed 依赖的数据重新进行依赖收集。

当前,setter 的实现就更加简单了:

代码语言:javascript复制
  set value(newValue: T) {
    this._setter(newValue)
  }

_setter 是我们传入 computed 的 setter,当调用 setter 时仅仅是执行传入的 setter 即可。

这一步,我们实现了computed 中的缓存以及 computed 中依赖的响应式数据发生改变时 Effect 会重新计算 computed 的值。

接下来还遗留一些反向的逻辑,computed 中的数据发生变化时 computed 不仅会重新计算同时也要通知依赖于该 computed 的 Effct 会重新执行从而造成页面渲染之类的数据响应式效果。

其实依赖下的功能,简单来讲也就是说所谓的 computed 计算属性不仅仅拥有收集自身依赖的数据的特点。同时也需要收集依赖于它的 Effect 的相关功能。

当 computed 发生变化时,同样需要通知依赖于它的 Effect 重新执行。从而导致页面重新渲染。

我们围绕上述的功能来分析源代码中是如何实现的:

首先在 getter 中我们遗失的逻辑:

代码语言:javascript复制
    // #3376 在 Vue 3.0.7 前在 readonly() 中包装 computed() 会破坏计算的功能,这里是为了解决在 readonly 包裹 computed 时保留计算属性的特殊处理。
    const self = toRaw(this)
    // 在访问getter时进行依赖收集
    trackRefValue(self)
    if (self._dirty || !self._cacheable) {
      self._dirty = false
      self._value = self.effect.run()!
    }
    return self._value

getter 中的重点在于 trackRefValue(self) 中,简单来说在每次获取 computed 的 value 值时,首先会进行 trackRefValue(self) 的调用。

会将当前正在运行的 Effect 关联到 computed 中的 dep 属性上(依赖收集),所谓正在运行的 Effect 指的是比如当前某个组件的模板中依赖了某个 computed 。

我们清楚所有的组件最终会被编译为 render 函数进行渲染,同样所有的组件最外层都会被 Effect 包裹处理。换句话说,当前组件渲染时,全局正在运行的 Effect 即是当前组件的渲染 Effect (被称为 activeEffect)。

所以,每次访问 computed 时会收集当前 activeEffect 将它保存进入当前 computed 中的 dep(Set) 中。

我们来看看所谓的 trackRefValue (packages/reactivity/src/ref.ts) 逻辑:

代码语言:javascript复制
export function trackRefValue(ref: RefBase<any>) {
  // 当前 activeEffect 存在,并且允许收集
  if (shouldTrack &amp;&amp; activeEffect) {
    // 将传入的 ref ,也就是 computed 对象转为原始对象
    ref = toRaw(ref)
    if (__DEV__) {
      trackEffects(ref.dep || (ref.dep = createDep()), {
        target: ref,
        type: TrackOpTypes.GET,
        key: 'value'
      })
    } else {
      // createDep 会创建一个 Set
      // trackEffects 的作用即是将当前 activeEffect 加入到 ref.dep 中
      trackEffects(ref.dep || (ref.dep = createDep()))
    }
  }
}
代码语言:javascript复制
// packages/reactivity/src/effect.ts
export function trackEffects(
  dep: Dep,
  debuggerEventExtraInfo?: DebuggerEventExtraInfo
) {
  let shouldTrack = false
  if (effectTrackDepth <= maxMarkerBits) {
    if (!newTracked(dep)) {
      dep.n |= trackOpBit // set newly tracked
      shouldTrack = !wasTracked(dep)
    }
  } else {
    // Full cleanup mode.
    shouldTrack = !dep.has(activeEffect!)
  }

  if (shouldTrack) {
    // 重点是这里
    dep.add(activeEffect!)
    activeEffect!.deps.push(dep)
    if (__DEV__ &amp;&amp; activeEffect!.onTrack) {
      activeEffect!.onTrack({
        effect: activeEffect!,
        ...debuggerEventExtraInfo!
      })
    }
  }
}

trackEffects 方法,你只需要重点关注 dep.add(activeEffect!) 即可。本质上还是我们刚才提到的,当我们访问 computed 的值时,会依次调用 getter => trackRefValue => trackEffects

最终将当前 activeEffect 添加进入当前 computed 实例中的 dep 中。

上述过程中,在 computed 中我们完成了依赖收集的过程,会将使用到 computed 的相关 Effect 添加进入当前 computed 的 dep 属性中。

之后,每当 computed 中依赖的响应式数据发生变化时。我们在之前提过到每当 computed 中依赖的数据发生变化时会执行自身 Effect 中的 scheduler

代码语言:javascript复制
  // ...
  constructor(
    getter: ComputedGetter<T>,
    private readonly _setter: ComputedSetter<T>,
    isReadonly: boolean,
    isSSR: boolean
  ) {
    this.effect = new ReactiveEffect(getter, () => {
      if (!this._dirty) {
        this._dirty = true
        // triggerRefValue 派发更新
        triggerRefValue(this)
      }
    })
    this.effect.computed = this
    this.effect.active = this._cacheable = !isSSR
    this[ReactiveFlags.IS_READONLY] = isReadonly
  }

当 computed 中依赖的数据发生变化时,如果 this._dirty 为 false 会调用 triggerRefValue 进行派发更新。

代码语言:javascript复制
// packages/reactivity/src/ref.ts
export function trackRefValue(ref: RefBase<any>) {
  if (shouldTrack &amp;&amp; activeEffect) {
    ref = toRaw(ref)
    if (__DEV__) {
      trackEffects(ref.dep || (ref.dep = createDep()), {
        target: ref,
        type: TrackOpTypes.GET,
        key: 'value'
      })
    } else {
      trackEffects(ref.dep || (ref.dep = createDep()))
    }
  }
}

trackRefValue 中逻辑处理同时也非常简单,它会首先将当前传入的 value 转化为原始的 object。之后会调用 trackEffects(ref.dep) 去依次触发当前 computed 中的 Effects(ref.dep)。

代码语言:javascript复制
export function triggerEffects(
  dep: Dep | ReactiveEffect[],
  debuggerEventExtraInfo?: DebuggerEventExtraInfo
) {
  // spread into array for stabilization
  const effects = isArray(dep) ? dep : [...dep]
  for (const effect of effects) {
    if (effect.computed) {
      // computed 进入以下逻辑
      triggerEffect(effect, debuggerEventExtraInfo)
    }
  }
  for (const effect of effects) {
    if (!effect.computed) {
      triggerEffect(effect, debuggerEventExtraInfo)
    }
  }
}

function triggerEffect(
  effect: ReactiveEffect,
  debuggerEventExtraInfo?: DebuggerEventExtraInfo
) {
  if (effect !== activeEffect || effect.allowRecurse) {
    if (__DEV__ &amp;&amp; effect.onTrigger) {
      effect.onTrigger(extend({ effect }, debuggerEventExtraInfo))
    }
    if (effect.scheduler) {
      effect.scheduler()
    } else {
      effect.run()
    }
  }
}

上述的逻辑其实我相信对于大家来说都是小菜一碟了,其实就是遍历 deps 中的各个 Effect 进行依次调用即可。

这样,也就达到了我们刚才的需求:当 computed 中依赖的数据发生改变时会触发自身的 Effect 执行,在自身 Effect 中的处理函数同时会通知依赖于当前 computed 的 Effect 依次执行(达成重新渲染视图等效果)从而实现响应式特性。

总结

可以看到 computed 的实现还是非常简单的,我们稍微来总结下这个过程。

所谓的计算属性 computed 本身就是一个 Effect,默认情况下 computed 是不会进行计算的。

当我们使用了该 computed 时,访问 computed 的 getter 属性。会发生:

  • 调用 this.effect.run() 执行当前 computed 的 getter 方法,获得返回值保存进入 this._value 记录。
  • this._dirty 重置为 false,利用 _dirty_value 实现缓存的特性。
  • 同时调用 trackRefValue 收集当前 activeEffect ,将当前活跃的 Effect 存储到 computed 的 dep 属性中,进行依赖收集。

之后,如果 computed 依赖的响应式数据发生改变,会发生:

  • 首先,computed 中依赖的响应式数据发生改变。会重新调用当前 computed 的 effect 通知依赖于该 computed 的 effect 重新执行。
  • 当依赖于该 computed 的 effect 重新执行时,会重新访问到 computed 的 getter 此时会重新计算 computed 中的值,得到更新后的 value 进行重新缓存。

简单来说,所谓 computed 的核心实现思路就是如此。

当前,如果对某个细节不是特别清楚的小伙伴可以在评论区留下你的问题,或者自行查阅源代码。

写在结尾

比起Vue2,Vue3 的源码其实显得非常见解和易读。

有兴趣的小伙伴可以关注我的专栏,之后会为大家带来更多关于](https://juejin.cn/column/7048582588327264286),之后会为大家带来更多关于) Vue 的见解。

大家加油!

0 人点赞