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