VueUse SSR 安全使用

VueUse 是 Vue 生态中最大的 Composables 工具库——200+ 个函数覆盖浏览器 API、传感器、状态管理、动画等。但在 Nuxt4 的 SSR 环境中,VueUse 的很多函数依赖浏览器 API(windowdocumentlocalStorage),直接使用会导致服务端报错。本章讲清楚 SSR 环境下使用 VueUse 的正确姿势,以及如何构建自己的 SSR 安全 Composables。

1. SSR 的核心问题

1.1 服务端没有浏览器 API

Nuxt4 的 SSR 在 Node.js 中执行组件的 setup() 函数。Node.js 没有:

不可用 API依赖它的 VueUse 函数
windowuseWindowSizeuseScrolluseMediaQuery
documentuseEventListeneruseMutationObserver
localStorageuseLocalStorageuseSessionStorage
navigatoruseGeolocationuseOnlineuseClipboard
IntersectionObserveruseIntersectionObserveruseElementVisibility

直接在 setup() 中调用这些函数,SSR 阶段会抛出 ReferenceError: window is not defined

1.2 VueUse 的内置安全机制

好消息是,VueUse 大部分函数已经做了 SSR 兼容——它们在服务端返回安全的默认值(空对象、false、0),不会报错。但有些细节需要注意。

2. 常用函数的 SSR 行为

2.1 useLocalStorage / useSessionStorage

const theme = useLocalStorage('theme', 'light')
// SSR 阶段:返回默认值 'light'(不访问 localStorage)
// 客户端 Hydration 后:读取 localStorage 真实值

潜在问题:SSR 渲染用默认值('light'),但客户端 localStorage 中可能是 'dark'。Hydration 时值会变化,导致短暂的闪烁(先亮后暗)。

解决方案:使用 Cookie 在 SSR 阶段就获取正确的值(参见第 60 章暗黑模式)。或者使用 useCookie

// 用 Cookie 替代 localStorage,SSR 也能读到
const theme = useCookie('theme', { default: () => 'light' })

2.2 useMediaQuery

const isMobile = useMediaQuery('(max-width: 768px)')
// SSR 阶段:始终返回 false
// 客户端:根据实际视口返回 true/false

SSR 阶段无法知道用户的屏幕宽度。如果你的布局依赖 isMobile 做条件渲染,SSR 和客户端可能渲染不同的内容——导致 Hydration Mismatch。

安全做法

<template>
  <!-- 用 CSS 响应式替代 JS 条件渲染 -->
  <div class="hidden md:block">桌面端内容</div>
  <div class="md:hidden">移动端内容</div>
</template>

CSS 媒体查询在客户端即时生效,不会产生 Hydration Mismatch。只在真正需要 JS 逻辑时才用 useMediaQuery

2.3 useIntersectionObserver

const target = useTemplateRef<HTMLElement>('target')
const { isIntersecting } = useIntersectionObserver(target, ([entry]) => {
  // 元素进入/离开视口
})

SSR 阶段:target 是 null,Observer 不会创建。客户端 Hydration 后自动初始化。无需特殊处理

2.4 useWindowSize

const { width, height } = useWindowSize()
// SSR 阶段:width = Infinity, height = Infinity(VueUse 的默认值)

注意 SSR 默认值是 Infinity,不是 0。如果你的代码中有 if (width.value &lt; 768) 这样的判断,SSR 阶段会走 false 分支。

2.5 useEventListener

useEventListener('scroll', () => {
  // 处理滚动
})
// SSR 安全:服务端不会添加事件监听器

VueUse 的 useEventListener 内部会检查环境,SSR 阶段直接跳过。无需特殊处理

3. SSR 安全的判断模式

3.1 import.meta.client / import.meta.server

Nuxt4 提供了编译时常量来判断运行环境:

if (import.meta.client) {
  // 只在客户端执行
  const canvas = document.createElement('canvas')
}
 
if (import.meta.server) {
  // 只在服务端执行
  const data = await readFile('./data.json')
}

编译时优化import.meta.client 在服务端构建时被替换为 false,整个 if 块被 Tree Shaking 移除——不会增加 bundle 体积。

3.2 onMounted 保护

onMounted 只在客户端执行(服务端不会调用):

<script setup>
let chart: Chart | null = null
 
onMounted(async () => {
  const { Chart } = await import('chart.js')
  chart = new Chart(canvasEl.value, { /* ... */ })
})
 
onUnmounted(() => {
  chart?.destroy()
})
</script>

3.3 &lt;ClientOnly&gt; 组件

Nuxt4 内置 &lt;ClientOnly&gt; 组件,包裹的内容只在客户端渲染:

<template>
  <ClientOnly>
    <VideoPlayer :src="videoUrl" />
    <template #fallback>
      <div class="skeleton animate-pulse h-64 rounded-lg bg-gray-200" />
    </template>
  </ClientOnly>
</template>

#fallback 插槽在 SSR 阶段渲染,客户端 Hydration 后替换为真实组件。适合重度依赖浏览器 API 的组件(视频播放器、地图、Canvas 绑定的图表)。

3.4 .client 后缀

Nuxt4 约定 .client.vue 后缀的组件只在客户端加载:

components/
├── VideoPlayer.client.vue   ← 只在客户端渲染
├── AppHeader.vue            ← 双端渲染
└── ServerBanner.server.vue  ← 只在服务端渲染

4. 构建 SSR 安全的 Composables

4.1 设计原则

自定义 Composable 要做到 SSR 安全,遵循三条原则:

  1. 不在顶层访问浏览器 APIsetup() 中不能直接用 windowdocument
  2. 提供安全默认值:SSR 阶段返回合理的默认值(false、0、空字符串)
  3. 延迟初始化:在 onMountedwatch 中初始化浏览器相关逻辑

4.2 示例:useVideoPlayer

// composables/useVideoPlayer.ts
export function useVideoPlayer(videoRef: Ref<HTMLVideoElement | null>) {
  const isPlaying = ref(false)
  const currentTime = ref(0)
  const duration = ref(0)
 
  // SSR 安全:onMounted 中初始化
  onMounted(() => {
    const el = videoRef.value
    if (!el) return
 
    useEventListener(el, 'play', () => { isPlaying.value = true })
    useEventListener(el, 'pause', () => { isPlaying.value = false })
    useEventListener(el, 'timeupdate', () => {
      currentTime.value = el.currentTime
    })
    useEventListener(el, 'loadedmetadata', () => {
      duration.value = el.duration
    })
  })
 
  function play() {
    videoRef.value?.play()
  }
 
  function pause() {
    videoRef.value?.pause()
  }
 
  function seek(time: number) {
    if (videoRef.value) {
      videoRef.value.currentTime = time
    }
  }
 
  return { isPlaying, currentTime, duration, play, pause, seek }
}

4.3 示例:useScrollProgress

// composables/useScrollProgress.ts
export function useScrollProgress() {
  const progress = ref(0)
 
  if (import.meta.client) {
    useEventListener('scroll', () => {
      const scrollTop = document.documentElement.scrollTop
      const scrollHeight = document.documentElement.scrollHeight
      const clientHeight = document.documentElement.clientHeight
      progress.value = scrollTop / (scrollHeight - clientHeight)
    })
  }
 
  return { progress }
}

import.meta.client 保护浏览器 API 调用,SSR 阶段 progress 始终为 0。

5. 常见陷阱

5.1 Hydration Mismatch

根因:服务端和客户端渲染的 HTML 不一致。

常见触发场景:

  • new Date() 在服务端和客户端返回不同时间
  • Math.random() 两端生成不同值
  • useMediaQuery SSR 返回 false,客户端返回 true
  • useLocalStorage SSR 返回默认值,客户端返回存储值

排查:Nuxt4 开发模式下会在控制台输出 Hydration Mismatch 警告,包含不匹配的具体位置。

5.2 内存泄漏

SSR 环境中的内存泄漏比 SPA 更严重——SPA 中页面刷新就释放内存,SSR 的 Node.js 进程持续运行。

// ❌ 模块级别的状态会在所有请求间共享
const globalCache = new Map()
 
// ✅ 在 composable 中使用,每个请求独立
export function useCache() {
  const cache = useState('cache', () => new Map())
  return cache
}

useState 是 Nuxt4 提供的 SSR 安全的状态管理——每个请求有独立的状态实例,不会跨请求污染。

本章小结

  • SSR 核心限制:服务端没有 window/document/localStorage,VueUse 大部分函数已内置安全兼容
  • 常见函数行为useLocalStorage 返回默认值(可能闪烁),useMediaQuery 返回 false(用 CSS 替代),useEventListener 自动跳过
  • 保护模式import.meta.client(编译时移除)、onMounted(客户端执行)、&lt;ClientOnly&gt;(组件级)、.client.vue(文件级)
  • 自定义 Composable:不在顶层访问浏览器 API,提供安全默认值,onMounted 中延迟初始化
  • 避坑:Hydration Mismatch 用 useCookie 替代 useLocalStorage,用 useState 替代模块级变量防止跨请求污染