第 9 章

effectScope、customRef、triggerRef:响应式系统进阶 API 的设计意图

第9章:effectScope、customRef、triggerRef——响应式系统进阶 API 的设计意图

Pinia 的每个 store 内部都用了 effectScope()——它不是一个锦上添花的 API,而是 Vue 3 响应式系统能够支撑复杂状态管理库的基础设施。

本章核心问题:当你需要"一次性停止所有 watcher"时该怎么做?什么时候应该完全控制 track/trigger 时机?为什么 ECharts 对象必须用 markRaw 处理?

读完本章你将理解


Level 1 · 你需要知道的(1-3年经验)

effectScope:批量管理 effect 的生命周期

在 Vue 3 组件中,watchwatchEffectcomputed 等 API 创建的 effect 会自动绑定到组件的生命周期——组件卸载时自动清理。但在组件之外使用这些 API 时(如状态管理库、工具函数),就需要手动管理生命周期。

effectScope 提供了一种优雅的解决方案:

import { effectScope, ref, watch, computed, onScopeDispose } from 'vue'

// 创建一个 scope
const scope = effectScope()

// 在 scope 内运行的所有 effect 都由 scope 管理
scope.run(() => {
  const count = ref(0)
  const doubled = computed(() => count.value * 2)
  
  watch(count, (val) => {
    console.log('count changed:', val)
  })
  
  watchEffect(() => {
    console.log('doubled:', doubled.value)
  })
  
  // onScopeDispose:scope 停止时的清理钩子
  onScopeDispose(() => {
    console.log('scope 已停止,执行清理')
  })
})

// 一次性停止所有 effect
scope.stop()
// 所有 watch、computed、watchEffect 全部停止
// onScopeDispose 回调被调用

effectScope 的核心价值:不用手动维护一个 stop 函数数组,一行 scope.stop() 搞定所有清理。

effectScope 的嵌套与 detached 模式

const parentScope = effectScope()

parentScope.run(() => {
  // 子 scope(默认:随父 scope 停止而停止)
  const childScope = effectScope()
  childScope.run(() => {
    watchEffect(() => console.log('child effect'))
  })
  
  // detached scope(独立于父 scope)
  const detachedScope = effectScope(true)  // 传入 true = detached
  detachedScope.run(() => {
    watchEffect(() => console.log('detached effect'))
  })
  
  // 停止父 scope
  // childScope 里的 effect 会停止
  // detachedScope 里的 effect 不会停止
})

parentScope.stop()
// 'child effect' 的 watcher 已停止
// 'detached effect' 的 watcher 仍然运行

detached scope 的使用场景:全局 modal、通知系统等需要跨越组件生命周期存在的副作用。

customRef:完全控制 track/trigger

customRef 允许你创建一个自定义的 ref,完全控制何时追踪依赖、何时触发更新:

import { customRef } from 'vue'

function useDebouncedRef<T>(value: T, delay = 300) {
  let timer: ReturnType<typeof setTimeout>
  
  return customRef((track, trigger) => {
    return {
      get() {
        track()         // 通知:我被读取了,追踪这个依赖
        return value
      },
      set(newValue: T) {
        clearTimeout(timer)
        timer = setTimeout(() => {
          value = newValue
          trigger()     // 通知:我的值变了,触发依赖更新
        }, delay)
      }
    }
  })
}

// 使用
const searchQuery = useDebouncedRef('', 300)

// 在 300ms 内的多次输入只触发一次更新
watch(searchQuery, (query) => {
  fetchSearchResults(query)
})

customRef 工厂函数接收 (track, trigger) 参数

triggerRef 与 shallowRef

shallowRefref 的浅层版本——只追踪 .value 本身是否被替换,不追踪内部属性:

import { shallowRef, triggerRef } from 'vue'

const state = shallowRef({ count: 0, name: 'Vue' })

// ✓ 触发更新(替换了整个 .value)
state.value = { count: 1, name: 'Vue 3' }

// ❌ 不触发更新(修改内部属性,shallowRef 不追踪)
state.value.count = 99

// ✅ 修改内部属性后手动触发
state.value.count = 99
triggerRef(state)  // 手动触发所有依赖 state 的 effect

triggerRef 的使用场景

  1. 使用了 shallowRef 包装大对象,需要手动通知更新
  2. 集成第三方库(如 ECharts)时,库内部修改了状态,需要通知 Vue

toRaw 与 markRaw:打破响应式

import { reactive, toRaw, markRaw } from 'vue'

// toRaw:获取响应式对象的原始版本(临时打破响应式)
const state = reactive({ count: 0 })
const rawState = toRaw(state)

rawState.count = 100    // 直接修改原始对象,不触发 Vue 响应式
console.log(state.count)  // 0(响应式代理未被触发)
console.log(rawState.count)  // 100

// markRaw:永久标记对象为不可响应式
import * as echarts from 'echarts'

const chartInstance = markRaw(echarts.init(document.getElementById('chart')))

// 现在可以安全地放入响应式状态
const appState = reactive({
  chart: chartInstance   // 不会被 Proxy 包装
})

Level 2 · 它是怎么运行的(3-5年经验)

effectScope 的内部实现原理

effectScope 的核心思想是跟踪在其 run 内部创建的所有 effect。当 scope 停止时,批量调用所有 effect 的 stop() 方法。

内部数据结构

EffectScope
├── active: boolean              // scope 是否活跃
├── effects: ReactiveEffect[]    // scope 内的所有 effect
├── cleanups: (() => void)[]     // onScopeDispose 注册的回调
├── parent: EffectScope | undefined  // 父 scope
└── scopes: EffectScope[]        // 子 scope

effect 注册流程

effectScope.run(() => { ... })
        │
        ▼
activeEffectScope = this
        │
        ▼
在回调内创建 ReactiveEffect
        │
        ▼
ReactiveEffect 构造函数检测 activeEffectScope
        │
        ▼
将 this 加入 activeEffectScope.effects 数组
        │
        ▼
activeEffectScope = parent(恢复)
effectScope.stop()
        │
        ├─► 遍历 this.effects → 每个调用 effect.stop()
        │
        ├─► 遍历 this.scopes → 每个调用 childScope.stop()
        │
        └─► 遍历 this.cleanups → 每个调用 cleanup()

Pinia 中的 effectScope 应用

Pinia 是 effectScope 的最佳实践案例。每个 Pinia store 内部都创建了一个 effectScope:

// pinia 源码简化(packages/pinia/src/store.ts)
function createSetupStore(id, setup, options, pinia) {
  // 为这个 store 创建专属的 effectScope
  const scope = pinia._e.run(() => {
    const scope = effectScope()
    return scope
  })
  
  // 在 scope 内运行 setup 函数
  const setupStore = scope.run(() => setup())
  
  // store 销毁时,停止所有 effect
  store.$dispose = () => {
    scope.stop()
    // ... 其他清理
  }
  
  return store
}

这意味着:调用 store.$dispose() 会自动停止这个 store 内所有的 watchcomputedwatchEffect,不需要手动追踪每一个。

customRef 的双重角色

customRef 实现了一个精妙的设计:它既是 ref(有 .value),又是完全自定义的(track/trigger 由用户控制)。

防抖 ref 的完整实现与工作原理

输入框输入 "V" → searchQuery.value = "V"
                        │
                        ▼
              set() 被调用
                        │
                        ▼
              clearTimeout(timer) → 清除旧定时器
              timer = setTimeout(() => {
                value = "V"
                trigger()  ← 300ms 后才执行
              }, 300)
                        │
              (300ms 内又输入 "Vu")
                        │
                        ▼
              set() 再次被调用
              clearTimeout(timer) → 300ms 的定时器被清除!
              timer = setTimeout(() => {
                value = "Vu"
                trigger()  ← 新的 300ms 定时器
              }, 300)
                        │
              (300ms 内继续输入 "Vue")
                        │
              ... 同样的流程 ...
                        │
              (300ms 无新输入)
                        │
                        ▼
              trigger() 执行
              所有依赖 searchQuery 的 effect 重新执行
              watch(searchQuery, ...) 回调被触发
              fetchSearchResults("Vue")

节流 ref 实现

function useThrottledRef<T>(value: T, interval = 500) {
  let lastTime = 0
  
  return customRef((track, trigger) => ({
    get() {
      track()
      return value
    },
    set(newValue: T) {
      const now = Date.now()
      if (now - lastTime >= interval) {
        lastTime = now
        value = newValue
        trigger()
      }
      // 时间间隔内的更新被忽略
    }
  }))
}

持久化 ref(自动同步 localStorage)

function useLocalStorageRef<T>(key: string, initialValue: T) {
  const stored = localStorage.getItem(key)
  let value: T = stored ? JSON.parse(stored) : initialValue
  
  return customRef((track, trigger) => ({
    get() {
      track()
      return value
    },
    set(newValue: T) {
      value = newValue
      localStorage.setItem(key, JSON.stringify(newValue))
      trigger()
    }
  }))
}

// 使用
const theme = useLocalStorageRef('theme', 'light')
theme.value = 'dark'  // 自动持久化到 localStorage

shallowRef 与 triggerRef 的设计意图

shallowRef 存在的理由

对于大型复杂对象(如 ECharts 实例、Three.js 场景、大型 JSON 数据),将其包装为普通 ref 会让 Vue 对其所有嵌套属性进行 Proxy 代理——这会带来严重的性能问题:

shallowRef 只代理 .value 这一层,内部对象保持原始状态:

普通 ref({ count: 0, nested: { deep: 1 } })
    ├─ .value → Proxy { count: 0, nested: Proxy { deep: 1 } }
    └─ 所有嵌套属性都被代理

shallowRef({ count: 0, nested: { deep: 1 } })
    ├─ .value → Proxy { count: 0, nested: { deep: 1 } }
    └─ 只有 .value 本身被追踪,内部不代理

triggerRef 的设计意图:既然 shallowRef 不追踪内部变化,当你确实修改了内部并需要通知 Vue 时,需要一个"手动触发"的机制——这就是 triggerRef

// 集成第三方库的正确模式
const chartInstance = shallowRef(null)

onMounted(() => {
  const instance = markRaw(echarts.init(el.value))
  chartInstance.value = instance
})

function updateChart(newData) {
  // ECharts 内部更新数据
  chartInstance.value.setOption({ series: [{ data: newData }] })
  
  // 如果有其他 Vue 代码依赖 chartInstance,手动触发
  // (通常不需要,因为 ECharts 更新是命令式的,不需要 Vue 响应)
  // triggerRef(chartInstance)
}

markRaw 的工作原理

// packages/reactivity/src/reactive.ts

export function markRaw<T extends object>(
  value: T
): Raw<T> {
  // 在对象上设置 __v_skip 标记
  def(value, ReactiveFlags.SKIP, true)
  return value
}

// 在 reactive() 创建 Proxy 时检查这个标记
function createReactiveObject(target, ...) {
  // ...
  // 如果对象有 __v_skip 标记,直接返回原始对象,不创建 Proxy
  if (
    target[ReactiveFlags.SKIP] ||
    !canObserve(target)
  ) {
    return target
  }
  // ...创建 Proxy
}

markRaw 本质上是在对象上打了一个 __v_skip: true 的标记,响应式系统在试图代理这个对象时看到这个标记就跳过。


Level 3 · 设计文档与源码(资深开发者)

effectScope 源码:核心实现

文件路径packages/reactivity/src/effectScope.ts

export class EffectScope {
  /**
   * @internal
   */
  private _active = true
  /**
   * @internal
   */
  effects: ReactiveEffect[] = []
  /**
   * @internal
   */
  cleanups: (() => void)[] = []

  /**
   * only assigned by undetached scope
   * @internal
   */
  parent: EffectScope | undefined
  /**
   * record undetached scopes
   * @internal
   */
  scopes: EffectScope[] | undefined
  /**
   * track a child scope's index in its parent's scopes array for optimized
   * removal
   * @internal
   */
  private index: number | undefined

  constructor(public detached = false) {
    this.parent = activeEffectScope
    if (!detached && activeEffectScope) {
      // 非 detached:注册到父 scope
      this.index =
        (activeEffectScope.scopes || (activeEffectScope.scopes = [])).push(
          this,
        ) - 1
    }
  }

  get active() {
    return this._active
  }

  run<T>(fn: () => T): T | undefined {
    if (this._active) {
      const currentEffectScope = activeEffectScope
      try {
        activeEffectScope = this  // 设置当前 scope
        return fn()               // 运行回调,内部创建的 effect 自动注册到 this
      } finally {
        activeEffectScope = currentEffectScope  // 恢复
      }
    } else if (__DEV__) {
      warn(`Cannot run an inactive effect scope.`)
    }
  }

  stop(fromParent?: boolean) {
    if (this._active) {
      let i, l
      // 停止所有直接 effect
      for (i = 0, l = this.effects.length; i < l; i++) {
        this.effects[i].stop()
      }
      // 执行所有清理回调
      for (i = 0, l = this.cleanups.length; i < l; i++) {
        this.cleanups[i]()
      }
      // 递归停止所有子 scope
      if (this.scopes) {
        for (i = 0, l = this.scopes.length; i < l; i++) {
          this.scopes[i].stop(true)
        }
      }
      // 从父 scope 中移除自身(避免内存泄漏)
      if (!this.detached && this.parent && !fromParent) {
        const last = this.parent.scopes!.pop()
        if (last && last !== this) {
          this.parent.scopes![this.index!] = last
          last.index = this.index!
        }
      }
      this.parent = undefined
      this._active = false
    }
  }
}

核心设计亮点

  1. stop(fromParent) 参数避免了父 scope 停止子 scope 时的重复移除操作(父 scope 知道自己在停止所有子 scope,子 scope 不需要再反向从父中移除)
  2. 子 scope 从父的 scopes 数组中移除时使用了"末尾填洞"策略(O(1) 而非 O(n))

customRef 源码分析

文件路径packages/reactivity/src/ref.ts

export function customRef<T>(factory: CustomRefFactory<T>): Ref<T> {
  return new CustomRefImpl(factory) as any
}

class CustomRefImpl<T> {
  public dep?: Dep = undefined
  private readonly _get: ReturnType<CustomRefFactory<T>>['get']
  private readonly _set: ReturnType<CustomRefFactory<T>>['set']
  public readonly __v_isRef = true

  constructor(factory: CustomRefFactory<T>) {
    const { get, set } = factory(
      // track 函数:在 get 中调用,追踪依赖
      () => trackRefValue(this),
      // trigger 函数:在 set 中调用,触发更新
      () => triggerRefValue(this),
    )
    this._get = get
    this._set = set
  }

  get value() {
    return this._get()
  }

  set value(newVal) {
    this._set(newVal)
  }
}

customRef 的实现极其简洁——它把 trackRefValuetriggerRefValue 包装成 tracktrigger 函数传给用户,其余的 track/trigger 逻辑复用了普通 ref 的实现。

triggerRef 的源码

// packages/reactivity/src/ref.ts

export function triggerRef(ref: Ref): void {
  triggerRefValue(ref, __DEV__ ? ref.value : void 0)
}

就这么简单——直接调用 triggerRefValue,等效于手动触发了这个 ref 的所有依赖更新。

响应式系统对 class 实例的处理

// packages/reactivity/src/reactive.ts

// 可被观察的对象类型
function targetTypeMap(rawType: string) {
  switch (rawType) {
    case 'Object':
    case 'Array':
      return TargetType.COMMON   // 可以代理
    case 'Map':
    case 'Set':
    case 'WeakMap':
    case 'WeakSet':
      return TargetType.COLLECTION  // 特殊集合处理
    default:
      return TargetType.INVALID  // 不代理(Date, RegExp, class 实例等)
  }
}

function canObserve(value: Target): boolean {
  return (
    !value[ReactiveFlags.SKIP] &&           // 未被 markRaw
    isObject(value) &&
    !Object.isFrozen(value) &&
    targetTypeMap(toRawType(value)) !== TargetType.INVALID
  )
}

toRawType 使用 Object.prototype.toString.call(value) 获取类型字符串。自定义 class 实例返回 "Object",因此可以被 reactive 代理(与 DateRegExp 不同):

class User {
  constructor(public name: string, public age: number) {}
}

const user = reactive(new User('Vue', 3))
user.name = 'React'  // ✓ 触发更新(class 实例默认可被代理)

const date = reactive(new Date())
// date 是 Date 类型,targetTypeMap 返回 INVALID
// reactive 直接返回原始对象,不创建 Proxy

Level 4 · 边界与陷阱(全体适用)

陷阱1:effectScope 外部创建的 ref 不受 scope 管理

const count = ref(0)  // ❌ 在 scope.run 外部创建

const scope = effectScope()
scope.run(() => {
  // 在 scope 内监听外部的 ref
  watch(count, (val) => console.log(val))
  // ✓ 这个 watch 的 effect 受 scope 管理,scope.stop() 会停止它
})

// 但 count 本身不受 scope 管理
// scope.stop() 后,count 依然存在,只是没有 watcher 了
scope.stop()
count.value = 100  // 不会有任何反应(watcher 已停止)

关键理解:scope 管理的是effect(watch、watchEffect、computed),不是响应式数据(ref、reactive)。scope.stop() 停止的是监听行为,不是数据本身。

陷阱2:customRef 中忘记调用 track() 导致依赖失效

// ❌ 错误:getter 中忘记调用 track()
const badRef = customRef((track, trigger) => ({
  get() {
    // 没有 track()!
    return value
  },
  set(newValue) {
    value = newValue
    trigger()
  }
}))

// 结果:watch(badRef, ...) 永远不会触发!
// 因为没有 track,任何 effect 都不知道它依赖于 badRef
watch(badRef, (val) => {
  console.log(val)  // 永远不会打印
})

正确实现:确保在每次 getter 被调用时都执行 track()

陷阱3:shallowRef 的深层修改不触发视图更新

const config = shallowRef({
  theme: 'light',
  fontSize: 14,
  nested: { color: 'blue' }
})

// ❌ 不触发更新
config.value.theme = 'dark'           // 修改内部属性
config.value.nested.color = 'red'    // 修改嵌套属性

// ✅ 触发更新方案1:替换整个 .value
config.value = { ...config.value, theme: 'dark' }

// ✅ 触发更新方案2:修改后手动 triggerRef
config.value.theme = 'dark'
triggerRef(config)

容易犯错的场景:在大型项目中,有人把 shallowRef 当成普通 ref 使用,修改内部属性后疑惑为什么视图没有更新。

陷阱4:markRaw 是永久性的,无法撤销

const obj = { name: 'data', value: 42 }
const rawObj = markRaw(obj)  // 在 obj 上打 __v_skip 标记

// 尝试使其变为响应式
const reactiveObj = reactive(rawObj)
// 结果:reactiveObj 就是 obj 本身(没有被 Proxy)
// 因为 __v_skip 标记已经在 obj 上了

console.log(reactiveObj === obj)  // true
// 修改 reactiveObj.value 不会触发任何响应式更新!

实践建议markRaw 通常用于:

  1. 大型第三方库实例(ECharts、Three.js)
  2. 静态配置对象(不会变化的数据)
  3. 函数引用(函数本来就不应该被代理)

不要在需要响应式追踪的数据上使用 markRaw

陷阱5:Date 对象在 reactive 中的行为

const state = reactive({
  date: new Date('2024-01-01')
})

// ⚠️ 修改 Date 对象本身不触发更新
state.date.setMonth(5)  // 修改的是 Date 对象内部,reactive 不追踪

// ✅ 替换整个 date 属性才触发更新
state.date = new Date('2024-06-01')

根因:Vue 3 对 Date 类型返回 TargetType.INVALID,不创建 Proxy,因此 Date 对象的方法调用不会被追踪。要追踪日期变化,必须替换整个 Date 实例,或者使用 ref 包装时间戳(ref(Date.now()))。


本章小结

  1. effectScope 是响应式系统的生命周期管理基础设施:它解决了"如何批量停止一组 effect"的问题,是 Pinia 等状态管理库得以在组件之外安全使用 Vue 响应式 API 的核心机制。

  2. customRef 给了你完全的 track/trigger 控制权:防抖、节流、持久化等需要自定义"何时响应"的场景,都可以通过 customRef 实现,而无需跳出 Vue 响应式体系。

  3. shallowRef + triggerRef 是大对象的最佳搭档:对 ECharts、Three.js 等大型库的实例使用 shallowRef 避免深度代理的性能开销,在需要通知更新时手动调用 triggerRef。

  4. markRaw 是单向的、永久的:一旦标记,对象就永远不会被 Vue 的响应式系统代理,适用于确实不需要响应式的大型对象,但不可撤销。

  5. 响应式系统有明确的边界:class 实例可以被代理,Date 和 RegExp 不行;深层嵌套对象使用 reactive 时全部代理,使用 shallowRef 时只代理第一层。理解这些边界,是正确选择响应式 API 的前提。

本章评分
4.7  / 5  (40 评分)

💬 留言讨论