第六章:响应系统 - computed && watch
01:开篇
对于响应性系统而言,除了我们在前两章接触的 ref
和 reactive
之外,还有另外两个也是我们经常使用到的,那就是:
- 计算属性:
computed
- 侦听器:
watch
那么本章节,我们就来看一下,这两个 API
是如何进行实现的。
在看本章节的内容之前,大家需要:搞明白 vue 3
中 computed
和 watch
的作用和基本用法。
搞明白了这两个 API
的基本用法之后,大家就可以来开始本章节的学习啦~~~
02:源码阅读:computed 的响应性,跟踪 Vue 3 源码实现逻辑
计算属性
computed
会 基于其响应式依赖被缓存,并且在依赖的响应式数据发生变化时 重新计算
那么根据计算属性的概念,我们可以创建对应的测试实例:packages/vue/examples/imooc/computed.html
:
<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
那么在这 5 个步骤中,有些步骤进行的操作我们是了解的,所以我们只需要看之前没有了解过得即可。
computed
computed
的代码在 packages/reactivity/src/computed.ts
中,我们可以在这里为 computed
函数增加断点:
代码进入
computed
函数执行
const onlyGetter = isFunction(getterOrOptions)
方法:getterOrOptions
为传入的第一个参数, 因为我们传入的为函数,所以onlyGetter = true
执行:
getter = getterOrOptions
,即:getter
为我们传入的函数执行:
setter = NOOP
,NOOP
为() => {}
执行:
new ComputedRefImpl
,创建ComputedRefImpl
实例。那么这里的ComputedRefImpl
是什么呢?进入
ComputedRefImpl
在构造函数中,可以看到:**创建了
ReactiveEffect
实例 **,并且传入了两个参数:getter
:触发computed
函数时,传入的第一个参数- 匿名函数:当
this._dirty
为false
时,会触发triggerRefValue
,我们知道triggerRefValue
会 依次触发依赖
js() => { // _dirty 表示 “脏” 的意思,这里可以理解为 《依赖的响应性数据发生了变化,计算属性需要重新计算了》 if (!this._dirty) { this._dirty = true triggerRefValue(this) } }
而对于
ReactiveEffect
而言,我们之前也是有了解过的:- 它位于
packages/reactivity/src/effect.ts
文件中 - 提供了一个
run
方法 和一个stop
方法:run
方法:触发fn
,即传入的第一个参数stop
方法:语义上为停止的意思,目前咱们还没有实现
- 生成的实例,我们一般把它叫做
effect
- 它位于
执行
this.effect.computed = this
,即:effect 实例 被挂载了一个新的属性computed
为当前的ComputedRefImpl
的实例。ReactiveEffect
构造函数执行完成在
computed
中返回了ComputedRefImpl
实例
由以上代码可知,当我们在执行 computed
函数时:
- 定义变量
getter
为我们传入的回调函数 - 生成了
ComputedRefImpl
实例,作为computed
函数的返回值 ComputedRefImpl
内部,利用了ReactiveEffect
函数,并且传入了 第二个参数
computed 的 getter
当 computed
代码执行完成之后,我们在 effect
中触发了 computed
的 getter
:
computedObj.value
根据我们之前在学习 ref
的时候可知,.value
属性的调用本质上是一个 get value
的函数调用,而 computedObj
作为 computed
的返回值,本质上是 ComputedRefImpl
的实例, 所以此时会触发 ComputedRefImpl
下的 get value
函数。
进入
ComputedRefImpl
下的get value
函数执行
trackRefValue(self)
,该方法我们是有过了解的,知道它的作用是:收集依赖,它接收一个ref
作为参数,该ref
本质上就是ComputedRefImpl
的实例:执行
self._dirty = false
,我们知道_dirty
是 脏 的意思,如果_dirty = true
则会 触发执行依赖 。在 当前(标记为false
之前),self._dirty = true
所以接下来执行
self.effect.run()!
,执行了run
方法,我们知道run
方法内部其实会触发fn
函数,即:computed
接收的第一个参数接下来把
self._value = self.effect.run()!
,此时self._value
的值为computed
第一个参数(fn
函数)的返回值, 即为:计算属性计算之后的值最后执行
return self._value
,返回计算的值
由以上代码可知:
ComputedRefImpl
实例本身就没有 代理监听,它本质上是一个get value
和set value
的触发- 在每一次
get value
被触发时,都会主动触发一次 依赖收集 - 根据
_dirty 和 _cacheable
的状态判断,是否需要触发run
函数 computed
的返回值,其实是run
函数执行之后的返回值
ReactiveEffect 的 scheduler
到现在为止,我们貌似已经分析完成了 computed
的源码执行逻辑,但是大家仔细来看上面的逻辑分析,可以发现,目前这样的逻辑还存在一些问题。
我们知道对于计算属性而言,当它依赖的响应式数据发生变化时,它将重新计算。那么换句话而言就是:当响应性数据触发 setter
时,计算属性需要触发依赖。
在上面的代码中,我们知道,当 《每一次 get value
被触发时,都会主动触发一次 依赖收集》,但是 触发依赖 的地方在哪呢?
根据以上代码可知:在 ComputedRefImpl
的构造函数中,我们创建了 ReactiveEffect
实例,并且传递了第二个参数,该参数为一个回调函数,在这个回调函数中:我们会根据 脏 的状态来执行 triggerRefValue
,即 触发依赖,重新计算。
那么这个 ReactiveEffect
第二个参数 是什么呢?它会在什么时候被触发,以 触发依赖 呢?
我们来看一下:
进入
packages/reactivity/src/effect.ts
中查看
ReactiveEffect
的构造函数,可以第二个参数为scheduler
scheduler
表示 调度器 的意思,我们查看packages/reactivity/src/effect.ts
中triggerEffect
方法,可以发现这里进行了调度器的判定:jsfunction triggerEffect(...) { ... if (effect.scheduler) { effect.scheduler() } ... }
那么接下来我们就可以跟踪一下代码的实现。
跟踪代码
我们知道 延迟两秒之后,会触发 obj.name
即 reactive
的 setter
行为,所以我们可以在 packages/reactivity/src/baseHandlers.ts
中为 set
增加一个断点:
进入
reactive
的setter
(注意:这里是延迟两秒之后setter
行为)跳过之前的相同逻辑之后,可知,最后会触发:
trigger(target, TriggerOpTypes.SET, key, value, oldValue)
方法进入
trigger
方法:同样跳过之前相同逻辑,可知,最后会触发:
triggerEffects(deps[0], eventInfo)
方法进入
triggerEffects
方法:这里要注意:因为我们在
ComputedRefImpl
的构造函数中,执行了this.effect.computed = this
,所以此时的if (effect.computed)
判断将会为true
:此时我们注意看
effects
,此时effect
的值为ReactiveEffect
的实例,同时scheduler
存在值 :接下来进入
triggerEffect
:在
triggerEffect
中执行
if (effect.scheduler)
判断,因为effect
存在scheduler
,所以会 执行scheduler
函数此时会进入
ComputedRefImpl
类的构造函数中,传递的回调函数- 进入
scheduler
回调 - 此时
this
的状态如下
所以会执行
triggerRefValue
函数:进入
triggerRefValue
函数会再次触发
triggerEffects
函数,把当前的this.dep
作为参数传入再次进入
triggerEffects
注意: 此时的
effects
的值为:这次的
ReactiveEffect
不再包含 调度器接下来进入
triggerEffect
:在
triggerEffect
因为effect
不再包含调度器scheduler
所以会直接执行
fn
函数fn
函数的触发,标记着computedObj.value
触发,而我们知道computedObj.value
本质上是get value
函数的触发,所以代码接下来会触发ComputedRefImpl
的get value
接下来进入
get value
进入
get value
执行
self._value = self.effect.run()!
,而run
函数的执行本质上是fn
函数的执行,而此时fn
函数为:执行该函数得到计算的值
最后作为
computedObj.value
的返回值省略后续的触发…
- 进入
至此,整个 obj.name
引发的副作用全部执行完成。
由以上代码可知,整个的计算属性的逻辑是非常复杂的,我们来做一下整理:
整个事件有
obj.name
开始触发
proxy
实例的setter
执行
trigger
,第一次触发依赖注意,此时
effect
包含调度器属性,所以会触发调度器调度器指向
ComputedRefImpl
的构造函数中传入的匿名函数在匿名函数中会:再次触发依赖
即:两次触发依赖
最后执行 :
js() => { return '姓名:' + obj.name }
得到值作为 computedObj 的值
总结
那么到这里我们基本上了解了 computed
的执行逻辑,里面涉及到了一些我们之前没有了解过的概念,比如 调度器 scheduler
,并且整体的 computed
的流程也相当复杂。
所以接下来我们去实现 computed
的时候,会分步骤一步一步执行。
03:框架实现:构建 ComputedRefImpl ,读取计算属性的值
对于 computed
而言,整体比较复杂,所以我们将分步进行实现。
那么对于本小节而言,我们的首先的目标是:构建 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
属性:
export class ReactiveEffect<T = any> {
/**
* 存在该属性,则表示当前的 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
发生变化时,我们可以发现浏览器 并不会 跟随变化,即:计算属性并非是响应性的。那么想要完成这一点,我们还需要进行更多的工作才可以。
04:框架实现:computed 的响应性:初见调度器,处理脏的状态
根据之前的代码可知,如果我们想要实现 响应性,那么必须具备两个条件:
- 收集依赖:该操作我们目前已经在
get value
中进行。 - 触发依赖:该操作我们目前尚未完成,而这个也是我们本小节主要需要做的事情。
那么根据第二小节的源码可知,这部分代码是写在 ReactiveEffect
第二个参数上的:
() => {
if (!this._dirty) {
this._dirty = true
triggerRefValue(this)
}
}
这个参数是一个匿名函数,被叫做 scheduler
调度器。
该匿名函数中,又涉及到了一个 _dirty
变量,该变量我们把它叫做 脏。
那么想要实现 computed
的响应性,就必须要搞明白这两个东西的概念:
调度器
调度器 scheduler
是一个相对比较复杂的概念,它在 computed
和 watch
中都有涉及,但是在当前的 computed
实现中,它的作用还算比较清晰。
所以根据我们秉承的:没有使用就当做不存在 的理念,我们只需要搞清楚,它在当前的作用即可。
根据我们在第二小节的源码阅读,我们可以知道,此时的 scheduler
就相当于一个 回调函数。
在 triggerEffect
只要 effect
存在 scheduler
,则就会执行该函数。
_dirty 脏
对于 dirty
而言,相对比较简单了。
它只是一个变量,我们只需要知道:它为 false 时,表示需要触发依赖。为 true 时表示需要重新执行 run 方法,获取数据。 即可。
实现
那么明确好了以上两个概念之后,接下来我们就来进行下 computed
的响应性实现:
- 在
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
以具备响应性。
05:框架实现:computed 的缓存性
我们知道 computed
区别于 function
最大的地方就是:computed 具备缓存,当多次触发计算实行时,那么计算属性只会计算 一次。
那么秉承着这样的一个理念,我们来创建一个测试用例:
- 创建
packages/vue/examples/reactivity/computed-cache.html
:
<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(() => {
obj.name = '李四'
}, 2000);
</script>
运行到浏览器,我们发现当前代码出现了 死循环 的问题。
那么这个 死循环 是因为什么呢?
如果我们想要实现计算属性的缓存性,又应该如何进行实现呢?
带着这两个问题,我们继续来往下看。
为什么会出现死循环
我们为当前的代码进行 debugger
,查看出现该问题的原因。我们知道这个死循环是在 延迟两秒后 出现的,而延迟两秒之后是 obj.name
的调用,即: reactive
的 setter
行为被触发,也就是 trigger
方法触发时:
为
packages/reactivity/src/effect.ts
中的trigger
方法增加断点,延迟两秒之后,进入断点:此时执行的代码是
obj.name = '李四'
,所以在target
为{name: '李四'}
但是要 注意,此时
targetMap
中,已经在 收集过effect
了,此时的dep
中包含一个 计算属性的effect
:代码继续向下进行,进入
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
函数:因为他 不是 计算属性的
effect
,所以会直接执行run
方法。而我们知道
run
方法中,其实就是触发了fn
函数,所以最终会执行:
() => {
document.querySelector('#app').innerHTML = computedObj.value
document.querySelector('#app').innerHTML = computedObj.value
}
但是在这个
fn
函数中,是有触发computedObj.value
的,而computedObj.value
其实是触发了computed
的get value
方法。但是在这个
fn
函数中,是有触发computedObj.value
的,而computedObj.value
其实是触发了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
,就像 第七步 一样从而导致 死循环
以上逻辑就是为什么会出现死循环的原因。
那么明确好了导致死循环的代码逻辑之后,接下来就是如何解决这个死循环的问题呢?
PS:这里大家要注意:
vue-next-mini
是一个学习vue 3
核心源代码的库,所以它在一些复杂业务中会存在各种bug
。而这样的bug
在vue3
的源码中处理完善的逻辑非常非常复杂,我们不可能完全按照vue 3
的标准来去处理。所以我们秉承着 最少代码的实现逻辑 来解决对应的
bug
,它 并不是一个完善的方案(相比于vue 3
源代码),但是我们可以保证 它是vue 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)
}
}
}
那么为什么这样就可以解决死循环的 bug
呢?
我们再按照刚才的顺序跟踪下代码进行查看:
为
packages/reactivity/src/effect.ts
中的trigger
方法增加断点,延迟两秒之后,进入断点:此时执行的代码是
obj.name = '李四'
,所以在target
为{name: '李四'}
但是要 注意,此时
targetMap
中,已经在 收集过effect
了,此时的dep
中包含一个 计算属性的effect
:代码继续向下进行,进入
triggerEffects(dep)
方法在
triggerEffects(dep)
方法中,继续进入triggerEffect(effect)
在
triggerEffect
中接收到的effect
,即为刚才查看的 计算属性的effect
:此时因为
effect
中存在scheduler
,所以会执行该计算属性的scheduler
函数,在scheduler
函数中,会触发triggerRefValue(this)
,而triggerRefValue
则会再次触发triggerEffects
**** 不同从这里开始****
因为此时我们在
triggerEffects
中,增加了 判断逻辑,所以 永远会先触发 计算属性的effect
所以此时再次进入到
triggerEffect
时,此时的effect
依然为 计算属性的effect
:从而因为存在
scheduler
,所以会执行:js() => { // 判断当前脏的状态,如果为 false,表示需要《触发依赖》 if (!this._dirty) { // 将脏置为 true,表示 this._dirty = true triggerRefValue(this) } })
但是此时要注意:此时 _dirty 脏的状态 为
true
,即:不会触发triggerRefValue
来触发依赖,此次计算属性的scheduler
调度器会 直接结束然后代码 跳回到
triggerEffects
两次循环中,使用 非计算属性的effect
执行triggerEffect
方法本次进入
triggerEffect
时,effect
数据如下:那么这次
run
的执行会触发 两次computed
的get value
所以代码会进入到
computed
的get value
中:- 第一次进入:
- 进入
computed
的get value
: - 首先收集依赖
- 接下来检查
dirty
脏的状态,执行this.effect.run()!
- 获取最新值,返回
- 进入
- 第二次进入:
- 进入
computed
的get value
: - 首先收集依赖
- 接下来检查
dirty
脏的状态,**因为在上一次中dirty
已经为false
**,所以本次 不会在触发this.effect.run()!
- 直接返回结束
- 进入
- 第一次进入:
所有代码逻辑结束。
查看测试实例的打印,computed
只计算了一次。
总结
那么到这里我们就解决了计算属性的死循环问题和缓存的问题。
其实解决的方式非常的简单,我们只需要控制 computed
的 effect
和 非 computed
的 effect
的执行顺序,通过明确的 dirty
来控制 run
和 triggerRefValue
的执行即可。
06:总结:computed 计算属性
那么到这里我们已经完成了 computed
计算属性的构建。
接下来我们来总结一下计算属性实现的重点:
- 计算属性的实例,本质上是一个
ComputedRefImpl
的实例 ComputedRefImpl
中通过dirty
变量来控制run
的执行和triggerRefValue
的触发- 想要访问计算属性的值,必须通过
.value
,因为它内部和ref
一样是通过get value
来进行实现的 - 每次
.value
时都会触发trackRefValue
即:收集依赖 - 在依赖触发时,需要谨记,先触发
computed
的effect
,再触发非computed
的effect
07:源码阅读:响应性的数据监听器 watch,跟踪源码实现逻辑
我们可以点击 这里 来查看 watch
的官方文档。
watch
的实现和 computed
有一些相似的地方,但是作用却与 computed
大有不同。watch
可以监听响应式数据的变化,从而触发指定的函数
在 vue3
中使用 watch
的代码如下所示:
watch(() => obj.name, (value, oldValue) => {
console.log('watch 监听被触发');
console.log('oldValue', oldValue);
console.log('value', value);
}, {
immediate: true,
deep: true
})
上述代码中 watch
函数接收三个参数:
- 监听的响应式对象
- 回调函数
cb
- 配置对象:
options
immediate
:watch
初始化完成后被立刻触发一次deep
:深度监听
由此可见,watch
函数颇为复杂,所以我们在跟踪 watch
的源码实现时,应当分步骤进行跟踪。
基础的 watch
实例
修改 packages/vue/examples/imooc/watch.html
实例代码如下:
<script>
const { reactive, watch } = Vue
const obj = reactive({
name: '张三'
})
watch(obj, (value, oldValue) => {
console.log('watch 监听被触发');
console.log('value', value);
})
setTimeout(() => {
obj.name = '李四'
}, 2000);
</script>
在以上代码中:
- 首先通过
reactive
函数构建了响应性的实例 - 然后触发
watch
- 最后触发
proxy
的setter
摒弃掉之前熟悉的 reactive
,我们从 watch
函数开始跟踪:
watch 函数
在
packages/runtime-core/src/apiWatch.ts
中找到watch
函数,开始debugger
:执行
doWatch
函数:- 进入
doWatch
函数 - 因为
source
为reactive
类型数据,所以getter = () => source
,目前source
为proxy
实例,即:
jsgetter = () => Proxy{name: '张三'}
- 紧接着,指定 deep = true,即:source 为 reactive 时,默认添加 options.deep = true
- 执行
if (cb && deep)
,条件满足:- 创建新的常量
baseGetter = getter
- 创建新的常量
- 执行
let oldValue = isMultiSource ? [] : INITIAL_WATCHER_VALUE
:- 其中
isMultiSource
表示是否有多个源,我们当前只有一个源,所以oldValue = INITIAL_WATCHER_VALUE
INITIAL_WATCHER_VALUE = {}
- 其中
- 执行
const job: SchedulerJob = () => {...}
,我们知道Scheduler
是一个调度器,SchedulerJob
其实就是一个调度器的处理函数,在之前我们接触了一下Scheduler
调度器,但是并没有进行深入了解,那么这里将涉及到调度器的比较复杂的一些概念,所以后面我们想要实现watch
,还需要 深入的了解下调度器的概念,现在我们暂时先不需要管它。 - 接下来还是 调度器 概念,直接执行:
let scheduler: EffectScheduler = () => queuePreFlushCb(job)
6、7
结合,将得到一个完整的调度器函数scheduler
,该函数被触发时,会返回queuePreFlushCb(job)
函数执行的结果- 代码继续执行得到一个
ReactiveEffect
的实例,注意: 该实例包含一个完善的调度器scheduler
- 代码继续执行,进入如下判断逻辑:
js// cb 是 watch 的第二个参数 if (cb) { // immediate 是 options 中的 immediate,表示:watch 是否立刻执行。 // 那么根据这个这个概念和一下代码,其实可以猜测:《 job() 触发,表示 watch 被立刻执行了一次 》 if (immediate) { job() } else { // 不包含 immediate,则通过 effect.run() 获取旧值。 // 根据我们前面创建 effect 的代码可知,run() 的执行其实是 getter 的执行。 // 所以此处可以理解为 getter 被触发,则获取了 oldValue // 我们的代码将执行 else oldValue = effect.run() }
- 最后
return
了一个回调函数:
jsreturn () => { effect.stop() if (instance && instance.scope) { remove(instance.scope.effects!, effect) } }
回调函数中的代码我们 无需深究,但是根据 代码语义
stop 停止
、remove 删除
,可以猜测:该函数被触发watch
将停止监听,同时删除依赖那么至此
watch
函数的逻辑执行完成。由以上代码可知:
watch
函数的代码很长,但是逻辑还算清晰- 调度器
scheduler
在watch
中很关键 scheduler
、ReactiveEffect
两者之间存在互相作用的关系,一旦effect
触发了scheduler
那么会导致queuePreFlushCb(job)
执行- 只要
job()
触发,那么就表示watch
触发了一次
- 进入
reactive 触发 setter
等待两秒,reactive
实例将触发 setter
行为,setter
行为的触发会导致 trigger
函数的触发,所以我们可以直接在 trigger
中进行 debugger
在
trigger
中进行debugger
根据我们之前的经验可知,
trigger
最终会触发到triggerEffect
,所以我们可以 省略中间 步骤,直接进入到triggerEffect
中进入
triggerEffect
此时
effect
为:关键其中两个比较重要的变量:
fn
:值为traverse(baseGetter())
:根据
2-4-1
可知baseGetter = getter
根据
2-2
可知:getter = () => Proxy{name: 'xx'}
所以
fn = traverse(() => Proxy{name: 'xx'})
scheduler
:值为
() => queuePreFlushCb(job)- 目前已知
job()
触发表示watch
被回调一次
- 目前已知
因为
scheduler
存在,所以会直接执行scheduler
,即等同于直接执行queuePreFlushCb(job)
所以接下来我们 进入
queuePreFlushCb
函数,看看queuePreFlushCb
做了什么:进入
queuePreFlushCb
触发
queueCb(cb, ..., pendingPreFlushCbs, ...)
函数,此时cb = job
,即:cb()
触发一次,意味着watch
触发一次进入
queueCb
函数执行
pendingQueue.push(cb)
,pendingQueue
从语义中看表示 队列 ,为一个 数组执行
queueFlush()
函数:进入
queueFlush()
函数执行
isFlushPending = true
执行
currentFlushPromise = resolvedPromise.then(flushJobs)
- 查看
resolvedPromise
可知:const resolvedPromise = Promise.resolve()
,即:promise
的成功状态 - 我们知道
promise
主要存在 三种状态 : - 待定(pending):初始状态,既没有被兑现,也没有被拒绝。
- 已兑现(fulfilled):意味着操作成功完成。
- 已拒绝(rejected):意味着操作失败。
- 结合语义,其实可知:
isFlushPending = true
应该是一个 标记,表示promise
进入pending
状态 - 而同时我们知道 Promise.resolve() 是一个 已兑现 状态的状态切换函数,它是一个 异步的微任务 ,即:它是一个优先于
setTimeout(() => {}, 0)
的异步任务
- 查看
而
flushJobs
是将是一个.then
中的回调,即 异步执行函数,它会等到 同步任务执行完成之后 被触发我们可以 给
flushJobs
函数内部增加一个断点
至此整个
trigger
就执行完成
由以上代码可知:
- 整个
trigger
的执行核心是触发了scheduler
调度器,从而触发queuePreFlushCb
函数 queuePreFlushCb
函数主要做了以下几点事情:- 构建了任务队列
pendingQueue
- 通过
Promise.resolve().then
把flushJobs
函数扔到了微任务队列中
- 构建了任务队列
同时因为接下来 同步任务已经执行完成,所以 异步的微任务 马上就要开始执行,即接下来我们将会进入 flushJobs
中。
flushJobs 函数
进入
flushJobs
函数执行
flushPreFlushCbs(seen)
函数,这个函数非常关键,我们来看一下:第一行代码执行
if (pendingPreFlushCbs.length)
,这个pendingPreFlushCbs
此时的值为:- 通过截图代码可知,
pendingPreFlushCbs
为一个数组,其中第一个元素就是 job 函数- 从 《
reactive 触发 setter
的2-5-2
》 中可以看到传参
- 从 《
- 执行
activePreFlushCbs = [...new Set(pendingPreFlushCbs)]
,即:activePreFlushCbs = pendingPreFlushCbs - 执行
for
循环,执行activePreFlushCbs[preFlushIndex]()
,即从activePreFlushCbs
这个数组中,取出一个函数,并执行。- 根据
2
、3
步可知,此时取出并且执行的函数即为 :job 函数!
- 根据
- 通过截图代码可知,
那么到这里,job
函数被成功执行,我们知道 job
执行意味着 watch
执行,即当前 watch
的回调 即将被执行
由以上代码可知:
flushJobs
的主要作用就是触发job
,即:触发watch
job 函数
进入
job
的执行函数执行
const newValue = effect.run()
,此时effect
为 :- 我们知道执行
run
,本质上是执行fn
- 而
traverse(baseGetter())
即为traverse(() => Proxy{name: 'xx'})
- 结合代码获取到的是
newValue
,所以我们可以大胆猜测,测试fn
的结果等同于:
jsfn: () => {name: '李四'}
接下来执行:
callWithAsyncErrorHandling(cb ......)
进入
callWithAsyncErrorHandling
函数:函数接收的第一个参数
fn
的值为watch 的第二个参数 cb
:接下来执行
callWithErrorHandling(fn ......)
进入
callWithErrorHandling
这里的代码就比较简单了,其实就是触发了
fn(...args)
,即:watch 的回调被触发,此时args
的值为:但是比较有意思的是,这里执行了一次
try ... catch
jstry { res = args ? fn(...args) : fn() } catch (err) { handleError(err, instance, type) }
TODO…
- 我们知道执行
截止到此时 watch
的回调终于 被触发了。
由以上代码可知:
job
函数的主要作用其实就是有两个:- 拿到
newValue
和oldValue
- 触发
fn
函数执行
- 拿到
总结
到目前为止,整个 watch
的逻辑就已经全部理完了。整体氛围了四大块:
watch
函数本身reactive
的setter
flushJobs
job
整个 watch
还是比较复杂的,主要是因为 vue
在内部进行了很多的 兼容性处理,使代码的复杂度上升了好几个台阶,我们自己去实现的时候 会简单很多 的。
08:框架实现:深入 scheduler 调度系统实现机制
经过了 computed
的代码和 watch
的代码之后,其实我们可以发现,在这两块代码中都包含了同样的一个概念那就是:调度器 scheduler
。完整的来说,我们应该叫它:调度系统
整个调度系统其实包含两部分实现:
lazy
:懒执行scheduler
:调度器
懒执行
懒执行相对比较简单,我们来看 packages/reactivity/src/effect.ts
中第 183 - 185
行的代码:
if (!options || !options.lazy) {
_effect.run()
}
这段代码比较简单,其实就是如果存在 options.lazy
则 不立即 执行 run
函数。
我们可以直接对这段代码进行实现:
export interface ReactiveEffectOptions {
lazy?: boolean
scheduler?: EffectScheduler
}
/**
* effect 函数
* @param fn 执行方法
* @returns 以 ReactiveEffect 实例为 this 的执行函数
*/
export function effect<T = any>(fn: () => T, options?: ReactiveEffectOptions) {
// 生成 ReactiveEffect 实例
const _effect = new ReactiveEffect(fn)
// !options.lazy 时
if (!options || !options.lazy) {
// 执行 run 函数
_effect.run()
}
}
那么此时,我们就可以新建一个测试案例来测试下 lazy
,创建 packages/vue/examples/reactivity/lazy.html
:
<script>
const { reactive, effect } = Vue
const obj = reactive({
count: 1
})
// 调用 effect 方法
effect(() => {
console.log(obj.count);
}, {
lazy: true
})
obj.count = 2
console.log('代码结束');
</script>
当不存在 lazy
时,打印结果为:
1
2
代码结束
当 lazy
为 true
时,因为不在触发 run
,所以不会进行依赖收集,打印结果为:
代码结束
scheduler:调度器
调度器比懒执行要稍微复杂一些,整体的作用分成两块:
- 控制执行顺序
- 控制执行规则
控制执行顺序
我们先来看一个 vue 3
官网的例子,创建测试案例 packages/vue/examples/imooc/scheduler.htm
:
<script>
const { reactive, effect } = Vue
const obj = reactive({
count: 1
})
// 调用 effect 方法
effect(() => {
console.log(obj.count);
})
obj.count = 2
console.log('代码结束');
</script>
当前代码执行之后的打印顺序为:
1
2
代码结束
那么现在我们期望 在不改变测试案例代码顺序的前提下 修改一下代码的执行顺序,使其变为:
1
代码结束
2
那么想要达到这样的目的我们应该怎么做呢?
修改一下当前测试案例的代码:
// 调用 effect 方法
effect(() => {
console.log(obj.count);
}, {
scheduler() {
setTimeout(() => {
console.log(obj.count);
})
}
})
我们给 effect
传递了第二个参数 options
,options
是一个对象,内部包含一个 scheduler
的选项,此时再次执行代码,得到 期望 的打印结果。
那么为什么会这样呢?
我们来回忆一下我们的代码,我们知道,目前在我们的代码中,执行 scheduler
的地方只有一个,那就是在 packages/reactivity/src/effect.ts
中:
/**
* 触发指定的依赖
*/
export function triggerEffect(effect: ReactiveEffect) {
if (effect.scheduler) {
effect.scheduler()
} else {
effect.run()
}
}
当 effect
存在 scheduler
时,我们会执行该调度器,而不是直接执行 run
,所以我们就可以利用 这个特性,在 scheduler
函数中执行我们期望的代码逻辑。
接下来,我们也可以为我们的 effect
增加 scheduler
,以此来实现这个功能:
- 在
packages/reactivity/src/effect.ts
中:
export function effect<T = any>(fn: () => T, options?: ReactiveEffectOptions) {
// 生成 ReactiveEffect 实例
const _effect = new ReactiveEffect(fn)
+ // 存在 options,则合并配置对象
+ if (options) {
+ extend(_effect, options)
+ }
if (!options || !options.lazy) {
// 执行 run 函数
_effect.run()
}
}
- 在
packages/shared/src/index.ts
中,增加extend
函数:
/**
* Object.assign
*/
export const extend = Object.assign
- 创建测试案例
packages/vue/examples/reactivity/scheduler.html
:
<script>
const { reactive, effect } = Vue
const obj = reactive({
count: 1
})
// 调用 effect 方法
effect(() => {
console.log(obj.count);
}, {
scheduler() {
setTimeout(() => {
console.log(obj.count);
})
}
})
obj.count = 2
console.log('代码结束');
</script>
最终,得到期望的执行顺序。
控制执行规则
说完了执行顺序,那么对于执行规则而言,指的又是什么意思呢?
同样我们来看下面的例子 packages/vue/examples/imooc/scheduler-2.html
:
<script>
const { reactive, effect } = Vue
const obj = reactive({
count: 1
})
// 调用 effect 方法
effect(() => {
console.log(obj.count)
})
obj.count = 2
obj.count = 3
</script>
运行当前测试实例,得出打印结果:
1
2
3
但是我们知道,对于当前代码而言,最终的执行结果是必然为 3
的,那么我们可以不可以 跳过 中间的 2
的打印呢?
那么想要达到这个目的,我们可以按照以下的流程去做:
在
packages/runtime-core/src/index.ts
中,为./scheduler
新增一个导出方法:diffexport { nextTick, + queuePreFlushCb } from './scheduler'
在测试实例中,使用
queuePreFlushCb
配合scheduler
:
// 调用 effect 方法
effect(() => {
console.log(obj.count)
}, {
+ scheduler() {
+ queuePreFlushCb(() => { console.log(obj.count) })
+ }
})
- 得到打印结果为:
1
3 // 打印两次
那么为什么会这样呢?queuePreFlushCb
又做了什么?
在 第七小节:watch 的源码阅读 中,我们知道在 packages/runtime-core/src/apiWatch.ts
中 第 348 行
:
scheduler = () => queuePreFlushCb(job)
通过 queuePreFlushCb
方法,构建了 scheduler
调度器。而根据源码我们知道 queuePreFlushCb
方法,最终会触发(这里不再详细讲解源码执行流程,忘记的同学可以看一下 第七小节:watch 的源码阅读):
resolvedPromise.then(flushJobs)
那么根据以上逻辑,我们也可以实现对应的代码:
- 创建
packages/runtime-core/src/scheduler.ts
:
// 对应 promise 的 pending 状态
let isFlushPending = false
/**
* promise.resolve()
*/
const resolvedPromise = Promise.resolve() as Promise<any>
/**
* 当前的执行任务
*/
let currentFlushPromise: Promise<void> | null = null
/**
* 待执行的任务队列
*/
const pendingPreFlushCbs: Function[] = []
/**
* 队列预处理函数
*/
export function queuePreFlushCb(cb: Function) {
queueCb(cb, pendingPreFlushCbs)
}
/**
* 队列处理函数
*/
function queueCb(cb: Function, pendingQueue: Function[]) {
// 将所有的回调函数,放入队列中
pendingQueue.push(cb)
queueFlush()
}
/**
* 依次处理队列中执行函数
*/
function queueFlush() {
if (!isFlushPending) {
isFlushPending = true
currentFlushPromise = resolvedPromise.then(flushJobs)
}
}
/**
* 处理队列
*/
function flushJobs() {
isFlushPending = false
flushPreFlushCbs()
}
/**
* 依次处理队列中的任务
*/
export function flushPreFlushCbs() {
if (pendingPreFlushCbs.length) {
let activePreFlushCbs = [...new Set(pendingPreFlushCbs)]
pendingPreFlushCbs.length = 0
for (let i = 0; i < activePreFlushCbs.length; i++) {
activePreFlushCbs[i]()
}
}
}
- 创建
packages/runtime-core/src/index.ts
,导出queuePreFlushCb
函数:
export { queuePreFlushCb } from './scheduler'
- 在
packages/vue/src/index.ts
中,新增导出函数:
export { queuePreFlushCb } from '@vue/runtime-core'
- 创建测试案例
packages/vue/examples/reactivity/scheduler-2.html
:
<script>
const { reactive, effect, queuePreFlushCb } = Vue
const obj = reactive({
count: 1
})
// 调用 effect 方法
effect(() => {
console.log(obj.count)
}, {
scheduler() {
queuePreFlushCb(() => { console.log(obj.count) })
}
})
obj.count = 2
obj.count = 3
</script>
- 执行代码可得打印结果为:
1
3 // 打印两次
那么至此,我们就完成了调度器中两个比较重要的概念。
总结
懒执行相对比较简单,所以我们的总结主要针对调度器来说明。
调度器是一个相对比较复杂的概念,但是它本身并不具备控制 执行顺序 和 执行规则 的能力。
想要完成这两个能力,我们需要借助一些其他的东西来实现,这整个的一套系统,我们把它叫做 调度系统
那么到目前,我们调度系统的代码就已经实现完成了,这个代码可以在我们将来实现 watch
的时候直接使用。
09:框架实现:初步实现 watch 数据监听器
那么这一小节,我们来看一下 watch
函数应该如何进行实现。
- 创建
packages/runtime-core/src/apiWatch.ts
模块,创建watch
与doWatch
函数:
/**
* watch 配置项属性
*/
export interface WatchOptions<Immediate = boolean> {
immediate?: Immediate
deep?: boolean
}
/**
* 指定的 watch 函数
* @param source 监听的响应性数据
* @param cb 回调函数
* @param options 配置对象
* @returns
*/
export function watch(source, cb: Function, options?: WatchOptions) {
return doWatch(source as any, cb, options)
}
function doWatch(
source,
cb: Function,
{ immediate, deep }: WatchOptions = EMPTY_OBJ
) {
// 触发 getter 的指定函数
let getter: () => any
// 判断 source 的数据类型
if (isReactive(source)) {
// 指定 getter
getter = () => source
// 深度
deep = true
} else {
getter = () => {}
}
// 存在回调函数和deep
if (cb && deep) {
// TODO
const baseGetter = getter
getter = () => baseGetter()
}
// 旧值
let oldValue = {}
// job 执行方法
const job = () => {
if (cb) {
// watch(source, cb)
const newValue = effect.run()
if (deep || hasChanged(newValue, oldValue)) {
cb(newValue, oldValue)
oldValue = newValue
}
}
}
// 调度器
let scheduler = () => queuePreFlushCb(job)
const effect = new ReactiveEffect(getter, scheduler)
if (cb) {
if (immediate) {
job()
} else {
oldValue = effect.run()
}
} else {
effect.run()
}
return () => {
effect.stop()
}
}
- 在
packages/reactivity/src/reactive.ts
为reactive
类型的数据,创建 标记:
export const enum ReactiveFlags {
IS_REACTIVE = '__v_isReactive'
}
function createReactiveObject(
...
) {
...
// 未被代理则生成 proxy 实例
const proxy = new Proxy(target, baseHandlers)
// 为 Reactive 增加标记
proxy[ReactiveFlags.IS_REACTIVE] = true
...
}
/**
* 判断一个数据是否为 Reactive
*/
export function isReactive(value): boolean {
return !!(value && value[ReactiveFlags.IS_REACTIVE])
}
- 在
packages/shared/src/index.ts
中创建EMPTY_OBJ
:
/**
* 只读的空对象
*/
export const EMPTY_OBJ: { readonly [key: string]: any } = {}
- 在
packages/runtime-core/src/index.ts
和packages/vue/src/index.ts
中导出watch
函数 - 创建测试实例
packages/vue/examples/reactivity/watch.html
:
<script>
const { reactive, watch } = Vue
const obj = reactive({
name: '张三'
})
watch(obj, (value, oldValue) => {
console.log('watch 监听被触发');
console.log('value', value);
})
setTimeout(() => {
obj.name = '李四'
}, 2000);
</script>
此时运行项目,却发现,当前存在一个问题,那就是 watch
监听不到 reactive
的变化
那么这是因为什么呢?
大家可以先思考下这个问题,我们下一小节继续来说~~~
10:问题分析:watch 下的依赖收集原则
现在我们还差一步就可以完成 watch
的响应式数据监听了,那么这一步是什么呢?
根据代码可知,watch
内部本质上也是通过:ReactiveEffect + scheduler
进行实现的。
那么对于 ReactiveEffect
而言,我们知道,他需要拥有两个先决条件才可以完成响应性:
- 依赖收集
- 触发依赖
那么对于我们当前的代码而言,我们在 setTimeout
中,触发了 触发依赖 操作。但是我们在哪里进行的 依赖收集呢?
答案是:没有
这就是我们为什么没有办法触发 watch
监听的原因。
那么这个依赖收集我们应该怎么做呢?
不知道大家还记不记得,我们之前在看源码的时候,看到过一个 traverse
方法。
之前的时候,我们一直没有看过该方法,那么现在我们可以来说一下它了。
它的源码在 packages/runtime-core/src/apiWatch.ts
中:
查看源代码可以发现,这里面的代码其实有些 莫名其妙,他好像什么都没有做,只是在 循环的进行 xxx.value
的形式,我们知道 xxx.value
这个行为,我们把它叫做 getter
行为。并且这样会产生 副作用,那就是 依赖收集!。
所以我们知道了,对于 traverse
方法而言,它就是一个不断在触发响应式数据 依赖收集 的方法。
我们可以通过该方法来触发依赖收集,然后在两秒之后,触发依赖,完成 scheduler
的回调。
11:框架实现:完成 watch 数据监听器的依赖收集
根据上一节所说,我们接下来就需要去实现 traverse
函数,在 packages/runtime-core/src/apiWatch.ts
中,创建 traverse
方法:
/**
* 依次执行 getter,从而触发依赖收集
*/
export function traverse(value: unknown) {
if (!isObject(value)) {
return value
}
for (const key in value as object) {
traverse((value as any)[key])
}
return value
}
在 doWatch
中通过 traverse
方法,构建 getter
:
// 存在回调函数和deep
if (cb && deep) {
// TODO
const baseGetter = getter
getter = () => traverse(baseGetter())
}
此时再次运行测试实例, watch
成功监听。
同时因为我们已经处理了 immediate
的场景:
if (cb) {
if (immediate) {
job()
} else {
oldValue = effect.run()
}
} else {
effect.run()
}
所以,目前 watch
也支持 immediate
的配置选项,ref
场景下的处理也可以得到支持,具体可见如下测试案例 packages/vue/examples/reactivity/watch-2.html
<script>
const { ref, watch } = Vue
const obj = ref({
name: '张三'
})
watch(obj.value, (value, oldValue) => {
console.log('watch 监听被触发');
console.log('value', value);
}, {
immediate: true
})
setTimeout(() => {
obj.value.name = '李四'
}, 2000);
</script>
12:总结:watch 数据侦听器
那么现在我们已经完成了 watch
侦听器的实现。
对于 watch
而言本质上还是依赖于 ReactiveEffect
来进行的实现。
本质上依然是一个 依赖收集、触发依赖 的过程。只不过区别在于此时的依赖收集是被 “被动触发” 的。
除此之外,还有一个调度器的概念,对于调度器而言,它起到的的主要作用就是 控制执行顺序、控制执行规则 ,但是大家也需要注意调度器本身只是一个函数,想要完成调度功能,还需要其他的东西来配合才可以。
13:总结
那么到这里,咱们整个的 响应系统 就全部讲解完成了。整个响应系统我们分成了:
- reactive
- ref
- computed
- watch
四大块来进行分别的实现。
通过之前的学习可以知道,响应式的核心 API
为 Proxy
。整个 reactive
都是基于此来进行实现。
但是 Porxy
只能代理 复杂数据类型,所以延伸除了 get value
和 set value
这样 以属性形式调用的方法, ref
和 computed
之所以需要 .value
就是因为这样的方法。
好,响应系统 闭幕,下一章我们将开始学习新的大模块: 渲染系统