## 第 3 章：组件与布局（复用 UI 与统一结构）
本章讲解 Nuxt 4 组件机制与布局系统，结合完整代码 + 实战演练，完成导航栏、内容卡片、多布局等通用模块封装，适配掘金小册平台页面复用需求。
### 3.1 组件自动导入：components 目录与全局注册
Nuxt 4 基于约定式目录，**app/components/** 下的组件无需手动 import，全局自动导入，直接在模板中使用。
#### **目录规则**
- 1，一级组件：components/BookCard.vue → 直接使用 <BookCard />
- 2，子目录组件：components/Header/Nav.vue → 自动转为 <HeaderNav />（目录名 + 组件名）
#### **实战代码**
新建全局组件 app/components/Hello.vue
```js
<template>
  <div class="demo-component">
    <h3>Nuxt 4 自动导入组件 Hello</h3>
  </div>
</template>
```
在任意页面直接使用，无需引入：
app/pages/index.vue
```js
<template>
  <div>
    <h1>网站首页</h1>
    <!-- 直接使用组件 -->
    <Hello />
  </div>
</template>
```
#### **实战演练**
- 1，在新建 Tips.vue 组件，编写简单提示文本
- 2，在首页、小册列表页分别引用该组件
- 3，运行项目，验证无需 import 即可正常渲染

### 3.2 组合式组件：defineProps / defineEmits / defineExpose
在 Vue3 + Nuxt4 的 **\<script setup\>** 语法中，使用内置 API 实现父子组件传值、事件派发、实例暴露，是业务组件开发基础。
#### **1. defineProps 父传子**
子组件 app/components/BookItem.vue
```js
<template>
  <div class="book-item">
    <p>小册名称：{{ title }}</p>
    <p>阅读量：{{ viewCount }}</p>
  </div>
</template>

<script setup>
// 定义接收父组件参数
const props = defineProps({
  title: String,
  viewCount: Number
})
</script>
```
父组件使用并传参：
```js
<template>
  <div>
    <BookItem title="Nuxt4 实战教程" :view-count="1200" />
  </div>
</template>
```
#### **2. defineEmits 子传父（触发自定义事件）**
改造子组件，添加点击派发事件：
子组件 app/components/BookItem2.vue
```js
<template>
  <div class="book-item2" @click="handleClick">
    <p>小册名称：{{ title }}</p>
  </div>
</template>

<script setup>
const props = defineProps(['title'])
// 定义自定义事件
const emit = defineEmits(['select'])

const handleClick = () => {
  // 向父组件派发事件并传参
  emit('select', props.title)
}
</script>
```
父组件监听事件：
```js
<template>
  <BookItem2 title="Vue3 入门1" @select="onBookSelect" />
  <BookItem2 title="Vue2 KK" @select="onBookSelect" />
</template>

<script setup>
const onBookSelect = (name) => {
  console.log('选中小册：', name)
}
</script>
```
#### **3. defineExpose 暴露组件实例与方法**
子组件向外暴露内部属性 / 方法，供父组件通过 ref 调用：
子组件增加 app/components/BookItem.vue
```js
<script setup>
const msg = '组件内部数据'
const showInfo = () => {
  alert('组件方法被调用')
}

// 对外暴露
defineExpose({
  msg,
  showInfo
})
</script>
```
父组件调用：
父组件 app/pages/index.vue 追加如下内容
```js
<template>
  <BookItem title="对外暴露数据和方法" :viewCount="1000000" ref="bookRef" />
  <button @click="callChildMethod">调用子组件方法</button>
</template>

<script setup>
import { ref } from 'vue'
const bookRef = ref(null)

const callChildMethod = () => {
  bookRef.value.showInfo()
  console.log(bookRef.value.msg)
}
</script>
```
#### **实战演练**
- 1，封装小册条目组件，使用 defineProps 接收名称、封面、价格
- 2，绑定点击事件，通过 defineEmits 向上传递选中事件
- 3，使用 defineExpose 暴露一个重置方法，父组件点击触发

### 3.3 布局系统：layouts 目录与默认布局 default.vue
Nuxt4 布局用于统一页面公共头部、底部、侧边栏，所有页面默认继承 layouts/default.vue。

#### **规则说明**
- 目录：app/layouts/
- default.vue：全局默认布局，所有页面默认使用
- 布局出口：必须使用 <slot /\> 承载页面主体内容

#### **实战代码**
创建默认布局 
app/layouts/default.vue
```js
<template>
  <!-- 公共导航栏 -->
  <header style="padding: 1rem; background: #f5f5f5;">
    <h2>diibook小册平台 - 公共导航</h2>
  </header>

  <!-- 页面内容插槽 -->
  <main style="padding: 2rem;">
    <slot />
  </main>

  <!-- 公共底部 -->
  <footer style="text-align: center; padding: 1rem; border-top: 1px solid #eee;">
    版权所有 © 小册实战项目
  </footer>
</template>
```
修改入口文件 
app/app.vue
```js
<template>
  <div>
    <NuxtLayout>
      <NuxtPage />
    </NuxtLayout>
  </div>
</template>
```
任意页面无需额外配置，自动套用该布局。

#### **实战演练**
- 1，编写默认布局，添加导航、主体插槽、页脚
- 2，启动项目，访问首页、列表页，查看所有页面统一套用布局

### 3.4 多布局切换：definePageMeta({ layout: 'xxx' })
项目中常存在前台布局、后台布局、登录页独立布局，通过 definePageMeta 快速切换布局。

#### **1. 新建自定义布局**
创建后台布局 
app/layouts/admin.vue
```js
<template>
  <div style="display: flex;">
    <!-- 后台侧边栏 -->
    <aside style="width: 200px; background: #222; color: #fff; padding: 1rem;">
      <p>创作者后台菜单</p>
    </aside>
    <!-- 后台内容区 -->
    <div style="flex: 1; padding: 2rem;">
      <slot />
    </div>
  </div>
</template>
```
#### **2. 页面指定布局**
后台页面 app/pages/admin/index.vue，通过路由元信息切换布局：
```js
<template>
  <div>创作者控制台</div>
</template>

<script setup>
// 指定使用 admin 布局
definePageMeta({
  layout: 'admin'
})
</script>
```
#### **3. 禁用布局（独立页面）**
登录页、空白页可关闭所有布局，设置 **layout: false**：
```js
<script setup>
definePageMeta({
  layout: false
})
</script>
```
#### **实战演练** 
- 1，新建 admin 后台布局、login 登录布局
- 2，分别给后台页、登录页绑定对应布局
- 3，访问不同页面，验证布局切换效果

### 3.5 全局公共组件封装（导航栏、侧边栏、卡片）
结合自动导入特性，封装掘金小册平台高频复用组件。

#### **1. 导航栏组件 app/components/GlobalNav.vue**
```js
<template>
  <nav style="background: #1677ff; padding: 0.8rem; color: #fff;">
    <NuxtLink to="/" style="margin-right: 1rem; color: #fff;">首页</NuxtLink>
    <NuxtLink to="/books" style="margin-right: 1rem; color: #fff;">小册列表</NuxtLink>
    <NuxtLink to="/admin" style="color: #fff;">创作者中心</NuxtLink>
  </nav>
</template>
```
#### **2. 小册卡片组件 app/components/BookCard.vue**
```js
<template>
  <div style="border: 1px solid #eee; padding: 1rem; border-radius: 6px; width: 240px;">
    <h4>{{ bookName }}</h4>
    <p>作者：{{ author }}</p>
    <p>阅读量：{{ views }}</p>
    <NuxtLink :to="`/books/${bookId}`">查看详情</NuxtLink>
  </div>
</template>

<script setup>
defineProps({
  bookId: [String, Number],
  bookName: String,
  author: String,
  views: Number
})
</script>
```
#### **3. 在默认布局中引入公共组件**
修改 layouts/default.vue，嵌入全局导航：
```js
<template>
  <!-- 全局导航 -->
  <GlobalNav />
  <main style="padding: 2rem;">
    <slot />
  </main>
  <footer>版权所有 © 小册实战项目</footer>
</template>
```
#### **4. 页面批量使用卡片组件**
app/pages/books/index.vue
```js
<template>
  <div style="display: flex; gap: 1rem; flex-wrap: wrap;">
    <BookCard book-id="1" book-name="Nuxt4 全栈实战" author="技术作者" :views="3600" />
    <BookCard book-id="2" book-name="Vue3 进阶指南" author="前端达人" :views="2800" />
  </div>
</template>
```
#### **实战演练** 
- 1，封装全局导航、小册卡片两个通用组件
- 2，将导航嵌入默认布局，实现全站统一导航
- 3，在列表页循环使用卡片组件展示多条小册数据

### 3.6 组件懒加载与性能优化
对于大体积组件、弹窗、非首屏组件，使用懒加载减少首屏资源体积，提升页面加载速度。
Nuxt4 已经把组件懒加载、懒水合、数据懒加载做成内置能力，不需要手动写 defineAsyncComponent 也能实现 “点击再加载弹窗” 这类需求Nuxt。

#### **1. 组件动态懒加载（局部懒加载）**
Nuxt4 自动扫描 components/ 下的组件，名字加 Lazy 就自动变成异步懒加载组件 Nuxt
弹窗场景最佳实践：
app/components/Modal.vue
```js
<template>
  <div class="mask" @click.self="$emit('close')">
    <div class="box">
      <slot />
      <button @click="$emit('close')">关闭</button>
    </div>
  </div>
</template>

<style scoped>
.mask { position: fixed; inset: 0; background: rgba(0,0,0,0.5); display: flex; align-items: center; justify-content: center; }
.box { background: white; padding: 24px; border-radius: 8px; }
</style>
```
页面使用（内置懒加载）
app/pages/books/index.vue
```js
<template>
  <button @click="showModal = true">打开弹窗</button>
  <LazyModal v-if="showModal" @close="showModal = false"> 
    我是懒加载弹窗内容
  </LazyModal>
</template>

<script setup>
const showModal = ref(false)
</script>
```

#### **2. 懒加载组件懒加载 + 四种水合策略 + 图片懒加载 + 数据懒加载**
基础组件
app/components/HeavyTable.vue
```js
<!-- 重型表格组件 -->
<template>
  <div class="table-box">
    <h3>大数据表格（懒加载组件）</h3>
    <table border="1" cellpadding="6">
        <tbody>
            <tr>
                <td>序号</td>
                <td>名称</td>
                <td>状态</td>
            </tr>
            <tr v-for="i in 10" :key="i">
                <td>{{ i }}</td>
                <td>测试数据 {{ i }}</td>
                <td>正常</td>
            </tr>
        </tbody>
    </table>
  </div>
</template>

<script setup lang="ts">
// 模拟复杂逻辑
console.log('HeavyTable 组件已加载 & 水合 ')
</script>
```
components/Modal.vue
```js
<template>
  <div class="mask" @click.self="$emit('close')">
    <div class="box">
      <slot />
      <p>点击后才完成加载与水合</p>
      <button @click="$emit('close')">关闭</button>
    </div>
  </div>
</template>
<script setup lang="ts">
alert('Modal 已水合')
</script>
<style scoped>
.mask { position: fixed; inset: 0; background: rgba(0,0,0,0.5); display: flex; align-items: center; justify-content: center; }
.box { background: white; padding: 24px; border-radius: 8px; }
</style>
```
components/FooterInfo.vue
```js
<!-- 页脚（浏览器空闲时水合） -->
<template>
  <div class="footer">
    网站底部信息 · 懒加载+空闲水合
  </div>
</template>

<script setup lang="ts">
console.log('FooterInfo 空闲时完成水合')
</script>

<style scoped>
.footer {
  margin-top: 800px;
  padding: 20px;
  background: #eee;
}
</style>
```
>加入nuxt/image组件
```js
yarn add @nuxt/image
```
>启用（nuxt.config.ts）
```js
import { defineConfig } from 'nuxt/app'

export default defineConfig({
  modules: [
    '@nuxt/image'
  ]
})
```
页面调用（内置懒加载）
app/pages/index.vue
```js
<template>
  <div>
    <h2>Nuxt4 内置懒加载 实战演示</h2>

    <!-- 1. 可见时水合：进入视口才加载+激活（长列表/表格首选） -->
    <div style="margin-top: 600px;">
      <LazyHeavyTable hydrate-on-visible />
    </div>

    <!-- 2. 交互时水合：点击/hover 才加载激活（弹窗/表单） -->
    <button @click="showModal = true">打开弹窗</button>
    <LazyModal v-if="showModal" @close="showModal = false">
      我是懒加载弹窗内容
    </LazyModal>

    <!-- 3. 浏览器空闲时水合：非关键组件 -->
    <LazyFooterInfo hydrate-on-idle />

    <!-- 4. 图片懒加载（Nuxt 内置 <NuxtImg> 自动懒加载） -->
    <div style="margin-top: 300px;">
      <h3>图片懒加载</h3>
      <NuxtImg
        src="images/demo.png"
        width="600"
        height="300"
        alt="示例图"
      />
    </div>
</template>

<script setup lang="ts">
import { ref } from 'vue'
// 控制弹窗显示
const showModal = ref(false)
</script>
```

#### **3. 基础优化建议**
- 首屏非必需组件全部使用懒加载
- 大图片、富文本编辑器、表格等重型组件优先异步引入
- 拆分巨型组件，按功能拆分为多个小组件

#### **4. 实战演练**
- 1，封装一个弹窗组件，实现点击后懒加载
- 2，查看浏览器网络面板，验证组件仅在触发后才请求资源
- 3，对后台复杂组件配置全局懒加载

### 本章综合实战任务
- 1，完善全局导航、页脚，整合进默认布局，前台页面统一样式结构
- 2，封装可复用的小册卡片组件，实现参数传值与跳转
- 3，搭建独立后台布局，区分前台 / 后台页面样式
- 4，对弹窗、富文本等重型组件实现懒加载，优化首屏性能
- 5，熟练使用 defineProps / defineEmits 完成组件交互

### 本章总结
- 1，Nuxt4 components 目录组件自动全局导入，简化开发；
- 2，掌握 defineProps / defineEmits / defineExpose 实现组件通信；
- 3， layouts 布局实现全站结构统一，default.vue 为默认布局；
- 4，通过 definePageMeta 灵活切换多套布局，适配前台 / 后台 / 登录页；
- 5，封装导航、卡片等公共组件，提升代码复用率；
- 6，使用异步组件实现懒加载，完成页面性能基础优化。

