从 0 搭建一个 mini-vue 项目(四):computed 的响应性
前言
对于响应性系统而言,除了前两章接触的 ref 和 reactive 之外,还有另外两个也是我们经常使用到的,那就是:
- 计算属性:
computed - 侦听器:
watch
本章我们先来实现一下 computed 这个 API
1. computed 计算属性
计算属性
computed会 基于其响应式依赖被缓存,并且在依赖的响应式数据发生变化时 重新计算
我们来看下面这段代码:
<div id="app"></div>
<script>
const { reactive, computed, effect } = Vue
const obj = reactive({
name: '张三'
})
const computedObj = computed(() => {
return '姓名:' + obj.name
})
effect(() => {
document.querySelector('#app').innerHTML = computedObj.value
})
setTimeout(() => {
obj.name = '李四'
}, 2000)
</script>
上面的代码,程序主要执行了 5 个步骤:
- 使用
reactive创建响应性数据 - 通过
computed创建计算属性computedObj,并且触发了obj的getter - 通过
effect方法创建fn函数 - 在
fn函数中,触发了computed的getter - 延迟触发了
obj的setter
接下来我们将从源码中研究 computed 的实现:
2. computed 源码阅读
- 因为研究过了
reactive的实现,所以我们直接来到packages/reactivity/src/computed.ts中的第84行,在computed函数出打上断点:

- 可以看到
computed方法其实很简单,主要就是创建并返回了一个ComputedRefImpl对象,我们将代码跳转进ComputedRefImpl类。

- 在
ComputedRefImpl的构造函数中 创建了ReactiveEffect实例,并且传入了两个参数:getter:触发computed函数时,传入的第一个参数- 匿名函数:当
this._dirty为false时,会触发triggerRefValue,我们知道triggerRefValue会 依次触发依赖 (_dirty 在这里以为 脏 的意思,可以理解为 《依赖的数据发生变化,计算属性就需要重新计算了》)
- 对于
ReactiveEffect而言,我们之前是有了解过的,生成的实例,我们一般把它叫做effect,他主要提供两个方法:run方法:触发fn,即传入的第一个参数stop方法:语义上为停止的意思,我这里目前还没有实现
至此,我们已经执行完了 computed 函数,我们来总结一下做了什么:
- 定义变量
getter为我们传入的回调函数 - 生成了
ComputedRefImpl实例,作为computed函数的返回值 ComputedRefImpl内部,利用了ReactiveEffect函数,并且传入了 第二个参数
- 当
computed代码执行完成之后,我们在effect中触发了computed的getter:
computedObj.value
根据我们之前在学习 ref 的时候可知,.value 属性的调用本质上是一个 get value 的函数调用,而 computedObj 作为 computed 的返回值,本质上是 ComputedRefImpl 的实例, 所以此时会触发 ComputedRefImpl 下的 get value 函数。

-
在
get value中,做了两件事:- 做了
trackRefVale依赖收集。 - 执行了之前存在
computed中的函数() => return '姓名' + obj.name,并返回了结果
- 做了
-
这里可以提一下第
59行中的判断条件,_dirty初始化是ture(_cacheable 初始化false),所以会执行这个if, 在if中将_dirty改为了false,也就是说只要不改这个_dirty,下次再去获取computedObj.value值时,不会重新执行fn。 -
effect函数执行完成,页面显示姓名:张三,延迟两秒之后,会触发obj.name即reactive的setter行为,所以我们可以在packages/reactivity/src/baseHandlers.ts中为set增加一个断点:

- 可以发现因为之前
oldValue是张三 ,现在value是李四,hasChange方法为true,进入到trigger方法

- 同样跳过之前相同逻辑,可知,最后会触发:
triggerEffects(deps[0], eventInfo)方法。进入triggerEffects方法:

- 这里要注意:因为我们在
ComputedRefImpl的构造函数中,执行了this.effect.computed = this,所以此时的if (effect.computed)判断将会为true。此时我们注意看effects,此时effect的值为ReactiveEffect的实例,同时scheduler存在值; - 接下来进入
triggerEffect:

- 不知道大家还有没有印象,在
ComputedRefImpl的构造函数创建ReactiveEffect实例时传进去的第二个参数,那个参数就是这里scheduler。

- 我们进入
scheduler回调:

- 此时的
_dirty是false,所以会执行triggerRefValue 函数,我们进入triggerRefValue:

triggerRefValue会再次触发triggerEffects依赖触发函数,把当前的this.dep作为参数传入。注意此时的effect是没有computed和scheduler属性的。

fn函数的触发,标记着computedObj.value触发,而我们知道computedObj.value本质上是get value函数的触发,所以代码接下来会触发ComputedRefImpl的get value

-
获取到
computedObj.value后 通过ocument.querySelector('#app').innerHTML = computedObj.value修改视图。 -
至此,整个过程结束。
梳理一下修改 obj.name 到修改视图的过程:
-
整个事件有
obj.name开始 -
触发
proxy实例的setter -
执行
trigger,第一次触发依赖 -
注意,此时
effect包含scheduler调度器属性,所以会触发调度器 -
调度器指向
ComputedRefImpl的构造函数中传入的匿名函数 -
在匿名函数中会:再次触发依赖
-
即:两次触发依赖
-
最后执行 :
() => {
return '姓名:' + obj.name
}
得到值作为 computedObj 的值
总结:
到这里我们基本上了解了 computed 的执行逻辑,里面涉及到了一些我们之前没有了解过的概念,比如 调度器 scheduler ,并且整体的 computed 的流程也相当复杂。
对于 computed 而言,整体比较复杂,所以我们将分步进行实现
3. 构建 ComputedRefImpl ,读取计算属性的值
我们的首先的目标是:构建 ComputedRefImpl 类,创建出 computed 方法,并且能够读取值
- 创建
packages/reactivity/src/computed.ts:
import { isFunction } from '@vue/shared'
import { Dep } from './dep'
import { ReactiveEffect } from './effect'
import { trackRefValue } from './ref'
/**
* 计算属性类
*/
export class ComputedRefImpl<T> {
public dep?: Dep = undefined
private _value!: T
public readonly effect: ReactiveEffect<T>
public readonly __v_isRef = true
constructor(getter) {
this.effect = new ReactiveEffect(getter)
this.effect.computed = this
}
get value() {
// 触发依赖
trackRefValue(this)
// 执行 run 函数
this._value = this.effect.run()!
// 返回计算之后的真实值
return this._value
}
}
/**
* 计算属性
*/
export function computed(getterOrOptions) {
let getter
// 判断传入的参数是否为一个函数
const onlyGetter = isFunction(getterOrOptions)
if (onlyGetter) {
// 如果是函数,则赋值给 getter
getter = getterOrOptions
}
const cRef = new ComputedRefImpl(getter)
return cRef as any
}
- 在
packages/shared/src/index.ts中,创建工具方法:
/**
* 是否为一个 function
*/
export const isFunction = (val: unknown): val is Function =>
typeof val === 'function'
- 在
packages/reactivity/src/effect.ts中,为ReactiveEffect增加computed属性:
/**
* 存在该属性,则表示当前的 effect 为计算属性的 effect
*/
computed?: ComputedRefImpl<T>
-
在
packages/reactivity/src/index.ts和packages/vue/src/index.ts导出 -
创建测试实例:
packages/vue/examples/reactivity/computed.html:
<body>
<div id="app"></div>
</body>
<script>
const { reactive, computed, effect } = Vue
const obj = reactive({
name: '张三'
})
const computedObj = computed(() => {
return '姓名:' + obj.name
})
effect(() => {
document.querySelector('#app').innerHTML = computedObj.value
})
setTimeout(() => {
obj.name = '李四'
}, 2000)
</script>
此时,我们可以发现,计算属性,可以正常展示。
但是: 当 obj.name 发生变化时,我们可以发现浏览器 并不会 跟随变化,即:计算属性并非是响应性的。那么想要完成这一点,我们还需要进行更多的工作才可以。
4. 初见调度器,处理脏的状态
如果我们想要实现 响应性,那么必须具备两个条件:
- 收集依赖:该操作我们目前已经在
get value中进行。 - 触发依赖:该操作我们目前尚未完成,而这个也是我们本小节主要需要做的事情。
代码实现:
- 在
packages/reactivity/src/computed.ts中,处理脏状态和 scheduler:
export class ComputedRefImpl<T> {
...
/**
* 脏:为 false 时,表示需要触发依赖。为 true 时表示需要重新执行 run 方法,获取数据。即:数据脏了
*/
public _dirty = true
constructor(getter) {
this.effect = new ReactiveEffect(getter, () => {
// 判断当前脏的状态,如果为 false,表示需要《触发依赖》
if (!this._dirty) {
// 将脏置为 true,表示
this._dirty = true
triggerRefValue(this)
}
})
this.effect.computed = this
}
get value() {
// 触发依赖
trackRefValue(this)
// 判断当前脏的状态,如果为 true ,则表示需要重新执行 run,获取最新数据
if (this._dirty) {
this._dirty = false
// 执行 run 函数
this._value = this.effect.run()!
}
// 返回计算之后的真实值
return this._value
}
}
- 在
packages/reactivity/src/effect.ts中,添加scheduler的处理:
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
) {}
...
}
- 最后不要忘记,触发调度器函数
/**
* 触发指定的依赖
*/
export function triggerEffect(effect: ReactiveEffect) {
// 存在调度器就执行调度函数
if (effect.scheduler) {
effect.scheduler()
}
// 否则直接执行 run 函数即可
else {
effect.run()
}
}
此时,重新执行测试实例,则发现 computed 已经具备响应性。
5. computed 的 缓存问题 和 死循环问题
到目前为止,我们的 computed 其实已经具备了响应性,但是还存在一点问题。我们来看下下面的代码
5.1 存在的问题
我们来看下面的代码:
<body>
<div id="app"></div>
</body>
<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(() => {
computedObj.value = '李四'
}, 2000)
</script>
结果报错了:
调用了两次 computedObj.value 按理说 computed 只会执行一次才对,但是却提示 超出最大调用堆栈大小。
5.2 为什么会出现死循环
我们继续从源码中找问题,我们接着从两秒之后的 obj.name = '李四' 开始调试。
- 修改
obj.name = '李四',此时会进行obj的依赖处理trigger函数中

-
代码继续向下进行,进入
triggerEffects(dep)方法 -
在
triggerEffects(dep)方法中,继续进入triggerEffect(effect) -
在
triggerEffect中接收到的effect,即为刚才查看的 计算属性的effect -
此时因为
effect中存在scheduler,所以会执行该计算属性的scheduler函数

- 在
scheduler函数中,会触发triggerRefValue(this)

-
而
triggerRefValue则会再次触发triggerEffects。
-
特别注意: 此时
effects的值为 计算属性实例的dep:

-
循环
effects,从而再次进入triggerEffect中。 -
再次进入
triggerEffect,此时effect为 非计算属性的effect,即fn函数(修改DOM的函数) -
因为他 不是 计算属性的
effect,所以会直接执行run方法。 -
而我们知道
run方法中,其实就是触发了fn函数,所以最终会执行:
document.querySelector('#app').innerHTML = computedObj.value
document.querySelector('#app').innerHTML = computedObj.value
-
但是在这个
fn函数中,是有触发computedObj.value的,而computedObj.value其实是触发了computed的get value方法。 -
那么这次
run的执行会触发 两次computed的get value
- 第一次进入:
- 进入
computed的get value: - 首先收集依赖
- 接下来检查
dirty脏的状态,执行this.effect.run()! - 获取最新值,返回
- 进入
- 第二次进入:
- 进入
computed的get value: - 首先收集依赖
- 接下来检查
dirty脏的状态,因为在上一次中dirty已经为false,所以本次 不会在触发this.effect.run()! - 直接返回结束
- 进入
- 按说代码应该到这里就结束了,但是不要忘记,在刚才我们进入到
triggerEffects时,effets是一个数组,内部还存在一个computed的effect,所以代码会 继续 执行,再次来到triggerEffect中:
- 此时
effect为computed的effect:

这会导致,再次触发 scheduler,scheduler 中还会再次触发 triggerRefValue,triggerRefValue 又触发 triggerEffects ,再次生成一个新的 effects 包含两个 effect,就像 第五、第六、第七步 一样
从而导致 死循环
5.3 解决方法
想要解决这个死循环的问题,其实比较简单,我们只需要 packages/reactivity/src/effect.ts 中的 triggerEffects 中修改如下代码:
export function triggerEffects(dep: Dep) {
// 把 dep 构建为一个数组
const effects = isArray(dep) ? dep : [...dep]
// 依次触发
// for (const effect of effects) {
// triggerEffect(effect)
// }
// 不在依次触发,而是先触发所有的计算属性依赖,再触发所有的非计算属性依赖
for (const effect of effects) {
if (effect.computed) {
triggerEffect(effect)
}
}
for (const effect of effects) {
if (!effect.computed) {
triggerEffect(effect)
}
}
}
查看测试实例的打印,此时 computed 只计算了一次。
5.4 解决方法的原理
原理就是将具有 computed 属性的 effect 放在前面,先执行有 computed 属性的 effect,再执行没有 computed 属性的 effect
第一个执行的有 computed 属性的 effect:

第二个执行的没有 computed 属性的 effect:

6. 总结
计算属性实现的重点:
- 计算属性的实例,本质上是一个
ComputedRefImpl的实例 ComputedRefImpl中通过dirty变量来控制run的执行和triggerRefValue的触发- 想要访问计算属性的值,必须通过
.value,因为它内部和ref一样是通过get value来进行实现的 - 每次
.value时都会触发trackRefValue即:收集依赖 - 在依赖触发时,需要谨记,先触发
computed的effect,再触发非computed的effect
转载自:https://juejin.cn/post/7183760494736441401