Skip to content

effectScope:副作用作用域管理

组件卸载时,如何确保所有的 effect、computed、watch 都被正确清理?

这是一个典型的资源管理问题。 手动管理很容易遗漏:

javascript
const effect1 = effect(() => console.log(a.value))
const effect2 = effect(() => console.log(b.value))
const stop1 = watch(c, () => console.log('c changed'))
const stop2 = watchEffect(() => console.log(d.value))

// 清理时需要一个个停止
onUnmounted(() => {
  effect1.stop()
  effect2.stop()
  stop1()
  stop2()
  // 忘了清理某一个怎么办?
})

effectScope 解决这个问题——批量管理副作用。这是一个非常典型的"作用域"模式:收集、统一释放。

基本使用

javascript
const scope = effectScope()

scope.run(() => {
  // 在作用域内创建的所有副作用都会被收集
  const doubled = computed(() => counter.value * 2)
  
  watch(counter, () => console.log('counter changed'))
  
  watchEffect(() => console.log(doubled.value))
})

// 一次性停止所有副作用
scope.stop()

effectScope 实现

javascript
let activeEffectScope

class EffectScope {
  effects = []    // 收集的 effect
  cleanups = []   // 清理函数
  scopes = []     // 子作用域
  parent = null   // 父作用域
  active = true   // 是否激活
  
  constructor(detached = false) {
    // 如果不是分离的,挂载到当前活动作用域下
    if (!detached && activeEffectScope) {
      this.parent = activeEffectScope
      activeEffectScope.scopes.push(this)
    }
  }
  
  run(fn) {
    if (this.active) {
      const previousScope = activeEffectScope
      activeEffectScope = this
      
      try {
        return fn()
      } finally {
        activeEffectScope = previousScope
      }
    }
  }
  
  stop(fromParent = false) {
    if (this.active) {
      // 停止所有 effect
      for (const effect of this.effects) {
        effect.stop()
      }
      
      // 执行清理函数
      for (const cleanup of this.cleanups) {
        cleanup()
      }
      
      // 停止子作用域
      for (const scope of this.scopes) {
        scope.stop(true)
      }
      
      // 从父作用域移除
      if (!fromParent && this.parent) {
        const index = this.parent.scopes.indexOf(this)
        if (index >= 0) {
          this.parent.scopes.splice(index, 1)
        }
      }
      
      this.active = false
    }
  }
}

function effectScope(detached = false) {
  return new EffectScope(detached)
}

effect 与 scope 的关联

思考一下:为什么在 scope.run 中创建的 effect 会被自动收集? 答案在于 activeEffectScope 这个全局变量。

effect 创建时检查当前作用域:

javascript
function effect(fn, options = {}) {
  const _effect = new ReactiveEffect(fn, options.scheduler)
  
  // 如果有活动作用域,记录到作用域中
  if (activeEffectScope) {
    activeEffectScope.effects.push(_effect)
  }
  
  // ...
  return _effect
}

同样的,computedwatchwatchEffect 内部也会把创建的 effect 添加到当前作用域。

onScopeDispose

注册在作用域销毁时执行的清理函数:

javascript
const scope = effectScope()

scope.run(() => {
  const timer = setInterval(() => console.log('tick'), 1000)
  
  // 注册清理函数
  onScopeDispose(() => {
    clearInterval(timer)
  })
})

scope.stop()  // clearInterval 被调用

实现:

javascript
function onScopeDispose(fn) {
  if (activeEffectScope) {
    activeEffectScope.cleanups.push(fn)
  } else if (__DEV__) {
    console.warn('onScopeDispose called without active effect scope')
  }
}

getCurrentScope

获取当前活动的作用域:

javascript
function getCurrentScope() {
  return activeEffectScope
}

// 使用
scope.run(() => {
  console.log(getCurrentScope() === scope)  // true
})

嵌套作用域

现在问一个问题:作用域可以嵌套吗? 答案是可以,而且这是一个非常实用的特性:

javascript
const parentScope = effectScope()

parentScope.run(() => {
  effect(() => console.log('parent effect'))
  
  // 创建子作用域
  const childScope = effectScope()
  
  childScope.run(() => {
    effect(() => console.log('child effect'))
  })
})

// 停止父作用域会连带停止子作用域
parentScope.stop()
// parent effect 停止
// child effect 也停止

分离作用域 (detached)

但有时候我们不希望子作用域随父作用域关闭,怎么办? 比如一个全局的资源,它的生命周期不应该绑定在某个组件上。

这时候需要"分离"作用域:

javascript
const parentScope = effectScope()

parentScope.run(() => {
  // 分离的子作用域
  const detachedScope = effectScope(true)  // detached = true
  
  detachedScope.run(() => {
    effect(() => console.log('detached'))
  })
  
  effect(() => console.log('parent'))
})

// 停止父作用域不影响分离的作用域
parentScope.stop()
// 只停止了 'parent' effect
// 'detached' effect 仍在运行

// 需要单独停止
detachedScope.stop()

组件与 effectScope

这是理解 Vue 组件清理机制的关键。 Vue 内部,每个组件实例都有自己的 effectScope:

javascript
function setupComponent(instance) {
  // 创建组件的作用域
  instance.scope = effectScope()
  
  instance.scope.run(() => {
    // setup 中的所有响应式 API 都在这个作用域内
    const result = setup(props, setupContext)
    
    // computed、watch 等都被收集到组件作用域
  })
}

// 组件卸载时
function unmountComponent(instance) {
  // 停止组件作用域,清理所有副作用
  instance.scope.stop()
}

这就是为什么你在 setup 中创建的 watchcomputed 会在组件卸载时自动清理。

实际应用场景

场景 1:条件性副作用

javascript
const enabled = ref(false)
let innerScope

watch(enabled, (value) => {
  if (value) {
    // 启用时创建作用域
    innerScope = effectScope()
    innerScope.run(() => {
      // 这些副作用只在 enabled 时存在
      watch(data, callback)
      watchEffect(someEffect)
    })
  } else {
    // 禁用时停止所有副作用
    innerScope?.stop()
  }
})

场景 2:Composable 的清理

javascript
function useFeature() {
  const scope = effectScope()
  
  scope.run(() => {
    // feature 相关的副作用
  })
  
  // 返回停止函数
  return {
    stop: () => scope.stop()
  }
}

场景 3:手动测试

javascript
test('reactive behavior', () => {
  const scope = effectScope()
  
  scope.run(() => {
    const count = ref(0)
    const double = computed(() => count.value * 2)
    
    expect(double.value).toBe(0)
    count.value = 1
    expect(double.value).toBe(2)
  })
  
  // 清理,避免测试间干扰
  scope.stop()
})

完整实现

javascript
let activeEffectScope

class EffectScope {
  constructor(detached = false) {
    this.active = true
    this.effects = []
    this.cleanups = []
    this.scopes = []
    this.parent = null
    
    if (!detached && activeEffectScope) {
      this.parent = activeEffectScope
      activeEffectScope.scopes.push(this)
    }
  }
  
  run(fn) {
    if (this.active) {
      const previousScope = activeEffectScope
      activeEffectScope = this
      try {
        return fn()
      } finally {
        activeEffectScope = previousScope
      }
    }
  }
  
  stop(fromParent = false) {
    if (this.active) {
      this.effects.forEach(e => e.stop())
      this.cleanups.forEach(c => c())
      this.scopes.forEach(s => s.stop(true))
      
      if (!fromParent && this.parent) {
        const i = this.parent.scopes.indexOf(this)
        if (i >= 0) this.parent.scopes.splice(i, 1)
      }
      
      this.active = false
    }
  }
}

function effectScope(detached = false) {
  return new EffectScope(detached)
}

function getCurrentScope() {
  return activeEffectScope
}

function onScopeDispose(fn) {
  if (activeEffectScope) {
    activeEffectScope.cleanups.push(fn)
  }
}

本章小结

effectScope 提供了副作用的批量管理能力,这是一个经典的"收集器"模式

  • scope.run(fn):在作用域内执行函数,收集副作用
  • scope.stop():停止所有收集的副作用
  • onScopeDispose:注册清理函数
  • getCurrentScope:获取当前作用域

设计哲学:资源管理不应该是分散的,而应该是集中的。effectScope 让你把所有相关的副作用放在一个"箱子"里,需要时一起清理。

嵌套作用域:

  • 默认情况下,子作用域随父作用域停止
  • detached = true 创建独立的作用域

组件应用:

  • 每个组件有自己的 effectScope
  • 组件卸载时自动停止作用域

练习与思考

  1. 实现 effectScopeonScopeDisposegetCurrentScope

  2. 以下代码会发生什么?

javascript
const scope = effectScope()
scope.run(() => {
  const stop = watchEffect(() => console.log('running'))
})
scope.stop()
// watchEffect 还会执行吗?
  1. 思考:为什么需要"分离"(detached)作用域?有什么实际使用场景?
effectScope:副作用作用域管理 has loaded