☰
从零搭建资讯类移动端项目:Vue 3 + Vant 实战复盘与避坑指南
2026/10/14 1:31:23 网站建设 项目流程

1. 从零搭建资讯类移动端项目:我的实战复盘

做移动端开发这些年,我接手过不少资讯聚合类的项目,但真正让我觉得“值得反复拆解”的,是那种从零到一、覆盖完整业务闭环的练手项目。今天聊的这个“黑马头条学习”项目,就是这样一个典型——它模拟了一个完整的资讯类App,涵盖内容展示、频道管理、搜索、评论、登录鉴权、文章详情等核心模块。说白了,就是让你亲手搭一个“今日头条”的简化版。

这个项目适合谁?如果你已经掌握了基础的前端或移动端开发知识,比如组件化开发、路由管理、状态管理、网络请求封装,但一直缺少一个能把所有知识点串起来的完整项目,那它非常适合你。它解决的核心问题是:让你从“会写单个页面”进阶到“能独立负责一个完整业务模块”。我见过太多开发者,单看每个知识点都懂,但一上手做完整项目就不知道从哪下手,模块之间怎么拆分、数据怎么流转、状态怎么管理,全是一团浆糊。这个项目就是来治这个病的。

我前后完整跟做了两遍,第一遍是照着教程走,第二遍是自己关掉参考文档从零重构。两遍下来,踩的坑、悟到的技巧、对架构设计的理解,完全不是一个量级。下面我把整个项目的设计思路、核心细节、实操过程和排查经验,按我自己的复盘逻辑重新整理一遍。不是教程的复读机,而是我实际动手后的经验沉淀。

2. 项目整体架构与设计思路拆解

2.1 为什么选这种技术栈组合

这个项目主流的技术选型是Vue 3 + Vant + Pinia + Vue Router + Axios这套组合。我一开始也想过用 React 或者原生小程序来做,但实际对比下来,Vue 3 的组合式 API 在这种“多页面、多状态、多请求”的资讯类项目里,写起来确实顺手。

为什么?资讯类项目的核心特点是:页面多、状态共享频繁、数据请求密集。比如频道列表和文章列表之间要共享频道数据,登录状态要在评论、收藏、个人中心之间共享,搜索历史要在搜索页和首页之间同步。这种场景下,Pinia 的状态管理比 Vuex 简洁太多,没有 mutation 那一层,直接 action 改 state,心智负担小。

Vant 这个组件库选得也很准。资讯类项目大量用到下拉刷新、上拉加载、Tab 切换、搜索框、评论列表这些组件,Vant 基本都有现成的,而且移动端适配做得好。我试过用其他组件库,要么移动端手势支持不行,要么样式定制太麻烦,Vant 在这块确实省心。

Axios 做请求封装是标配,但关键在于拦截器的设计。这个项目里,请求拦截要统一加 token,响应拦截要统一处理错误码和 token 过期。我第一遍做的时候没重视这块,结果每个页面都在重复写错误处理逻辑,后来重构时把拦截器抽出来,代码量直接少了三分之一。

2.2 模块拆分背后的逻辑

整个项目我把它拆成了六个核心模块:首页频道流、文章详情、搜索、评论、登录注册、个人中心。这个拆分不是随便定的,而是按照“用户使用路径”来的。

用户打开 App,第一眼看到的是首页频道流,这是入口。看到感兴趣的文章,点进去是文章详情。想找特定内容,用搜索。看完想表达观点,发评论。评论需要登录,所以有登录注册。登录后能管理自己的信息,所以有个人中心。这条路径走下来,每个模块的职责非常清晰,模块之间的依赖关系也明确。

我见过有人把评论功能直接塞在文章详情页里,结果文章详情页的代码超过两千行,维护起来想死。正确的做法是:文章详情页只负责展示文章内容和评论列表的容器,评论的输入、发送、列表渲染全部抽成独立组件。这样文章详情页的代码控制在五百行以内,评论组件单独维护,互不干扰。

2.3 数据流转的设计考量

这个项目的数据流转,我总结为“三层结构”:API 层、Store 层、组件层。

API 层负责定义所有接口请求函数,比如getChannels()、getArticles(params)、postComment(data)。这一层只做请求,不做任何数据处理。

Store 层负责管理全局状态,比如频道列表、当前登录用户信息、搜索历史。组件需要数据时,从 Store 里取;需要更新数据时,调用 Store 的 action。

组件层只负责渲染和用户交互,不直接调 API,也不直接改 Store 的 state,而是通过 action 来操作。

这个三层结构的好处是:职责清晰,测试方便,复用性高。比如搜索历史这个状态,搜索页要用,首页的搜索入口也要用,放在 Store 里,两个页面都能直接取,不用来回传参。

注意:很多人做项目时喜欢在组件里直接调 API,然后 setState。小项目没问题,但一旦页面多了,数据同步就是噩梦。我踩过这个坑,后来强制自己所有请求都走 Store,代码清爽很多。

3. 核心功能模块的细节实现与避坑指南

3.1 频道管理:拖拽排序与持久化

频道管理是这个项目里我觉得最有意思的模块。用户可以在频道管理页拖拽排序、添加或删除频道,而且这些操作要持久化,下次打开 App 还是用户自定义的顺序。

实现拖拽排序,我用的是Vue Draggable这个库。它的核心原理是基于 HTML5 的拖拽 API,但做了封装,支持移动端触摸事件。配置起来很简单,关键是v-model绑定的数组要跟 Store 里的频道列表同步。

这里有个坑:拖拽结束后,数组顺序变了,但 Store 里的数据没变。因为v-model只是双向绑定了本地数组,不会自动同步到 Store。我的做法是在@end事件里手动调用 Store 的 action,把新顺序传进去。

// 频道管理页拖拽结束处理 const onDragEnd = () => { // localChannels 是本地拖拽后的数组 channelStore.updateChannelOrder(localChannels) }

持久化这块,我用的是localStorage。每次频道列表变化,就把整个列表序列化存进去。App 启动时,先从 localStorage 读,如果没有,再用默认频道列表。

实操心得:localStorage 存的时候,建议存一个版本号。比如{ version: 1, channels: [...] }。这样以后频道结构变了,可以根据版本号做数据迁移,不至于老数据把新代码搞崩。

3.2 文章列表:下拉刷新与上拉加载的细节

文章列表用的是 Vant 的List组件配合PullRefresh组件。看起来简单,但细节很多。

下拉刷新的关键是:刷新时要重置页码为 1,清空旧数据,重新请求。我第一遍做的时候忘了清空旧数据,结果刷新后新数据和旧数据混在一起,列表里出现重复文章。后来在onRefresh里加了list = []和page = 1,问题解决。

上拉加载的关键是:要判断是否还有更多数据。接口返回的数据里通常有个hasMore或者根据返回条数判断。如果返回条数小于 pageSize,说明没有更多了,要设置finished = true,否则 List 组件会一直触发加载。

// 上拉加载逻辑 const onLoad = async () => { const res = await getArticles({ channelId, page, pageSize: 10 }) if (page === 1) list.value = [] list.value.push(...res.data.results) if (res.data.results.length < 10) { finished.value = true } else { page++ } }

还有个容易忽略的点:切换频道时,要重置列表状态。用户从“推荐”切到“科技”,如果不重置 page 和 list,就会把科技频道的数据追加到推荐频道后面。我的做法是监听频道变化,一旦变化就重置所有状态,然后重新触发加载。

3.3 文章详情:内容渲染与图片处理

文章详情页的核心是内容渲染。接口返回的文章内容通常是 HTML 字符串,需要用v-html渲染。但这里有个安全问题:直接渲染 HTML 有 XSS 风险。虽然后端一般会做过滤,但前端最好也做一层处理。我用的方案是用 DOMPurify 库对 HTML 做净化,把危险标签和属性过滤掉。

图片处理是另一个重点。文章里的图片宽度不固定,直接渲染会溢出屏幕。我的做法是用 CSS 控制:

.article-content img { max-width: 100%; height: auto; display: block; margin: 10px auto; }

但这样还不够,因为图片加载前高度为 0,加载后突然撑开,会导致页面跳动。更好的做法是给图片设置一个占位高度,或者用aspect-ratio属性。我后来用了aspect-ratio: 16 / 9作为默认比例,图片加载完再自适应,体验好很多。

注意:文章详情页的评论列表和文章内容是分开请求的。不要等文章加载完再请求评论,可以并行请求,用Promise.all同时发,减少用户等待时间。

3.4 搜索模块:历史记录与联想搜索

搜索模块看起来简单,但要做好体验,细节不少。

搜索历史的存储逻辑:每次搜索成功后,把关键词存到 localStorage,去重,最多存 10 条。搜索页打开时读取,点击历史记录直接触发搜索,点击删除图标移除单条,点击“清空”移除全部。

联想搜索是提升体验的关键。用户输入时,实时请求联想接口,展示下拉列表。这里要做防抖处理,不然每输入一个字符就发一次请求,服务器扛不住。我用的防抖时间是 300ms,实测下来既能保证及时性,又不会请求太频繁。

// 防抖函数 const debounce = (fn, delay = 300) => { let timer = null return (...args) => { clearTimeout(timer) timer = setTimeout(() => fn(...args), delay) } }

还有个细节:搜索结果的 keyword 高亮。用户搜“科技”,结果里的“科技”两个字要标红。我的做法是用正则替换,把关键词包在<span class="highlight">里,然后v-html渲染。注意正则里的特殊字符要转义,不然用户搜“.”会匹配所有字符。

3.5 登录鉴权:Token 管理与路由守卫

登录鉴权是很多项目的痛点。这个项目用的是JWT Token方案。登录成功后,后端返回 token,前端存到 localStorage,后续请求在请求拦截器里统一加到 header 里。

路由守卫是关键。个人中心、评论发布这些页面需要登录才能访问。我在路由配置里给这些页面加了meta: { requiresAuth: true },然后在全局前置守卫里判断:如果目标路由需要登录且没有 token,就跳转到登录页,并把当前路径作为 redirect 参数带上,登录成功后跳回来。

router.beforeEach((to, from, next) => { const token = localStorage.getItem('token') if (to.meta.requiresAuth && !token) { next({ path: '/login', query: { redirect: to.fullPath } }) } else { next() } })

Token 过期处理是另一个坑。token 是有有效期的,过期后接口返回 401。我在响应拦截器里统一处理:如果状态码是 401,清除本地 token,跳转到登录页。

实操心得:token 过期时,不要直接弹窗让用户重新登录,体验很差。更好的做法是:如果用户正在操作(比如正在写评论),先保存草稿,登录后恢复。这个项目里我没做草稿功能,但实际产品中这是必备的。

3.6 评论模块:发布、回复与点赞

评论模块的复杂度在于层级关系。评论有顶级评论,顶级评论下还有回复。数据结构通常是树形的,但渲染时要注意性能。

我的做法是:只渲染两层。顶级评论直接展示,回复折叠在顶级评论下面,点击“展开更多回复”再请求。这样避免一次性渲染太多 DOM,页面卡顿。

发布评论时,要注意乐观更新。用户点击发送后,不要等接口返回才显示评论,而是先把评论插入列表,显示“发送中”状态,接口成功后再替换成真实数据,失败则移除并提示。这样用户感觉响应很快。

// 乐观更新示例 const postComment = async (content) => { const tempComment = { id: Date.now(), content, status: 'sending' } commentList.value.unshift(tempComment) try { const res = await api.postComment({ content }) tempComment.id = res.data.id tempComment.status = 'success' } catch (e) { commentList.value = commentList.value.filter(c => c.id !== tempComment.id) showToast('评论失败,请重试') } }

点赞功能要注意防重复点击。用户快速连点,会发多次请求。我的做法是加一个loading状态,请求期间禁用按钮。

4. 完整实操流程与关键环节实现

4.1 项目初始化与依赖配置

第一步是创建项目。我用的是 Vite 创建 Vue 3 项目,比 Webpack 快很多。

npm create vite@latest my-toutiao -- --template vue cd my-toutiao npm install

然后安装核心依赖:

npm install vant pinia vue-router axios vue-draggable-plus dompurify

Vant 按需引入需要配置unplugin-vue-components,在vite.config.js里加:

import Components from 'unplugin-vue-components/vite' import { VantResolver } from 'unplugin-vue-components/resolvers' export default defineConfig({ plugins: [ vue(), Components({ resolvers: [VantResolver()] }) ] })

这样 Vant 组件就能自动按需引入,不用手动 import,打包体积也小。

4.2 请求封装与拦截器配置

在src/utils/request.js里封装 Axios:

import axios from 'axios' import { showToast } from 'vant' import router from '@/router' const request = axios.create({ baseURL: import.meta.env.VITE_API_BASE_URL, timeout: 10000 }) // 请求拦截器 request.interceptors.request.use(config => { const token = localStorage.getItem('token') if (token) { config.headers.Authorization = `Bearer ${token}` } return config }) // 响应拦截器 request.interceptors.response.use( response => response.data, error => { if (error.response?.status === 401) { localStorage.removeItem('token') router.push('/login') showToast('登录已过期,请重新登录') } else { showToast(error.response?.data?.message || '请求失败') } return Promise.reject(error) } ) export default request

这个封装的关键点是:响应拦截器直接返回response.data,这样业务代码里不用每次都.data,简洁很多。

4.3 频道列表与文章流的联动实现

首页的核心是频道 Tab 和文章列表的联动。我用 Vant 的Tabs组件做频道切换,每个 Tab 对应一个文章列表组件。

<van-tabs v-model:active="activeChannel" @change="onChannelChange"> <van-tab v-for="channel in channels" :key="channel.id" :title="channel.name"> <ArticleList :channel-id="channel.id" /> </van-tab> </van-tabs>

这里有个性能优化点:不要一次性渲染所有频道的文章列表。Vant Tabs 默认是懒加载的,只有切换到某个 Tab 才渲染对应内容。但如果用户快速切换,还是会频繁请求。我的做法是加一个缓存:已经加载过的频道,切换回来时直接用缓存数据,不再请求。

const articleCache = new Map() const onChannelChange = (channelId) => { if (articleCache.has(channelId)) { list.value = articleCache.get(channelId) } else { fetchArticles(channelId) } }

4.4 文章详情页的完整实现

文章详情页的请求逻辑:

const loadArticle = async (id) => { loading.value = true try { const [articleRes, commentRes] = await Promise.all([ getArticleDetail(id), getComments({ articleId: id, page: 1 }) ]) article.value = articleRes.data comments.value = commentRes.data.results } finally { loading.value = false } }

用Promise.all并行请求,比串行快一倍。实测下来,文章详情页的加载时间从 800ms 降到 400ms 左右。

内容渲染部分:

<div class="article-content" v-html="sanitizedContent"></div>
import DOMPurify from 'dompurify' const sanitizedContent = computed(() => { return DOMPurify.sanitize(article.value?.content || '') })

4.5 搜索功能的完整链路

搜索页的完整逻辑:

const keyword = ref('') const history = ref(JSON.parse(localStorage.getItem('searchHistory') || '[]')) const suggestions = ref([]) // 输入联想(防抖) const onInput = debounce(async () => { if (!keyword.value.trim()) { suggestions.value = [] return } const res = await getSuggestions(keyword.value) suggestions.value = res.data }, 300) // 执行搜索 const onSearch = async () => { if (!keyword.value.trim()) return // 保存历史 history.value = [keyword.value, ...history.value.filter(k => k !== keyword.value)].slice(0, 10) localStorage.setItem('searchHistory', JSON.stringify(history.value)) // 跳转搜索结果页 router.push({ path: '/search-result', query: { keyword: keyword.value } }) }

4.6 登录注册与用户信息管理

登录页的表单验证用 Vant 的Form组件,配置规则:

const rules = { mobile: [ { required: true, message: '请输入手机号' }, { pattern: /^1[3-9]\d{9}$/, message: '手机号格式不正确' } ], code: [ { required: true, message: '请输入验证码' }, { pattern: /^\d{6}$/, message: '验证码为6位数字' } ] }

登录成功后,把 token 和用户信息存到 Store 和 localStorage:

const onLogin = async () => { const res = await login({ mobile, code }) localStorage.setItem('token', res.data.token) userStore.setUserInfo(res.data.user) router.push(route.query.redirect || '/') }

5. 常见问题排查与避坑经验实录

5.1 跨域问题与代理配置

开发阶段最常见的报错就是跨域。浏览器控制台报Access-Control-Allow-Origin错误。解决方案是在vite.config.js里配置代理:

server: { proxy: { '/api': { target: 'http://后端地址', changeOrigin: true, rewrite: path => path.replace(/^\/api/, '') } } }

注意changeOrigin: true必须加,否则后端收到的 Host 还是前端地址,可能被拒绝。

5.2 移动端适配方案选择

移动端适配我试过三种方案:rem 方案、vw 方案、viewport 方案。最后选了 vw 方案,用postcss-px-to-viewport插件自动转换。

// postcss.config.js module.exports = { plugins: { 'postcss-px-to-viewport': { viewportWidth: 375, unitPrecision: 5, viewportUnit: 'vw', selectorBlackList: ['.ignore'], minPixelValue: 1 } } }

设计稿按 375px 宽度做,写 px 自动转 vw,适配各种屏幕。注意 Vant 组件默认是 px,如果也要转,需要把 Vant 的样式也纳入转换范围。

5.3 常见报错速查表

报错信息原因解决方案
Cannot read property 'xxx' of undefined数据未加载完就渲染用v-if或可选链?.
401 Unauthorizedtoken 过期或未携带检查拦截器,重新登录
Network Error跨域或后端未启动检查代理配置和后端服务
Duplicate keys detectedv-for 的 key 重复确保 key 唯一,用 id 而非 index
Failed to resolve component组件未注册检查 import 和 components 配置
图片溢出屏幕未限制图片宽度CSS 设置max-width: 100%
列表重复数据刷新时未清空旧数据刷新时重置 list 和 page

5.4 性能优化实战技巧

图片懒加载:文章列表里的图片用v-lazy指令,只加载可视区域内的图片。Vant 自带Lazyload组件,配置一下就行。

路由懒加载:所有页面组件用动态 import,首屏只加载首页,其他页面按需加载。

const routes = [ { path: '/', component: () => import('@/views/Home.vue') }, { path: '/detail/:id', component: () => import('@/views/Detail.vue') } ]

请求缓存:频道列表、用户信息这些不常变的数据,请求一次后缓存到 Store,避免重复请求。

虚拟列表:如果文章列表特别长(比如超过 100 条),可以考虑用虚拟列表,只渲染可视区域内的 DOM。不过这个项目里用分页加载就够了,虚拟列表属于过度优化。

5.5 我踩过的三个大坑

第一个坑:频道切换时数据串了。原因是所有频道共用一个文章列表组件,切换时没有重置状态。解决方案是给每个频道的 ArticleList 加独立的key,强制重新渲染。

第二个坑:token 过期后接口全部报错。原因是响应拦截器里没有统一处理 401,每个页面都在单独处理。解决方案是在拦截器里统一跳转登录页,业务代码不用管。

第三个坑:评论发布后列表不更新。原因是发布评论的接口和获取评论的接口是分开的,发布成功后没有重新请求列表。解决方案是用乐观更新,发布时先插入本地列表,成功后再用真实数据替换。

6. 项目扩展方向与个人体会

这个项目做完后,我最大的感受是:完整项目跟单个页面的差距,不在代码量,而在架构思维。单个页面你只需要考虑怎么把数据渲染出来,但完整项目你要考虑模块怎么拆、状态怎么管、请求怎么封装、错误怎么处理、性能怎么优化。这些东西,看教程是学不会的,必须自己动手踩一遍坑。

如果要把这个项目继续扩展,我觉得有几个方向值得做。一是加入离线缓存,用 Service Worker 缓存文章内容,没网时也能看已读文章。二是加入主题切换,日间模式和夜间模式,这个在资讯类 App 里很常见。三是加入推送通知,用 Web Push 实现新文章提醒。四是加入数据埋点,记录用户行为,分析哪些频道受欢迎。

不过说实话,这个项目最大的价值不在于功能多全,而在于它逼着你把前端开发的完整链路走了一遍。从项目初始化、依赖配置、请求封装、状态管理、路由守卫,到组件拆分、性能优化、错误处理,每一个环节你都得亲自趟过去。趟完之后,再看其他项目,心里就有底了。

最后分享一个小技巧:做这类项目时,先画数据流图,再写代码。把每个页面的数据从哪来、到哪去、经过哪些 Store、触发哪些 action,全部画出来。图画清楚了,代码就是翻译工作,效率高很多。我第二遍重构时就是这么干的,比第一遍快了将近一倍。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询