Vue 3 进阶能力:浅层响应式、原始对象、Teleport 与 Suspense

系列导航:Vue 3 现代开发指南
上一篇:Pinia 4 状态管理

Vue 默认的深层响应式适合绝大多数业务状态。只有在大型不可变数据、第三方类实例、外部状态系统或特殊渲染边界中,才需要 shallowRefmarkRaw 等逃生舱 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 } // 替换根属性会触发更新

不要把浅层响应式对象嵌进深层响应式树中,这会产生难以解释的混合行为。它更适合作为清晰的状态根边界。

三、readonlyshallowReadonly

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 下,以避开祖先的 overflowtransform 和层叠上下文。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>

需要明确三个边界:

  1. 截至本文更新时,Vue 官方仍把 Suspense 标为实验性 API;
  2. Suspense 本身不提供错误 UI,应结合 onErrorCaptured() 或上层错误处理;
  3. Vue Router 的路由懒加载与 Suspense 异步依赖不同,不会仅因为动态导入路由就自动触发 fallback。

常规请求页面自行维护 loading / error / data 往往更直接。只有需要协调一棵异步组件树时,再考虑 Suspense。

十、KeepAlive:缓存动态组件实例

<KeepAlive> 可以在组件切换后保留实例状态:

<KeepAlive :include="['UserList', 'UserDetail']" :max="5">
  <component :is="activeView" />
</KeepAlive>

被缓存组件会触发 onActivated()onDeactivated()。缓存不是越多越好:实例、DOM 和订阅仍会占用资源,应通过 includeexcludemax 控制范围。

十一、全局 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,不要随意混合深层和浅层代理;
  • 第三方实例优先 shallowRefmarkRaw
  • toRaw 只做临时互操作,不作为长期写入口;
  • Suspense 是实验性能力,升级 Vue 时要验证行为;
  • 性能优化应以测量为依据,而不是根据对象“看起来很大”猜测。

小结

shallowRefshallowReactivetoRawmarkRawcustomRef 都是边界工具,不是默认状态 API。Teleport 解决 DOM 挂载位置,异步组件解决代码分割,KeepAlive 解决实例缓存;Suspense 能协调异步依赖,但目前仍需谨慎采用。

官方资料

系列导航:返回目录 · 上一篇