5-Vue源码之【Effect】
前言
我们在响应式中提到过 get
触发 track
而 set
会去触发 trigger
,这 2 个方法就是定义在 effect.ts
文件中的。此外这里还定义了一个 ReactiveEffect
类,该类非常重要,我们响应式挂钩的函数都是经过他包装的。
首先我们要先了解 Dep
和 ReactiveEffect
。
Dep
Dep
是一个存放了 ReactiveEffect
实例的 Set
集合,又定义了 2 个 number 变量来维护跟踪层级状态。
export type Dep = Set<ReactiveEffect> & TrackedMarkers
// wasTracked和newTracked维护了几个级别的效果跟踪递归的状态。每个级别一位用于定义是否跟踪了依赖关系。
// 当前trackOpBit位(当前层级) 是否存在 w 和 n
export const wasTracked = (dep: Dep): boolean => (dep.w & trackOpBit) > 0
export const newTracked = (dep: Dep): boolean => (dep.n & trackOpBit) > 0
// w 和 n 都为二进制数,具体是为了优化
type TrackedMarkers = {
/**
* 已跟踪位
*
* Effect实例调用 run 方法时,如果已经存在 deps 里已存在 dep ,会调用 initDepMarkers ,去设置 w 值
* 后续会在 trackEffects 时,如果发现 w 有值,那么就将 shouldTrack 设为false,避免重复存储 dep
*/
w: number
/**
* 新跟踪位
*
* dep.n 会在 trackEffects 时,去设置,
* 避免了同一个 fn 函数中,多次去调用同一个属性,避免多次收集。
* 等到最后调用 finalizeDepMarkers 时,如果那一层的 n 没值,且 w 有值(w 有值,说明 dep 和 Effect挂钩过)
* 那么就说明可能不要这个 effect 了,就清掉他。(一般深度超过30会出现这种情况)
*/
n: number
}
/**
* 创建 dep 的方法,可以接收一个 ReactiveEffect[]
*
* @param effects
* @returns
*/
export const createDep = (effects?: ReactiveEffect[]): Dep => {
const dep = new Set<ReactiveEffect>(effects) as Dep
dep.w = 0
dep.n = 0
return dep
}
w 和 n 具体作用,我们暂且不谈,后面会分析。再来看下 ReactiveEffect
这个类
ReactiveEffect
/**
* Dep 与 ReactiveEffect 的关系:
* Dep 是一个 ReactiveEffect 的 Set 集合。且又存有 w 和 n 属性。
* 由于是 ReactiveEffect 的集合,所以在 [...dep] 时,会返回一个 ReactiveEffect 的数组
*/
export class ReactiveEffect<T = any> {
active = true // 当前ReactiveEffect对象是否激活状态,默认为true,如果为 false 则不进行依赖收集
deps: Dep[] = [] // 在 effect 被停止时,需要遍历这里的数组,去删除各个 Dep 中带有这个 effect 的 Set元素
parent: ReactiveEffect | undefined = undefined
computed?: any
allowRecurse?: boolean
private deferStop?: boolean // 稍后暂停
// 【注】 在构造函数参数中如果带有 修饰符,则表示new的时候会自动添加上属性,如下方便是可以调用 this.fn this.scheduler
constructor(public fn: () => T, public scheduler: EffectScheduler | null = null, scope?: any) {
// new ReactiveEffect 时候,如果存在 scope ,那么将其实例存储在 scope.effects 中
// 这里不做这方面的考虑
// recordEffectScope(this, scope)
}
// 后面解释,这里暂且先只做声明
run() {}
stop() {
// 如果 activeEffect 是当前实例,正在run,那么需要在其结束之后才调用 this.stop()
// 利用 deferStop 属性,结合上面的 run() 里最后的
if (activeEffect === this) {
this.deferStop = true
} else if (this.active) {
cleanupEffect(this)
this.active = false
}
}
}
active:如果当前 ReactiveEffect
对象的 active
为 false
,那么后续就不会进行依赖收集。
deps:非常重要,追踪的属性 和 effect
是相互储存的。比如我页面上依赖了 name
这个属性,那么 name
就会通过 dep
关联并存储上 页面更新(effect),使得 name
被触发 trigger
时,能够找到关联的 页面更新 的方法。同样的,页面更新 这个方法所在的 effect
上的 deps 属性(该属性)
也会保存刚刚提到的 dep
, 因为 dep
是一个 Set 集合,里面存储着各种各样的 effect
,然后这里只需要 dep.delete(effect)
就可以删除掉这个存储着(页面更新)副作用函数的对象
parent:用来避免调用 run
方法时, activeEffect
刚好是当前 ReactiveEffect
实例,如果是,则退出函数,不进行操作。
computed:计算属性相关,不做讨论
allowRecurse:是否允许允许递归调用
fn:TS 语法,构造函数中传入就会自动给 this.fn
赋值,这就是我们 track
的副作用函数
scheduler:TS 语法,构造函数中传入就会自动给 this.scheduler
赋值,如果存在在 run
的时候,就会调用这个方法,这个方法会让调度器来判断什么时候执行 fn
方法
run
依赖追踪之前都会先执行一次该方法。举个例子:
在 renderer.ts
中,渲染页面时会调用 new ReactiveEffect
这时候就创建了一个 ReactiveEffect
实例,并且将 页面渲染 的方法传入 fn
,随即会在之后调用一个该实例的 run
方法,最终会调用传入的 fn
,而我们的 fn
里就会去执行 track
或者 trigger
【注】
1、 shouldTrack 是全局属性,用来判断是否 可以追踪依赖 2、 activeEffect 是全局属性,当 effect 实例执行到 run 方法时候,就会将当前实例 this 赋值给 activeEffect,等到调用 this.fn 时,里面 track 的属性,只会把当前 activeEffect 挂钩的 fn 存储到对应的 key 的 dep 里
const run = () => {
/* ============= 第一部分 =============== */
// 未激活则不进行依赖收集,直接调用 fn 函数
if (!this.active) {
return this.fn()
}
let parent: ReactiveEffect | undefined = activeEffect
let lastShouldTrack = shouldTrack
// 这段代码的作用是:防止 activeEffect 等于当前 this
// 这里 parent 必须为 undefined 才能继续往下走。
// 首先 activeEffect 会在 run 的时候被赋值为 this
// 而在 run 执行到最后的 fn() 之后,会被重新赋值回去
// 如果这里出现了 effect 嵌套行为,那么 parent = activeEffect
// 在嵌套的第二层的 effect 中, activeEffect 就为 第一层的 effect
// 然后通过链表不断向上查找 parent ,如果找到的 parent 等于 this ,那么就退出这次 run
// 如果找到了 undefined ,那么即可退出循环,继续向下走
while (parent) {
// 防止出现 activeEffect 有值且等于 this 的情况,一遇到这种情况直接退出
if (parent === this) {
return
}
parent = parent.parent
}
try {
// fn 执行的时候,会触发到响应式属性的 get ,继而会需要正确的 activeEffect ,所以在fn 执行之前,
// 我们要先处理一下 activeEffect
// activeEffect 这时候还是上一层的Effect 或 undefined ,赋值给 this.parent ,形成链表关系,然后修改 activeEffect
this.parent = activeEffect
activeEffect = this
shouldTrack = true
// 根据effect递归的深度,修改 trackOpBit
trackOpBit = 1 << ++effectTrackDepth
// 深度只要不超过30
if (effectTrackDepth <= maxMarkerBits) {
initDepMarkers(this) // 将 this.deps 里的 w 设置 trackOpBit位
} else {
// 如果超过了30 ,则清除当前effect关联的所有Dep映射
cleanupEffect(this)
}
/* ============= 第二部分 =============== */
return this.fn()
} finally {
/* ============= 第三部分 =============== */
// 执行完 fn() 调用 finally
if (effectTrackDepth <= maxMarkerBits) {
finalizeDepMarkers(this)
}
// 还原 trackOpBit
trackOpBit = 1 << --effectTrackDepth
// fn 执行完之后,还原 activeEffect 值
activeEffect = this.parent
shouldTrack = lastShouldTrack
this.parent = undefined
// 如果用户调用 effect.stop 时,刚好是在该effect运行期间,那么就会给其打赏 deferStop标志 ,有了这个标志,那么就会在执行完这个effect之后停止,清除这个 effect
if (this.deferStop) {
this.stop()
}
}
}
我们可以将整个 run 分为三部分:
-
fn 执行前:处理 effect 嵌套,赋值 activeEffect,处理递归深度
-
执行 fn: 可以调用 代理对象 里的属性,触发了 track
-
fn 执行后:清空当前深度的 dep 的 w 和 n
1. 第一部分
activeEffect
activeEffect = this
shouldTrack = true
重点来看 try 块
包裹起来的代码,首先确定了 activeEffect
,这样再后面首次 key 被 track
时,就能知道 dep
要与那个 effect
关联。
trackOpBit 、effectTrackDepth
trackOpBit = 1 << ++effectTrackDepth
这是 2 个全局数值,跟深度有关,和 w、n
配合,给框架带来了更好的性能提升。
这里的深度指的是递归,举个例子便于理解:
effect(() => {
console.log(state.name)
effect(() => {
console.log(state.age)
})
})
state 是一个响应式数据,这里的 effect
内部会调用 new ReactiveEffect()
并且将回调带给 fn
后,立刻执行一次 run
方法。
然后我们就会走到 run
的第一部分,赋值 activeEffect
,这里 trackOpBit
也会变成
// 初始状态
trackOpBit = 0
effectTrackDepth = 1
// 进入第一层 effect
trackOpBit = 0b0010 // 实际是以32位带符号的整数运算的,因此设立了 maxMarkerBits 最大深度30,我这里简单用 4位2进制位 表示
紧接着我们去执行 fn
方法,上面也说了, fn
是 effect
的回调,所以实际上是去执行了下面的代码
console.log(state.name)
effect(() => {
console.log(state.age)
})
这里因为调用了响应式数据的 name
,触发了 track
紧接着又执行了一次 effect
,这时候 trackOpBit
再次进行变化,接着又触发第二层的回调,响应式数据 age
触发 track
// 进入第二层 effect
trackOpBit = 0b0100
会发现正是由于我们的 trackOpBit
,我们知道的我们响应式数据是再哪一层进行的 track
当然目前效果还不明显,主要是在后面会让 dep 的 w 和 n
与 trackOpBit
进行 位或运算。这样便使得我们能够知道这个 dep 或者说这个 响应式数据的 key 分别在哪一层发生了收集。
那它们何时发生位或的呢?继续往下看。
initDepMarkers、cleanupEffect
// 深度只要不超过30
if (effectTrackDepth <= maxMarkerBits) {
initDepMarkers(this) // 将 this.deps 里的 w 设置 trackOpBit位
} else {
// 如果超过了30 ,则清除当前effect关联的所有Dep映射
cleanupEffect(this)
}
// 遍历传入的 deps,将其设为 已跟踪
export const initDepMarkers = ({ deps }: ReactiveEffect) => {
if (deps.length) {
for (let i = 0; i < deps.length; i++) {
deps[i].w |= trackOpBit
}
}
}
// 遍历传入的 deps,并清空 effect
function cleanupEffect(effect: ReactiveEffect) {
const { deps } = effect
if (deps.length) {
for (let i = 0; i < deps.length; i++) {
// deps 是 dep 的数组,dep 又是各个 Effect 的 Set 集合
// 这里是遍历 deps ,然后由于 effect 要被停掉,
// 那么就需要删除 dep 中属于 effect 的依赖
// 这样之后,有这个 effect 的其他 dep,在触发trigger 时,就不会调用到这个 effect
deps[i].delete(effect)
}
deps.length = 0
}
}
initDepMarkers
即是将当前 effect
所挂钩的 deps
里的 dep
的 w
都打上 深度标志,确定是处于哪一层。值得注意的是,首次 new ReactiveEffect
之后执行的第一次 run
方法,是不存在 deps
的,所以,首次执行的 dep 的 w 都没有打上标志,那么就可以用这个 w 来判断,如果首次执行,则在 key
和 effect
发生 track
时需要挂钩一次,后续的话, w 有值的时候,就不需要重复挂钩了
// 挂钩就是指下面的这个方法, dep 和 effect 挂钩, dep 又是和 响应式对象的 key 关联
// 下面的代码在 track 方法中会写到
// 只有首次创建的 dep 或者 清空了effect 之后才会进入该方法
if (shouldTrack) {
dep.add(activeEffect!)
activeEffect!.deps.push(dep)
}
cleanupEffect
使用二进制位的最终目的就是为了优化,而一旦超出限制,即比如你的 effect 深度超出了最大范围 30 层(正常来说不可能,一般一层就够用了,感觉很少业务会去嵌套这些 effect)就需要清空那个 effect 挂钩的 dep,在等到后面 track 时,重新进行挂钩。
2. 第二部分
return this.fn()
这里就会执行 fn
,内部如果有去获取响应式对象的 key
,则会触发 track
track
/**
* 响应式数据会在 componentUpdateFn 的时候(DOM渲染)调用,
* 然后调用 effect.run() 时,这时候 activeEffect 就有值了,最后调用 this.fn 即调用 componentUpdateFn 会去 render 我们的vnode。
* 然后里面的值就被 track 了
*
* @param target
* @param type
* @param key
*/
export function track(target: object, type: TrackOpTypes, key: unknown) {
// 只有可以收集依赖 且 存在活动的 effect 时执行
if (shouldTrack && activeEffect) {
// 跟据 target -> key -> 依赖的关系,先获取到 依赖Map
let depsMap = targetMap.get(target)
// 没有就先生成一个
if (!depsMap) {
targetMap.set(target, (depsMap = new Map()))
}
// 在用 key 去获取到 相应的依赖
let dep = depsMap.get(key)
// 不存在依赖,调用 createDep 生成
// 这里生成的 dep 是一个 new Set 里面存放了 ReactiveEffect
// 会在后面将 activeEffect 存入
if (!dep) {
depsMap.set(key, (dep = createDep()))
}
trackEffects(dep)
}
}
export function trackEffects(dep: Dep) {
let shouldTrack = false
// 深度不超过30
if (effectTrackDepth <= maxMarkerBits) {
// 如果ReactiveEffect实例的fn函数中,多次使用了同一个代理对象的同一个属性,有了这个条件判断可以直接避免多次收集。
// 比如:effect(() => { console.log(state.count);console.log(state.count) }) 因为第一次会给 n 赋值
// 所以当第二次获取 state.count 时,执行到这一步,会被卡住, shouldTrack 还是为 false
if (!newTracked(dep)) {
dep.n |= trackOpBit // 设置新跟踪位
shouldTrack = !wasTracked(dep) // 由于执行 run 方法之前会调用 w |= trackOpBit,所以大概率 shouldTrack 为 false, 除了首次的情况,首次不存在 dep, dep也是新的,所以 w 也是 0
}
} else {
// 深度超过 30 之后,清除掉了 activeEffect ,需要重新去挂载,所以这里大概率返回 true
shouldTrack = !dep.has(activeEffect!)
}
// 只有首次创建的 dep 或者 清空了effect 之后才会进入该方法
if (shouldTrack) {
// 将 activeEffect 存入 dep ,等待 trigger 遍历
dep.add(activeEffect!)
// 这里互存,便于找到彼此(比如在 cleanupEffect 中就是用 deps 去清空依赖的)
// 注意这里的 dep 是一个 new Set 值,所以在 cleanupEffect 中是用 deps[i].delete(effect)
activeEffect!.deps.push(dep)
}
}
这里维护了两个 Map, 一个是以 targetMap
和 depsMap
。他们的关系如下:
在我们 track
的时候,就可以通过 target
找到对应的 depsMap
,在用 key
找到对应的 dep
。
顺便再回忆下刚刚说到的 dep
和 effect
的关系
这里简单概括下 track方法
的功能就是:
首次进入,会利用
target , key
生成一个dep
,让其与activeEffect
相互关联,如果dep
已存在且shouldTrack
为 false,则说明dep
已经关联过了,不需要重复关联。
QA.1. 如何判断需不需要 shouldTrack?
在 第一部分 的时候,调用 effect.run()
会有一步 initDepMarkers
,它会给已有的 dep
的 w
位或运算上 trackOpBit
,而我们首次调用 effect
, dep
是不存在的,会再后面 track 方法
中去生成一个 新的 dep
,这个 dep
的 w
为 0 ,所以这里需要与 effect
相互关联,后面再次进入 run
,就会设上 w
的值,然后 track
的时候发现 w
大于 0,就不会去重复关联了
trigger
export const trigger = (target: object, type: TriggerOpTypes, key?: unknown) => {
const depsMap = targetMap.get(target)
if (!depsMap) {
return
}
const deps: (Dep | undefined)[] = []
if (key != undefined) {
deps.push(depsMap.get(key))
}
switch (type) {
// 【注】 最开始我没有这一步
// 直到后来,调用 list.push("xx") 的时候,触发了 trigger
// 但是由于 xx 对应的下标是 新下标 , 是不存在 dep 的
// 所以这里临时用 length 的 dep
case TriggerOpTypes.ADD:
if (isArray(target) && isIntegerKey(key)) {
deps.push(depsMap.get('length'))
}
break
default:
break
}
// 由于 deps 是 dep的数组,而 dep 又是一个 Set。 所以需要 遍历一次,解构一次
// 最后得到的 effect 数组即需要执行的数组
const effects: ReactiveEffect[] = []
deps.forEach((dep) => {
dep && effects.push(...dep)
})
if (effects.length > 0) {
triggerEffects(effects)
}
}
export const triggerEffects = (effects: ReactiveEffect[]) => {
for (let i = 0; i < effects.length; i++) {
const effect = effects[i]
// 这个判断避免了会有重复 trigger
// 即 只有 effect 不等于当前活动的 activeEffect 时,才触发 run
// 因为 activeEffect 就是在 run 的时候去设置的,且在 run 里还有一层 if (parent === this) return 拦截
if (effect !== activeEffect || effect.allowRecurse) {
// 如果有调度器则使用调度器(异步执行)
if (effect.scheduler) {
effect.scheduler()
} else {
effect.run()
}
}
}
}
简单概括下:
trigger
通过target 和 key
找到dep
,由于我们dep
是一个effect
的Set
集合 所以只需要遍历去执行effect.run
即可 Vue 里其实还针对特殊类型做了一些特殊处理,有兴趣的可以源码了解了解
QA.2. effect 为啥不会重复调用?
// 下面的代码,既触发了 track 和 trigger
// 而 trigger 又会触发 effect 的回调,那按照感觉来说,应该要重复调用
// 可这里却只执行了一次,是为什么?
effect(() => {
state.name += 1
})
上面的代码里, trigger 是触发了,但是不一定会调用回调。
trigger 调用回调的地方有一个 if (effect !== activeEffect || effect.allowRecurse)
这个判断拦截了,如果遇到了 effect 和 activeEffect 相同的情况,除非他允许自己递归,
否则是不会进去调用回调的。
且在 effect.run() 里, if (parent === this) return ,这里又会进行一层判断
如果发现 activeEffect 是自己,那么也会退出,这样也执行不到回调方法
3. 第三部分
// 执行完 fn() 调用 finally
if (effectTrackDepth <= maxMarkerBits) {
finalizeDepMarkers(this)
}
// 还原 trackOpBit
trackOpBit = 1 << --effectTrackDepth
// fn 执行完之后,还原 activeEffect 值
activeEffect = this.parent
shouldTrack = lastShouldTrack
this.parent = undefined
接下来就简单了,基本是进行还原操作。
/**
* 当 track 结束后(执行完 run 里的 fn()) 会调用这个方法
* 他会去清除无效的 effect 且重置当前深度的 w 和 n
*
* @param effect
*/
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
}
// 清空那一层的 w 和 n
dep.w &= ~trackOpBit
dep.n &= ~trackOpBit
}
deps.length = ptr
}
}
总结
effect
这块当时看的很绕,不知道写出来的文档能不能让大家看的清晰,就怕写的太烂,写着写着又变成只有自己知道的那种感觉((lll ¬ ω ¬))……
总结下流程:
用户首次进入项目,在 虚拟 DOM 构建完成,这里会创建一个 ReactiveEffect
实例 effect
,然后将页面渲染的方法存储进回调里。
然后主动调用一次 effect.run
,进入第一部分,修改 activeEffect = this
, 递归深度, 由于是首次进入, effect
的 deps
长度为 0 ,就没有 dep
能被改写 w
。
紧接着进入第二部分,调用 this.fn()
这里就会去调用 页面渲染 ,然后假设这里用了代理对象 proxyState
的 name
属性。那么就会创建一个相关的 dep
(因为首次进入,会创建一个新的,后面进来则不需要重新创建了)
新创建的 dep
的 w
和 n
都是 0 ,所以这时候 shouldTrack
会被设为 true
,进而会将 dep
和 activeEffect
关联起来。
第三部分就是还原操作。
然后,每当 name
被修改,那么会触发 trigger
,遍历 dep
,从中找到与之关联的所有的 effect
,在调用 effect.run()
就会继续从 第一部分开始
转载自:https://juejin.cn/post/7237531176587018300