在 vue3 文档 深入响应式系统 什么是响应式中有提到过几个核心的概念,副作用 (effect),依赖 (dependency),核心思想就是当读取数据时触发 track,数据变化时触发 trigger。
我们先来探究下 track 执行的过程,对这几个概念深入理解
响应式 track 过程探究
vue3 的响应式是可以单独引入的,下载 vue3 源码后,执行 pnpm build reactivity --types --sourcemap 可以在 reactivity -> dist 目录下得到 reactivity 相关代码打包后的结果
下面从一个最简单的例子看看vue3响应式都是怎么做的
<div id="app"></div>
<script type="module">
import { effect, reactive } from '../../../reactivity/dist/reactivity.esm-browser.js'
function render(vNode, container) {
if (typeof container === 'string') {
container = document.querySelector(container);
}
container.innerHTML = vNode;
}
const data = reactive({
count: 1
})
effect(() => {
render(`<h1>${data.count}</h1>`, "#app")
})
setTimeout(()=>{
data.count++;
},1000)
</script>
这段代码中 render 函数通过 innerHTML 将模板字符串转为 DOM 挂载到页面
调用栈
将断点卡在 track 函数上可以得到调用栈:
通过调用栈可以得出在调用 track 函数前会经过如下操作:
- 代码执行到副作用函数 effect 时,会执行作为参数传递的匿名函数。
- 匿名函数是在 run 函数中执行的,run 函数是类 ReactiveEffect 的方法。
- 匿名函数执行中读取 reactive 代理的对象的属性时触发 get ,get 是 mutableHandlers 对象的属性,执行 createGetter 方法得到。
- 触发 track 操作,进行依赖收集
接着逐一确认下每个函数的实现过程
effect 分析
export function effect<T = any>(
fn: () => T,
options?: ReactiveEffectOptions
): ReactiveEffectRunner {
if ((fn as ReactiveEffectRunner).effect) {
fn = (fn as ReactiveEffectRunner).effect.fn
}
const _effect = new ReactiveEffect(fn)
if (options) {
extend(_effect, options)
if (options.scope) recordEffectScope(_effect, options.scope)
}
if (!options || !options.lazy) {
_effect.run()
}
const runner = _effect.run.bind(_effect) as ReactiveEffectRunner
runner.effect = _effect
return runner
}
effect 函数先是判断 fn 上面有没有 effect 属性,一开始执行的时候肯定是没有的
那什么时候有,很明显是 runner.effect = _effect 进行赋值的,为什么这么肯定。首先对比两者的类型能发现他们是相同的
使用 ReactiveEffect 创建 _effect 实例,包含 deps/fn/parent/scheduler 属性。
定义了 runner 为 ReactiveEffect 类的 Run 函数,添加了 effect 属性值为 _effect 实例。
因为 option 未进行配置,所以会直接执行 runner,可以说 effect 函数执行的其实是 ReactiveEffect 类的 Run 函数。
ReactiveEffect 分析
export class ReactiveEffect<T = any> {
active = true
deps: Dep[] = []
constructor(
public fn: () => T,
public scheduler: EffectScheduler | null = null,
scope?: EffectScope
) {
recordEffectScope(this, scope)
}
run() {
if (!this.active) {
return this.fn()
}
let parent: ReactiveEffect | undefined = activeEffect
let lastShouldTrack = shouldTrack
while (parent) {
if (parent === this) {
return
}
parent = parent.parent
}
try {
this.parent = activeEffect
activeEffect = this
shouldTrack = true
trackOpBit = 1 << ++effectTrackDepth
if (effectTrackDepth <= maxMarkerBits) {
initDepMarkers(this)
} else {
cleanupEffect(this)
}
return this.fn()
} finally {
if (effectTrackDepth <= maxMarkerBits) {
finalizeDepMarkers(this)
}
trackOpBit = 1 << --effectTrackDepth
activeEffect = this.parent
shouldTrack = lastShouldTrack
this.parent = undefined
if (this.deferStop) {
this.stop()
}
}
}
}
在这个简单的例子中, ReactiveEffect 实际也就 run 函数运行了,重点看下 run 相关的代码。run 函数的代码逻辑在 3.2 版本后有更新了 EffectScope 相关的功能,左侧为更新前的代码,右侧为更新后的代码。所以我们重点关注下红框中的代码逻辑即可。
在红框中,存在 trackOpBit,effectTrackDepth,maxMarkerBits:
- effectTrackDepth 用来记录 effect递归嵌套的深度
- trackOpBit 和前者进行左移运算得到最高位为1,其他位为0的数,用来记录依赖收集的状态,具体怎么记录 track 的时候进行分析
- maxMarkerBits 表示最大的递归嵌套深度
判断 effectTrackDepth 和 maxMarkerBits 大小,分别执行不同的函数。cleanupEffect 函数是为了解决分支切换带来不必要的更新而存在,当嵌套深度小于 maxMarkerBits 时,使用的是 initDepMarkers 方法。
关于分支切换的问题可以通过如下代码理解:
const data = reactive({
isShow:true,
text:'hello world'
})
effect(()=>document.body.innerText = data.isShow ? data.text:'hehe')
当 isShow 为 true 时,text 需要收集依赖。当 isShow 为 false 时,text 不需要收集依赖。所以 vue 在每次执行 run 函数前都先清空收集的依赖,然后再执行依赖收集。也就是 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 ,其是在 track 的时候进行赋值的,dep.add(activeEffect!);activeEffect!.deps.push(dep),先将当前执行中的副作用函数 _effect 收集到 dep 集合内,同时还把 dep 集合绑定到当前执行中的副作用函数,这样做的目的是为了知道 _effect 关联了几个依赖集合,当数据发生更新时能获得这些依赖集合。
执行 return this.fn(),这里的 fn 是 effect 的参数即匿名函数。执行匿名函数则会执行里面的 render 方法,data 是代理对象,读取属性触发 get ,get 触发 track。
最后当函数执行完后,重新计算 trackOpBit 。如果 effect 为嵌套 effectTrackDepth 则会减1。
对 ReactiveEffect 有了一定了解后,接下来看看 track 做了什么:
track 分析
export function track(target: object, type: TrackOpTypes, key: unknown) {
if (shouldTrack && activeEffect) {
let depsMap = targetMap.get(target)
if (!depsMap) {
// 每一个 target 对应一个 depsMap
targetMap.set(target, (depsMap = new Map()))
}
let dep = depsMap.get(key)
if (!dep) {
// 每一个 key 对应一个 dep 集合
/**
wasTracked和newTracked 根据比特位的来记录递归状态。
每个比特定义是否跟踪依赖项。
export const createDep = (effects?: ReactiveEffect[]): Dep => {
const dep = new Set<ReactiveEffect>(effects) as Dep
dep.w = 0 // wasTracked
dep.n = 0 // newTracked
return dep
}
*/
depsMap.set(key, (dep = createDep()))
}
trackEffects(dep, eventInfo)
}
}
track 函数主要做的事情是根据被代理对象 target ,属性 key,将_effect(ReactiveEffect的实例)储存到 set 中,这个过程就是依赖收集,三者关系如下:
创建过程如上过程是在 track 函数中完成的,依赖收集过程是通过 trackEffects 完成的。
实际情况如下图:
为了更加清楚的理解 trackEffects 的依赖收集过程,我们多添加几个响应式对象和 effect 嵌套执行来说明
<div id="app"></div>
<div id="app2"></div>
<div id="app3"></div>
<script type="module">
import { effect, reactive } from '../../../reactivity/dist/reactivity.esm-browser.js'
function render(vNode, container) {
if (typeof container === 'string') {
container = document.querySelector(container);
}
container.innerHTML = vNode;
}
const data = reactive({
count: 1,
foo: {
bar: 2
}
})
const data2 = reactive({
msg: "hello world",
})
effect(() => {
render(`<h1>${data.count} - ${data.foo.bar}-${data2.msg}</h1>`, "#app");
effect(() => {
render(`<h2>${data.foo.bar}</h2>`, "#app2");
effect(() => {
render(`<h3>${data2.msg}</h3>`, "#app3");
})
})
})
setTimeout(() => {
data.count++;
}, 1000)
</script>
括号内代表二进制
| trackEffects 执行次数 | effectTrackDepth 值 | trackOpBit 值 | dep.n 值 | target | key |
|---|---|---|---|---|---|
| 1 | 1 | 2(10) | 2(10) | {count:1,foo:{}} | count |
| 2 | 1 | 2(10) | 2(10) | {count:1,foo:{}} | foo |
| 3 | 1 | 2(10) | 2(10) | {bar:2} | bar |
| 4 | 1 | 2(10) | 2(10) | {msg:"hello world"} | msg |
| 5 | 2 | 4(100) | 6(110) | {count:1,foo:{}} | foo |
| 6 | 2 | 4(100) | 6(110) | {bar:2} | bar |
| 7 | 3 | 8(1000) | 10(1010) | {msg:"hello world"} | msg |
从表格和执行结果很容易回答 trackOpBit 怎么记录依赖收集的状态:
trackOpBit 初始值为 1,dep.n 初始值为 0。当 effect 执行时,收集 target 每个 key 的依赖, trackOpBit 只有在最高位为 1,effect 嵌套一层,trackOpBit 按位左移一位,dep.n |= trackOpBit dep.n 和 trackOpBit 按位或运算,对于相同的 key 只要在当前 effect 的嵌套层级出现则会将该比特位置 1 ,没有则是 0。对于每个 key 就可以从 dep.n 的比特位看出其依赖收集状况。
track优化 --- 分支切换
另外关于当 effectTrackDepth < maxMarkerBits 时,是使用 initDepMarkers 来代替 cleanupEffect,为什么可以这么代替呢?
和这功能实现相关代码主要是如下部分:
export const wasTracked = (dep: Dep): boolean => (dep.w & trackOpBit) > 0
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]
if (wasTracked(dep) && !newTracked(dep)) {
dep.delete(effect)
} else {
deps[ptr++] = dep
}
// clear bits
dep.w &= ~trackOpBit
dep.n &= ~trackOpBit
}
deps.length = ptr
}
}
首次进行 track 时,由于 deps 是空的 initDepMarkers 其实是不进行任何操作的。
track 完成后最后会执行 finalizeDepMarkers 函数重置 dep.w 和 dep.n 为 0; 再看下如下例子
const data2 = reactive({
isShow:true,
msg: "hello world",
})
effect(() => {
render(`<h1>${data2.isShow ? data2.msg : 'hello vue'} </h1>`, "#app");
})
setTimeout(() => {
data2.isShow = false;
}, 1000)
当 isShow = true 时,会收集 isShow 和 msg 的依赖,WeakMap 如下:
当 1s 后 isShow 设置为 false 我们想要的结果只会收集 isShow 的依赖,但是在执行 finalizeDepMarkers 时可以看出两个依赖目前还是存在,只是 dep.w 和 dep.n 不同,从红框中的代码可以看出是否删除是由这两个变量来决定的
这两个变量又是在什么时候变的不一样的呢,dep.w 在同一个状态下两者的值是相同的,和 dep.w 变化相关的代码如下:
export const initDepMarkers = ({ deps }: ReactiveEffect) => {
if (deps.length) {
for (let i = 0; i < deps.length; i++) {
deps[i].w |= trackOpBit // set was tracked
}
}
}
当isShow = false 时,deps.length = 2,trackOpBit = 2,按位或运算 dep.w 会统一设置为 2
dep.n 的值改变相关代码如下:
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 {
// ...
}
}
isShow = false 触发 track 会调用 trackEffects 执行 dep.n |= trackOpBit 得到 2,但是 msg 不会触发 track
了解了 dep.w 和 dep.n 的差异后,看看 wasTracked 和 newTracked 实现后
export const wasTracked = (dep: Dep): boolean => (dep.w & trackOpBit) > 0
export const newTracked = (dep: Dep): boolean => (dep.n & trackOpBit) > 0
| 状态 | key | dep.w | dep.n | trackOpBit | wasTracked | newTracked | 是否删除依赖 |
|---|---|---|---|---|---|---|---|
| isShow = true | isShow | 0 | 2(10) | 2(10) | false | true | false |
| isShow = true | msg | 0 | 2(10) | 2(10) | false | true | false |
| isShow = false | isShow | 2(10) | 2(10) | 2(10) | true | true | false |
| isShow = false | msg | 2(10) | 0 | 2(10) | true | false | true |
只要 wasTracked 为真,newTracked 为假就会删除依赖,达到和 cleanEffect 相同的目的