简介:这份资源是Vue.js新闻应用项目的第三次迭代源码包,面向已掌握Vue基础、希望通过完整项目巩固组件化开发与工程化配置的前端学习者。项目基于Vue CLI搭建,涵盖组件、路由、状态管理与样式组织等核心模块,适合作为练手项目或课程作业参考。压缩包共32个文件,以18个.vue单文件组件为主,辅以5个.js脚本、2个.css与1个.less样式文件、2个.json配置、1个.html入口及1个.jpg图片资源,整体约220KB,体积轻量便于本地运行与阅读。目录中可见src下的components、views、router、store、assets等典型分层,以及babel.config.js、vue.config.js等构建配置文件,能直观了解Vue项目的标准结构与自定义配置方式。目前已有120人学习下载。通过阅读源码,读者可掌握组件拆分、路由跳转、状态管理及开发与生产环境构建的完整流程,并借鉴其目录组织与配置思路,快速迁移到自己的项目中。
1. 第三次迭代的 Vue 资讯项目,到底在解决什么问题
很多前端团队做资讯类项目,第一次迭代通常只求“能跑”——列表能渲染、详情能打开、路由能跳转。第二次迭代开始补样式、补响应式、补接口容错。到了第三次,真正的痛点才浮出水面:数据流开始打架,组件之间靠 props 和事件层层传递,改一个字段要动五六个文件;列表页滚动位置丢失,用户从详情返回后又要重新翻;接口请求没有统一拦截,token 过期了还在傻等;打包体积膨胀,首屏白屏时间从 1 秒变成 3 秒。
hm-news-vue-03这个标题里的“03”不是版本号,而是第三次迭代的标记。它代表的是一个 Vue 资讯项目从“能跑”走向“能维护、能扩展、能上线”的转折点。这篇文章面向的是已经用 Vue 写过一两个项目、但每次写到中期就开始混乱的开发者。我会把第三次迭代里最值得做的几件事拆开讲:状态管理怎么选、请求层怎么封装、列表与详情的缓存策略怎么设计、构建配置怎么调。每一步都给出可复现的代码和参数说明,不堆概念,只讲我实际踩过的路。
2. 状态管理选型与请求层封装:从散落各处的 fetch 到统一数据流
2.1 为什么第三次迭代必须动状态管理
第一次迭代时,资讯列表页的数据请求直接写在组件的mounted里,详情页的数据靠路由参数重新请求。这种写法在页面少的时候没问题,但第三次迭代通常会增加这些需求:首页推荐流、频道页、搜索页、个人收藏页,它们共享同一份文章数据。如果每个页面各自请求、各自存储,就会出现三个问题:同一篇文章被请求三次;收藏状态在 A 页面改了,B 页面不知道;下拉刷新时不知道应该刷新哪个缓存。
常见做法是引入 Pinia。相比 Vuex,Pinia 的模块定义更接近组合式 API 的写法,TypeScript 推断也更顺。我一般会按“领域”拆 store,而不是按“页面”拆。资讯项目里至少有三个 store:articleStore管文章列表和详情缓存,channelStore管频道分类,userStore管登录态和收藏。下面是一个最小可用的articleStore结构。
// stores/articleStore.js import { defineStore } from 'pinia' import { fetchArticleList, fetchArticleDetail } from '@/api/article' export const useArticleStore = defineStore('article', { state: () => ({ // 用对象而不是数组存列表,key 是频道 id,方便按频道取 listMap: {}, // 详情缓存,key 是文章 id detailMap: {}, // 记录每个频道是否已经加载过第一页 loadedChannels: new Set(), }), actions: { async loadList(channelId, page = 1) { // 第一页且已加载过,直接返回缓存,避免重复请求 if (page === 1 && this.loadedChannels.has(channelId)) { return this.listMap[channelId] } const res = await fetchArticleList({ channelId, page, pageSize: 20 }) if (page === 1) { this.listMap[channelId] = res.list this.loadedChannels.add(channelId) } else { // 翻页时追加,注意去重 const existing = this.listMap[channelId] || [] const existingIds = new Set(existing.map(item => item.id)) const newItems = res.list.filter(item => !existingIds.has(item.id)) this.listMap[channelId] = [...existing, ...newItems] } return this.listMap[channelId] }, async loadDetail(articleId) { if (this.detailMap[articleId]) { return this.detailMap[articleId] } const detail = await fetchArticleDetail(articleId) this.detailMap[articleId] = detail return detail }, }, })这段代码的关键参数有三个。listMap用对象而不是数组,是为了按频道隔离数据,避免切换频道时列表串台。loadedChannels用Set而不是数组,是因为Set的has判断是 O(1),频道多了以后性能差异明显。翻页去重那几行是血泪经验:接口在边界情况下会返回重复文章,不去重的话列表会出现两个相同 id 的卡片,点击详情时路由参数一样但组件不重新渲染,用户以为卡死了。
2.2 请求层封装:拦截器、重试与取消
状态管理解决的是“数据放哪”,请求层解决的是“数据怎么来”。第三次迭代里,请求层至少要处理四件事:统一 baseURL、token 注入、错误码映射、重复请求取消。我一般会在api/request.js里做一层封装,而不是在每个 api 文件里写axios.get。
// api/request.js import axios from 'axios' const pendingMap = new Map() // 生成请求唯一 key,用于取消重复请求 function getRequestKey(config) { const { method, url, params, data } = config return [method, url, JSON.stringify(params), JSON.stringify(data)].join('&') } // 请求发出前,把上一个相同请求取消掉 function addPending(config) { const key = getRequestKey(config) if (pendingMap.has(key)) { pendingMap.get(key).abort() pendingMap.delete(key) } const controller = new AbortController() config.signal = controller.signal pendingMap.set(key, controller) } // 请求结束后清理 function removePending(config) { const key = getRequestKey(config) if (pendingMap.has(key)) { pendingMap.delete(key) } } const instance = axios.create({ baseURL: import.meta.env.VITE_API_BASE_URL, timeout: 10000, }) instance.interceptors.request.use((config) => { addPending(config) const token = localStorage.getItem('token') if (token) { config.headers.Authorization = `Bearer ${token}` } return config }) instance.interceptors.response.use( (response) => { removePending(response.config) const { code, data, message } = response.data if (code === 0) return data // 业务错误码统一在这里处理 if (code === 401) { localStorage.removeItem('token') window.location.href = '/login' } return Promise.reject(new Error(message || '请求失败')) }, (error) => { if (error.config) removePending(error.config) // 超时和网络错误分开提示 if (error.code === 'ECONNABORTED') { return Promise.reject(new Error('请求超时,请检查网络')) } return Promise.reject(error) } ) export default instanceAbortController是现代浏览器原生支持的取消请求方式,不需要额外引入库。pendingMap用Map而不是对象,是因为Map的 key 可以是任意字符串且不会和原型链上的属性冲突。超时时间设 10 秒是资讯类接口的常见值,列表接口通常 2 秒内返回,详情接口可能稍慢,但超过 10 秒基本是服务端有问题,继续等没有意义。
注意:取消重复请求只对“同一时刻的相同请求”有效。如果用户快速切换频道,不同频道的请求 key 不同,不会被取消,这时候需要在组件层面用
onBeforeUnmount或路由守卫来清理。
2.3 列表与详情的缓存策略
资讯项目里最影响体验的不是接口速度,而是“返回时列表位置丢失”。第三次迭代必须解决这个问题。我的做法是在articleStore里额外记录每个频道的滚动位置和已加载页码,配合路由的scrollBehavior和组件的keep-alive。
// router/index.js const router = createRouter({ history: createWebHistory(), routes: [ { path: '/channel/:id', name: 'channel', component: () => import('@/views/ChannelPage.vue'), meta: { keepAlive: true }, }, { path: '/article/:id', name: 'article', component: () => import('@/views/ArticlePage.vue'), }, ], scrollBehavior(to, from, savedPosition) { // 从详情返回列表时,恢复之前的位置 if (savedPosition) return savedPosition return { top: 0 } }, })keepAlive配合savedPosition能解决大部分场景。但有一个坑:如果列表页用了虚拟滚动或无限加载,savedPosition恢复的是浏览器滚动条位置,而虚拟滚动容器的内部滚动位置需要手动恢复。这时候要在articleStore里存一个scrollTopMap,在onActivated钩子里读取并设置。
// views/ChannelPage.vue 片段 import { onActivated, onDeactivated, ref } from 'vue' import { useArticleStore } from '@/stores/articleStore' const store = useArticleStore() const listRef = ref(null) onDeactivated(() => { // 离开页面时记录滚动位置 store.scrollTopMap[route.params.id] = listRef.value?.scrollTop || 0 }) onActivated(() => { // 回来时恢复 const top = store.scrollTopMap[route.params.id] if (top && listRef.value) { listRef.value.scrollTop = top } })这套组合拳打下来,用户从详情返回列表时,看到的是离开前的那一屏,不会跳回顶部。这个细节在第三次迭代里优先级很高,因为它直接决定用户愿不愿意继续往下翻。
3. 组件拆分与性能优化:把首屏时间从 3 秒压到 1 秒内
3.1 列表项组件的拆分粒度
第一次迭代时,列表项通常写成一个ArticleItem.vue,里面包含标题、摘要、封面图、作者、时间、标签。这个组件在列表页会被渲染几十次。如果每个ArticleItem都引入完整的工具库、都注册一遍全局组件,渲染开销会成倍增加。
第三次迭代要做的是“按需拆分”。把不常变的部分抽成纯展示组件,把交互逻辑留在父级。比如封面图单独抽一个LazyImage.vue,用IntersectionObserver做懒加载;标签单独抽一个TagList.vue,用v-memo缓存渲染结果。
<!-- components/LazyImage.vue --> <template> <img v-if="isVisible" :src="src" :alt="alt" loading="lazy" @load="onLoad" @error="onError" /> <div v-else class="placeholder" :style="{ paddingTop: ratio }"></div> </template> <script setup> import { ref, onMounted, onBeforeUnmount } from 'vue' const props = defineProps({ src: { type: String, required: true }, alt: { type: String, default: '' }, ratio: { type: String, default: '56.25%' }, // 16:9 占位 }) const isVisible = ref(false) const el = ref(null) let observer = null onMounted(() => { observer = new IntersectionObserver( (entries) => { if (entries[0].isIntersecting) { isVisible.value = true observer.disconnect() } }, { rootMargin: '200px' } // 提前 200px 开始加载 ) observer.observe(el.value) }) onBeforeUnmount(() => { observer?.disconnect() }) function onLoad() {} function onError() { // 图片加载失败时替换为默认图 isVisible.value = false } </script>rootMargin: '200px'这个参数的意思是:当图片距离视口还有 200 像素时就开始加载,而不是等它进入视口才加载。这样用户快速滚动时不会看到大片空白。ratio参数用来撑开占位高度,避免图片加载完成后页面跳动,这个细节对 CLS(累积布局偏移)指标影响很大。
3.2 路由级代码分割与预加载
第三次迭代时,项目通常已经有十几个路由页面。如果全部打包进一个index.js,首屏体积很容易超过 1MB。Vue 的异步组件配合 Vite 的import()可以自动做代码分割,但默认情况下,用户点击某个路由时才去下载对应的 chunk,会有短暂白屏。
我的做法是在路由配置里加webpackChunkName注释(Vite 也支持类似语法),并在空闲时间预加载可能访问的页面。
// router/index.js const routes = [ { path: '/', name: 'home', component: () => import(/* webpackChunkName: "home" */ '@/views/HomePage.vue'), }, { path: '/channel/:id', name: 'channel', component: () => import(/* webpackChunkName: "channel" */ '@/views/ChannelPage.vue'), }, { path: '/article/:id', name: 'article', component: () => import(/* webpackChunkName: "article" */ '@/views/ArticlePage.vue'), }, ] // 在首页加载完成后,预加载频道页和详情页 router.isReady().then(() => { if ('requestIdleCallback' in window) { requestIdleCallback(() => { import('@/views/ChannelPage.vue') import('@/views/ArticlePage.vue') }) } })requestIdleCallback会在浏览器空闲时执行预加载,不阻塞首屏渲染。如果浏览器不支持,可以降级用setTimeout。这个策略的效果是:用户从首页点击进入频道页时,chunk 已经在缓存里,切换几乎无感。
3.3 构建配置里的三个关键参数
Vite 的默认配置对资讯项目来说通常够用,但第三次迭代要关注三个参数:build.rollupOptions.output.manualChunks、build.chunkSizeWarningLimit、build.assetsInlineLimit。
// vite.config.js import { defineConfig } from 'vite' import vue from '@vitejs/plugin-vue' export default defineConfig({ plugins: [vue()], build: { // 超过 500KB 才警告,默认是 500,这里调大避免噪音 chunkSizeWarningLimit: 800, // 小于 4KB 的资源内联为 base64,减少请求数 assetsInlineLimit: 4096, rollupOptions: { output: { manualChunks: { // 把 vue 全家桶单独打一个包,利用长期缓存 vendor: ['vue', 'vue-router', 'pinia'], // 把 UI 库单独打一个包 ui: ['vant'], }, }, }, }, })manualChunks的作用是把不常变的依赖单独打包。用户第一次访问后,vendor.js和ui.js会被浏览器缓存,后续发版只要这两个包的内容没变,用户就不需要重新下载。assetsInlineLimit设 4096 字节是经验值,小图标内联成 base64 可以减少 HTTP 请求,但太大的图片内联会让 JS 体积膨胀,反而拖慢解析速度。
注意:
manualChunks不要拆得太细。我见过有人把每个 npm 包都拆成一个 chunk,结果首屏要发 20 多个请求,HTTP/2 也扛不住。按“变更频率”拆,vue 全家桶一个、UI 库一个、业务代码自动分割,就够了。
4. 避坑与排查:第三次迭代里最容易翻车的五个地方
4.1 现象:列表页切换频道后,滚动位置错乱
原因:keep-alive缓存了多个频道页实例,但scrollTopMap的 key 用的是路由参数,如果路由参数变化但组件复用,onActivated里读到的 key 可能是旧值。
解决:在onActivated里用route.params.id重新取 key,而不是依赖组件初始化时的参数。同时确保scrollTopMap在 store 里是响应式对象,不是普通对象。
4.2 现象:详情页返回列表后,收藏状态没更新
原因:列表页的收藏按钮状态是从articleStore.listMap里读的,但详情页修改收藏状态时只更新了detailMap,没有同步回listMap。
解决:在articleStore里加一个updateArticleStatus(articleId, status)方法,同时更新listMap和detailMap里对应文章的字段。不要在两个地方分别维护状态。
4.3 现象:接口返回 401 后,页面跳转到登录页,但登录成功后回不到原页面
原因:拦截器里直接用了window.location.href = '/login',丢失了当前路由信息。
解决:改用router.push({ name: 'login', query: { redirect: router.currentRoute.value.fullPath } }),登录成功后读取redirect参数跳回去。注意在拦截器里引入 router 实例时要避免循环依赖,可以把 router 挂到window上,或者用事件总线通知。
4.4 现象:打包后图片 404,开发环境正常
原因:图片路径用了@/assets/xxx.png这种别名,但构建后别名解析和开发环境不一致,或者图片放在了public目录但引用时写了相对路径。
解决:静态图片统一放src/assets,用new URL('@/assets/xxx.png', import.meta.url).href的方式引用。如果图片数量多且不常变,放public目录并用绝对路径/images/xxx.png引用。
4.5 现象:首屏加载时,字体图标闪烁或显示为方块
原因:字体文件没有预加载,浏览器在字体下载完成前用了回退字体。
解决:在index.html里加<link rel="preload" href="/fonts/iconfont.woff2" as="font" type="font/woff2" crossorigin>。同时给字体图标元素设置font-display: swap,避免文字长时间不可见。
5. 一个具体技巧:用路由元信息驱动页面标题和埋点
第三次迭代里,产品通常会要求“每个页面有独立的标题”和“关键行为要埋点”。如果每个页面手动写document.title = 'xxx',改起来很烦。我的做法是在路由的meta里声明标题和埋点 key,用一个全局后置钩子统一处理。
// router/index.js const routes = [ { path: '/channel/:id', name: 'channel', component: () => import('@/views/ChannelPage.vue'), meta: { title: '频道', trackKey: 'channel_view', keepAlive: true, }, }, { path: '/article/:id', name: 'article', component: () => import('@/views/ArticlePage.vue'), meta: { title: '文章详情', trackKey: 'article_view', }, }, ] router.afterEach((to) => { // 设置页面标题 const baseTitle = '资讯速览' document.title = to.meta.title ? `${to.meta.title} - ${baseTitle}` : baseTitle // 上报埋点 if (to.meta.trackKey) { // 这里用 sendBeacon 避免阻塞页面切换 const data = JSON.stringify({ key: to.meta.trackKey, path: to.fullPath, time: Date.now(), }) if (navigator.sendBeacon) { navigator.sendBeacon('/api/track', data) } else { // 降级方案 fetch('/api/track', { method: 'POST', body: data, keepalive: true }) } } })navigator.sendBeacon是专门为“页面卸载时发送数据”设计的 API,它不会阻塞页面跳转,比在beforeunload里发同步请求可靠得多。keepalive: true是 fetch 的降级方案,效果类似。
这个技巧的价值在于:新增页面时只需要在路由配置里加两行 meta,标题和埋点自动生效,不需要在每个组件的mounted里重复写。我后来把这个模式用在了三个项目里,每次加页面省下的时间不多,但省下的心智负担很值——不用再想“这个页面标题设了没”。
希望帮到你。
本文还有配套的精品资源,点击获取