码上问学问代码之理,学编程之道

第 6 章 · 组件化开发

前面几章你写的都是「一个 .vue 文件搞定一个页面」——写到第 400 行时,你自己都不想再看它了。这一章教你像搭积木一样写界面:把页面拆成一个个职责单一、能反复使用、能互相传数据的组件。学完你会拿到一套完整的父子通信工具——props 往下传、emit 往上报、插槽留活口——并且亲手把作品列表页拆成三个文件。

为什么不拆组件就是灾难

先说清代价
先讲为什么一栋楼不会是一个巨大的空房间,而是分成一个个有门的房间——因为「找人方便」「改水电不影响别人」「可以标准化复制」。代码也一样。
不拆组件的页面会精确地按这个顺序烂掉:① 找不到东西(想改加载状态,得在 500 行里翻);② 改一处崩一片(调了卡片的内边距,分页器跟着错位);③ 无法复用(详情页也要显示作品卡片,只能再复制 30 行);④ 无法协作(两个人同时改这个文件,天天解冲突)。拆组件不是为了「看起来专业」,是为了让你三个月后还能改这个项目。
动手做先看看「不拆」到底长什么样——这是常见的 ArticleListView.vue,请数一下它同时管了几件事。
src/views/ArticleListView.vue(不拆,486 行)vue
<script setup>
// 1-90 行:分类筛选(currentCategory、switchCategory、请求参数拼装)
// 91-150 行:加载状态 / 错误状态 / 空状态
// 151-210 行:列表数据与分页参数
// 211-300 行:收藏、点赞两个动作 + 接口调用 + 乐观更新
// 301-380 行:卡片展示逻辑(推荐角标、浏览量格式化、封面兜底图)
// 381-420 行:一个手写的分页器(上一页 / 下一页 / 页码计算)
// 421-486 行:骨架屏、无障碍属性、以及 3 个只在某些分支用到的常量
</script>

<template>
  <!-- 下面这 300 多行模板里,所有状态都混在同一层作用域 -->
  <!-- 卡片那 30 行在 <li> 里、分页那 40 行在 <div> 里、
       收藏按钮的 @click 和「加载失败重试」的 @click 相隔 200 行 -->
</template>
页面效果组件渲染结果
运行结果: 浏览器里看起来一切正常,也没报错 —— 灾难不在运行,而在维护: -- 想给卡片加个「已收藏」小图标? 得先滚到第 301 行,还要小心别把分页器的 .card 样式一起改了 -- 详情页也想显示这张卡片? 复制粘贴那 30 行模板 + 依赖的 3 个函数 -- 两个同学同时改「收藏」和「分页」? 同一个文件,git 冲突必然发生 -- 这个文件现在有 7 个职责,任何一个需求变更都从这里开始烂
讲透:拆的判据是什么
  • 看「能不能用一个短句说清它是什么」:ArticleCard(一张作品卡片)、Pagination(一个分页器)、EmptyState(一个空状态)——都能一句话说清,就该独立成组件。
  • 看「会不会被第二个地方用到」:作品卡片会被列表页、搜索结果、我的收藏、作者主页用到,那就必须拆出来。
  • 看「自己有没有独立状态」:分页器自己管「当前第几页」,跟页面其它部分没交集,独立出去最干净。
  • 别拆过头:一个只有 <p>暂无数据</p> 的东西,包成 EmptyState.vue 就有点过度(除非它出现 5 次以上,且文案/图标需要统一)。拆的标准是「职责」,不是「行数」。
  • 拆完之后页面组件变成什么:只负责「取数据、决定用哪个组件、把数据分发下去」。这一章最后的实战就会让你看到这一层。

组件的定义与使用

一个 .vue 文件就是一个组件
先讲为什么组件不用你去「注册」什么全局表——一个 .vue 文件天然就是一个组件。.vue 文件(业内叫 SFC,单文件组件)把三块东西装在一起:<script setup> 写逻辑、<template> 写结构、<style> 写样式。谁用它,谁 import 进来,然后在模板里当成标签写。就这么简单。
动手做先写一个最小的组件,再在页面里用两次——注意它是被「复制」了两份,各自拥有独立的状态。
src/components/HelloCard.vuevue
<script setup>
import { ref } from 'vue'

const title = '古典文学网'
const likes = ref(0)
</script>

<template>
  <div class="card">
    <h3>{{ title }}</h3>
    <p>品读古典文学,涵养中国心灵</p>
    <button @click="likes++">点赞 {{ likes }}</button>
  </div>
</template>

<style scoped>
.card { padding: 16px; border: 1px solid #eee; border-radius: 10px; }
</style>
页面效果组件渲染结果
运行结果: -- 这是一个组件文件,不能单独运行,必须被别的组件使用。 -- 使用后浏览器显示效果(见下一个代码块):两个卡片各自点赞互不影响。
src/views/HomeView.vuevue
<script setup>
// 导入即可用 —— <script setup> 里不需要再写 components: { HelloCard }
import HelloCard from '@/components/HelloCard.vue'
</script>

<template>
  <HelloCard />
  <HelloCard />
</template>
页面效果组件渲染结果
运行结果: 浏览器显示效果: ┌──────────────────────────┐ │ 古典文学网 │ │ 品读古典文学,涵养中国心灵 │ │ [ 点赞 0 ] │ └──────────────────────────┘ ┌──────────────────────────┐ │ 古典文学网 │ │ 品读古典文学,涵养中国心灵 │ │ [ 点赞 0 ] │ └──────────────────────────┘ -- 点上面那张卡片的按钮:只变成「点赞 1」 -- 点下面那张:上面那张还是 1,下面变成 1 —— 两个实例各自有独立的 likes
讲透
  • <script setup> 里 import 进来的组件,模板中直接用,不用写注册那一步。这就是它比 Vue 2 的 components: { HelloCard } 省事的地方——少写一步,也少一个「导入了却忘了注册」的 bug。
  • 组件名用 PascalCase(大驼峰):ArticleCard、CommentList。好处是一眼和原生标签区分开——看到 <div> / <span> 知道是 HTML 的,看到 <ArticleCard> 知道是自己写的。
  • 模板里两种写法等价:<ArticleCard /> 和 <article-card /> 都能匹配到同一个组件。但统一用 PascalCase,别混着写。
  • 每个组件实例有自己独立的状态:用了两次就得到两份 likes,互不影响——这跟函数里 const 局部变量一个道理。
  • 组件自闭合写法:没有子内容时写 <ArticleCard />,比 <ArticleCard></ArticleCard> 简洁。
  • <style scoped> 是组件化的另一半:加 scoped 后样式只作用于当前组件,不会污染别的组件——「改一处崩一片」的 CSS 问题也就断在这儿了。
避坑三种最常见的「组件没渲染出来」:① 忘了 import——模板里写了 <ArticleCard /> 却没导入,控制台警告 Failed to resolve component: ArticleCard,页面上那个位置就是一片空白;② 文件名和导入名对不上(文件叫 ArticleCard.vue 却写成 import Article from '...' 然后模板里写 <ArticleCard />)——变量名是什么,模板里就得写什么;③ 把组件名起成 HTML 原生标签,比如 Header.vue、Button.vue、List.vue,容易和原生标签或第三方库撞名,Vue 也可能给警告。**组件名至少两个单词**:AppHeader、BaseButton。

props:数据往下传

父组件给子组件喂数据
父子组件通信:props 往下喂数据,emit 往上报消息——数据永远单向流动父组件 ArticleList.vue持有数据 list,模板里写 <ArticleCard :item=“行” @like=“…”/>子组件 ArticleCard.vuedefineProps 接数据,defineEmits 发消息props:item、尺寸等(只读!)emit:like、remove 事件铁律:子组件不能直接改 props——想改,通知父组件改(数据源头只有一个,才不会乱)就像公司:需求从上往下派(props),结果从下往上汇报(emit),不能越级改上层的决定
先讲为什么ArticleCard 要做成通用的,它自己不该去请求数据——数据从哪来,是页面的事;怎么显示,才是卡片的事。所以卡片需要一个「入口」:外面把一篇作品递进来,它负责渲染。
这个入口就是 props。它像函数的形参:父组件传什么,子组件就拿到什么。而且有一条铁规矩——数据只能从上往下流(单向数据流):子组件可以读,但不能改。因为如果子组件能随手改父组件的数据,一篇文章被 5 个地方引用,一个地方改了,另外 4 个地方为什么变了你永远查不清。
动手做用 defineProps 声明三个 prop:必填的对象、有默认值的布尔、带校验的字符串。
src/components/ArticleCard.vuevue
<script setup>
const props = defineProps({
  // 必填:没有它这张卡片无从渲染
  item: {
    type: Object,
    required: true
  },
  // 有默认值:父组件不传就是 false
  showBadge: {
    type: Boolean,
    default: false
  },
  // 带校验:只允许 sm / md / lg,传错了开发环境会警告
  size: {
    type: String,
    default: 'md',
    validator: (value) => ['sm', 'md', 'lg'].includes(value)
  }
})
</script>

<template>
  <div class="card" :class="`card-${size}`">
    <span v-if="item.isRecommend && showBadge" class="badge">推荐</span>
    <h3>{{ item.title }}</h3>
    <p>{{ item.author }} · {{ item.dynasty }}</p>
  </div>
</template>
页面效果组件渲染结果
运行结果: -- 组件文件本身不运行;被父组件使用后的浏览器显示效果见下一段。 -- 校验规则说明: -- required 没传 → 开发环境警告 Missing required prop: "item" -- validator 不通过(如 size="xl")→ 警告 Invalid prop: custom validator check failed for prop "size" -- 这两个警告都只在开发环境出现,生产构建会被剔除,不影响运行
src/views/ArticleListView.vue(父组件)vue
<script setup>
import ArticleCard from '@/components/ArticleCard.vue'

const articles = [
  { id: 1, title: '静夜思', author: '李白', dynasty: '唐', isRecommend: true },
  { id: 2, title: '春望', author: '杜甫', dynasty: '唐', isRecommend: false }
]
</script>

<template>
  <ArticleCard
    v-for="item in articles"
    :key="item.id"
    :item="item"
    :show-badge="true"
    size="lg"
  />
</template>
页面效果组件渲染结果
运行结果: 浏览器显示效果: ┌────────────────────────────────┐ │ [推荐] │ │ 静夜思 │ │ 李白 · 唐 │ └────────────────────────────────┘ ┌────────────────────────────────┐ │ 春望 │ │ 杜甫 · 唐 │ └────────────────────────────────┘ -- 注意两张卡片的差别:只有 isRecommend 为 true 的那张有「推荐」角标 -- 判断条件是 item.isRecommend && showBadge,两个条件都得成立
讲透
  • 传值语法::item="item" 里的冒号表示「这是个 JS 表达式」,值可以是任意变量、对象、函数;不写冒号就是传字符串字面量——size="lg" 传的是字符串 'lg',而 :size="lg" 传的是变量 lg(没定义就会报错)。这个差别坑过无数人。
  • 命名转换:子组件里声明 showBadge(camelCase),父组件模板里写 :show-badge(kebab-case)——Vue 会自动对应。因为 HTML 属性名不区分大小写,所以模板里必须用连字符写法。两个都要会认。
  • 类型校验 type:String / Number / Boolean / Array / Object / Function,也可以写成数组表示「几种都行」(type: [String, Number])。**只在开发环境生效**,作用是在你写错时早一点报警,不是「运行时安全」。
  • default 的坑:基本类型直接写;对象和数组必须写成工厂函数,例如 default: () => ({})、default: () => []。写成 default: {} 会让所有实例共享同一个对象——一个组件改了它,别的组件跟着变,这是极难查的 bug。
  • props 是只读的:子组件里 props.item.title = 'x' 改的是父组件的数据(对象是引用传递),控制台会警告 Set operation on key "title" failed: target is readonly。要改,就把修改意图「报」给父组件——这就是下一节的 emit。
  • 需要「本地可改的副本」时:用 const localTitle = ref(props.item.title) 复制一份再改,或者干脆算在父组件里改完再传下来。别绕过去改 props。
避坑「不要直接修改 props」是本章最重要的一条。新手常见的写法是:子组件里收到 item,直接 item.likes++ 想实现点赞。结果有三个:① 父组件的数据被悄悄改了(对象是引用);② 开发环境一路警告;③ 如果同一篇文章在页面上被渲染成两张卡片,你会发现两张卡片的点赞数一起跳。正确姿势:子组件 emit 一个事件,父组件拿到事件后改自己的数据,数据再顺着 props 流下来。数据永远是「从上往下」走,这样任何一处变化都能顺着链路查到源头。

emit:消息往上报

子组件通知父组件
先讲为什么数据不能往上改,那 ArticleCard 里的「收藏」按钮点了以后怎么办?总得有人去调接口、去更新列表。
办法是:子组件只负责「喊一声」,具体怎么做由父组件决定。就像前台接待:客户来了要办业务,前台不会自己跑去改系统,她按铃通知后台,后台按规程处理。「按铃」就是 emit 事件。它的第一个参数是事件名(铃铛是哪一个),后面跟的参数就是「客户是谁、要办什么」。
动手做给卡片加「收藏」和「看详情」两个动作:卡片内部用 defineEmits 声明能喊哪两声,用 emit(...) 送出去;父组件用 @事件名 接住。
src/components/ArticleCard.vue(子组件)vue
<script setup>
const props = defineProps({
  item: { type: Object, required: true }
})

// 声明「我可能会喊这两声」——事件名用小写连字符风格
const emit = defineEmits(['favorite', 'view-detail'])

function onFavorite() {
  // 第一个参数:事件名;后面的参数:要报给父组件的数据
  emit('favorite', props.item.id, props.item.title)
}

function onViewDetail() {
  emit('view-detail', props.item.id)
}
</script>

<template>
  <div class="card">
    <h3 @click="onViewDetail">{{ item.title }}</h3>
    <p>{{ item.author }} · {{ item.dynasty }}</p>
    <button @click="onFavorite">收藏</button>
  </div>
</template>
页面效果组件渲染结果
运行结果: -- 子组件只是「发信号」,点了按钮页面不会立刻有任何视觉变化。 -- 真正的效果要等父组件接住事件并处理(见下一段)。
src/views/ArticleListView.vue(父组件)vue
<script setup>
import { ref } from 'vue'
import ArticleCard from '@/components/ArticleCard.vue'

const articles = ref([
  { id: 1, title: '静夜思', author: '李白', dynasty: '唐' },
  { id: 2, title: '春望', author: '杜甫', dynasty: '唐' }
])

// 事件处理函数:收到的参数就是子组件 emit 时带过来的
async function handleFavorite(articleId, title) {
  console.log('收到收藏事件:', articleId, title)

  const res = await fetch('http://127.0.0.1:5000/api/favorite/toggle', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ article_id: articleId })
  })
  const json = await res.json()
  console.log('接口返回:', json.msg, json.data)
}

function handleDetail(articleId) {
  console.log('去详情页:', articleId)
}
</script>

<template>
  <ArticleCard
    v-for="item in articles"
    :key="item.id"
    :item="item"
    @favorite="handleFavorite"
    @view-detail="handleDetail"
  />
</template>
页面效果组件渲染结果
运行结果: 点第 1 张卡片的「收藏」按钮,控制台输出: 收到收藏事件: 1 静夜思 接口返回: 操作成功 { favorited: true } 点标题「春望」,控制台输出: 去详情页: 2 -- 接口来源:POST /api/favorite/toggle,请求体 { "article_id": 1 } -- 返回统一格式:{ "code": 200, "msg": "操作成功", "data": { "favorited": true } }
讲透
  • 事件名建议用「小写连字符」(favorite、view-detail、delete-comment):HTML 属性名不区分大小写,@viewDetail 在某些模板里会被小写成 @viewdetail,就和 emit('viewDetail') 对不上了。用连字符风格,父子两边写法完全一致,最不容易出错。
  • 事件名是「做了什么」,不是「怎么改」:favorite、view-detail 这类名字表达的是「用户在卡片上做了这个动作」,具体是调接口、还是跳页面、还是先确认弹窗,由父组件决定。如果事件名叫 setFavoriteTrue,说明你把父组件的实现细节泄漏到子组件里了。
  • 必须写 defineEmits:不写也能跑(直接 emit 会由编译器兜底),但写了才有三件好处——声明即文档(看开头就知道这组件会喊什么)、IDE 有补全和参数提示、eslint-plugin-vue 能帮你检查父组件有没有写错事件名。
  • 参数可以传多个,也可以只传一个对象:emit('favorite', { id, title }),父组件接的时候写 @favorite="handleFavorite",回调的第一个参数就是这个对象。参数少用「按位置」的方式,超过两个就打包成对象。
  • 父组件自己的事件处理函数不传参也行:@favorite="handleFavorite" 会收到子组件 emit 的全部参数;写成 @favorite="handleFavorite(item.id)" 就变成「渲染时立刻调用」了——那是个 bug。想额外传父组件的变量,用箭头函数包一层:@favorite="() => handleFavorite(item.id)"。
避坑「子组件里直接 await fetch(...) 改数据」是新手最想走的那条近路:卡片里自己调收藏接口、自己改 item.favorited。这样写能跑,但立刻带来三个后果:① 接口地址在子组件里写死,卡片从此不通用(详情页也复用不了它);② 收藏成功后要刷新列表,子组件没法通知父组件去重新请求;③ 卡片变成了「业务组件」,不再是「展示组件」。规矩:负责展示的组件不发请求,只发事件。数据请求统一放在页面组件或其调用的组合式函数里(第 5 章的 useArticleList 就是干这个的)。

插槽:把结构留活口

默认插槽 / 具名插槽 / 作用域插槽
插槽:组件留「活口」,父组件决定往里放什么结构默认插槽<slot/>:一个口Card 里放任意内容具名插槽<slot name="head"/>:多个口,按名对号弹窗的头/身/脚作用域插槽<slot :row="行"/>:把子组件的数据递出去表格列自定义渲染心智模型:props 传「数据」,插槽传「结构」——结构也要能从外面塞进来
先讲为什么写卡片组件时很快会遇到这个问题:有的卡片底部要放收藏按钮,有的放评分,有的什么都不放。用 props 传?props 只能传数据,传不了「一段结构」。
插槽(slot)就是干这个的:组件在自己的模板里挖一个「洞」,然后问「谁来用我,谁就负责把这里填上」。这就像微波炉——炉体、门、转盘是固定的,但里面放什么菜由你决定。有了插槽,一个组件能应付千百种显示需求,而不用为每种情况加一个 prop。
动手做写一个通用的 BaseCard,头部、主体、底部各留一个坑:头部和底部是具名插槽(有名字,各填各的),中间是默认插槽(不写名字的那个)。
src/components/BaseCard.vuevue
<script setup>
defineProps({
  title: { type: String, default: '未命名卡片' }
})
</script>

<template>
  <div class="base-card">
    <div class="card-head">
      <!-- 具名插槽 header:父组件不填就用 props 里的 title 兜底 -->
      <slot name="header">{{ title }}</slot>
    </div>

    <div class="card-body">
      <!-- 默认插槽:没写 name 的那个 -->
      <slot>(这里还什么都没有)</slot>
    </div>

    <div class="card-foot">
      <!-- 具名插槽 footer:父组件不填,这个 div 就是空的 -->
      <slot name="footer"></slot>
    </div>
  </div>
</template>

<style scoped>
.base-card { border: 1px solid #e7ddca; border-radius: 10px; overflow: hidden; }
.card-head { padding: 10px 14px; border-bottom: 1px solid #e7ddca; font-weight: 600; }
.card-body { padding: 14px; }
.card-foot { padding: 10px 14px; border-top: 1px solid #e7ddca; }
</style>
页面效果组件渲染结果
运行结果: -- 组件本身不渲染,等父组件填坑(见下一段)。 -- 兜底逻辑:header 不填时显示 props.title,默认值是「未命名卡片」
src/views/ArticleDetailView.vue(父组件填坑)vue
<script setup>
import { ref } from 'vue'
import BaseCard from '@/components/BaseCard.vue'

const favorited = ref(false)
</script>

<template>
  <BaseCard title="静夜思">
    <!-- 想自定义头部,就用 template + #插槽名 -->
    <template #header>
      <b>静夜思</b><span class="tag">唐诗</span>
    </template>

    <!-- 不写名字的内容,全部落到「默认插槽」 -->
    <p>床前明月光,疑是地上霜。</p>
    <p>举头望明月,低头思故乡。</p>

    <!-- 底部插槽:想放什么都可以 -->
    <template #footer>
      <button @click="favorited = !favorited">
        {{ favorited ? '已收藏' : '收藏' }}
      </button>
    </template>
  </BaseCard>
</template>
页面效果组件渲染结果
运行结果: 浏览器显示效果: ┌──────────────────────────────────────┐ │ 静夜思 [唐诗] │ ← 填入了 header 插槽 ├──────────────────────────────────────┤ │ 床前明月光,疑是地上霜。 │ ← 默认插槽 │ 举头望明月,低头思故乡。 │ ├──────────────────────────────────────┤ │ [ 收藏 ] │ ← 填入了 footer 插槽 └──────────────────────────────────────┘ -- 点「收藏」:文字变成「已收藏」(favorited 这个状态属于父组件,不是卡片的)

插槽还有第三种用法:作用域插槽——子组件把「循环里的数据」通过插槽抛出来,让父组件决定怎么渲染每一行。列表组件几乎都会用这一招,因为它把「循环、key、空状态」的活干了,把「每行长什么样」的决定权留给你。

src/components/ArticleList.vue + 父组件用法vue
<!-- ① 子组件:负责循环和空状态,每行内容交给插槽 -->
<script setup>
defineProps({
  list: { type: Array, default: () => [] }
})
</script>

<template>
  <p v-if="list.length === 0">暂无数据</p>
  <ul v-else>
    <li v-for="(item, index) in list" :key="item.id">
      <!-- 把 item 和 index「抛」给使用方,父组件在插槽里就能拿到 -->
      <slot name="item" :item="item" :index="index"></slot>
    </li>
  </ul>
</template>

<!-- ② 父组件:用 #item="scope" 接住子组件抛出来的数据 -->
<script setup>
import ArticleList from '@/components/ArticleList.vue'

const articles = [
  { id: 1, title: '静夜思', author: '李白', views: 328 },
  { id: 2, title: '春望', author: '杜甫', views: 256 }
]
</script>

<template>
  <ArticleList :list="articles">
    <!-- 解构写法:{ item, index } 就是子组件 :item :index 绑的变量名 -->
    <template #item="{ item, index }">
      <span>{{ index + 1 }}</span>
      <b>{{ item.title }}</b>
      <span>{{ item.author }}</span>
      <span>{{ item.views }} 次浏览</span>
    </template>
  </ArticleList>
</template>
页面效果组件渲染结果
运行结果: 浏览器显示效果: 1 静夜思 李白 328 次浏览 2 春望 杜甫 256 次浏览 -- 关键点:item / index 这两个变量是「子组件循环出来的」, -- 却能在父组件的插槽内容里使用 —— 这就是「作用域插槽」的意义: -- 结构与数据分离,渲染形式由使用方决定 -- list 传空数组时,显示「暂无数据」(空状态逻辑在子组件里,不用每个页面重写)
讲透:三种插槽怎么选
  • 默认插槽:组件最主要的那块内容由使用方填。<slot /> 的位置就是「填进来」的位置。适合「弹窗的主体」「卡片的正文」。
  • 具名插槽:一个组件里有多处要填,用 name 区分,使用方用 <template #名字> 对应。名字必须一字不差——子组件写 name="footer",父组件写 #foot 就是不匹配,内容会静默消失(它会落到默认插槽,或者干脆没地方去)。
  • 作用域插槽:子组件有数据、父组件想决定它长什么样时用。<slot :item="item" :index="index" /> 把数据「绑」在插槽上,父组件用 #item="{ item, index }" 解构出来。列表组件 + 作用域插槽是最经典的组合。
  • 插槽的兜底内容:<slot>默认文案</slot> 中间写的内容,在没人填插槽时显示。这是让组件「不填也能看」的好习惯。
  • 插槽和 props 的分工:要传「数据」用 props,要传「结构」用插槽。一句话判断:如果这个参数你会写 <b>文字</b>,那它就该是插槽,不是 prop。
  • 插槽内容里的作用域:默认插槽的内容在父组件的模板作用域里编译——意思是它只能用父组件的变量,用不了子组件的。所以子组件要把数据主动「抛」出来(作用域插槽),这设计是刻意的:数据流向依旧清晰。
避坑两个「内容凭空消失」的现场:① 具名插槽名字写错——子组件是 name="header",父组件写成 #head 或 #headers,这一段内容不会报错、但也不会出现,你就盯着屏幕纳闷。② 多个插槽内容忘了包 <template>——#footer 这种简写只能写在 <template> 标签上,直接写在 <div #footer> 上是错的(那会被当成一个叫 #footer 的属性)。另外提醒一句:写在组件标签内部的、没有 #名字 的内容,全部进默认插槽,所以父组件里写了多段并列内容时,只有一段会落到默认插槽的位置。

实战:把列表页拆成三个文件

组件化真正的样子

现在把前面五节合起来用一遍。目标只有一个:让 ArticleListView.vue 从 486 行降到 40 行,并且每个文件都能一句话说清职责。

① src/components/ArticleCard.vuevue
<script setup>
const props = defineProps({
  item: { type: Object, required: true },
  showBadge: { type: Boolean, default: true }
})

const emit = defineEmits(['favorite', 'view-detail'])
</script>

<template>
  <div class="article-card">
    <span v-if="item.isRecommend && showBadge" class="badge">推荐</span>

    <h3 class="title" @click="emit('view-detail', item.id)">
      {{ item.title }}
    </h3>

    <p class="meta">{{ item.author }} · {{ item.dynasty }}</p>
    <p class="summary">{{ item.summary }}</p>

    <div class="foot">
      <span>{{ item.views }} 次浏览</span>
      <!-- 卡片只喊一声,具体做什么由页面决定 -->
      <button @click="emit('favorite', item.id)">收藏</button>
    </div>
  </div>
</template>

<style scoped>
.article-card { border: 1px solid #e7ddca; border-radius: 10px; padding: 14px; }
.badge { display: inline-block; padding: 1px 6px; border-radius: 6px; background: #f8ecda; color: #c0491c; font-size: 12px; }
.title { margin: 6px 0; cursor: pointer; }
.meta { color: #736a5b; font-size: 13px; }
.summary { font-size: 13.5px; }
.foot { display: flex; justify-content: space-between; align-items: center; font-size: 13px; }
</style>
页面效果组件渲染结果
运行结果: -- 单独看不出来,因为它是一张「被使用才有意义」的卡片。 -- 它现在有两件本事:收 item 渲染、喊 favorite / view-detail 两个事件。 -- 它不知道接口是什么、也不知道路由怎么跳 —— 这是刻意的。
② src/components/ArticleList.vuevue
<script setup>
// 容器只管「怎么排、空了怎么办」,不碰任何业务
defineProps({
  list: { type: Array, default: () => [] }
})
</script>

<template>
  <p v-if="list.length === 0" class="empty">暂无作品,换个分类试试~</p>

  <div v-else class="article-list">
    <div v-for="(item, index) in list" :key="item.id">
      <!-- 循环、key、空状态都由这个组件负责;
           每行长什么样,交给使用方通过插槽决定。
           注意:插槽里的内容是在「使用方」的作用域里编译的,
           所以卡片上的 @favorite 由使用方(页面组件)直接监听即可。 -->
      <slot name="item" :item="item" :index="index"></slot>
    </div>
  </div>
</template>

<style scoped>
.article-list { display: grid; gap: 12px; }
.empty { color: #736a5b; padding: 24px 0; text-align: center; }
</style>
页面效果组件渲染结果
运行结果: -- 同上:容器组件本身不产生界面,靠插槽内容撑起来。 -- 它保证了两件事:list 为空时显示「暂无作品」;有数据时按 id 循环、key 正确。 -- 至于「每行放什么」,完全由使用方决定 —— 这正是插槽的价值所在。
③ src/views/ArticleListView.vue(页面,40 行)vue
<script setup>
import { ref, onMounted } from 'vue'
import { useArticleList } from '@/composables/useArticleList'
import ArticleList from '@/components/ArticleList.vue'
import ArticleCard from '@/components/ArticleCard.vue'

// 第 5 章的组合式函数:状态与请求都在它里面
const { list, total, loading, error, fetchArticles } = useArticleList()

onMounted(() => fetchArticles({ order_by: 'hot' }))

async function handleFavorite(articleId) {
  const res = await fetch('http://127.0.0.1:5000/api/favorite/toggle', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ article_id: articleId })
  })
  const json = await res.json()
  if (json.code === 200) console.log(json.msg, json.data.favorited)
}

function handleDetail(articleId) {
  console.log('跳转详情页:', articleId)   // 第 7 章换成 router.push
}
</script>

<template>
  <div class="page">
    <h2>作品列表<span class="meta">共 {{ total }} 篇</span></h2>

    <p v-if="loading">加载中……</p>
    <p v-else-if="error" class="err">{{ error }}</p>

    <!-- 空状态与循环都在 ArticleList 里,这里只负责「把卡片塞进去」 -->
    <ArticleList
      v-else
      :list="list"
      @favorite="handleFavorite"
      @view-detail="handleDetail"
    >
      <template #item="{ item }">
        <ArticleCard :item="item" />
      </template>
    </ArticleList>
  </div>
</template>

<style scoped>
.meta { margin-left: 8px; color: #736a5b; font-size: 13px; font-weight: 400; }
.err { color: #c0491c; }
</style>
页面效果组件渲染结果
运行结果: 浏览器显示效果: 作品列表 共 6 篇 ┌──────────────────────────────────────┐ │ [推荐] │ │ 念奴娇·赤壁怀古 │ │ 苏轼 · 宋 │ │ 大江东去,浪淘尽,千古风流人物。 │ │ 412 次浏览 [ 收藏 ] │ └──────────────────────────────────────┘ ┌──────────────────────────────────────┐ │ [推荐] │ │ 静夜思 │ │ 李白 · 唐 │ │ 床前明月光…… │ │ 328 次浏览 [ 收藏 ] │ └──────────────────────────────────────┘ (其余 4 篇同理,按浏览量从高到低排列) -- 点卡片右下角「收藏」→ 控制台打印:操作成功 true -- 点标题 → 控制台打印:跳转详情页: 3 -- list 为空时,页面上显示「暂无作品,换个分类试试~」(这句文案写在 ArticleList 里) -- 请求失败时显示 error 文案,整块列表都不渲染
讲透:拆完之后发生了什么
  • 职责分层清晰:ArticleCard 只管「一张卡片长什么样」;ArticleList 只管「一组卡片怎么排、空了怎么办」;页面只管「数据从哪来、事件怎么办」。
  • 每层都能单独替换:想换卡片样式?只改 ArticleCard.vue。想做「两列网格」?只改 ArticleList.vue 的样式。想换接口?只改组合式函数。
  • 复用变成免费的:搜索结果页要显示作品?<ArticleList :list="result"> 套上 ArticleCard 就完事,一行都不用复制。
  • 为什么页面能直接监听卡片的事件:ArticleCard 写在 ArticleList 的插槽里,但插槽内容是在「使用方」的作用域里编译的——所以那段 <ArticleCard @favorite="..." /> 的监听实际上是挂在页面组件上的。这是插槽的一个关键性质:结构由子组件安排、作用域归使用方所有。(如果 ArticleList 改为在自己内部直接渲染卡片,那它就得自己 defineEmits 再把事件往上转发一遍。)
  • 和下一章的衔接:页面里 handleDetail 现在只是 console.log,第 7 章把它换成 router.push(`/article/${id}`),你的列表页就真的能跳转了——今天拆的这三个文件,就是最终项目的前端骨架。

命名规范与自定义 v-model

让组件像个原生输入框
先讲为什么你已经用过无数次的 v-model(v-model="keyword" 绑输入框),它其实是「:value 传值 + @input 上报」这两件事的语法糖。
那自己写的组件能不能也享受这个语法糖?比如「收藏按钮」这个组件,父组件想写 <FavoriteSwitch v-model="isFav" /> 就完成双向绑定,不用手写 :value 和 @update。
当然可以。Vue 3.4 起提供了 defineModel,一行就搞定——这也是为什么自定义组件看起来「像原生输入框一样好用」。
动手做写一个 FavoriteSwitch 组件,内部一行 defineModel,父组件用 v-model 绑定。
src/components/FavoriteSwitch.vuevue
<script setup>
// Vue 3.4+ 写法:一行得到一个「可读可写」的 model
// 它内部自动做了两件事:读父组件的值、改值时 emit('update:modelValue', 新值)
const favorited = defineModel({ type: Boolean, default: false })

function toggle() {
  // 直接赋值就会同步给父组件 —— 不需要手写 emit
  favorited.value = !favorited.value
}
</script>

<template>
  <button class="fav" :class="{ on: favorited }" @click="toggle">
    {{ favorited ? '★ 已收藏' : '☆ 收藏' }}
  </button>
</template>

<style scoped>
.fav { border: 1px solid #e7ddca; background: #fff; border-radius: 8px; padding: 6px 14px; cursor: pointer; }
.fav.on { border-color: #b06a24; color: #c0491c; background: #f8ecda; }
</style>
页面效果组件渲染结果
运行结果: -- 组件本身不渲染;父组件用 v-model 绑定后的浏览器显示效果见下一段。 -- 两种状态: 未收藏:☆ 收藏 (浅色边框) 已收藏:★ 已收藏 (橙色边框 + 橙色文字)
父组件用法 + 具名 v-modelvue
<script setup>
import { ref } from 'vue'
import FavoriteSwitch from '@/components/FavoriteSwitch.vue'

const isFav = ref(false)
</script>

<template>
  <!-- 默认 v-model:绑定的是子组件的 modelValue -->
  <FavoriteSwitch v-model="isFav" />
  <p>当前状态:{{ isFav ? '已收藏' : '未收藏' }}</p>
</template>

<!-- 子组件里改成具名 model:
     const checked = defineModel('checked', { type: Boolean, default: false })
     父组件则写:  <FavoriteSwitch v-model:checked="isFav" /> -->
页面效果组件渲染结果
运行结果: 浏览器显示效果: [ ☆ 收藏 ] 当前状态:未收藏 -- 点一下按钮: [ ★ 已收藏 ] 当前状态:已收藏 -- 注意:isFav 是父组件的 ref,却因为 v-model 跟着子组件的点击变了。 -- 这正是「双向绑定」——但数据流依旧是父 → 子(props)与子 → 父(emit)两段, -- 只是被 v-model 这个语法糖包了起来。
讲透:命名与目录规范(照着做,团队协作不出错)
  • 组件名用 PascalCase 且至少两个单词:ArticleCard、BaseButton、EmptyState。不用单个单词(Card.vue)——避免和 HTML 标签、第三方库组件撞名。
  • 文件名 = 组件名,一个文件一个组件。ArticleCard.vue 里就是 ArticleCard,找文件不用猜。
  • 目录分工记三条线:components/ 放跨页面复用的通用组件;views/ 放页面级组件(一个路由一个);composables/ 放有状态的逻辑(第 5 章)。页面里独有的、只出现一次的片段,就留在页面文件里,不用进 components。
  • props 名在 JS 里 camelCase、在模板里 kebab-case:声明 showBadge,使用 :show-badge。
  • 事件名用「做了什么」的动词短语,小写连字符:delete-comment、view-detail、page-change。
  • defineModel 的两个注意点:① 需要 Vue 3.4 及以上(本项目用的 3.4/3.5,没问题);② 不要同时对同一个字段写 defineProps 和 defineModel——defineModel 本身已经声明了这个 prop,重复声明会冲突。它的默认名字是 modelValue,配 v-model;给个名字就是具名 model,配 v-model:名字。
避坑用了 v-model 之后最容易出的两个问题:① 在子组件里直接改「父组件传进来的普通 prop」而不是 model——只有 defineModel 返回的那个变量才能改,其它 props 依然只读;② 规则一松就乱来——把「一整个作品对象」用 v-model 双向绑定,等于子组件可以随意改父组件的业务数据,单向数据流的收益全丢了。自定义 v-model 只适合「开关 / 输入值 / 页码」这类简单、明确的字段,复杂业务数据仍然走 props 下传 + emit 上报。
动手练习
1把「书评列表」拆成两个组件:CommentItem.vue(单条书评:头像、昵称、内容、点赞数)和 CommentList.vue(列表容器,负责循环与空状态)。说明每个组件该收哪些 props、该喊哪些事件。
2为什么子组件不能直接修改 props?假设 ArticleCard 里有「收藏」按钮,请写出「点击后收藏数 +1」的正确实现(提示:数据在哪、事件怎么走)。
3迁移练习:「红影赏析」要做一个通用的 MovieCard.vue——显示海报、片名、评分、类型;底部那块要能放不同的东西(首页放「购票」,榜单页放「加入片单」);并且每个用户看到的评分旁要显示「我的评分」。请说明:哪些用 props、哪块用插槽、作用域插槽把什么抛出去。
参考答案
练习答案(按题分块).vuevue
<!-- 第 1 题 -->
<!-- CommentItem.vue:只展示一条书评
     props:comment(Object,required;含 id / content / likes / user.nickname)
     emits:like(点了点赞,带 comment.id)、reply(点了回复,带 comment.id) -->
<script setup>
defineProps({
  comment: { type: Object, required: true }
})
const emit = defineEmits(['like', 'reply'])
</script>

<template>
  <div class="comment-item">
    <img :src="comment.user.avatar" :alt="comment.user.nickname">
    <b>{{ comment.user.nickname }}</b>
    <p>{{ comment.content }}</p>
    <span @click="emit('like', comment.id)">赞 {{ comment.likes }}</span>
    <span @click="emit('reply', comment.id)">回复</span>
  </div>
</template>

<!-- CommentList.vue:容器,循环 + 空状态 + 事件透传
     props:list(Array,default: () => [])
     emits:like、reply(原样往上转发) -->
<script setup>
defineProps({
  list: { type: Array, default: () => [] }
})
const emit = defineEmits(['like', 'reply'])
</script>

<template>
  <p v-if="list.length === 0">还没有书评,来写第一条吧</p>
  <div v-else>
    <div v-for="c in list" :key="c.id">
      <CommentItem :comment="c" @like="emit('like', $event)" @reply="emit('reply', $event)" />
    </div>
  </div>
</template>

<!-- 接口对应:GET /api/comment/list?article_id=1&page=1&page_size=10
     返回 data.list[].user.nickname / likes / children(楼中楼回复) -->

<!-- 第 2 题 -->
<!-- 不能改 props 的原因:单向数据流。props 是父组件数据的「引用」,
     子组件改它 = 绕过父组件悄悄改数据。后果:页面多了以后,
     谁改的数据查不出来;同一个对象被多张卡片引用时,改一处、多处一起变。
     正确实现:数据放在父组件(页面),子组件只喊事件。 -->

<!-- 子组件 ArticleCard.vue -->
<script setup>
const props = defineProps({ item: { type: Object, required: true } })
const emit = defineEmits(['favorite'])
</script>

<template>
  <div class="article-card">
    <h3>{{ item.title }}</h3>
    <span>{{ item.likes }} 人收藏</span>
    <button @click="emit('favorite', item.id)">收藏</button>
  </div>
</template>

<!-- 父组件:数据在这边改,改完顺着 props 自动流下去 -->
<script setup>
import { ref } from 'vue'
import ArticleCard from '@/components/ArticleCard.vue'

const articles = ref([
  { id: 1, title: '静夜思', likes: 42 }
])

async function handleFavorite(id) {
  const res = await fetch('http://127.0.0.1:5000/api/favorite/toggle', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ article_id: id })
  })
  const json = await res.json()

  // 接口返回 { "code": 200, "msg": "操作成功", "data": { "favorited": true } }
  // 真实项目里收藏数由后端返回,这里拿到结果后再更新本地数据
  if (json.code === 200) {
    const target = articles.value.find(a => a.id === id)
    if (target) target.likes += json.data.favorited ? 1 : -1
  }
}
</script>

<template>
  <ArticleCard
    v-for="item in articles"
    :key="item.id"
    :item="item"
    @favorite="handleFavorite"
  />
</template>

<!-- 第 3 题(红影赏析) -->
<!-- 用 props 传的:
       · movie:Object,required(id / title / poster / rating / genre)
     用插槽的:
       · 默认插槽:卡片主体(想换排版时使用方自己决定)——也可以不用,直接内部渲染
       · 具名插槽 #footer:底部操作区。首页填「购票」按钮,榜单页填「加入片单」,
         不同页面填不同东西,组件完全不用改
       · 具名插槽 #rating-extra:评分右侧那块「我的评分」
     作用域插槽抛出去的:
       · 把 movie(以及 myRating 这类组件内部算出来的数据)通过 :item="movie"
         :my-rating="myRating" 抛出去,让使用方在插槽里拿到并决定怎么显示 -->

<script setup>
const props = defineProps({
  movie: { type: Object, required: true }
})
</script>

<template>
  <div class="movie-card">
    <img :src="movie.poster" :alt="movie.title">
    <h3>{{ movie.title }}</h3>
    <p>{{ movie.genre }} · 评分 {{ movie.rating }}</p>

    <!-- 作用域插槽:把 movie 和内部状态一起交给使用方 -->
    <slot name="rating-extra" :item="movie"></slot>

    <div class="foot">
      <slot name="footer" :item="movie">(还没有可用操作)</slot>
    </div>
  </div>
</template>

<!-- 首页这样用 -->
<MovieCard :movie="m">
  <template #rating-extra="{ item }">我的评分:{{ item.rating }}</template>
  <template #footer><button>购票</button></template>
</MovieCard>

<!-- 榜单页这样用(同一个组件,一个字都不用改) -->
<MovieCard :movie="m">
  <template #footer><button>加入片单</button></template>
</MovieCard>
页面效果组件渲染结果
运行结果: -- 第 1 题:浏览器显示效果 ┌────────────────────────────────────────┐ │ [头像] 管理员 │ │ 短短二十字,写尽思乡之情。 │ │ 赞 5 回复 │ └────────────────────────────────────────┘ -- list 为空时显示「还没有书评,来写第一条吧」 -- 第 2 题:点「收藏」→ 按钮无变化(子组件不改数据), -- 父组件收到 favorite 事件、调接口、改 articles → 收藏数 42 变成 43 -- 第 3 题(红影赏析): -- 首页渲染结果(带「我的评分」和「购票」) ┌──────────────────┐ │ [海报] │ │ 星际穿越 │ │ 科幻 · 评分 9.3 │ │ 我的评分:9.3 │ │ [ 购票 ] │ └──────────────────┘ -- 榜单页用同一个组件,只是把 footer 插槽换成了「加入片单」 ┌──────────────────┐ │ [海报] │ │ 星际穿越 │ │ 科幻 · 评分 9.3 │ │ [ 加入片单 ] │ └──────────────────┘

第 3 题的判断法则:「数据」用 props,「结构」用插槽,「子组件的数据要给父组件用」就用作用域插槽。底部那块不同页面长得不一样,天然适合具名插槽——这也正是「红影赏析」和「古典文学网」共用一套组件库的原因。

本章小结