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