Vue3 响应式系统在 Nuxt4 中的运用
状态管理的底层基石是 Vue3 的响应式系统——
ref、reactive、computed、watch、watchEffect这些 API 在 SSR 环境下的行为与纯客户端并不完全相同。理解响应式原理、掌握 SSR 特有的陷阱和最佳实践,才能写出健壮的 Nuxt4 状态代码。本章聚焦 Nuxt4 SSR 场景下的响应式要点,而非重复 Vue 基础文档。
1. ref vs reactive:Nuxt4 的选型结论
Vue3 提供两套响应式 API:ref(包装器,通过 .value 访问)和 reactive(Proxy 代理,直接访问属性)。在 Nuxt4 中推荐统一用 ref,原因如下:
| 场景 | 推荐 | 原因 |
|---|---|---|
| 基础类型(数字、字符串、布尔) | ref | reactive 不支持基础类型 |
| 对象/数组 | ref | 可整体替换(list.value = newList) |
| 表单状态 | reactive | 多字段、不需要整体替换 |
| Composable 返回值 | ref | 解构后保持响应式 |
| Pinia Setup Store | ref | 与 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 不匹配