ClientOnly 与 .client/.server 组件

SSR 模式下,组件代码同时运行在服务端和客户端——这就意味着某些依赖浏览器 API 的组件(视频播放器、地图、图表)会在服务端报错。Nuxt4 提供了三层防护机制:<ClientOnly> 组件、.client.vue 后缀、.server.vue 后缀。本章讲清每种机制的原理、适用场景和最佳实践。

1. ClientOnly 的 fallback 插槽

1.1 基本用法

<ClientOnly> 是 Nuxt 内置组件——包裹的内容只在客户端渲染,SSR 时跳过:

<template>
  <ClientOnly>
    <!-- 只在客户端渲染 -->
    <VideoPlayer :url="video.url" />
  </ClientOnly>
</template>

SSR 阶段的输出:

<!-- 服务端渲染的 HTML 中,ClientOnly 内部为空 -->
<div></div>
 
<!-- 客户端 Hydration 后,内容出现 -->
<div>
  <div class="video-player">...</div>
</div>

1.2 fallback 插槽

#fallback 插槽提供 SSR 阶段的占位内容,避免布局跳动:

<template>
  <ClientOnly>
    <VideoPlayer :url="video.url" />
 
    <template #fallback>
      <!-- SSR 时渲染这个,客户端接管后替换 -->
      <div class="aspect-video bg-gray-200 animate-pulse rounded-xl flex items-center justify-center">
        <span class="text-gray-500">加载播放器...</span>
      </div>
    </template>
  </ClientOnly>
</template>

1.3 渲染时机

SSR 阶段:
  <ClientOnly> → 渲染 #fallback 内容(或空)

客户端 Hydration:
  <ClientOnly> → 替换为实际组件内容

纯 SPA 模式(ssr: false):
  <ClientOnly> → 直接渲染实际组件(无需特殊处理)

1.4 常见使用场景

<template>
  <!-- 场景 1:依赖 canvas/WebGL 的组件 -->
  <ClientOnly>
    <ThreeJsScene />
    <template #fallback>
      <img src="/scene-preview.jpg" alt="3D 预览" />
    </template>
  </ClientOnly>
 
  <!-- 场景 2:实时时间/动态内容 -->
  <ClientOnly>
    <LiveClock />
    <template #fallback>
      <span>--:--:--</span>
    </template>
  </ClientOnly>
 
  <!-- 场景 3:地图组件 -->
  <ClientOnly>
    <MapContainer :center="[39.9, 116.4]" />
    <template #fallback>
      <div class="h-64 bg-gray-100 flex items-center justify-center">
        <span>地图加载中...</span>
      </div>
    </template>
  </ClientOnly>
</template>

2. 避免 SSR 报错的典型场景

2.1 报错原因分类

类别错误示例根本原因
浏览器全局对象window is not defined服务端没有 window
DOM APIdocument.querySelector is not defined服务端没有 document
Web APInavigator is not defined服务端没有 navigator
第三方库HTMLElement is not defined库内部访问了浏览器 API
浏览器存储localStorage is not defined服务端没有 localStorage

2.2 解决方案速查表

方案代码方式适用场景
&lt;ClientOnly&gt;模板包裹整个组件只需客户端渲染
.client.vue 后缀文件命名组件文件级别隔离
import.meta.client条件判断代码块级别隔离
onMounted生命周期DOM 操作
process.client条件判断import.meta.client
.client.ts 插件文件命名第三方库注册

2.3 实战修复示例

// ❌ 错误:第三方库在顶层代码访问 window
import EmojiPicker from 'emoji-picker'  // 💥 import 时就执行了 window 相关代码
 
// ✅ 修复 1:动态导入
const EmojiPicker = defineAsyncComponent(
  () => import('emoji-picker')
)
 
// ✅ 修复 2:包裹在 ClientOnly 中
// <ClientOnly><EmojiPicker /></ClientOnly>
 
// ✅ 修复 3:创建 .client.vue 封装
// app/components/EmojiPicker.client.vue
// ❌ 错误:composable 中直接使用浏览器 API
export function useWindowSize() {
  const width = ref(window.innerWidth)   // 💥 服务端报错
  const height = ref(window.innerHeight)
  return { width, height }
}
 
// ✅ 修复:延迟到客户端
export function useWindowSize() {
  const width = ref(0)
  const height = ref(0)
 
  if (import.meta.client) {
    width.value = window.innerWidth
    height.value = window.innerHeight
 
    useEventListener(window, 'resize', () => {
      width.value = window.innerWidth
      height.value = window.innerHeight
    })
  }
 
  return { width, height }
}

3. 服务端与客户端专属组件命名约定

3.1 .client.vue — 仅客户端组件

app/components/
├── VideoPlayer.client.vue    ← 仅客户端渲染
├── MapView.client.vue        ← 仅客户端渲染
└── AdBanner.client.vue       ← 仅客户端渲染

.client.vue 组件在 SSR 时完全跳过(等同于被 &lt;ClientOnly&gt; 包裹):

<!-- app/components/VideoPlayer.client.vue -->
<template>
  <div ref="playerEl" class="plyr-container" />
</template>
 
<script setup lang="ts">
import Plyr from 'plyr'
 
const props = defineProps<{ url: string }>()
const playerEl = ref<HTMLElement>()
 
onMounted(() => {
  const player = new Plyr(playerEl.value!, {
    source: { type: 'video', sources: [{ src: props.url }] },
  })
})
</script>

使用时无需 &lt;ClientOnly&gt;

<template>
  <!-- .client.vue 组件自动只在客户端渲染 -->
  <VideoPlayer :url="video.url" />
</template>

3.2 .server.vue — 仅服务端组件

app/components/
├── ServerMetrics.server.vue   ← 仅服务端渲染
└── DebugInfo.server.vue       ← 仅服务端渲染

.server.vue 组件只在 SSR 时渲染,客户端不会 Hydrate,也不会包含在 JS bundle 中:

<!-- app/components/DebugInfo.server.vue -->
<template>
  <pre v-if="isDev" class="text-xs text-gray-500">
    Rendered at: {{ new Date().toISOString() }}
    Node: {{ process.version }}
    Memory: {{ (process.memoryUsage().heapUsed / 1024 / 1024).toFixed(2) }}MB
  </pre>
</template>
 
<script setup lang="ts">
const isDev = process.env.NODE_ENV === 'development'
</script>

3.3 同名组件配对

可以为同一组件创建服务端和客户端两个版本:

app/components/
├── Comments.server.vue   ← SSR 时渲染(静态预览)
└── Comments.client.vue   ← 客户端渲染(交互版本)
<!-- Comments.server.vue — 服务端版本:轻量展示 -->
<template>
  <div class="space-y-3">
    <div v-for="c in comments" :key="c.id" class="flex gap-2">
      <img :src="c.avatar" class="w-8 h-8 rounded-full" />
      <div>
        <span class="font-medium">{{ c.author }}</span>
        <p>{{ c.text }}</p>
      </div>
    </div>
  </div>
</template>
 
<script setup lang="ts">
const { data: comments } = await useFetch('/api/comments?limit=3')
</script>
<!-- Comments.client.vue — 客户端版本:完整交互 -->
<template>
  <div>
    <CommentList :comments="comments" />
    <CommentForm @submit="handleSubmit" />
    <button @click="loadMore">加载更多</button>
  </div>
</template>
 
<script setup lang="ts">
const { data: comments, refresh } = useFetch('/api/comments')
// 完整的客户端交互逻辑...
</script>

使用时只写一个名字,Nuxt 自动根据环境选择版本:

<template>
  <!-- SSR 时渲染 Comments.server.vue -->
  <!-- 客户端时渲染 Comments.client.vue -->
  <Comments />
</template>

3.4 决策指南

这个组件需要浏览器 API 吗?
├── 是 → .client.vue 或 <ClientOnly>
└── 否 → 需要 SSR 和客户端不同的渲染吗?
    ├── 是 → .server.vue + .client.vue 配对
    └── 否 → 需要排除出 JS bundle 吗?
        ├── 是 → .server.vue(纯 SSR 组件)
        └── 否 → 普通 .vue(两端都渲染)

本章小结

  • &lt;ClientOnly&gt;:模板级隔离,SSR 时渲染 #fallback 占位,客户端替换为实际内容
  • .client.vue:文件级隔离,整个组件只在客户端渲染,SSR 时跳过
  • .server.vue:文件级隔离,整个组件只在 SSR 时渲染,不包含在 JS bundle 中
  • 同名配对.server.vue + .client.vue 为同一组件提供不同环境的实现
  • 避免报错import.meta.client 条件判断、onMounted 延迟执行、动态导入
  • 最佳实践:重量级第三方库用 .client.vue;纯展示内容用 .server.vue;需要 fallback 用 &lt;ClientOnly&gt;

下一章讲 Layouts 布局系统——管理页面的公共框架结构。