对于响应性系统而言,除了前两章接触的 ref
和 reactive
之外,还有另外两个也是我们经常使用到的,那就是:
computed
watch
本章我们先来实现一下 computed
这个 API
计算属性
computed
会 基于其响应式依赖被缓存,并且在依赖的响应式数据发生变化时 重新计算
我们来看下面这段代码:
js<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
的实现:
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
:tscomputedObj.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
的构造函数中传入的匿名函数
在匿名函数中会:再次触发依赖
即:两次触发依赖
最后执行 :
js() => {
return '姓名:' + obj.name
}
得到值作为 computedObj
的值
总结:
到这里我们基本上了解了 computed
的执行逻辑,里面涉及到了一些我们之前没有了解过的概念,比如 调度器 scheduler
,并且整体的 computed
的流程也相当复杂。
对于 computed
而言,整体比较复杂,所以我们将分步进行实现
我们的首先的目标是:构建 ComputedRefImpl
类,创建出 computed
方法,并且能够读取值
packages/reactivity/src/computed.ts
:tsimport { 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
中,创建工具方法:ts/**
* 是否为一个 function
*/
export const isFunction = (val: unknown): val is Function =>
typeof val === 'function'
packages/reactivity/src/effect.ts
中,为 ReactiveEffect
增加 computed
属性:ts /**
* 存在该属性,则表示当前的 effect 为计算属性的 effect
*/
computed?: ComputedRefImpl<T>
在 packages/reactivity/src/index.ts
和 packages/vue/src/index.ts
导出
创建测试实例:packages/vue/examples/reactivity/computed.html
:
ts <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
发生变化时,我们可以发现浏览器 并不会 跟随变化,即:计算属性并非是响应性的。那么想要完成这一点,我们还需要进行更多的工作才可以。
如果我们想要实现 响应性,那么必须具备两个条件:
get value
中进行。代码实现:
packages/reactivity/src/computed.ts
中,处理脏状态和 scheduler:tsexport 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
的处理:tsexport type EffectScheduler = (...args: any[]) => any
/**
* 响应性触发依赖时的执行类
*/
export class ReactiveEffect<T = any> {
/**
* 存在该属性,则表示当前的 effect 为计算属性的 effect
*/
computed?: ComputedRefImpl<T>
constructor(
public fn: () => T,
public scheduler: EffectScheduler | null = null
) {}
...
}
ts/**
* 触发指定的依赖
*/
export function triggerEffect(effect: ReactiveEffect) {
// 存在调度器就执行调度函数
if (effect.scheduler) {
effect.scheduler()
}
// 否则直接执行 run 函数即可
else {
effect.run()
}
}
此时,重新执行测试实例,则发现 computed
已经具备响应性。
到目前为止,我们的 computed
其实已经具备了响应性,但是还存在一点问题。我们来看下下面的代码
我们来看下面的代码:
html<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 只会执行一次才对,但是却提示 超出最大调用堆栈大小
。
我们继续从源码中找问题,我们接着从两秒之后的 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
函数,所以最终会执行:
jsdocument.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
,就像 第五、第六、第七步 一样
从而导致 死循环
想要解决这个死循环的问题,其实比较简单,我们只需要 packages/reactivity/src/effect.ts
中的 triggerEffects
中修改如下代码:
tsexport 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
只计算了一次。
原理就是将具有 computed
属性的 effect
放在前面,先执行有 computed
属性的 effect
,再执行没有 computed
属性的 effect
第一个执行的有 computed
属性的 effect
:
第二个执行的没有 computed
属性的 effect
:
计算属性实现的重点:
ComputedRefImpl
的实例ComputedRefImpl
中通过 dirty
变量来控制 run
的执行和 triggerRefValue
的触发.value
,因为它内部和 ref
一样是通过 get value
来进行实现的.value
时都会触发 trackRefValue
即:收集依赖computed
的 effect
,再触发非 computed
的 effect
本文作者:叶继伟
本文链接:
版权声明:本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!