前言
对于响应性系统而言,除了前两章接触的 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