别人学 Vue 都想快点把组件、路由、请求这些零散知识点串起来,但真到动手写一个多文件项目的时候,很多人卡在了“该建哪些文件、每个文件写什么、组件之间怎么配合”上。商品列表这个案例挺有意思,它不像 TodoMVC 那样只练列表渲染,也不像后台管理系统那样一上来就铺一堆表格和表单,它刚好卡在中间:有数据请求、有列表渲染、有路由跳转、有组件拆分,甚至能自然延伸到购物车、详情页和后端联调。我最早跟着黑马 Vue 教程学的时候,就是从这个多文件商品列表项目开始真正理解的。
这个案例适合正在学 Vue 基础、刚掌握组件和路由、想看看一个相对完整的项目怎么组织的人。它能帮你把 beforeCreate、mounted、props、$emit、动态路由、Axios 封装这些东西串成一条线。这篇文章我会从整体设计思路讲起,再拆到目录结构、组件划分、核心代码实现和常见问题,最后分享几个我实际踩过的坑,希望对你有用。
1. 多文件项目到底在解决什么问题?先想清楚目录分层
先别急着写代码,得先理解为什么学 Vue 一定要过“多文件项目”这道坎。很多入门教程一开始都在 HTML 里直接引 vue.js,写几个组件就完事。那种写法适合验证语法,不适合真实业务。真实项目里,你要面对的是几十上百个组件、路由、接口封装、状态管理、工具函数,如果不按文件拆开,全都写在几个大文件里,改一个地方可能影响另外十个地方,调试起来想死的心都有。
黑马这套教程里的商品列表案例,本质是在训练一种“按职责拆分”的思维。职责拆分不是说把每个按钮都做成一个组件,而是把一个页面拆成“容器组件”和“展示组件”。商品列表页是一个容器,它负责拿到数据、处理交互逻辑;商品卡片是一个展示组件,它只负责把数据渲染成好看的卡片样子;搜索栏、筛选栏、分页器这些也都应该是独立组件,各管一段,各写各的。
多文件项目的目录结构一般长这样,我以 Vue CLI 创建的项目为例。下面是目录规划表,这是“合格从业者最常用的方案”,可以结合实际项目调整。
| 路径 | 职责 | 对应商品列表案例的实际内容 |
|---|---|---|
| src/views | 页面级组件,一个路由对应一个 views 文件 | ProductList.vue(商品列表页)、ProductDetail.vue(详情页) |
| src/components | 可复用的业务组件或展示组件 | ProductCard.vue、SearchBar.vue、Paginator.vue |
| src/router | 路由配置 | 列表路由和详情页动态路由配置 |
| src/api | 接口请求统一封装 | 商品列表接口、商品详情接口 |
| src/utils | 通用工具函数 | 格式化价格、节流防抖等工具函数 |
| src/store | 全局状态管理(可选) | 购物车状态,列表页暂时先不引入也能跑通 |
从这个表能看出,多文件项目并不是文件越多越好,而是要让每个文件都有明确且单一的任务。容器组件里面可以写逻辑,展示组件里尽量不要写业务逻辑,只通过 props 接收数据、通过 $emit 上报事件。商品列表案例里,ProductCard 就是最典型的展示组件,商品数据从 ProductList 传下来,ProductCard 内部只做展示和按钮事件上报,不直接发请求。
另一个要提前想清楚的是数据流。商品列表页需要的数据来自接口,拿到之后存到当前组件的 data 里,然后向下传给子组件。子组件里用户点击“加入购物车”或者“查看详情”,子组件并不负责跳转或存状态,而是告诉父组件“有人点了这个商品”,父组件再决定下一步。这么做在初期可能会觉得代码变多了,但等页面复杂起来,你会感谢自己当初把逻辑收敛在了一个地方。
2. 商品列表案例的核心技术提前拆解
这个案例能覆盖 Vue 学习阶段的大部分核心知识点,我按“必用”和“延伸”两类列一下。
| 知识点 | 在这个案例里的具体场景 | 重要等级 |
|---|---|---|
| 组件通信(props / $emit) | 父组件传商品数据给 ProductCard,子组件点击按钮后上报事件 | 必修 |
| 生命周期 | created 或 mounted 里发请求拉取商品列表 | 必修 |
| 路由基础与动态路由 | /product 列表页跳转 /product/detail/:id 详情页 | 必修 |
| Axios 基础用法与封装 | 封装 request 工具,统一错误处理和 loading | 必修 |
| 列表渲染与 key 的作用 | v-for 渲染商品卡片,key 用商品 id | 必修 |
| 插槽 slot | 商品卡片里预留插槽,不同页面传入不同额外内容 | 延伸 |
| Vuex 或事件总线 | 购物车数量全局状态管理 | 延伸 |
| 路由的编程式导航 | 点击卡片后 this.$router.push 跳转详情页 | 必修 |
这些知识点看着多,其实都是围绕“商品列表”这个业务场景自然出现的。比如动态路由,如果你不写详情页,就很难体会 :id 参数到底怎么获取;如果你不封装 Axios,第一个接口还好,第二个接口开始你就能感受到复制粘贴请求代码有多痛苦。所以这个案例的安排是合理的,它用一条完整的业务线把所有知识串起来了。
组件通信要单独多说一点。我在带人做这个案例时发现,很多人理解不了为什么子组件不能直接改 props。想象一下,如果每个商品卡片都能自己改价格、自己改库存,那父组件里那本书上真正的价格是多少?数据源被搞乱了。Vue 的设计原则是单向数据流,父传子靠 props,子传父靠 $emit,这样谁改数据、什么时候改数据,都能在代码里顺着事件链条找出来。
售后经验里有个很实际的补充:组件传参的对象是引用类型,如果子组件不小心改了 props 里的对象内部属性,不会立刻报错,但会污染父组件数据,开发阶段看是“正常”,上线之后出现数据错乱,非常难排查。我统一的规范是:子组件拿到 props 数据后,要么直接展示,要么用 computed 或 data 作一次拷贝再操作。
3. 从零实现商品列表页:完整流程与关键代码
下面进入实操环节,我按照一个新人第一次接触这个案例的路线来讲,从建项目到跑通列表页,再扩展到详情页,每一段都有当时可以直接参考的代码和配置。
3.1 第一步:创建项目并安装依赖
先用 Vue CLI 创建一个项目框架。这个步骤很简单,但有些同学会在 Node 版本上卡住,建议 Node 版本至少 14 以上。创建的时候如果问要不要安装 Router,直接选上;要不要安装 Vuex,可以选也可以后面再装。
vue create vue-commodity-list cd vue-commodity-list npm install axios我只额外装了一个 axios,Router 在创建时已经装好。Element 之类的 UI 组件库我没在这阶段用,原因很简单:这个案例的目标是把 Vue 的核心机制搞明白,一旦引入 UI 库,很多细节会被组件库隐藏掉,反而不利于建立“数据到视图”的直觉。如果后续做后台管理系统,再上 UI 库也不迟。
3.2 第二步:确定数据结构和 Mock 数据
写页面前先定义好商品数据长什么样。这是很多新手容易忽略的点,但我想提醒你:数据结构的形状直接决定了你的组件 props 怎么写、列表怎么遍历、详情页怎么接收参数。我定义的商品结构如下:
{ id: 101, name: "机械键盘", price: 299, originalPrice: 399, image: "https://xxx.com/keyboard.jpg", category: "数码外设", stock: 15, sales: 1024 }字段不用很多,但 id、名称、价格、图片、库存这些都在了。这个案例暂时没有后端,我用 Mock 数据模拟接口返回。
Mock 数据的呈现方式有两种主流做法。一种是直接用 Mock.js 拦截 Ajax,另一种是自己写一个 Promise 函数模拟延迟。我建议新手先用第二种,因为你肉眼能看出请求是从哪里发起的,对理解 axios 怎么工作更有帮助。到了后续项目里接真实接口,再把 Mock 换成真正的后端地址就行。有的项目里会引入 mockjs 生成随机数据,但在这个阶段,固定数据比随机数据好,页面渲染效果更可控。
3.3 第三步:封装 Axios 请求工具
不封装 axios,直接在列表页里写axios.get()也能跑通,只是等你写完详情页和购物车,就会发现大量重复代码:每个请求都要处理 loading、错误提示、Token 头、统一的接口超时时间。
这里我提供一个最小化的封装方案:
// src/api/request.js import axios from 'axios' const request = axios.create({ baseURL: '/api', timeout: 10000 }) request.interceptors.request.use( (config) => { // 这里可以统一加 token、loading 态等 return config }, (error) => Promise.reject(error) ) request.interceptors.response.use( (response) => { return response.data }, (error) => { // 统一错误提示,避免每个页面都写一遍 console.error(error) return Promise.reject(error) } ) export default request这样做的好处是,页面代码里只出现“请求哪个接口、拿到数据做什么”,不再关心请求细节。比如商品列表接口是这样:
// src/api/product.js import request from './request' export function getProductList(params) { return request.get('/product/list', { params }) } export function getProductDetail(id) { return request.get(`/product/detail/${id}`) }这里多说一句:baseURL: '/api'看着像是个相对路径,实际在开发阶段得配合 Vue CLI 的 devServer 代理。因为你在本地跑的是localhost:5173或localhost:8080,而真实接口在localhost:3000,如果不配代理,浏览器直接跨域。我在vue.config.js里这么配:
// vue.config.js module.exports = { devServer: { proxy: { '/api': { target: 'http://localhost:3000', changeOrigin: true, pathRewrite: { '^/api': '' } } } } }changeOrigin: true的作用是让后端以为请求来自自己的域名,这解决了开发环境的跨域问题。生产环境下,真实部署时后端一般会用 Nginx 做反向代理,同样能把/api转发到后端服务名上,原理是类似的。
3.4 第四步:搭列表页组件结构
这个案例的列表页不会只有商品卡片,至少会有搜索框、筛选栏目、商品卡片网格,以及分页器。我当时的组件结构是这样组织的:
views/ProductList.vue // 容器组件:数据管理、交互逻辑 components/SearchBar.vue // 搜索框 components/CategoryFilter.vue // 分类筛选 components/ProductCard.vue // 单个商品卡片展示 components/Paginator.vue // 分页器ProductList 的工作是维护一个查询参数对象,比如关键字keyword、分类category、页码page,这些参数变化时重新拉取商品列表;商品列表数据拿到之后循环渲染 ProductCard;ProductCard 只接收一个商品对象,内部渲染卡片。它的代码核心片段如下:
<!-- components/ProductCard.vue --> <template> <div class="product-card"> <img :src="product.image" :alt="product.name" @click="goDetail" /> <h3>{{ product.name }}</h3> <p class="price">¥{{ product.price.toFixed(2) }}</p> <button @click="addCart">加入购物车</button> <button @click="goDetail">查看详情</button> </div> </template> <script> export default { name: 'ProductCard', props: { product: { type: Object, required: true } }, methods: { goDetail() { this.$emit('view-detail', this.product.id) }, addCart() { this.$emit('add-cart', this.product) } } } </script>这个卡片组件一点业务请求都不发,只负责展示和上报用户操作。父组件 ProductList 监听view-detail和add-cart事件,再决定跳转详情页还是把商品存进购物车状态,责任划分就非常清楚了。
3.5 第五步:列表页数据加载完整流程
列表页的 script 部分是整个案例的核心,它演示了一个“页面生命周期 + 数据请求 + 事件处理”的标准流程。我先贴关键代码:
// views/ProductList.vue export default { data() { return { products: [], loading: false, searchKeyword: '', currentPage: 1, pageSize: 8, totalCount: 0 } }, created() { this.fetchProductList() }, methods: { async fetchProductList() { this.loading = true try { const params = { keyword: this.searchKeyword, page: this.currentPage, pageSize: this.pageSize } const data = await getProductList(params) this.products = data.list this.totalCount = data.total } finally { this.loading = false } }, handleSearch(keyword) { this.searchKeyword = keyword this.currentPage = 1 this.fetchProductList() }, handleViewDetail(id) { this.$router.push({ path: `/product/detail/${id}` }) }, handleAddCart(product) { // 这里先只是打印,后面接入 Vuex 存购物车 console.log('add cart:', product) } } }created里发请求,而不是mounted,是因为大多数接口请求在组件挂载前就需要发起,这样数据回来并 set 到 data 时,组件已经渲染,可以直接用数据。写在mounted也可以,只是多了一次无意义的数据更新。这是个小细节,面试偶尔也会问。
加载状态处理上,我用了loading标记。卡片区域如果正在加载就显示“加载中”,如果加载完但列表为空就显示“暂无商品”,如果请求失败就在页面顶部显示错误文案。这三个状态的判空逻辑看似简单,但很多项目会因为不处理空数据而直接白屏,对用户体验的影响非常大。
不想让每次搜索和翻页都闪一下 loading,可以在 handleSearch 进入时先不显示 loading,等请求超过 300ms 才显示。这个用防抖和三秒刷新配合也行,属于锦上添花。实际项目中比较大的优化点是避免旧请求覆盖新结果,方法是记录请求序号或取消请求,这个留到后面高频搜索场景再讲。
3.6 第六步:动态路由实现详情页跳转
列表页做完了,点卡片只是打一行 log 也没意思,所以得加一个详情页。这个过程中会自然用到动态路由。
路由配置:
// src/router/index.js import Vue from 'vue' import VueRouter from 'vue-router' import ProductList from '../views/ProductList.vue' Vue.use(VueRouter) const routes = [ { path: '/', redirect: '/product' }, { path: '/product', name: 'ProductList', component: ProductList }, { path: '/product/detail/:id', name: 'ProductDetail', component: () => import('../views/ProductDetail.vue') } ]详情页组件里通过this.$route.params.id拿到当前商品 id:
// views/ProductDetail.vue export default { data() { return { detail: null, loading: false } }, created() { this.fetchDetail() }, methods: { async fetchDetail() { this.loading = true try { const id = this.$route.params.id const data = await getProductDetail(id) this.detail = data } finally { this.loading = false } } } }这里有个小坑:如果从“商品A详情页”跳转到“商品B详情页”,组件是同一个,created不会重新执行,页面数据不会更新。解决方法是监听$route:
watch: { '$route.params.id'(newId) { if (newId) { this.fetchDetail() } } }这个知识点我在实际项目里用过很多次,尤其在详情页、编辑页这类“同组件不同参数”的场景。新手不知道这个坑的话,会出现“明明地址变了,页面却是上一次的数据”的诡异问题。
4. 新手最容易遇到的坑与排查心得
商品列表案例虽然代码量不大,但我在帮学员排错时遇到的典型问题真不少,下面按出现的频率从高到低整理成速查表。
| 现象 | 原因 | 解决方案 | 排查优先级 |
|---|---|---|---|
| 商品列表一直空白,控制台无报错 | 数据已经返回,但赋值赋到了局部变量而不是 data 字段,或者this指向问题 | 检查this.products赋值语句;检查异步回调里是否用了箭头函数 | 高 |
| 列表明明有数据,但页面一片白 | v-for里访问了不存在的字段,如product.image为undefined | 打开 Vue Devtools 查看 product 对象结构,改字段名 | 高 |
| v-for 渲染列表时控制台报警告 | 缺少:key或使用了 index 做 key | 用商品 id 作为 key | 中 |
| 点击“查看详情”,URL 变了但页面不跳转 | 路由配置里 path 未加:id,或在组件里跳转路径拼接错误 | 检查/product/detail/:id与this.$router.push路径是否一致 | 中 |
| 修改 Mock 数据但页面不更新 | Mock 数据是普通 JS 对象且在模块内部被修改,Vue 的响应式追踪没有覆盖到模块外部变量 | 把数据塞进组件 data 或 Vuex | 高 |
| 跨域请求报错 | 未配置 devServer proxy | 在 vue.config.js 里配置代理 | 中 |
| 刷新详情页 404 | Vue Router 是 history 模式,服务器未配置 fallback | 在 Nginx 配置 try_files,或改用 hash 模式 | 中 |
| 打包后商品图片裂图 | 图片用了相对路径或服务器图片地址失效 | 检查图片资源打包路径,参考 vue.config.js 的publicPath | 低 |
除了表格里的问题,我再讲一个经历。有次查一个“搜索商品后翻页,数据错位”的问题,最后发现是搜索关键字改变了,但页码没重置回第一页。这属于业务状态管理的疏漏,后来我在每个搜索、筛选的交互入口统一做了currentPage = 1的初始化,这个坑才算彻底堵住。
ESLint 也是新手常见的卡点。Vue CLI 默认开启了一堆 lint 规则,写代码时少个分号、多了一个空格都会导致红色报错。对这个案例来说,我建议学习阶段在vue.config.js里把lintOnSave: false关了,先把注意力放在 Vue 本身,等代码越写越熟练再开启 lint 不迟。
调试方面,我强烈建议装 Vue Devtools。它能直接看到当前组件树的 props、data、事件触发记录,排查商品列表数据不更新、props 传错字段的问题效率能提升一大截。我之前帮人排查问题,很多人都是靠 Devtools 一眼看出“父组件根本没有把数据传下来”,比自己瞎试快多了。
5. 案例做完了,下一步还能怎么扩展
商品列表这个综合案例做完之后,不要立刻扔掉。它作为一套最小业务闭环,很值得在它上面继续叠加功能,帮助巩固更多 Vue 知识点。
我的建议是先加购物车。购物车需要跨页面共享状态,你会发现用组件内 data 已经兜不住了,这时引入 Vuex 就顺理成章。购物车里要处理“数量加减”、“总价计算”、“同一商品重复添加合并”,这些涵盖了 action、mutation、getters 的完整用法。加完购物车再加登录状态,你会自然接触到登录凭证、路由守卫、持久化这些概念。再往后,把这个列表页适配到移动端、拆分出商品规格弹窗之类的子组件,整个项目会越撑越大,但你的知识体系也会越来越完整。
在做扩展的时候,尽量保持跟这个案例一致的规范:容器组件管数据,展示组件只拿 props 展示;所有接口走 api 层封装;路由页面放在 views,可复用组件放 components。这种习惯一旦在几百行的小项目里养成,到大项目里就不容易乱。
最后分享一个我对学习方法的真实体会。看黑马教程的时候,最有效的做法不是“边看边敲”,是看完一节课之后合上教程,只凭记忆和理解把代码重写一遍。写不出来就倒回去看,再合上。商品列表这个项目我前后重写了三遍,每一遍对组件拆分和路由跳转的理解都更深一层。你现在跟着文章一步步搭出来,只是第一遍;等你能不看代码把整个项目从建目录到跑通详情页做出来,才是真正把知识点攥在手里了。