effectScope、customRef、triggerRef:响应式系统进阶 API 的设计意图
第9章:effectScope、customRef、triggerRef——响应式系统进阶 API 的设计意图
Pinia 的每个 store 内部都用了
effectScope()——它不是一个锦上添花的 API,而是 Vue 3 响应式系统能够支撑复杂状态管理库的基础设施。
本章核心问题:当你需要"一次性停止所有 watcher"时该怎么做?什么时候应该完全控制 track/trigger 时机?为什么 ECharts 对象必须用 markRaw 处理?
读完本章你将理解:
effectScope的设计意图与 Pinia store 管理的关系customRef的工厂模式与防抖 ref 的完整实现triggerRef与shallowRef的配合使用场景toRaw与markRaw打破响应式的两种策略- Date、class 实例、RegExp 在响应式系统中的行为边界
Level 1 · 你需要知道的(1-3年经验)
effectScope:批量管理 effect 的生命周期
在 Vue 3 组件中,watch、watchEffect、computed 等 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) 参数:
track():在 getter 中调用,告诉响应式系统"记录当前 effect 依赖于这个 ref"trigger():在 setter 或其他地方调用,告诉响应式系统"这个 ref 的值变了,重新执行所有依赖它的 effect"
triggerRef 与 shallowRef
shallowRef 是 ref 的浅层版本——只追踪 .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 的使用场景:
- 使用了
shallowRef包装大对象,需要手动通知更新 - 集成第三方库(如 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 内所有的 watch、computed、watchEffect,不需要手动追踪每一个。
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 代理——这会带来严重的性能问题:
- ECharts 实例有数千个属性和方法,深度 proxy 耗时可达 50-200ms
- Three.js 的 Scene 对象更复杂,可能导致运行时错误(某些属性不可被 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
}
}
}
核心设计亮点:
stop(fromParent)参数避免了父 scope 停止子 scope 时的重复移除操作(父 scope 知道自己在停止所有子 scope,子 scope 不需要再反向从父中移除)- 子 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 的实现极其简洁——它把 trackRefValue 和 triggerRefValue 包装成 track 和 trigger 函数传给用户,其余的 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 代理(与 Date、RegExp 不同):
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 通常用于:
- 大型第三方库实例(ECharts、Three.js)
- 静态配置对象(不会变化的数据)
- 函数引用(函数本来就不应该被代理)
不要在需要响应式追踪的数据上使用 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()))。
本章小结
-
effectScope是响应式系统的生命周期管理基础设施:它解决了"如何批量停止一组 effect"的问题,是 Pinia 等状态管理库得以在组件之外安全使用 Vue 响应式 API 的核心机制。 -
customRef给了你完全的 track/trigger 控制权:防抖、节流、持久化等需要自定义"何时响应"的场景,都可以通过 customRef 实现,而无需跳出 Vue 响应式体系。 -
shallowRef+triggerRef是大对象的最佳搭档:对 ECharts、Three.js 等大型库的实例使用 shallowRef 避免深度代理的性能开销,在需要通知更新时手动调用 triggerRef。 -
markRaw是单向的、永久的:一旦标记,对象就永远不会被 Vue 的响应式系统代理,适用于确实不需要响应式的大型对象,但不可撤销。 -
响应式系统有明确的边界:class 实例可以被代理,Date 和 RegExp 不行;深层嵌套对象使用
reactive时全部代理,使用shallowRef时只代理第一层。理解这些边界,是正确选择响应式 API 的前提。