Skip to content

组件

app/components/ 目录下的 Vue 组件会自动注册,无需手动 import。这是 Nuxt 和纯 Vue 项目最大的区别之一——你创建组件文件,Nuxt 帮你完成注册和导入。

自动注册

text
app/components/
├── AppHeader.vue           → <AppHeader>
├── AppFooter.vue           → <AppFooter>
├── Button.vue              → <Button>
├── Card/
│   ├── Card.vue            → <Card>
│   ├── CardHeader.vue      → <CardHeader>
│   └── CardBody.vue        → <CardBody>
└── user/
    ├── UserCard.vue         → <UserCard>
    └── UserAvatar.vue       → <UserAvatar>
vue
<template>
  <AppHeader />
  <Card>
    <CardHeader>标题</CardHeader>
    <CardBody>内容</CardBody>
  </Card>
  <UserCard :user="user" />
</template>

<!-- 不需要写这些:
<script setup>
import AppHeader from '~/components/AppHeader.vue'
import Card from '~/components/Card/Card.vue'
import CardHeader from '~/components/Card/CardHeader.vue'
import CardBody from '~/components/Card/CardBody.vue'
import UserCard from '~/components/user/UserCard.vue'
</script>
-->

自动注册的本质

Nuxt 在构建时扫描 components/ 目录,为每个组件生成注册代码。你在模板中写 <AppHeader />,Nuxt 确保对应的组件已经被导入和注册。

INFO

新创建的组件如果 IDE 不识别 运行 nuxt prepare 重新生成类型声明,或重启开发服务器

命名规则

文件路径组件名说明
Button.vue<Button>文件名即组件名
BaseButton.vue<BaseButton>前缀保留
user/UserCard.vue<UserCard>目录名首字母大写作为前缀
blog/PostItem.vue<BlogPostItem>嵌套目录全保留
Icon/Home.vue<IconHome>仅当同级存在 Icon.vue 时加前缀

命名去重

当同级存在同名文件和目录时,重复部分会被去掉:

text
components/
├── Card.vue              → <Card>
├── Card/
│   ├── CardHeader.vue     → <CardHeader>(不是 <CardCardHeader>)
│   └── CardBody.vue       → <CardBody>(不是 <CardCardBody>)

去重规则

如果组件名和父目录名有重复部分,重复的前缀被去掉。这是因为 Card/CardHeader.vue 在逻辑上属于 Card 的子组件,不需要再加 Card 前缀。

命名冲突

text
components/
├── Button.vue              → <Button>
└── form/
      └── Button.vue        → <FormButton>  ← 自动加目录名前缀

INFO

如果安装了 UI 库(如 Element Plus、Naive UI) 它们的组件名可能和你的冲突。比如 Element Plus 也有 <Button> 组件

解决方案

  • 给自己的组件加前缀:BaseButton.vueAppButton.vue
  • 或者通过 nuxt.config.ts 配置全局前缀:
ts
export default defineNuxtConfig({
components: {
dirs: [{
path: '~/components',
pathPrefix: false,  // 不加目录前缀
prefix: 'My',       // 所有组件加 My 前缀:<MyButton>
}],
},
})

最佳实践

  • 基础组件加 Base 前缀:BaseButtonBaseInput
  • 单例组件加 App 前缀:AppHeaderAppFooter
  • 按功能分目录:form/card/table/
  • 避免和 UI 库组件重名

懒加载组件

组件名前加 Lazy 前缀即可懒加载——组件代码单独打包,需要时才下载:

vue
<template>
  <!-- 懒加载:组件需要时才下载,减少首屏体积 -->
  <LazyHeavyChart v-if="showChart" />

  <!-- 普通加载:打包进首屏 JS -->
  <Button>点击</Button>
</template>

懒加载的工作原理

  • 普通 <HeavyChart />:组件代码打包进页面 JS,页面加载时一起下载
  • <LazyHeavyChart />:组件代码单独打包为独立 chunk,v-if 为 true 时才下载
  • 懒加载本质上是 Vue 的 defineAsyncComponent + import()
场景是否用 Lazy原因
地图组件(50KB+)✅ 用体积大,只在特定页面用
富文本编辑器✅ 用体积大,只在编辑页面用
弹窗/抽屉✅ 用条件渲染,初始不需要
导航栏/页脚❌ 不用每个页面都用,懒加载反而多一次请求
小组件(< 5KB)❌ 不用体积小,懒加载的请求开销比节省的体积还大

INFO

懒加载的 SSR 行为:在 SSR 时 懒加载组件仍然会在服务端渲染(为了 SEO 和首屏内容完整)。只是客户端的下载被延迟了。如果你确实不想在 SSR 时渲染,用 <ClientOnly> 包裹

<ClientOnly> — 仅客户端渲染

<ClientOnly> 让内容只在客户端渲染,SSR 时跳过或显示 fallback:

vue
<template>
  <ClientOnly>
    <!-- 仅在客户端渲染 -->
    <MapComponent />

    <!-- SSR 时显示的 fallback -->
    <template #fallback>
      <div class="map-placeholder">地图加载中...</div>
    </template>
  </ClientOnly>
</template>

为什么需要 ClientOnly?

在 Nuxt SSR 中,组件代码可能在服务端执行。如果组件依赖浏览器 API,就会报错:

vue
<!-- ❌ SSR 报错:window is not defined -->
<script setup>
const width = window.innerWidth  // 服务端没有 window!
</script>

三种解决方案的对比:

方案代码适用场景
<ClientOnly>模板级别控制整个组件不能 SSR
onMounted逻辑级别控制只是个别代码不能在 SSR 执行
process.client条件判断需要在 <script setup> 中判断
vue
<!-- 方案 1:整个组件只在客户端渲染 -->
<ClientOnly>
  <MapComponent />
</ClientOnly>

<!-- 方案 2:组件仍 SSR,但浏览器相关代码放在 onMounted 中 -->
<script setup>
const width = ref(0)
onMounted(() => {
  width.value = window.innerWidth  // ✅ onMounted 只在客户端执行
})
</script>

<!-- 方案 3:条件判断 -->
<script setup>
if (process.client) {
  console.log(window.innerWidth)  // ✅ 只在客户端执行
}
</script>

<ClientOnly> 对 SEO 的影响

<ClientOnly> 包裹的内容不会出现在 SSR 的 HTML 中,搜索引擎爬虫看不到。因此:

  • ✅ 地图、图表等非文字内容 → 用 <ClientOnly> 没问题
  • ❌ 关键文字内容、SEO 关键词 → 不要<ClientOnly>,应该确保 SSR 渲染

典型需要 ClientOnly 的组件

组件类型依赖的浏览器 API是否有 fallback
地图(高德/百度/Google Maps)window、DOM✅ 静态地图图片
图表(ECharts/Chart.js)CanvasSVG✅ 加载占位符
富文本编辑器document.execCommand✅ 只读预览
第三方支付按钮window.Alipay✅ "加载中"提示
社交分享按钮window.FB✅ 静态链接

<DevOnly> — 仅开发环境渲染

<DevOnly> 只在开发环境(npm run dev)渲染,生产构建时完全移除

vue
<template>
  <DevOnly>
    <DebugPanel />  <!-- 生产环境不会包含,甚至不会出现在 JS 包中 -->
  </DevOnly>
</template>

<DevOnly> vs if (process.dev)

方案模板中生产构建结果
<DevOnly>✅ 可以在模板中使用组件代码完全移除,不占体积
v-if="isDev"✅ 可用组件代码仍包含在 JS 中,只是不渲染
process.dev❌ 只能在 script 中

推荐

模板中用 <DevOnly>,script 中用 process.dev

适合场景

  • 调试面板(显示当前路由、状态、性能数据)
  • 开发辅助工具(设计稿对比、组件文档)
  • Mock 数据切换
  • 环境信息展示(开发环境标识)

动态组件

使用 Vue 的 <component :is=""> 动态切换组件。在 Nuxt 中需要用 resolveComponent 解析自动注册的组件:

vue
<script setup>
const currentTab = ref('home')

// resolveComponent:解析自动注册的组件名
const tabs = {
  home: resolveComponent('HomeTab'),
  profile: resolveComponent('ProfileTab'),
  settings: resolveComponent('SettingsTab'),
}
</script>

<template>
  <component :is="tabs[currentTab]" />
</template>

为什么用 resolveComponent

在模板中写 <HomeTab /> 时,Vue 编译器自动处理组件解析。但在 <script> 中动态引用时,你需要手动调用 resolveComponent 来找到自动注册的组件。

INFO

SSR 注意事项:动态组件在 SSR 时会被渲染 如果某个 tab 的组件依赖浏览器 API,用 defineAsyncComponent + <ClientOnly> 配合

vue
<ClientOnly>
<component :is="tabs[currentTab]" />
</ClientOnly>

组件中的 SSR 注意事项

1. 不要在 <script setup> 顶层使用浏览器 API

vue
<!-- ❌ 错误:SSR 时报错 -->
<script setup>
const width = window.innerWidth  // ReferenceError: window is not defined
</script>

<!-- ✅ 正确:放在 onMounted 中 -->
<script setup>
const width = ref(0)
onMounted(() => {
  width.value = window.innerWidth
})
</script>

2. 避免服务端和客户端渲染不一致

vue
<!-- ❌ Hydration 不匹配:服务端和客户端时间不同 -->
<template>
  <p>当前时间:{{ new Date().toLocaleString() }}</p>
</template>

<!-- ✅ 只在客户端渲染时间 -->
<template>
  <p v-if="mounted">当前时间:{{ currentTime }}</p>
  <p v-else>加载中...</p>
</template>

<script setup>
const mounted = ref(false)
const currentTime = ref('')
onMounted(() => {
  mounted.value = true
  currentTime.value = new Date().toLocaleString()
})
</script>

3. 组件中使用 useFetch 的注意事项

vue
<script setup>
// ✅ 正确:useFetch 在 SSR 时获取数据,客户端复用
const { data } = await useFetch('/api/user')

// ❌ 错误:在 onMounted 中用 useFetch(SSR 时不执行,首屏无数据)
onMounted(async () => {
  const { data } = await useFetch('/api/user')  // SSR 时跳过!
})
</script>

规则

需要 SSR 渲染的数据获取,必须在 <script setup> 顶层使用 useFetch/useAsyncData,不要放在 onMounted 中。

4. useId() 生成 SSR 安全的唯一 ID

vue
<script setup>
// ✅ SSR 和客户端生成相同的 ID,不会 Hydration 不匹配
const inputId = useId()
</script>

<template>
  <label :for="inputId">用户名</label>
  <input :id="inputId" type="text" />
</template>

为什么不用 Math.random()Date.now()

这些在服务端和客户端会生成不同的值,导致 Hydration 不匹配。useId() 是 Vue 3.5+ 提供的 API,确保两端生成相同的 ID。

组件目录配置

自定义扫描目录

ts
// nuxt.config.ts
export default defineNuxtConfig({
  components: [
    // 默认目录
    '~/components',
    // 额外目录
    {
      path: '~/design-system/components',  // 扫描这个目录
      prefix: 'Ds',                        // 组件加 Ds 前缀:<DsButton>
    },
  ],
})

禁用自动导入某个目录

ts
export default defineNuxtConfig({
  components: [
    {
      path: '~/components/legacy',
      enabled: false,  // 不自动导入这个目录的组件
    },
  ],
})

什么时候需要自定义?

  • 使用设计系统时,给组件加统一前缀避免冲突
  • 有一些旧组件不想自动注册,但也不想删掉
  • 组件分散在多个目录中(如 monorepo)

常见问题

新建的组件不识别

  1. 重启开发服务器(Ctrl+Cnpm run dev
  2. 运行 nuxt prepare 重新生成类型
  3. 检查文件是否在 app/components/ 目录下

组件和 UI 库名字冲突

给组件加前缀,或通过 nuxt.config.ts 配置 prefix。详见上方"命名冲突"一节。

<ClientOnly> 内的组件样式闪烁

SSR 时 <ClientOnly> 内的内容不渲染,客户端 Hydration 后才渲染,可能导致布局跳动。解决方案:

vue
<ClientOnly>
  <MapComponent />
  <template #fallback>
    <!-- 用和实际组件相同尺寸的占位符,避免布局跳动 -->
    <div style="height: 400px; background: #f5f5f5;">地图加载中...</div>
  </template>
</ClientOnly>

知识脉络

text
布局 → 你在这里:组件

         ├─→ 下一步:页面过渡

         ├─→ 深入了解:自动导入(03-核心概念/03-自动导入)

         └─→ 深入了解:ClientOnly 内置组件(14-内置组件速查/03-条件渲染)

基于 Nuxt 4 官方文档整理编写