前情提要:上一章介绍了effect函数的优化部分——使用trackOpBit记录响应式副作用(ReactiveEffect)的嵌套深度,并记录wasTrack和newTrack状态代替了cleanup函数,以"打Tag"的方式优化了cleanup函数必要的O(n)循环(虽然在超过阈值时也是采用的cleanup的方式),大大提高了响应式副作用的收集效率。而本章节将介绍EffectScope,该对象用于给ReactiveEffect进行划区。
1. EffectScope的存在背景
effectScope的介绍参考 vue3的effectScope
我们在使用Vue3开发时,会频繁的使用watch,computed等API,而这些API内部会隐式的创建ReactiveEffect对象,该对象的作用就是当我们监听的数据发生改变时,用于重新触发我们的回调函数。
watch(obj.msg, () => console.log(obj.msg))
例如上述代码所示,当obj.msg发生改变时我们会重新打印它的值。我们在日常开发中是配合Vue的组件一起使用的,所以当Vue的组件被卸载时,Vue框架会帮助我们销毁这些响应式副作用。(简单理解为Reactive实例创建后会被Set,Queue等数据结构所引用,所以即使不触发也一直占用内存,Vue框架在组件卸载时会帮助我们清理这些没用的内存)。但是,如果我们不适用Vue的组件的前提下,使用watch,computed等API时,我们就需要手动的去销毁这些响应式副作用。
const disposables = []
const counter = ref(0)
const doubled = computed(() => counter.value * 2)
disposables.push(() => stop(doubled.effect))
const stopWatch1 = watchEffect(() => {
console.log(`counter: ${counter.value}`)
})
disposables.push(stopWatch1)
const stopWatch2 = watch(doubled, () => {
console.log(doubled.value)
})
disposables.push(stopWatch2)
类似上述代码(我们不需要关注stop函数的实现,而只需要关注这一思想),由此可见每次ReactiveEffect生成时我们都需要收集它,这非常的影响使用,一旦遗漏了就会产生内存占用。
2. EffectScope解决的问题
function useMouse() {
const x = ref(0)
const y = ref(0)
function handler(e) {
x.value = e.x
y.value = e.y
}
window.addEventListener('mousemove', handler)
onUnmounted(() => {
window.removeEventListener('mousemove', handler)
})
return { x, y }
}
以上是官网上关于effectScope解决问题的一个实例,可见示例是想实现一个对于鼠标位置的监听,在对象实例化时绑定了mousemove事件,在组件卸载时移除监听。该函数每次调用都会产生新实例,并且重复监听,这并不合理,应该将监听和refs提到函数外部,但是不能这样做,应该onUnmounted是和Vue的组件一对一绑定的,可以想象到如果同时有两个组件需要鼠标位置坐标,而其中一个组件先卸载了,那时监听便会移除,会导致另一个组件出错。
- onUnmounted(() => {
+ onScopeDispose(() => {
window.removeEventListener('mousemove', handler)
})
采用上述修改则使得移除监听的行为与组件完成了解耦,然后我们需要一个单元来集中管理这些effectScope。
function createSharedComposable(composable) {
let subscribers = 0
let state, scope
const dispose = () => {
if (scope && --subscribers <= 0) {
scope.stop()
state = scope = null
}
}
return (...args) => {
subscribers++
if (!state) {
scope = effectScope(true)
state = scope.run(() => composable(...args))
}
onScopeDispose(dispose)
return state
}
}
const useSharedMouse = createSharedComposable(useMouse)
以上是管理effectScope的集中单元,可见当首次调用useSharedMouse时,会产生effectScope实例,并且是单实例,所以当其他页面调用时并不会产生多实例,其内部会有一个subscribers记录订阅者数量,当所有有订阅鼠标位置的实例都卸载的时候(组件卸载会调用onScopeDispose是因为setup函数的执行环境就是在一个effectScope的实例下),则会停止该单元的effectScope并执行移除监听事件(注:此时所有用到鼠标位置的页面已经全部卸载,移除监听的事件与组件没有耦合)。
3. EffectScope的各项参数和实现原理
export function effectScope(detached?: boolean) {
return new EffectScope(detached)
}
effectScope函数调用时会创建一个EffectScopes实例,该实例管理ReactiveEffect的作用域范围。
run<T>(fn: () => T): T | undefined {
if (this._active) {
const currentEffectScope = activeEffectScope
try {
activeEffectScope = this
return fn()
} finally {
activeEffectScope = currentEffectScope
}
} else if (__DEV__) {
warn(`cannot run an inactive effect scope.`)
}
}
然后调用effectScope.run(),run方法先用临时变量currentEffectScope指向上一个activeEffectScope,然后把activeEffectScope指向自己,完成上下文环境的转换,然后执行外部传入的fn,这时,当fn函数内部访问activeEffectScope时,则指向自身,当fn执行完毕后进行回溯。
export function effect<T = any>(
fn: () => T,
options?: ReactiveEffectOptions
): ReactiveEffectRunner {
//......
const _effect = new ReactiveEffect(fn)
if (options) {
extend(_effect, options)
if (options.scope) recordEffectScope(_effect, options.scope)
}
//......
}
export function recordEffectScope(
effect: ReactiveEffect,
scope: EffectScope | undefined = activeEffectScope
) {
if (scope && scope.active) {
scope.effects.push(effect)
}
}
fn的执行会调用effect函数的执行,而effect函数的执行在上一章中我们已经知道了它会产生ReactiveEffect实例,而观察上述代码可知,当options中带有EffectScope实例时会调用recordEffectScope函数,该函数的作用就是将ReactiveEffect实例收集到EffectScope实例的effects属性中。
stop(fromParent?: boolean) {
if (this._active) {
let i, l;
for (i = 0, l = this.effects.length; i < l; i++) {
// 调用scope收集到的响应式副作用的stop,使其失活
this.effects[i].stop();
}
// 执行监听cleanup函数
for (i = 0, l = this.cleanups.length; i < l; i++) {
this.cleanups[i]();
}
if (this.scopes) {
// 遍历当前scope内的scopes使其都停止
for (i = 0, l = this.scopes.length; i < l; i++) {
this.scopes[i].stop(true);
}
}
// 表示该scope是嵌套的scope
// stop的调用并不是来自于父scope的stop调用,而是该嵌套scope主动掉用的
if (!this.detached && this.parent && !fromParent) {
const last = this.parent.scopes!.pop();
if (last && last !== this) {
// 这是一个O(1)的删除操作,类似在数组中删除某一项而不引起数组的重排
this.parent.scopes![this.index] = last;
last.index = this.index;
}
}
this.parent = undefined;
this._active = false;
}
}
以上是effectScope的stop方法,当stop调用时会遍历effects,cleanups和scopes分别对应着收集ReactiveEffects的数组,onScopeDiscope的回调数组和子EffectScope数组,而其中最关键的是ReactiveEffect实例的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
}
}
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
}
}
以上是ReactiveEffect的stop实现,关键点是cleanupEffect函数的执行从Set中删除了ReactiveEffect,避免了内存泄漏。另一个关键点是deferStop=true,需要延迟stop,下面将介绍为什么要延迟stop。
run() {
//......
try {
// ......
if (effectTrackDepth <= maxMarkerBits) {
initDepMarkers(this)
} else {
cleanupEffect(this)
}
return this.fn()
} finally {
// ......
if (this.deferStop) {
this.stop()
}
}
}
以上代码是ReactiveEffect的run函数代码,也是上一章优化effect函数的代码,假设当obj.msg也就是代码对象的属性还没有访问时已经执行了effectScope的stop方法,如果没有deferStop,此时会清空effectScope的effects属性,然后再执行对obj.msg的访问,而obj.msg的访问会产生ReactiveEffect,这些ReactiveEffect实例会被收集到“桶”中,使得之前的清空功能失效,产生内存泄漏。所以effectScope的stop方法的执行,需要确保ReactiveEffect已经产生了,即应该在this.fn执行后再执行,所以需要deferStop。
总结:EffectScope完成了对ReactiveEffect的作用域划分,使得响应式原理在Vue的整体框架中耦合度变得更低,方便开发者更好的使用vue的响应式系统。