第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![Simple tech illustration expla](https://blog.xxyye.com/wp-content/uploads/2026/06/f6609d9eee06185.png)\n\n![AI comic creation scene, creat](https://blog.xxyye.com/wp-content/uploads/2026/06/ac525b5968f34f2.png)\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 自动根据 currentTagsearchText 计算出「符合条件的文章」,每次变量变,它就自动重新算。


项目 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:refref.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 添加一个「按点赞数排序」的选项
- 预期输出:点击后文章按点赞数从高到低排列
- 提示:在 filteredArticlescomputed 后面加一个 .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 句话总结)

  1. refcomputed:响应式数据 + 自动计算,让页面「自动跟着数据变」
  2. 组件化思维:把列表、卡片、分页器拆成可复用的小积木
  3. computed + watch:筛选、分页、搜索的黄金组合——自动算、精准调

延伸学习资源

资源 链接 推荐理由
Vue 3 官方文档 https://vuejs.org/ 最权威,有中文版
《Vue.js 设计与实现》 纸书/电子书 从原理讲 Vue,读完能看懂下一章
Vue3 响应式源码解析(bilibili) 搜索 "Vue3 响应式源码" 视频讲解,更直观

互动钩子

🎉 恭喜你完成了仿掘金列表页!

你现在已经掌握了 Vue 3 最核心的「列表渲染 + 状态管理 + 筛选分页」技能。但你有没有想过——

Vue 3 是怎么知道「数据变了」的?refreactive 底层用的什么原理?

下一章我们就去扒开 Vue 3 响应式系统的源码,看看「依赖收集 + 触发更新」是怎么实现的。准备好了吗?


你在做列表筛选时,有没有遇到过「数据变了但页面没更新」的 bug? 评论区聊聊,老粉优先回复!👇

声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理。