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 API | document.querySelector is not defined | 服务端没有 document |
| Web API | navigator is not defined | 服务端没有 navigator |
| 第三方库 | HTMLElement is not defined | 库内部访问了浏览器 API |
| 浏览器存储 | localStorage is not defined | 服务端没有 localStorage |
2.2 解决方案速查表
| 方案 | 代码方式 | 适用场景 |
|---|---|---|
<ClientOnly> | 模板包裹 | 整个组件只需客户端渲染 |
.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 时完全跳过(等同于被 <ClientOnly> 包裹):
<!-- 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>使用时无需 <ClientOnly>:
<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(两端都渲染)
本章小结
<ClientOnly>:模板级隔离,SSR 时渲染#fallback占位,客户端替换为实际内容.client.vue:文件级隔离,整个组件只在客户端渲染,SSR 时跳过.server.vue:文件级隔离,整个组件只在 SSR 时渲染,不包含在 JS bundle 中- 同名配对:
.server.vue+.client.vue为同一组件提供不同环境的实现 - 避免报错:
import.meta.client条件判断、onMounted延迟执行、动态导入 - 最佳实践:重量级第三方库用
.client.vue;纯展示内容用.server.vue;需要 fallback 用<ClientOnly>
下一章讲 Layouts 布局系统——管理页面的公共框架结构。