Vue3 响应式系统在 Nuxt4 中的运用

状态管理的底层基石是 Vue3 的响应式系统——refreactivecomputedwatchwatchEffect 这些 API 在 SSR 环境下的行为与纯客户端并不完全相同。理解响应式原理、掌握 SSR 特有的陷阱和最佳实践,才能写出健壮的 Nuxt4 状态代码。本章聚焦 Nuxt4 SSR 场景下的响应式要点,而非重复 Vue 基础文档。

1. ref vs reactive:Nuxt4 的选型结论

Vue3 提供两套响应式 API:ref(包装器,通过 .value 访问)和 reactive(Proxy 代理,直接访问属性)。在 Nuxt4 中推荐统一用 ref,原因如下:

场景推荐原因
基础类型(数字、字符串、布尔)refreactive 不支持基础类型
对象/数组ref可整体替换(list.value = newList
表单状态reactive多字段、不需要整体替换
Composable 返回值ref解构后保持响应式
Pinia Setup Storeref与 Composition API 风格一致

唯一例外是表单状态——字段多且不需要整体替换时,reactive 更简洁(无需到处写 .value)。

2. computed:Nuxt4 中的可写双向绑定

只读 computed 是基础,不再赘述。Nuxt4 中更有价值的是可写 computed 实现双向绑定

// URL 查询参数双向绑定
const route = useRoute()
const router = useRouter()
const currentPage = computed({
  get: () => Number(route.query.page) || 1,
  set: (page: number) => {
    router.push({ query: { ...route.query, page: String(page) } })
  },
})
 
// Cookie 状态双向绑定
const themeCookie = useCookie('theme')
const theme = computed({
  get: () => themeCookie.value || 'light',
  set: (v: string) => { themeCookie.value = v },
})

这种模式将外部状态源(URL、Cookie)包装为组件可直接 v-model 的响应式值,是 Nuxt4 中非常实用的技巧。

3. watch 与 watchEffect 的 SSR 差异

3.1 服务端行为对比

这是 Nuxt4 中最容易踩坑的地方:

API服务端行为客户端行为
watch(source, cb)不执行回调正常监听
watch(source, cb, { immediate: true })立即执行一次立即执行 + 持续监听
watchEffect(cb)同步部分执行一次正常监听

如果只想在客户端监听(如埋点、浏览器 API),有两种写法:

// 方式 1:条件判断
if (import.meta.client) {
  watch(videoId, (id) => analytics.track('video_view', { id }))
}
 
// 方式 2:延迟到 onMounted
onMounted(() => {
  watch(videoId, (id) => updateBrowserTitle(id))
})

3.2 清理副作用

当依赖快速变化时(如搜索输入),需要取消上一次的异步操作。Vue 3.5+ 的 onCleanup 是推荐方式:

watch(keyword, async (newKeyword, oldKeyword, onCleanup) => {
  const controller = new AbortController()
  onCleanup(() => controller.abort())  // 下次执行前取消上一次请求
 
  const result = await $fetch('/api/search', {
    query: { q: newKeyword },
    signal: controller.signal,
  })
  searchResults.value = result.items
})

4. shallowRef:大列表与第三方实例

ref 会深度代理嵌套对象——一个 100 项的数组,每项 10 个字段,就会创建 1000+ 个 Proxy。shallowRef 只追踪 .value 赋值,内部属性变化不触发更新。

适用场景

  • 大列表(100+ 项的 API 数据):整体替换 list.value = newList 触发更新
  • 不可变数据(API 响应直接展示,不修改内部属性)
  • 第三方实例(Chart.js、Video.js 等不需要 Vue 追踪内部状态的对象)
const videoList = shallowRef<Video[]>([])
 
videoList.value.push(newVideo)                      // ❌ 不会触发更新
videoList.value = [...videoList.value, newVideo]     // ✅ 整体替换触发更新

如果偶尔需要修改内部属性后强制更新,可用 triggerRef(data) 手动触发。

5. toRefs 与 storeToRefs:解构保活

直接解构 reactive 对象或 Pinia Store 会丢失响应式。这在 Nuxt4 中是高频坑点:

// reactive 解构 → 用 toRefs
const state = reactive({ count: 0, name: 'test' })
const { count, name } = toRefs(state)     // ✅ 保持响应式
 
// Pinia Store 解构 → 用 storeToRefs
const videoStore = useVideoStore()
const { videos, loading } = storeToRefs(videoStore)  // ✅ state/getter 保持响应式
const { fetchVideos } = videoStore                    // action 直接解构即可

规则:Composable 返回 reactive 时,内部用 toRefs() 转换后再 return,确保调用者解构安全。

6. SSR 响应式生命周期

6.1 关键差异

服务端:setup() → 响应式创建 → watchEffect 同步执行一次 → 渲染 HTML → 销毁
        (没有 onMounted / onUnmounted / onBeforeMount)

客户端:setup() → 从 Payload 恢复响应式 → onMounted → watch/watchEffect 开始工作
        → 用户交互 → onUnmounted

核心原则:服务端没有 DOM 生命周期钩子,所有浏览器 API 必须延迟到 onMounted 或用 import.meta.client 保护。

6.2 SSR 安全的 Composable 模式

export function useWindowSize() {
  const width = ref(1920)   // SSR 默认值
  const height = ref(1080)
 
  onMounted(() => {
    const update = () => { width.value = window.innerWidth; height.value = window.innerHeight }
    update()
    window.addEventListener('resize', update)
    onUnmounted(() => window.removeEventListener('resize', update))
  })
 
  return { width: readonly(width), height: readonly(height) }
}

模式要点:给 SSR 合理的默认值 → onMounted 中更新为真实值 → onUnmounted 中清理

6.3 避免 Hydration 不匹配

服务端和客户端产生不同值的响应式数据会导致 Hydration mismatch:

// ❌ 服务端 14:30:00,客户端 14:30:02 → 不匹配
const now = ref(new Date().toLocaleTimeString())
 
// ✅ SSR 给空值,客户端再填充
const now = ref('')
onMounted(() => { now.value = new Date().toLocaleTimeString() })

常见触发源:Date.now()Math.random()window.innerWidth、用户代理判断。解决方案统一为:SSR 给确定性默认值,客户端 onMounted 后更新

7. 响应式调试技巧

Vue 提供了 onTrack / onTrigger 调试钩子,可用于 computed、watch 和组件渲染追踪:

// 给 computed 或 watch 加调试选项
const filtered = computed(() => { /* ... */ }, {
  onTrack(e) { console.log('追踪依赖:', e) },
  onTrigger(e) { console.log('重新计算:', e) },
})
 
// 组件级:追踪哪些响应式数据触发了重新渲染
onRenderTriggered((event) => {
  console.log('触发渲染:', event.key, event.oldValue, '→', event.newValue)
})

这些钩子仅在开发模式下生效,生产环境自动忽略,无需手动移除。

本章小结

  • 统一用 ref:Nuxt4 中除表单外推荐 ref,保持 Composition API 一致性
  • 可写 computed:双向绑定 URL 参数、Cookie 等外部状态源
  • watch SSR 差异:watch 回调服务端不执行,watchEffect 同步部分执行一次
  • shallowRef:大列表和第三方实例用 shallowRef 避免深层代理开销
  • storeToRefs:解构 Pinia Store 时保持 state/getter 响应式
  • SSR 生命周期:浏览器 API 延迟到 onMounted,给 SSR 确定性默认值避免 Hydration 不匹配