简介:面向刚接触 Vue 前端开发、希望用完整项目练手的学习者,这份压缩包提供一个迭代到第三版的新闻类单页应用源码,可用于理解组件拆分、路由组织与状态管理的实际写法。包内共 32 个文件,以 18 个 Vue 单文件组件为主体,配合 5 个 JavaScript 模块、2 个 CSS 和 1 个 Less 样式文件、2 个 JSON 配置,以及 HTML 入口、静态图片等项目素材,压缩后约 220KB,体量轻便,便于本地运行和逐文件阅读。已有 121 人学习,说明它在入门练手场景中具备一定参考价值。源码目录覆盖组件、视图、路由、状态与静态资源等常见分层,另有构建与自定义配置文件,读者可据此梳理新闻列表、详情页与路由跳转的实现脉络,也能对照 Vue CLI 的依赖安装、开发调试和生产构建流程,理解脚手架初始化与配置调整的落点,适合作为课程作业或小型前端项目的参考底稿。
1. 拿到第三版先翻 router,比先看首页更有用
拿到 news-vue-03 这份 Vue 新闻项目第三版,我第一个打开的是 router 目录,而不是首页。项目做到「第三次」,有价值的东西基本都落在路由和状态上,因为第一次、第二次通常只把页面摆出来,到第三次才把登录、token、列表缓存、返回位置这些真实业务里的麻烦事补进去。这份源码是一个能完整跑通的移动端新闻单页应用:频道 tab 切换、下拉刷新、上拉加载更多、详情页返回不丢位置、登录后凭 token 收藏。适合谁?把 Vue 基础语法过了一遍、组件会写但没独立做过完整项目的人,也适合想找结构规整的项目练手改造的人。它不花哨,胜在链路完整,几乎每个模块都能对应到真实业务里的一个坑。
2. 工程骨架:目录结构、依赖版本和一次启动的完整链路
很多人下载完直接npm install && npm run serve,报错就懵。问题往往不在代码,而在没先看清这个工程是怎么组织的。这一版的结构是典型的 Vue CLI 脚手架加业务分层,先把骨架认清楚,后面看任何一段代码都能立刻知道它该在哪个文件里。
2.1 目录分工:为什么 views 和 components 要分两层
src/ ├── api/ # 接口层,一个业务一个文件 │ ├── news.js # 频道、列表、详情 │ └── user.js # 登录、收藏 ├── assets/ # 静态资源,会被 webpack 处理 ├── components/ # 可复用组件,不含路由 │ ├── NewsItem.vue # 单条新闻卡片 │ └── TabBar.vue # 底部导航 ├── router/ │ └── index.js # 路由表 + 全局守卫 ├── store/ │ ├── index.js # 组装 module │ └── modules/user.js # token、用户信息 ├── utils/ │ └── request.js # axios 实例与拦截器 ├── views/ # 页面级组件,一个对应一条路由 │ ├── Home.vue │ ├── Detail.vue │ └── Login.vue ├── App.vue # 根组件,放 keep-alive 和 TabBar └── main.js # 入口,挂载插件views和components分开不是为了好看,而是一条硬规则:能被路由直接访问的放views,只被别人引用的放components。我见过不少新手把新闻卡片写成页面放进views,结果详情页也想复用它,引用路径就变成@/views/NewsItem.vue,语义上很别扭,后面拆分组件时又得全部搬一次。判断标准很简单:这个组件有没有自己的路由地址,有就是views,没有就是components。
api和utils分开也是同一个思路。utils/request.js只关心「怎么发请求」,api/news.js只关心「有哪些接口、参数叫什么」。业务组件里永远不直接import axios,这条约束守住,后面换域名、加签名、改超时时间,只需要在业务组件之外改一处。
2.2 依赖清单和技术选型
这一版的依赖是很典型的 Vue 2 移动端组合,看一眼package.json就知道整套技术栈:
| 依赖 | 作用 | 版本区间(常见) |
|---|---|---|
| vue | 视图框架 | 2.6.x |
| vue-router | 路由,用 history 模式 | 3.5.x |
| vuex | 全局状态,只放 token 和频道 | 3.6.x |
| axios | HTTP 请求 | 0.27.x |
| vant | 移动端 UI,列表、下拉刷新、Toast | 2.12.x |
| less / sass | 样式预处理 | 按项目选一种 |
| vue-cli-service | 构建与本地服务 | 4.x / 5.x |
为什么是 Vue 2 而不是 Vue 3?这类项目大多成型较早,配套的组件库、按需加载插件、脚手架配置都是 Vue 2 生态里的成熟方案,改造成本低。你如果手上是 Vue 3 + Vite 的版本,概念是一一对应的,差异点我在最后一章列了对照表,不用从头重学。
提示:
npm install之前先确认 Node 版本。Vue CLI 4 的项目在 Node 17 以上可能报ERR_OSSL_EVP_UNSUPPORTED,这类报错跟业务代码无关,别去翻源码。
2.3 从 main.js 到 router-view:一次启动到底发生了什么
入口文件是整个应用唯一的装配车间,所有全局插件、全局样式、根实例挂载都在这里完成:
// src/main.js import Vue from 'vue' import App from './App.vue' import router from './router' import store from './store' // 按需引入的组件库,只注册用到的几个 import { List, PullRefresh, Toast, Lazyload } from 'vant' import 'vant/lib/index.css' Vue.use(List).use(PullRefresh).use(Lazyload) Vue.prototype.$toast = Toast Vue.config.productionTip = false // 关掉生产环境提示 new Vue({ router, // 挂载后 this.$router / this.$route 才可用 store, // 挂载后 this.$store 才可用 render: h => h(App) }).$mount('#app')这段代码的执行顺序值得说清楚。Vue.use()是在给 Vue 构造函数装插件,必须在new Vue()之前调用,否则组件里<van-list>会直接报未知标签。router和store作为选项传进去,Vue 会在实例化时把它们注入到每个子组件的this上,所以任何组件里都能直接拿this.$route.params.id。
render: h => h(App)这个写法替代了模板编译,好处是不依赖运行时编译器,打包体积小一点。根组件App.vue里通常长这样:
<template> <div id="app"> <!-- include 写组件 name,只有匹配的页面才被缓存 --> <keep-alive include="Home"> <router-view /> </keep-alive> <tab-bar v-if="showTabBar" /> </div> </template>keep-alive只包住router-view,TabBar 放在外面,这样切页面时底部导航不会被销毁重建。showTabBar一般根据当前路由的meta判断,详情页和登录页把它藏掉。
2.4 启动命令和环境变量
装完依赖,正常两条命令就能起:
npm install # 装依赖,卡在 node-sass 就换 sass(dart-sass) npm run serve # 本地开发,默认 8080 npm run build # 产出 dist,部署用环境变量放在项目根目录的.env.development和.env.production里,只有VUE_APP_开头的才会被注入到客户端代码:
# .env.development VUE_APP_BASE_URL=/api # .env.production VUE_APP_BASE_URL=https://example.com/api这里有个细节:变量改完必须重启 dev server,热更新不会重新读取 env 文件。我在这上面浪费过半小时,一直以为是自己代码写错了。
3. 数据层:axios 实例 + 接口模块化,把请求管在一个文件里
一个项目乱不乱,看它的请求代码在哪里写就知道了。如果每个组件里都在axios.get('http://...'),那这个项目基本没法维护。这一版的做法是把请求集中到utils/request.js和api/两层,业务组件只负责调函数、拿数据、处理界面。这个分层做对了,后面写页面几乎不用关心网络细节。
3.1 一个 request 实例,两个拦截器
// src/utils/request.js import axios from 'axios' import { Toast } from 'vant' import store from '@/store' import router from '@/router' const request = axios.create({ baseURL: process.env.VUE_APP_BASE_URL, // 开发环境是 /api,由 devServer 转发到后端 timeout: 8000 // 移动端别设太长,超时让用户重试比一直转圈好 }) // 请求拦截:每个请求出门前统一带上 token request.interceptors.request.use( config => { const token = store.state.user.token if (token) { config.headers.Authorization = `Bearer ${token}` } return config }, err => Promise.reject(err) ) // 响应拦截:剥掉外层包装,统一处理业务码 request.interceptors.response.use( res => { const { status, data, message } = res.data if (status === 200) return data // 业务成功,直接把数据本体交给调用方 if (status === 401) { // token 失效:清掉本地状态并跳登录 store.commit('user/clearToken') router.replace({ path: '/login', query: { redirect: router.currentRoute.fullPath } }) } Toast(message || '请求失败') return Promise.reject(new Error(message)) }, err => { // 这里只处理网络层错误,业务错误已经在上面处理过了 const code = err.response && err.response.status Toast(code === 500 ? '服务器开小差了' : '网络异常,请稍后重试') return Promise.reject(err) } ) export default request逐段说。axios.create造出来的是一个独立实例,它有自己的baseURL、timeout和拦截器队列,不会污染全局axios。baseURL用process.env.VUE_APP_BASE_URL,是为了让同一份代码在开发和线上走不同地址,不用手动改字符串。
请求拦截器里那句Bearer ${token}是 OAuth 风格的约定,后端认的就是这个格式,写成config.headers.Authorization = token会被判成非法凭证。这里从 Vuex 取 token 而不是从localStorage直接读,是因为 Vuex 初始化时已经从localStorage恢复过一次,单一数据源不容易出岔子。
响应拦截器做的是「剥壳」。大多数接口返回的是{ status, message, data }这种结构,业务代码只关心data,所以在拦截器里直接return data,组件里const res = await getNewsList()拿到的就是列表本身。status === 401的处理也别省,token 是有过期时间的,过期后继续拿旧 token 发请求只会让用户看到一堆失败提示,不如直接清状态、回登录页、把当前地址塞进redirect,登录成功后还能跳回来。
注意:拦截器里不要用
this.$toast,那里没有组件实例,this是 undefined,只能import { Toast }用函数式调用。这个错我踩过不止一次。
3.2 接口按业务拆文件,而不是堆一个 api.js
// src/api/news.js import request from '@/utils/request' // 频道列表,首页 tab 的数据来源 export const getChannels = () => request.get('/channels') // 新闻列表,游标分页:带上频道 id 和上一页末尾的时间戳 export const getNewsList = params => request.get('/articles', { params }) // 新闻详情,id 走路径参数 export const getNewsDetail = id => request.get(`/articles/${id}`) // 收藏 / 取消收藏,同一套接口用 type 区分 export const collectArticle = (id, type) => request.post('/collections', { articleId: id, type })好处很直接:组件里不用再拼 URL,参数名改了只改一处;注释就是接口文档,接手的人翻这个文件就知道项目调了哪些接口;测试的时候也好替换,把api/news.js整个 mock 掉就行。
参数形式也有讲究。get的第二个参数是 config 对象,查询参数要放在params里,写成request.get('/articles', { channel_id: 1 })是不会拼到 URL 上的。post的第二个参数是请求体,{ articleId, type }会被序列化成 JSON。这两个搞混,表现就是后端一直说收不到参数。
3.3 开发环境跨域:devServer 转发和 pathRewrite
前端跑在 8080,后端跑在 3000,浏览器同源策略必然拦。这一版没有让后端加跨域头,而是在构建配置里把/api前缀的请求转发出去:
// vue.config.js module.exports = { devServer: { port: 8080, proxy: { '/api': { target: 'http://localhost:3000', // 后端真实地址 changeOrigin: true, // 转发时把 Host 头改成目标地址 pathRewrite: { '^/api': '' // 后端接口没有 /api 前缀,转发时去掉 } } } }, publicPath: './' // 打包产物放子目录也能正常加载资源 }pathRewrite是最容易被漏掉的一行。以/api/articles为例,如果pathRewrite写成{ '^/api': '/api' },转发出去就是http://localhost:3000/api/articles,后端如果没有这个前缀就会 404。判断方法很简单:在后端控制台看它收到的实际路径,多一个/api就说明 rewrite 规则和baseURL重复了。
changeOrigin也建议打开。它的作用是把请求头里的 Host 改成 target 的 host,有些后端或网关会根据 Host 做路由,不打开会出现「本地能过、换个环境就 404」的玄学问题。
3.4 baseURL 和环境变量切换
线上没有 devServer,转发就不存在了,所以生产环境必须让baseURL指向真实地址。前面.env.production里写的VUE_APP_BASE_URL就是干这个的。有一点要留意:环境变量在构建时就被静态替换进代码,运行时改不了,所以同一个dist不能同时对接测试和正式环境,要分环境打包。
如果项目确实需要运行时切换,常见做法是把接口地址写到public/config.js里,在index.html用<script>引入,打包后直接改这个文件即可。这种方案牺牲一点启动速度,换来部署灵活度,看团队习惯取舍。
4. 页面和状态:列表、详情、登录鉴权怎么串成一条线
前面两层是准备工作,真正体现项目水平的是这条链路:用户在列表页滑动、点进详情、返回、发现有收藏按钮、没登录被拦到登录页、登录完自动跳回来。这几步只要有一环没处理好,体验就会显得很粗糙。这一章按这条链路拆。
4.1 列表页:上拉加载和下拉刷新怎么不打架
<template> <van-pull-refresh v-model="refreshing" @refresh="onRefresh"> <van-list v-model="loading" :finished="finished" finished-text="没有更多了" :immediate-check="false" @load="onLoad" > <news-item v-for="item in list" :key="item.art_id" :item="item" /> </van-list> </van-pull-refresh> </template> <script> import { getNewsList } from '@/api/news' import NewsItem from '@/components/NewsItem.vue' export default { name: 'Home', // 这个 name 必须和 keep-alive 的 include 一致 components: { NewsItem }, data() { return { list: [], loading: false, finished: false, refreshing: false, timestamp: Date.now(), // 游标分页:记录当前拉到哪一页 channelId: 0 } }, activated() { // 只有列表为空时才请求,避免每次切回来都重新拉一遍 if (this.list.length === 0) this.onLoad() }, methods: { async onLoad() { const { results, pre_timestamp } = await getNewsList({ channel_id: this.channelId, timestamp: this.timestamp }) this.list.push(...results) this.timestamp = pre_timestamp this.loading = false this.finished = results.length === 0 // 返回空数组说明到底了 }, async onRefresh() { this.timestamp = Date.now() this.finished = false this.list = [] // 先清空,否则旧数据会和新数据拼在一起 const { results, pre_timestamp } = await getNewsList({ channel_id: this.channelId, timestamp: this.timestamp }) this.list = results this.timestamp = pre_timestamp this.refreshing = false } } } </script>关键点有三个。immediate-check="false"关掉的是「组件挂载时自动触发一次 load」,如果不关,你会看到进页面瞬间发两次请求,一次来自 activated,一次来自 list 的自动检查。loading必须在请求结束后手动置回 false,否则列表底部会一直转圈并不再触发下一页。finished的判定用results.length === 0,比用「返回条数小于 pageSize」更可靠,因为游标分页没有固定页大小。
下拉刷新里那句this.list = []很多人不写,结果刷新后旧数据还在上面,新数据追加在后面,看起来像是重复。另外两套状态loading和refreshing要分开,混用一个变量会导致下拉刷新结束时上拉加载也被误判成结束。
4.2 详情页:路由参数、keep-alive 和返回位置
// src/router/index.js const routes = [ { path: '/', redirect: '/home' }, { path: '/home', component: () => import('@/views/Home.vue'), meta: { keepAlive: true } // 首页需要缓存 }, { path: '/detail/:id', // 动态路由,详情页靠 id 区分 component: () => import('@/views/Detail.vue') }, { path: '/login', component: () => import('@/views/Login.vue') }, { path: '*', component: () => import('@/views/NotFound.vue') } ] const router = new VueRouter({ mode: 'history', routes, // 返回上一页时还原滚动位置,配合 keep-alive 才有效 scrollBehavior(to, from, savedPosition) { return savedPosition || { x: 0, y: 0 } } })详情页拿参数就一句const id = this.$route.params.id,但要注意它在组件内是响应式的,同页面切换不同 id 时不会重新创建组件,得用watch: { '$route.params.id': ... }监听,否则内容不更新。
scrollBehavior返回savedPosition的语义是:用户点浏览器返回时,滚动条回到他离开时的位置;如果是新进入某个页面,就回到顶部。这个能力和keep-alive是两回事——keep-alive保住的是组件状态,scrollBehavior保住的是滚动位置,两个都配了,返回列表才真正「回到原来那一屏」。
详情页自身一般不需要缓存,从列表点进去看第二条,不该看到第一条的内容。所以keep-alive的include只写"Home",别用exclude反着写,新增页面时容易漏。
4.3 Vuex 只放该放的东西:user 模块管 token
// src/store/index.js import Vue from 'vue' import Vuex from 'vuex' import user from './modules/user' Vue.use(Vuex) export default new Vuex.Store({ modules: { user } }) // src/store/modules/user.js import request from '@/utils/request' export default { namespaced: true, // 之后要用 commit('user/setToken') state: { // 初始化时从 localStorage 恢复,刷新页面不掉登录 token: localStorage.getItem('token') || '', userInfo: {} }, mutations: { setToken(state, token) { state.token = token localStorage.setItem('token', token) }, clearToken(state) { state.token = '' localStorage.removeItem('token') } }, actions: { async login({ commit }, payload) { // 登录接口返回 token,提交 mutation 落库 const { token } = await request.post('/login', payload) commit('setToken', token) } } }namespaced: true是必须的。没有它,所有 module 的 mutation 都挂到全局,两个模块都叫setToken就会打架。加了之后调用方式变成commit('user/setToken', token),写起来长一点,但边界清楚。
这里刻意只放 token 和用户信息。列表数据不放 Vuex,因为它只被一个页面用,放进全局状态反而要处理清理时机。判断标准是「这份数据有没有跨页面共享」,没有就别往 store 里塞。
4.4 路由守卫和 401:别让用户自己发现登录过期
// src/router/index.js 追加 import store from '@/store' const AUTH_PAGES = ['/profile', '/collections'] // 需要登录才能进的页面 router.beforeEach((to, from, next) => { const needAuth = AUTH_PAGES.includes(to.path) const hasToken = !!store.state.user.token if (needAuth && !hasToken) { // 带上目标地址,登录成功后原路返回 next({ path: '/login', query: { redirect: to.fullPath } }) } else { next() } })登录页在loginaction 成功之后读this.$route.query.redirect,有就replace过去,没有就回首页。用replace而不是push,是为了避免用户按返回又回到登录页。
配合 3.1 里响应拦截器的 401 处理,就形成了完整闭环:主动访问受限页面被前置守卫拦,请求过程中 token 过期被拦截器兜住,两条路径都汇到登录页,登录完都回到原处。这套东西不难,但缺任何一环,用户就会遇到「点了收藏没反应」这种说不清的问题。
5. 排查清单:这版在本地跑不起来,八成是这五处
下面这几条是我在帮人看这类项目时反复遇到的,按出现频率排序。每条都按现象、原因、解决的顺序写,对着自己的控制台逐条比就行。
5.1 刷新页面就 404,首页却正常
现象:点链接跳转一切正常,按 F5 刷新/detail/123直接白屏,控制台报 404。
原因:路由用了mode: 'history',浏览器把/detail/123当成真实路径去请求服务器,而服务器上只有index.html,没有这个目录。
解决:开发环境靠devServer.historyApiFallback(Vue CLI 默认开了),线上要让后端把所有未匹配路径回退到index.html。Nginx 里加一句try_files $uri $uri/ /index.html;即可。如果部署环境不允许改服务器配置,退一步用 hash 模式,代价是地址里多个#。
5.2 token 明明存了,请求头里却没有
现象:登录后localStorage里有 token,但后端一直返回未授权,抓包看请求头是空的。
原因有两个。一是拦截器在store初始化之前就执行了,取到的是初始空值;二是Vue.use(Vuex)之后store.state.user.token的路径写错,漏了模块名。
解决:确认request.js里import store from '@/store'引入的是同一个实例,不是重新new Vuex.Store();确认取值写成store.state.user.token,不是store.state.token。排查时在拦截器里console.log(config.headers)打一行,比盯代码快。
5.3 keep-alive 开了,返回列表还是回到顶部、数据被重置
现象:从详情页返回,列表重新请求了一次,滚动条也回到最上面。
原因:keep-alive的include匹配的是组件name选项,不是路由name,也不是文件名。组件里没写name: 'Home',include="Home"就永远不生效,组件每次都被销毁重建。
解决:在Home.vue的export default里补上name: 'Home';同时确认scrollBehavior返回的是savedPosition,而不是恒定{ x: 0, y: 0 }。两个都改完,返回时数据还在、位置也还原,这才是完整效果。
5.4 proxy 配了,接口照样 404 或者报跨域
现象:vue.config.js里写了转发,重启后依然 404,有时还提示 CORS。
原因:要么baseURL和pathRewrite重复加了前缀,要么target少了协议头(写localhost:3000而不是http://localhost:3000),要么改完配置没重启。
解决:先在浏览器 Network 里看实际请求路径,再对照后端日志看它收到的路径,两头一对就知道多加了还是少加了前缀。target必须带http://,changeOrigin建议开。改完vue.config.js必须重启 dev server,热更新对构建配置无效。
5.5 打包上线后一片空白,或者组件样式丢了
现象:本地npm run serve一切正常,npm run build出来的dist打开是白屏,控制台报资源 404。
原因:publicPath默认是/,产物被部署到子目录(比如/news/)时,/js/app.js就找不到了。样式丢失则常见于组件库按需引入时没引样式文件。
解决:publicPath改成'./'用相对路径,或显式写成部署的子目录路径。按需引入要配合babel-plugin-import并在main.js里引入对应样式;懒加载的图片记得配Lazyload,否则长列表滚动时容易抖。最后用npx serve dist本地起一个静态服务验证,别直接双击index.html,file://协议下路由和请求都不正常。
6. 二次开发:把第三版改成你自己的资讯站,从换数据源开始
这份代码最实用的地方是它足够通用,改造成自己的项目不用推倒重来。我一般的改造顺序是:换数据源、调页面结构、补自己的业务模块、最后处理构建。
第一步换数据源,只动两处:.env.*里的VUE_APP_BASE_URL和vue.config.js里的target、pathRewrite。如果新后端返回结构不是{ status, message, data },改utils/request.js响应拦截器里的剥壳逻辑就行,业务组件一行都不用动,这就是当初分层的好处。字段名对不上时,在api/层做一次映射,别把差异扩散到模板里。
第二步加页面,标准动作是四件事:views/下建组件、router/index.js加路由(需要缓存就配meta.keepAlive并给组件补name)、需要登录就加进AUTH_PAGES、底部有入口就改TabBar.vue。漏任何一步都会出现「页面能开但点不进去」或者「进去了但没鉴权」。
第三步考虑从 Vue 2 迁到 Vue 3,核心差异集中在这几处:
| Vue 2 写法 | Vue 3 对应写法 | 说明 |
|---|---|---|
new Vue({ router, store }) | createApp(App).use(router).use(store) | 挂载方式变了 |
this.$router | useRouter() | 组合式 API 里没有 this |
data() { return {} } | ref/reactive | 响应式声明方式变了 |
store.state.user.token | store.state.user.token | Vuex 4 基本兼容 |
mode: 'history' | createWebHistory() | 路由构造方式变了 |
activated() | onActivated() | 缓存生命周期改成钩子 |
迁移不必一次全做,可以先在 Vue 2 项目里用@vue/composition-api试写新组件,跑通了再整体切换。改完一定要按这条链路走一遍验收:首页滑动加载到底、下拉刷新、点进详情、返回看位置和数据、退出登录后再点收藏被拦、登录后自动跳回、刷新页面不掉登录。这七步每步都过,改造基本就没问题了。
我自己的习惯是每次动request.js或router/index.js这种全局文件之前,先在 git 上打一个 tag,改完照这七步手动过一遍再提交。血泪经验是这类全局改动影响面太大,靠单元测试兜不住,手动走一遍链路比事后翻车强。希望帮到你。
本文还有配套的精品资源,点击获取