Vue3源码阅读——响应式是如何实现的(ref + ReactiveEffect篇)
前言
本文属于笔者Vue3源码阅读系列第四篇文章,往期精彩:
在第三篇文中主要看了reactive
的相关源码,本文主要看看ref
以及effect
中的源码。effect
作为 reactive
的核心,主要负责收集依赖,更新依赖,那依赖是什么?
ref
带着这个问题,我们先来看下 ref
的源码(packages/reactivity/src/ref.ts
):
export function ref(value?: unknown) {
// 调用createRef
return createRef(value, false)
}
// ......
export function shallowRef(value?: unknown) {
return createRef(value, true)
}
function createRef(rawValue: unknown, shallow: boolean) {
// 如果已经是 ref对象,则直接返回
if (isRef(rawValue)) {
return rawValue
}
// 否则直接 new 一个 RefImpl
return new RefImpl(rawValue, shallow)
}
以上代码非常易懂,就不做太多解读了,接着看RefImpl
类。
RefImpl
RefImpl
类的作用主要是为了创建Ref
对象,每一个ref()
,返回的都是包含value
属性的Ref
对象,看下RefImpl
类的实现:
RefImpl
采用了ES2015
类的写法,如果打包后降级其实还是通过Object.defineProperty
对value
的访问做了拦截。我们还能看到shallowRef
和 ref
都是通过调用 createRef
实现,只是传入的参数不同。当使用 shallowRef
时,不会调用 toReactive
去将对象转换为响应式,因此shallowRef
返回的对象只支持对value
值的响应式,ref
返回的对象则支持对value
深度响应式,比如:
const shallow = shallowRef({a: 'hello'})
// shallow.value 响应式
// shallow.value.a 非响应式
const notShallow = shallowRef({a: 'hello'})
// notShallow.value 响应式
// notShallow.value.a 响应式
咱们接下来看下 trackRefValue 和 triggerRefValue 的实现。
trackRefValue
为什么要判断 shouldTrack
和 activeEffect
?
- 定义了一个
ref
变量,但没有任何地方使用到,此时activeEffect
为undefined
,就不需要收集依赖。 - 在上一篇文中,讲到了对数组的一些方法的重写,由于这些方法会改变数组的
length
,可能造成死循环,因此重写时设置了shouldTrack
为false
,因此这种情况也不需要收集依赖。
triggerRefValue
在trackRefValue
和 triggerRefValue
中,dep
是一个关键的参数,dep
是一个存着依赖的Set
对象——Set<ReactiveEffect>
。看下createDep
的源码(packages/reactivity/src/dep.ts
)
createDep
// createDep 的作用就是创建一个用来存依赖的 Set 对象
// 并初始化 w、n,这两个属性是用来区别依赖是否收集过
// 用于后续的副作用函数执行再次收集依赖时确保不重复收集、多余收集。
export const createDep = (effects?: ReactiveEffect[]): Dep => {
const dep = new Set<ReactiveEffect>(effects) as Dep
dep.w = 0
dep.n = 0
return dep
}
确定了dep
这个参数后,再分别调用trackEffects
和 triggerEffects
,在上篇文章的reactive
相关源码中,咱们遇到了track
和 trigger
两个函数,这四个函数都在packages/reactivity/src/effect.ts
文件中,接下来看下具体是怎么收集依赖和触发更新的。
effect
先从effect
函数看起:
effect
函数,有两个参数,第一个参数是一个函数,这个函数将被立即执行,并且会收集这个函数执行过程中的所有响应式依赖,当这些依赖变更,这个函数会再次执行;第二个参数可以传入一些选项以自定义这个副作用的行为,选项说明如下:
export interface ReactiveEffectOptions {
lazy?: boolean // 是否延迟触发 effect
computed?: boolean // 是否为计算属性
scheduler?: (job: ReactiveEffect) => void // 调度函数
onTrack?: (event: DebuggerEvent) => void // 追踪时触发
onTrigger?: (event: DebuggerEvent) => void // 触发回调时触发
onStop?: () => void // 停止监听时触发
}
effect
的主要逻辑如下:
- 创建包含响应式副作用函数的对象
const _effect = new ReactiveEffect(fn)
。 - 如果没有第二个参数,或者第二个参数中
lazy
不为true
,立即执行响应式副作用函数。 - 通过
bind
绑定_effect.run
执行的上下文为_effect
。 - 返回
runner
,可以通过这个函数来执行响应式副作用函数。
笔者在看effect
的时候,顺便看了一下会在哪些地方用到这个effect
函数,但是源码中一处都找不到,后面看了提交记录,发现是以前用到了,后面才封装了ReactiveEffect
,看日志应该是为了更佳的性能。
因此我们不必太关注这个effect
,虽然我们也能够 import
这个 effect
,但在开发中完全没必要使用它。
接下来看下ReactiveEffect
。
ReactiveEffect
在上面我们刚看了Dep
对象——用来存依赖的Set
对象(Set<ReactiveEffect>
),那是不是可以简单理解ReactiveEffect
就是封装依赖的对象?个人理解完全可以。
在 Vue2
中,有一个 Watcher
类,其种类包括 RenderWatcher(渲染Watcer)
、UserWatcher(用户定义的watch选项)
、ComputedWatcher(用户定义的computed选项)
。在对数据进行响应式处理时,数据会持有 Dep
实例,并且将这些 Watcher
实例添加到 subs
中;当依赖的数据变化,通过 dep.notify()
通知 subs
中的每个 Watcher
实例,Watcher
实例收到通知后再去做相应的处理。由此可见 Watcher
类的作用就是封装各种 Watcher
的处理逻辑。可以说在 Vue2
中 Watcher实例
就是依赖。
同样的 ReactiveEffect
的作用与 Watcher
差不多,笔者在源码中搜了一下,在下面这些地方会new ReactiveEffect
:
我们可认为 ReactiveEffect
的实例就是依赖。接下来看 ReactiveEffect
的实现。
ReactiveEffect
这个类用于创建一个包含响应式的副作用函数的对象。这个对象中包含很多属性,比如:
active
当前effect
实例是否在工作,当调用stop()
就会停止工作。parent
上一级effect
实例。computed
是不是computed
的effect
。fn
副作用函数fn
。scheduler
负责调用fn
的函数。- ......
run
和stop
才是ReactiveEffect
的核心,一个一个的看:
run
这个run
的作用就是用来执行副作用函数fn
,并且在执行过程中进行依赖收集:
⭐️ 在描述这个run
的逻辑之前,我们需要知道 effect.run
会执行副作用函数fn
,fn
中可能会触发其他effect.run
,就形成了嵌套调用,比如:
<script setup>
import Child from './Child.vue'
</script>
<template>
<h1>haha</h1>
<Child />
</template>
在上面这个很简单的例子中,根据之前 初始化文章 讲到组件渲染过程中会new ReactiveEffect()
创建一个渲染的副作用函数,在执行渲染的副作用函数时遇到 Child
组件,Child
组件也会有渲染的副作用函数,我们知道整个渲染过程是根据根组件的vnode
递归开箱,这样就形成了effect.run 的嵌套调用。
知道这个以后就可以分析run
的逻辑了:
- 如果当前
effect
实例是不工作状态,就仅仅执行一下fn
,不需要收集依赖。 - 由于在一个
effect.run
的过程中可能会触发另外的effect.run
, 暂存上一次的activeEffect、shouldTrack
,目的是为了本次执行完以后把activeEffect、shouldTrack
恢复回去。 - 设置
activeEffect、shouldTrack
。 effectTrackDepth
自增,trackOpBit
更新为1 << effectTrackDepth
。其中effectTrackDepth
是一个全局变量,表示当前嵌套调用effect.run
的深度。- 判断
effectTrackDepth
是否小于全局的maxMarkerBits = 30
,如果是就调用initDepMarkers
给dep.w
做标记(优化方案),否则就把依赖都删除(简单方案)。(这里其实是两种依赖收集的方式,在组件初始化我们已经收集到了依赖,后续有依赖变更触发了更新,会再次收集依赖,由于依赖项会发生变化,可能变多,可能变少。为了保证依赖的实时性,不多余收集,就有两种收集依赖的方式,一种是把上一次收集到的全部清除,以本次收集到的为准(简单粗暴);考虑到全部清除 & 重新收集损耗更多性能,因此就有了优化的收集方案——打标记,dep.w 代表已收集到的,dep.n 代表是本次收集到的,最后如果 有dep.w 没有 dep.n就需要从上次已收集到的依赖中移除,咱们先简单这么理解,源码中用了位运算去处理,稍后详细分析)。 - 执行
fn()
。 - 在
finally
中主要是做一些善后的工作了:移除多余依赖、恢复activeEffect、shouldTrack
、调用--effectTrackDepth
&trackOpBit
更新。 - 如果
deferStop
为true
,执行stop
,可能在调用stop
时,正在收集依赖,因此推迟到本次收集完成再stop
。
简单方案
这个非常简单,就是在执行fn
之前,先把effect.deps
中的dep
全部删掉,执行fn
收集到的依赖就是最新的。当嵌套的深度超过maxMarkerBits = 30
,就会采用这种方案。看下删除依赖的逻辑:
cleanupEffect
function cleanupEffect(effect: ReactiveEffect) {
const { deps } = effect
if (deps.length) {
for (let i = 0; i < deps.length; i++) {
deps[i].delete(effect)
}
deps.length = 0
}
}
遍历effect.deps
,删除每个dep
中的当前effect
。
为什么maxMarkerBits = 30
?
首先我们先得知道JS
是怎么进行位运算的:
JavaScript 将数字存储为 64 位浮点数,但所有按位运算都以 32 位二进制数执行。在执行位运算之前,JavaScript 将数字转换为 32 位有符号整数。执行按位操作后,结果将转换回 64 位 JavaScript 数。
32
位中有符号整数中第一位是 0
(正) 或 1
(负),1
是 0001
,所以可用的正整数前两位固定01,还有30
位可以补0
:
因此maxMarkerBits = 30
。
再来看下trackOpBit
和 effectTrackDepth
的关系。
trackOpBit
和 effectTrackDepth
的关系
effectTrackDepth(effect.run 嵌套调用深度) | trackOpBit |
---|---|
1 | 0001 << 1 = 0010 = 2 |
2 | 0001 << 2 = 0100 = 4 |
3 | 0001 << 3 = 1000 = 8 |
4 | 0001 << 4 = 1 0000 = 16 |
接下来看优化方案的具体实现过程。
优化方案详解
当嵌套的深度小于maxMarkerBits = 30
,就会采用优化方案。 咱们先来看看 &、|
位运算。
& 和 | 位运算
更详细的可以去查阅更多资料详细学习。接下来咱们就看下打标记的实现。
initDepMarkers
// 在 上面的 run 方法里
if (effectTrackDepth <= maxMarkerBits) {
initDepMarkers(this);
}
const initDepMarkers = ({ deps }) => {
// 首次收集依赖跳过,因为压根就还没收集过依赖,所以本次收集到的就是最新的,不需要打标记
// 如果已经有deps了,就需要遍历给每一个 dep 打标记
// 在createDep时 dep.w 和 dep.n 都被初始化为 0
// 在首次收集只会给 dep.n 设置为 dep.n |= trackOpBit,因此首次收集完以后 dep.w = 0
// 当依赖更新 -> 触发更新
// 打标记时 deps[i].w = deps[i].w | trackOpBit
// 在 dep.w = 0 时相当于 dep.w = trackOpBit
if (deps.length) {
for (let i = 0; i < deps.length; i++) {
deps[i].w |= trackOpBit; // 相当于 deps[i].w = deps[i].w | trackOpBit;
}
}
};
打完标记后,开始执行fn
,当访问了响应式的变量,就会调用trackEffects
:
trackEffects
// dep.ts
// 采用 AND运算符,假如现在深度为1; trackOpBit 为 2;
// 当依赖更新触发更新 执行 trackEffects 时
// 如果是effect.deps中的dep,那么 dep.w >= trackOpBit; dep.w & trackOpBit 就肯定会大于0
// 判断在当前深度,当前的 dep 是不是上一次track过程中track过的
export const wasTracked = (dep: Dep): boolean => (dep.w & trackOpBit) > 0
// dep.n 同理,判断在当前深度,本次track过程中,当前的 dep 是否track过
export const newTracked = (dep: Dep): boolean => (dep.n & trackOpBit) > 0
// 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__ && activeEffect!.onTrack) {
activeEffect!.onTrack(
extend(
{
effect: activeEffect!
},
debuggerEventExtraInfo!
)
)
}
}
}
trackEffects
的逻辑十分清晰:
if/else
还是对应简单方案/优化方案,在简单方案中,之前已经将effect.deps
中的dep
全删了,shouldTrack = !dep.has(activeEffect!)
的作用就是为了不重复收集;在优化方案中,先判断本次track
过程中是否已经track
过当前dep
,如果没有则更新dep.n
,且要不要track
决定于上一次track
过程中有没有track
过,如果track
过,就没必要再次track
,只需要dep.n
标记上即可。
如果shouldTrack
,那么就将当前effect
添加到此dep
,并且effect.deps
中也要添加这个dep
。
当fn
执行完,那么标记也打完了,进入到了finally
的逻辑,主要是调用finalizeDepMarkers
。
finalizeDepMarkers
export const finalizeDepMarkers = (effect: ReactiveEffect) => {
const { deps } = effect
if (deps.length) {
let ptr = 0
for (let i = 0; i < deps.length; i++) {
const dep = deps[i]
// 有dep.w 没有dep.n的
// 之前的 track 过程中 track 过,但是本次没有 track 到
// 说明这个effect的依赖项少了这个dep关联的响应式变量,需要删除掉
// 这个dep关联的响应式变量变化不再需要触发effect更新
if (wasTracked(dep) && !newTracked(dep)) {
dep.delete(effect)
} else {
// 走到 else 的话,说明这个dep不能删,其关联的响应式变量变更依然要触发effect更新
// 那么就 ptr++,注意这里++在后,假如ptr = 0; deps[ptr++] = dep
// 相当于 deps[ptr] = dep; ptr = ptr + 1; 也就是 deps[0] = dep
// 结合最后一行的 deps.length = ptr,其实这两个操作就是在确定最终的deps
// 每次遇到一个不能删的dep就往deps前面放,最后改变dep.length,超出ptr的部分就会被删掉。
deps[ptr++] = dep
}
// clear bits
// 这里是在将dep.w 和 dep.n 重置为0
dep.w &= ~trackOpBit
dep.n &= ~trackOpBit
}
deps.length = ptr
}
}
以上就是依赖收集的优化方案,也是整个依赖收集的核心逻辑,接下来看下看下effect
的stop
。
stop
stop
执行后会将 effect.active
变为false
,并且调用cleanupEffect
删除effect.deps
,其作用就是为了让一个effect
对象不再工作,因为当effect
为false
,不再会收集依赖,并且deps
已经并删除,不再会触发effect.run
。通常是在销毁之前调用这个stop
。
stop() {
// stopped while running itself - defer the cleanup
if (activeEffect === this) {
this.deferStop = true
} else if (this.active) {
cleanupEffect(this)
if (this.onStop) {
this.onStop()
}
this.active = false
}
}
在源码中用到这个stop
的地方有:
unwatch
中。
const unwatch = () => {
effect.stop()
if (instance && instance.scope) {
remove(instance.scope.effects!, effect)
}
}
unmountComponent
中。- 销毁逻辑中。
到此 RactiveEffect
的东西就讲的差不多了,effect.ts
中咱们还有三个函数没看,那就是track
以及触发更新的trigger
和 triggerEffects
。本来计划响应式部分的源码两篇文章就能搞定,看来还得再加一篇啊,这三个函数就放到下一篇详细解读吧!另外下一篇计划通过一些图将响应式部分串起来,敬请期待!
总结
本文主要解读了以下内容:
ref
的实现。effect
的实现(可以不用太关注,因为没用)。ReactiveEffect
的实现,这个是依赖类,非常重要。- 依赖收集的简单方案。
- 依赖收集的优化方案详解。
这是笔者第四篇源码分析类的文章,如果掘友们有什么建议,或者文中有错误,还请评论指出,谢谢!
如果本文对你有一点点帮助,点个赞支持一下吧,你的每一个【赞
】都是我创作的最大动力 ^_^
。
转载自:https://juejin.cn/post/7251237669013913655