第8章 8.5 综合实战:仿掘金 Web 端
📌 上章回顾:上一章我们用 Tauri 搭了一个桌面端「待办清单」,学会了如何把 Vue 3 项目打包成
.exe/.app安装包。现在你手里有了「一套代码跑多端」的能力。🎯 本章预告:光有工具不够,得有个真实项目练练手——今天我们仿做一个「掘金 Web 端」的文章列表页。学完这章,你就能写出一个带列表、筛选、分页的完整列表页了。下一章我们再往深了走,去看看 Vue 3 响应式的源码是怎么实现的。
🎯 开场 3 分钟:为什么要学这个?
你有没有这种感觉——看了一堆 Vue 3 教程,指令背熟了,组件会写了,但真让你「写一个带筛选、分页、标签的列表页」,脑子还是懵的?
因为缺少一个完整的、接底气的练手项目。
光写「Hello World」是学不会骑车的,得真的上路骑一段。今天我们就用仿掘金文章列表这个项目,把 Vue 3 的核心技能串起来:
- 用
v-for渲染列表 - 用
computed做筛选 - 用
v-m\n\n\n\n\n\nodel做标签切换 - 用「模拟数据 + 分页」模拟真实接口
学完你能写出一个「看起来还挺像那么回事」的列表页,下一章我们再去看它底层是怎么工作的。
🧱 基础 25 分钟:核心概念(小白视角)
什么是「响应式数据」?—— 把变量变成「会自己通知的闹钟」
普通变量是你设好就固定了,改了它不会主动告诉你。
let name = '小明'
name = '老王' // 你知道它变了,但页面不会自动更新
Vue 3 的响应式数据就像一个「会自己打电话通知」的闹钟——只要一变,就自动告诉所有需要知道的地方:「我变了,你们该更新了」。
import { ref } from 'vue'
const name = ref('小明')
console.log(name.value) // 输出: 小明
name.value = '老王' // 自动通知所有用到 name 的地方
什么是 computed?—— 自动计算的「懒人公式」
computed 就像 Excel 里的公式——你定义好规则,数据变了它自动重新算,你不用手动触发。
import { ref, computed } from 'vue'
const articles = ref([
{ title: 'Vue3 入门', tag: '前端' },
{ title: 'Python 技巧', tag: '后端' },
{ title: 'CSS 动画', tag: '前端' },
])
// 只看「前端」标签的文章
const frontendArticles = computed(() => {
return articles.value.filter(a => a.tag === '前端')
})
console.log(frontendArticles.value)
// 输出: [{ title: 'Vue3 入门', tag: '前端' }, { title: 'CSS 动画', tag: '前端' }]
什么是「组件」?—— 可复用的「乐高积木」
想象你玩乐高,不需要每次都从塑料颗粒开始做,你可以用现成的「轮子」「门」「窗户」拼。
Vue 组件就是这样的现成积木——比如「文章卡片」「标签按钮」「分页器」,写一次,到处用。
<!-- ArticleCard.vue -->
<template>
<div class="card">
<h3>{{ title }}</h3>
<span class="tag">{{ tag }}</span>
</div>
</template>
<script setup>
defineProps({
title: String,
tag: String
})
</script>
然后在父组件里这样用:
<ArticleCard title="Vue3 入门" tag="前端" />
<ArticleCard title="Python 技巧" tag="后端" />
什么是 v-for?—— 批量生成的「复印机」
v-for 就像复印机,把一份模板按照数据复制多份:
<div v-for="article in articles" :key="article.id">
<h3>{{ article.title }}</h3>
</div>
key 是给每个复制品一个唯一编号,方便 Vue 追踪「谁是谁」。
什么是 v-model?—— 双向绑定的「同步器」
想象你和朋友玩「真假猜猜乐」——你变了他跟着变,他变了你也自动知道。
v-model 就是这个「同步器」,数据变,页面变;页面改,数据也改。
<input v-model="searchText" placeholder="搜索文章" />
<p>正在搜索:{{ searchText }}</p>
🔥 实战 35 分钟:3 个递进的小项目
项目 1(5 分钟):渲染文章列表
目标:用 v-for 把一组文章数据渲染成卡片列表。
<template>
<div class="article-list">
<h1>掘金热文</h1>
<div
v-for="article in articles"
:key="article.id"
class="article-card"
>
<h3>{{ article.title }}</h3>
<div class="meta">
<span>{{ article.author }}</span>
<span>{{ article.likes }} 赞</span>
</div>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
const articles = ref([
{ id: 1, title: 'Vue3 设计原理探析', author: '前端小王', likes: 328 },
{ id: 2, title: 'Python 异步编程入门', author: '后端老李', likes: 256 },
{ id: 3, title: 'CSS Grid 布局精讲', author: '设计小张', likes: 189 },
])
</script>
<style scoped>
.article-card {
border: 1px solid #eaeaea;
border-radius: 8px;
padding: 16px;
margin-bottom: 12px;
}
.meta {
color: #999;
font-size: 14px;
display: flex;
gap: 16px;
}
</style>
预期输出:3 张卡片,标题、作者、点赞数整齐排列。
一句话解释:用 v-for 遍历 articles 数组,每项生成一张卡片,:key 保证列表渲染性能。
项目 2(15 分钟):添加标签筛选 + 搜索功能
目标:点击标签筛选文章 + 搜索框实时过滤。
<template>
<div class="juejin-page">
<h1>🏆 掘金热文榜</h1>
<!-- 搜索框 -->
<input
v-model="searchText"
class="search-input"
placeholder="搜索文章标题..."
/>
<!-- 标签筛选 -->
<div class="tags">
<button
v-for="tag in allTags"
:key="tag"
:class="{ active: currentTag === tag }"
@click="currentTag = tag"
>
{{ tag }}
</button>
</div>
<!-- 文章列表 -->
<div
v-for="article in filteredArticles"
:key="article.id"
class="article-card"
>
<h3>{{ article.title }}</h3>
<div class="meta">
<span>👤 {{ article.author }}</span>
<span>🏷️ {{ article.tag }}</span>
<span>❤️ {{ article.likes }}</span>
</div>
</div>
<!-- 空状态提示 -->
<div v-if="filteredArticles.length === 0" class="empty">
没有找到相关文章 😢
</div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
const searchText = ref('')
const currentTag = ref('全部')
const allTags = ['全部', '前端', '后端', '移动端', '人工智能']
const articles = ref([
{ id: 1, title: 'Vue3 响应式原理深入浅出', author: '前端小王', tag: '前端', likes: 528 },
{ id: 2, title: 'Python asyncio 异步编程', author: '后端老李', tag: '后端', likes: 412 },
{ id: 3, title: 'Flutter 跨平台开发实战', author: '移动端阿强', tag: '移动端', likes: 356 },
{ id: 4, title: 'React Hooks 最佳实践', author: '前端小丽', tag: '前端', likes: 489 },
{ id: 5, title: 'Transformer 模型详解', author: 'AI研究者', tag: '人工智能', likes: 632 },
{ id: 6, title: 'Node.js 性能优化技巧', author: '后端老李', tag: '后端', likes: 298 },
{ id: 7, title: 'SwiftUI 入门指南', author: 'iOS小陈', tag: '移动端', likes: 267 },
{ id: 8, title: 'Vue3 Teleport 组件探究', author: '前端小王', tag: '前端', likes: 345 },
])
// 核心筛选逻辑
const filteredArticles = computed(() => {
return articles.value.filter(article => {
const matchTag = currentTag.value === '全部' || article.tag === currentTag.value
const matchSearch = article.title.toLowerCase().includes(searchText.value.toLowerCase())
return matchTag && matchSearch
})
})
</script>
<style scoped>
.juejin-page { max-width: 600px; margin: 0 auto; padding: 20px; }
.search-input {
width: 100%; padding: 12px; border: 1px solid #ddd; border-radius: 8px; margin-bottom: 16px;
}
.tags { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 20px; }
.tags button {
padding: 6px 16px; border: none; border-radius: 20px; cursor: pointer;
background: #f0f0f0; transition: all 0.2s;
}
.tags button.active { background: #007aff; color: white; }
.article-card {
border: 1px solid #e8e8e8; border-radius: 8px; padding: 16px; margin-bottom: 12px;
}
.meta { display: flex; gap: 16px; color: #666; font-size: 14px; margin-top: 8px; }
.empty { text-align: center; padding: 40px; color: #999; }
</style>
预期输出:
- 默认显示全部 8 篇文章
- 点击「前端」标签,只显示 3 篇前端文章
- 搜索「Vue」,只显示 2 篇含「Vue」的文章
- 标签 + 搜索可以叠加使用
一句话解释:computed 自动根据 currentTag 和 searchText 计算出「符合条件的文章」,每次变量变,它就自动重新算。
项目 3(15 分钟):添加分页 + 数据模拟
目标:把项目 2 加上分页功能,模拟「每页显示 N 条」的真实场景。
<template>
<div class="juejin-page">
<h1>🏆 掘金热文榜</h1>
<input v-model="searchText" class="search-input" placeholder="搜索文章..." />
<div class="tags">
<button
v-for="tag in allTags"
:key="tag"
:class="{ active: currentTag === tag }"
@click="changeTag(tag)"
>
{{ tag }}
</button>
</div>
<div class="article-list">
<div
v-for="article in paginatedArticles"
:key="article.id"
class="article-card"
>
<h3>{{ article.title }}</h3>
<div class="meta">
<span>👤 {{ article.author }}</span>
<span>🏷️ {{ article.tag }}</span>
<span>❤️ {{ article.likes }}</span>
</div>
</div>
</div>
<!-- 分页器 -->
<div class="pagination">
<button
:disabled="currentPage === 1"
@click="currentPage--"
>上一页</button>
<span>第 {{ currentPage }} / {{ totalPages }} 页</span>
<button
:disabled="currentPage === totalPages"
@click="currentPage++"
>下一页</button>
</div>
<div class="stats">共 {{ filteredArticles.length }} 篇文章</div>
</div>
</template>
<script setup>
import { ref, computed, watch } from 'vue'
const searchText = ref('')
const currentTag = ref('全部')
const currentPage = ref(1)
const pageSize = 3 // 每页显示 3 条
const allTags = ['全部', '前端', '后端', '移动端', '人工智能']
const articles = ref([
{ id: 1, title: 'Vue3 响应式原理深入浅出', author: '前端小王', tag: '前端', likes: 528 },
{ id: 2, title: 'Python asyncio 异步编程', author: '后端老李', tag: '后端', likes: 412 },
{ id: 3, title: 'Flutter 跨平台开发实战', author: '移动端阿强', tag: '移动端', likes: 356 },
{ id: 4, title: 'React Hooks 最佳实践', author: '前端小丽', tag: '前端', likes: 489 },
{ id: 5, title: 'Transformer 模型详解', author: 'AI研究者', tag: '人工智能', likes: 632 },
{ id: 6, title: 'Node.js 性能优化技巧', author: '后端老李', tag: '后端', likes: 298 },
{ id: 7, title: 'SwiftUI 入门指南', author: 'iOS小陈', tag: '移动端', likes: 267 },
{ id: 8, title: 'Vue3 Teleport 组件探究', author: '前端小王', tag: '前端', likes: 345 },
{ id: 9, title: 'Django REST Framework 实战', author: '后端老李', tag: '后端', likes: 234 },
{ id: 10, title: 'Stable Diffusion 绘图入门', author: 'AI研究者', tag: '人工智能', likes: 567 },
])
const changeTag = (tag) => {
currentTag.value = tag
currentPage.value = 1 // 切标签时重置到第一页
}
// 监听搜索词变化,也重置页码
watch(searchText, () => {
currentPage.value = 1
})
// 筛选后的文章
const filteredArticles = computed(() => {
return articles.value.filter(article => {
const matchTag = currentTag.value === '全部' || article.tag === currentTag.value
const matchSearch = article.title.toLowerCase().includes(searchText.value.toLowerCase())
return matchTag && matchSearch
})
})
// 总页数
const totalPages = computed(() => {
return Math.ceil(filteredArticles.value.length / pageSize)
})
// 当前页的文章
const paginatedArticles = computed(() => {
const start = (currentPage.value - 1) * pageSize
const end = start + pageSize
return filteredArticles.value.slice(start, end)
})
</script>
<style scoped>
.juejin-page { max-width: 600px; margin: 0 auto; padding: 20px; }
.search-input {
width: 100%; padding: 12px; border: 1px solid #ddd; border-radius: 8px; margin-bottom: 16px;
}
.tags { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 20px; }
.tags button {
padding: 6px 16px; border: none; border-radius: 20px; cursor: pointer;
background: #f0f0f0; transition: all 0.2s;
}
.tags button.active { background: #007aff; color: white; }
.article-card {
border: 1px solid #e8e8e8; border-radius: 8px; padding: 16px; margin-bottom: 12px;
}
.meta { display: flex; gap: 16px; color: #666; font-size: 14px; margin-top: 8px; }
.pagination {
display: flex; justify-content: center; align-items: center; gap: 16px; margin-top: 20px;
}
.pagination button {
padding: 8px 16px; border: 1px solid #ddd; border-radius: 6px; cursor: pointer;
background: white;
}
.pagination button:disabled { opacity: 0.5; cursor: not-allowed; }
.stats { text-align: center; color: #999; margin-top: 12px; }
</style>
预期输出:
- 每页只显示 3 篇文章
- 点击「下一页」切换到第 2 页
- 切换标签时自动回到第 1 页
- 搜索关键词时也回到第 1 页
- 底部显示「共 X 篇文章」
一句话解释:用 slice() 从筛选后的数组里「切」出当前页要显示的部分,watch 监听变量变化来重置页码。
💪 进阶 20 分钟:常见坑 + 性能小贴士
坑 1:ref 和 ref.value 的区别
❌ 错误:直接拿 ref 当普通变量用
const count = ref(0)
console.log(count) // 输出: Ref<0>,不是 0!
✅ 正确:用 .value 读写
const count = ref(0)
console.log(count.value) // 输出: 0
count.value++
坑 2:computed 里不要改依赖的值
❌ 错误:在 computed 里改数据,会导致死循环
const doubled = computed(() => {
count.value = count.value * 2 // 别这样做!
return count.value
})
✅ 正确:computed 只负责「计算并返回」,不改原数据
const doubled = computed(() => count.value * 2)
坑 3::key 用 index 当 key 的隐患
❌ 错误:列表中间插入/删除时,key 不唯一会导致渲染错乱
<div v-for="(item, index) in list" :key="index">
✅ 正确:用数据的唯一 ID
<div v-for="item in list" :key="item.id">
坑 4:v-model 绑定的是 ref 不是 ref.value
❌ 错误:多此一举
<input v-model="searchText.value" /> // 错了!
✅ 正确:直接绑定 ref
<input v-model="searchText" /> // Vue 自动处理
坑 5:忘记 watch 监听会导致页码不重置
在项目 3 里,切换标签/搜索时如果不重置 currentPage,用户可能会看到「空页」(比如第 3 页但只有 2 条数据)。
调试技巧:加个 watch 打印日志
watch(searchText, (newVal) => {
console.log('搜索词变了:', newVal)
currentPage.value = 1
})
性能小贴士:v-if vs v-show 的选择
v-if:条件为 false 时完全不渲染(适合「很少显示」的内容)v-show:始终渲染,只是display: none(适合「频繁切换」的内容)
<!-- 筛选面板很少显示,用 v-if -->
<div v-if="showFilter">筛选面板</div>
<!-- 分页器一直显示,用 v-show -->
<div v-show="totalPages > 1">分页器</div>
✏️ 练习题 + 作业题
练习题(5 道,10 分钟内完成)
练习 1(2 分钟):改标签名称
- 输入:在项目 2 中,把 allTags 里的「前端」改成「前端开发」
- 预期输出:按钮文字从「前端」变成「前端开发」
- 提示:allTags 数组定义在第 32 行附近
练习 2(2 分钟):添加「后端」筛选
- 输入:在项目 2 基础上,在 allTags 添加一个新标签「全栈」
- 预期输出:出现「全栈」按钮,点击后显示所有 tag 为「后端」和「前端」的文章
- 提示:用 computed 里的 matchTag 逻辑,添加一个特殊判断
练习 3(2 分钟):加一个点赞数排序
- 输入:给项目 2 添加一个「按点赞数排序」的选项
- 预期输出:点击后文章按点赞数从高到低排列
- 提示:在 filteredArticles 的 computed 后面加一个 .sort()
练习 4(2 分钟):统计当前筛选结果数量
- 输入:在项目 2 的页面上,显示「当前筛选结果:X 篇」
- 预期输出:页面底部出现「当前筛选结果:3 篇」这样的文字
- 提示:filteredArticles.value.length 就是数量
练习 5(2 分钟):分析报错原因
- 输入:运行下面代码,控制台报什么错?
const name = ref('小明')
console.log(name + '很棒') // 这行报什么错?
- 预期输出:控制台显示
[object Object]很棒或者一个类型错误 - 提示:
ref对象不能直接和字符串拼接
作业题(30 分钟 - 2 小时)
作业:做一个「掘金文章管理小工具」
- 需求描述:仿掘金的文章列表页,但加一个「文章管理」功能——可以标记已读、收藏文章
- 功能点:
1. 列表展示所有文章(标题、作者、标签、点赞数)
2. 点击「收藏」按钮,文章被收藏(按钮变灰,显示「已收藏」)
3. 点击「已读」按钮,文章标题加删除线(表示已读过)
4. 顶部显示「已收藏 X 篇」「已读 Y 篇」的统计 - 加分项:
1. 刷新页面后,收藏和已读状态保持(用localStorage)
2. 加一个「只看已收藏」筛选按钮 - 验收标准:能跑起来 + 收藏/已读功能正常 + 有简单的样式区分
- 提交方式:评论区贴代码或 GitHub 链接
📚 总结 + 资源
本章核心点(1 句话总结)
ref和computed:响应式数据 + 自动计算,让页面「自动跟着数据变」- 组件化思维:把列表、卡片、分页器拆成可复用的小积木
computed+watch:筛选、分页、搜索的黄金组合——自动算、精准调
延伸学习资源
| 资源 | 链接 | 推荐理由 |
|---|---|---|
| Vue 3 官方文档 | https://vuejs.org/ | 最权威,有中文版 |
| 《Vue.js 设计与实现》 | 纸书/电子书 | 从原理讲 Vue,读完能看懂下一章 |
| Vue3 响应式源码解析(bilibili) | 搜索 "Vue3 响应式源码" | 视频讲解,更直观 |
互动钩子
🎉 恭喜你完成了仿掘金列表页!
你现在已经掌握了 Vue 3 最核心的「列表渲染 + 状态管理 + 筛选分页」技能。但你有没有想过——
Vue 3 是怎么知道「数据变了」的?ref 和 reactive 底层用的什么原理?
下一章我们就去扒开 Vue 3 响应式系统的源码,看看「依赖收集 + 触发更新」是怎么实现的。准备好了吗?
你在做列表筛选时,有没有遇到过「数据变了但页面没更新」的 bug? 评论区聊聊,老粉优先回复!👇

评论(0)