shallowRef、markRaw 与 Suspense:Vue 3 里哪些 API 只该用在边界上
Vue 默认的深层响应式适合绝大多数业务状态。只有在大型不可变数据、第三方类实例、外部状态系统或特殊渲染边界中,才需要 shallowRef、markRaw 这类逃生舱 API。下面逐个说清它们的适用位置,另外还有 Teleport、异步组件、KeepAlive,以及仍处于实验状态的 Suspense。
一、shallowRef:只追踪 .value 替换
shallowRef() 不会把内部对象转成深层响应式。只有替换整个 .value 才会触发依赖:
import { shallowRef } from 'vue'
interface Dashboard {
title: string
widgets: Array
}
const dashboard = shallowRef({ title: 'Overview', widgets: [] })
dashboard.value.title = 'Metrics' // 修改内部属性不会触发更新
// 推荐:替换根值
dashboard.value = { ...dashboard.value, title: 'Metrics' }
适合的场景:
- 把大型不可变对象作为整体替换;
- 保存图表、编辑器等第三方实例;
- 与外部状态系统集成;
- 避免 Vue 深度代理不需要观察的数据。
如果确实要在原对象上修改并手工通知依赖,可以用 triggerRef():
import { shallowRef, triggerRef } from 'vue'
const state = shallowRef({ count: 0 })
state.value.count++
triggerRef(state)
频繁依赖 triggerRef() 通常说明数据更新方式不够清晰,优先考虑不可变替换。
二、shallowReactive:只有根属性响应式
const state = shallowReactive({ count: 0, nested: { enabled: false } })
state.count++ // 会触发更新
state.nested.enabled = true // 嵌套对象本身不是响应式的
state.nested = { enabled: true } // 替换根属性会触发更新
不要把浅层响应式对象嵌进深层响应式树中,这会产生难以解释的混合行为。它更适合作为清晰的状态根边界。
三、readonly 与 shallowReadonly
readonly() 返回一个深层只读代理:
const state = reactive({ count: 0 })
const publicState = readonly(state)
state.count++ // 合法,publicState 会同步反映变化
// publicState.count++ // 开发环境警告
它保护的是通过只读代理发生的修改,不会冻结原对象。常见用途是在 provide() 时提供只读状态,并同时提供修改动作。
shallowReadonly() 只保护根属性,嵌套对象仍可修改。除非边界明确,否则深层 readonly() 更符合「使用者不可写」的直觉。
四、toRaw():临时访问原始对象
toRaw() 返回 Vue Proxy 背后的原始对象,适合临时传给严格检查对象身份、不能接收 Proxy 的外部 API。
不要长期保存 rawForm,也不要通过它修改状态:对原始对象的写入会绕过响应式触发,容易造成界面与数据不一致。序列化普通业务对象时通常不需要先调用 toRaw(),JSON.stringify() 可以读取响应式代理的可枚举属性。
五、markRaw():让对象保持非响应式
class MapController {
destroy() {}
}
const state = reactive({ controller: markRaw(new MapController()) })
markRaw() 适合第三方类实例、Vue 组件定义或不应被代理的复杂对象。它只保证被标记的根对象不会转为 Proxy;内部未标记对象如果之后进入响应式系统,仍可能被代理。不要对普通业务数据大面积使用它来「优化性能」。
六、customRef():自定义追踪与触发
用 customRef() 封装一个防抖 ref:
// composables/useDebouncedRef.ts
import { customRef, onScopeDispose, type Ref } from 'vue'
export function useDebouncedRef(initialValue: T, delay = 300): Ref {
let value = initialValue
let timer: ReturnType | undefined
onScopeDispose(() => {
if (timer) clearTimeout(timer)
})
return customRef((track, trigger) => ({
get() {
track()
return value
},
set(nextValue) {
if (timer) clearTimeout(timer)
timer = setTimeout(() => {
value = nextValue
trigger()
}, delay)
},
}))
}
customRef() 的 getter 不应每次创建新的对象,否则父子组件比较和更新可能出现意外行为。
七、Teleport:改变 DOM 位置,不改变组件关系
弹窗在组件树中可能嵌得很深,但 DOM 通常希望放在 body 下,以避开祖先的 overflow、transform 和层叠上下文。Teleport 可以移动渲染位置,例如用 `` 包住一个带 role="dialog"、aria-modal、aria-labelledby 的确认弹窗,并用 @click.self 关闭。
Teleport 只改变真实 DOM 的位置,Props、Emits、注入和逻辑父子关系仍按组件树工作。生产级对话框还要处理焦点圈定、Esc 关闭、关闭后恢复焦点和页面滚动锁定。to 指向的目标在挂载时必须存在,也可以通过 :disabled 暂时关闭传送。
八、异步组件
路由页面通常直接使用动态导入;普通组件可以使用 defineAsyncComponent():
const AnalyticsPanel = defineAsyncComponent({
loader: () => import('./AnalyticsPanel.vue'),
loadingComponent: LoadingPanel,
errorComponent: ErrorPanel,
delay: 200,
timeout: 10_000,
})
不要把首屏必需的小组件全部异步化。代码分割有网络请求和调度成本,应优先用于体积大、低频或条件出现的功能。
九、Suspense 仍是实验性功能
可以等待组件树中的异步 `setup()` 或异步组件,并显示统一 fallback。使用 顶层 await 的组件会成为异步依赖。
三个边界:
- 截至本文更新时,Vue 官方仍把 Suspense 标为实验性 API;
- Suspense 本身不提供错误 UI,应结合
onErrorCaptured()或上层错误处理; - Vue Router 的路由懒加载与 Suspense 异步依赖不同,不会仅因为动态导入路由就自动触发 fallback。
常规请求页面自行维护 loading / error / data 往往更直接。只有需要协调一棵异步组件树时再考虑 Suspense。
十、KeepAlive:缓存动态组件实例
被缓存组件会触发 onActivated() 和 onDeactivated()。缓存不是越多越好:实例、DOM 和订阅仍会占用资源,应通过 include、exclude 和 max 控制范围。
十一、全局 API 属于应用实例
Vue 3 的全局注册和配置都放在 createApp() 返回的应用实例上:app.component / app.directive / app.use / app.config.errorHandler / app.mount。这让同一页面上的多个 Vue 应用可以拥有隔离配置。全局注册会降低依赖可见性,普通业务组件仍优先局部导入。
十二、使用逃生舱 API 的原则
- 先用默认深层响应式,确认真实性能问题后再引入浅层 API;
- 在状态根边界使用浅层 API,不要随意混合深层和浅层代理;
- 第三方实例优先
shallowRef或markRaw; toRaw只做临时互操作,不作为长期写入口;- Suspense 是实验性能力,升级 Vue 时要验证行为;
- 性能优化应以测量为依据,而不是根据对象「看起来很大」猜测。
小结
shallowRef、shallowReactive、toRaw、markRaw 和 customRef 都是边界工具,不是默认状态 API。Teleport 解决 DOM 挂载位置,异步组件解决代码分割,KeepAlive 解决实例缓存;Suspense 能协调异步依赖,但目前仍需谨慎采用。
官方资料:Advanced Reactivity API、Teleport、Async Components、Suspense、KeepAlive。