前面几章你写的都是「一个 .vue 文件搞定一个页面」——写到第 400 行时,你自己都不想再看它了。这一章教你像搭积木一样写界面:把页面拆成一个个职责单一、能反复使用、能互相传数据的组件。学完你会拿到一套完整的父子通信工具——props 往下传、emit 往上报、插槽留活口——并且亲手把作品列表页拆成三个文件。
<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>
<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>
<script setup>
// 导入即可用 —— <script setup> 里不需要再写 components: { HelloCard }
import HelloCard from '@/components/HelloCard.vue'
</script>
<template>
<HelloCard />
<HelloCard />
</template>
<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>
<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>
<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>
<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>
<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>
<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>
插槽还有第三种用法:作用域插槽——子组件把「循环里的数据」通过插槽抛出来,让父组件决定怎么渲染每一行。列表组件几乎都会用这一招,因为它把「循环、key、空状态」的活干了,把「每行长什么样」的决定权留给你。
<!-- ① 子组件:负责循环和空状态,每行内容交给插槽 -->
<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>
现在把前面五节合起来用一遍。目标只有一个:让 ArticleListView.vue 从 486 行降到 40 行,并且每个文件都能一句话说清职责。
<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>
<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>
<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>
<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>
<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" /> -->
<!-- 第 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>
第 3 题的判断法则:「数据」用 props,「结构」用插槽,「子组件的数据要给父组件用」就用作用域插槽。底部那块不同页面长得不一样,天然适合具名插槽——这也正是「红影赏析」和「古典文学网」共用一套组件库的原因。