Vue CLI仿写外卖应用实战:组件拆分与Vuex状态管理避坑指南

发布时间:2026/10/17 5:48:06
Vue CLI仿写外卖应用实战:组件拆分与Vuex状态管理避坑指南
用 Vue CLI 仿写一遍某外卖应用这件事我完整地干了两轮。第一轮踩坑踩到想删库第二轮才算把组件拆分、Vuex 状态管理、移动端适配这些关键点理顺。如果你正准备拿这个项目练手或者手头有个 H5 移动端项目要上 Vue 全家桶这篇总结应该能帮你避掉一多半弯路。这个项目表面上是“模拟做一个外卖 App”实际上它考的是三件事第一你能不能把一个复杂商业产品拆成组件和路由第二你能不能把购物车、商家数据这类跨页面状态用 Vuex 管得不乱第三你懂不懂移动端到底有哪些区别于 PC 端的坑。标题里那句“仿写饿了么”本质上不是让你复刻界面而是让你学会它的工程化思路。这篇博文没有平台痕迹完全是我自己的实战复盘从脚手架搭建到上线可能踩的坑都会覆盖到。1. 项目整体设计仿写不是抄 UI是拆产品先聊点务虚的把这个项目到底要干什么想清楚再动手写代码。我发现很多人一上来就急着画页面结果做出来一个“长得像、动不了”的壳子连购物车都是假的。仿写项目的核心是先拆业务再拆技术。1.1 弄清楚“仿写”二字的价值“仿写”不是“照抄”更不是把别人的代码拿过来改个名。它的完整价值在于别人已经把产品功能、用户交互、业务流程都验证过了你只需要负责“重新实现”不需要纠结产品定位。这个阶段最适合练技术因为你不用分心思考业务是否成立。拿这个外卖应用来说我把它拆成下面几个核心业务块首页商家列表包含品牌、评分、月售、起送价、配送费、距离商家详情分类菜单、商品列表、购物车浮层、商家评价和店铺信息购物车跨页面同步的商品数据、金额计算、结算流程订单创建订单、订单列表、订单状态流转个人中心用户信息、收货地址管理这五个业务块对应到前端就是一个路由结构加一个状态管理的骨架。技术上真正有难度的是“商家详情下的购物车浮层”和“跨页面购物车数据同步”这两个点你拿下来项目就成了70%。1.2 为什么选择 Vue CLI 做这个项目有人会问现在 Vite 那么火为什么还要用 Vue CLI这个选择很简单稳定性、生态、资料多。Vue CLI 基于 webpack配置虽然重但对移动端各种图片压缩、样式预处理、代理转发处理得非常成熟。你只要vue create一条命令插件机制会把 babel、linter、router、vuex、CSS 预处理器全部配好不需要像 Vite 那样还得花时间排查插件兼容性。我也不是反对 Vite而是这个仿写项目本身就是练手用的重心应该放在业务代码和组件设计上。一个开箱即用的 Vue CLI 工程能让你把精力尽可能花在刀刃上。注意Vue CLI 5 内置的 webpack 5对 Node 版本有要求建议 12.13 以上。如果你电脑上同时有多个 Node 管理工具我用的是某版本管理工具创建项目前先node -v确认一下否则会遇到一堆报错。1.3 目录结构与数据流的顶层设计项目一创建完我会先做一件事把 src 下的目录结构重新整理一遍把带有删代码冲动的混乱扼杀在摇篮里。我的习惯是这样src/ ├── api/ # 接口请求封装 ├── assets/ # 静态资源、全局样式 ├── components/ # 公共组件 │ ├── common/ # 跨页面复用的基础组件 │ └── business/ # 业务级组件如商家卡片 ├── router/ # 路由配置 ├── store/ # Vuex 状态管理 │ ├── modules/ # 按业务拆模块 │ └── index.js ├── utils/ # 工具函数格式化、节流等 └── views/ # 页面级组件 ├── Home/ ├── Shop/ ├── Cart/ ├── Order/ └── Profile/数据流上我给自己定了一条死规矩父子组件通信只用来传展示数据和回调事件所有跨页面的“业务数据”购物车、订单、登录态一律走 Vuex。这条规矩能让你避免 90% 的组件通信混乱问题。2. 环境准备与移动端工程化配置这一章没有太多花活但每一个配置项都是实打实的坑。项目初始化看起来简单适配方案要是选错了后期改起来想死的心都有。2.1 初始化项目与依赖安装创建项目我用的是 Vue CLI 的交互式命令npm install -g vue/cli vue create eleme-mobile创建过程中需要选择预设。我建议手动选特性Manually select features至少要勾上Babel、Router、Vuex、CSS Pre-processors、Linter。ESLint 建议选 Airbnb 风格或标准风格别选 ESLint Prettier 混合模式因为两个工具的格式化规则互相打架时你会被报错逼疯。项目创建完之后我额外装了几个库这是移动端项目必备的组合拳npm install axios fastclick lib-flexible postcss-px2rem better-scrollaxios接口请求没啥好说的fastclick解决移动端 300ms 点击延迟lib-flexible动态设置根字体大小配合 rem 做移动端适配postcss-px2rem打包时自动把 px 转成 rem省得自己挨个算better-scroll实现商家商品列表联动滚动原生滚动在处理联动场景非常麻烦2.2 移动端适配方案rem 与 px2rem 的配合移动端适配是仿写项目第一个大坑。这个外卖应用是 H5 形态得在大屏手机、小屏手机上都保持稳定布局。我采用的方案是“lib-flexible 动态根字号 postcss-px2rem 自动转换”。lib-flexible 会自动读取屏幕宽度和设备像素比DPR给html设置对应的font-size根字号默认按照“屏幕宽度 / 10”来计算。你只需要在 main.js 引入它import lib-flexible然后配置 postcss-px2rem在postcss.config.js里设置remUnit: 75。为什么是 75因为 lib-flexible 把设计稿宽度默认假设为 750px750 / 10 75。也就是说设计稿上的75px就等于页面上的1rem写 px 的时候脑子里走一遍这个比例就行。实际开发中最容易忽略的地方是如果你用 Vue CLI 4 以上版本postcss 配置要写在postcss.config.js或者package.json里的postcss字段千万别往 webpack 配置文件里塞否则可能被插件内部的配置顺序覆盖掉。2.3 开发环境的 Mock 数据方案这个项目没有真实后端所以接口数据怎么造决定了你后面开发效率的上限。我的做法是这样在public/mock下放几个 JSON 文件用 axios 的本地请求指向它们配合vue.config.js里的 devServer 把请求代理到本地静态目录。// vue.config.js module.exports { devServer: { proxy: { /api: { target: http://localhost:8080, changeOrigin: true, pathRewrite: { ^/api: /mock } } } } }这样前端代码里统一请求/api/shop/list开发时命中的是public/mock/shop/list.json等后端接口就绪后只需要删掉 pathRewrite 就行。这个方案比引入 mockjs 更轻因为 mockjs 里的拦截逻辑偶尔会跟 axios 的拦截器打架排查起来浪费时间。直接用本地 JSON 文件最干净也最能模拟真实接口返回。3. 核心功能模块拆解与实现开始写业务代码了。这一章我按页面拆开讲每个页面都会给出我实际用到的组件拆法、核心逻辑和风格建议。3.1 底部 Tab 导航与路由设计外卖应用的五块业务我用一个底部导航栏串起来。路由配置分成两级一级路由是TabBar它内部嵌了五个子路由页面二级路由是商家详情页它独立于 Tab 架构出于业务考虑不能带着底部导航。{ path: /, component: () import(/views/TabBar.vue), redirect: /home, children: [ { path: home, name: Home, component: () import(/views/home/Home.vue) }, { path: cart, name: Cart, component: () import(/views/cart/Cart.vue) }, { path: order, name: Order, component: () import(/views/order/Order.vue) }, { path: profile, name: Profile, component: () import(/views/profile/Profile.vue) } ] }, { path: /shop/:id, name: Shop, component: () import(/views/shop/Shop.vue), redirect: /shop/:id/goods, children: [ { path: goods, name: ShopGoods }, { path: comments, name: ShopComments }, { path: info, name: ShopInfo } ] }这里有个关键点所有路由组件都用import()动态引入实现按需加载。不然移动端首屏体积会非常臃肿打开白屏时间让你怀疑人生。TabBar 这块用router-view配合keep-alive把首页和商家列表缓存切回来时不用重新请求数据体验感提升非常明显。3.2 首页商家列表接口数据到组件的单向流动首页看起来简单其实组件的拆分最考验功力。我的拆法是把“商家列表”这一大块做成了四个小组件HomeHeader.vue定位、搜索入口ShopList.vue列表容器负责数据请求和加载状态ShopItem.vue单个商家卡片负责展示评价、起送价、配送信息RatingStar.vue评分星星小组件抽出来为什么这么拆因为ShopItem.vue里嵌套了评分、品牌标识、配送信息等多个逻辑块如果全部塞在一个组件里模板会膨胀到几百行后期维护等于噩梦。商家数据的接口返回格式我固定为这样{ code: 0, data: [ { id: 1001, name: 某川菜馆, brand: brand, rating: 4.8, monthSales: 1200, deliveryFee: 5, deliveryTime: 30, distance: 1.2, image: /mock/images/shop1.png } ] }代码里用 axios 请求后把 data 部分存到组件的 data 中模板里用v-for渲染。每个字段都是独立的方便以后改版增加新字段。这里不要偷懒用整块渲染方案宁可多写几个字段也不要让组件变成一个黑盒子。3.3 商家详情页分类联动与滚动处理商家详情页是仿写项目里技术上最有分量的一个页面。它的交互特征是左侧是商品分类导航右侧是对应分类下的商品列表两个区域需要联动滚动——你滑右侧列表左侧分类高亮要跟着变你点左侧分类右侧列表要滚到对应位置。实现这个效果的核心是 better-scroll。我的做法是把左侧分类和右侧商品列表分别包进两个 better-scroll 实例或者用一个实例配合scrollToElement来做。监听右侧列表滚动事件根据当前滚动位置计算当前分类的 index更新左侧高亮。点击左侧分类调用右侧 scroll 实例的scrollTo方法滚动到对应分类的锚点。这里有一个样板代码你需要用心记住// 左侧菜单点击 handleMenuClick(index) { this.currentIndex index const targetEl document.querySelectorAll(.goods-list-item)[index] this.rightScroll.scrollToElement(targetEl, 300) } // 右侧滚动计算当前分类 calculateCurrentIndex() { const y Math.abs(this.rightScroll.y) const items this.leftCategories.map((item, i) { return this.rightScroll.elements[i].offsetTop }) // 用 y 和 items 比较取最后一个满足条件的索引 }坑点在于商品列表的 DOM 高度是动态的图片加载、卡片渲染都会让高度变化。必须在图片加载完成后手动调用refresh()刷新 better-scroll 的滚动高度。另外左侧分类高亮联动时我的习惯是计算一个currentIndex存到 data 里用:class绑定高亮样式不要直接操作 DOM 改 class。3.4 购物车模块组件与状态的第一次正面交锋购物车是整个仿写项目里最“黄金”的部分。商家详情页底部有购物车浮层首页商品加入购物车后购物车 Tab 页也能看到同一条数据这就意味着购物车数据必须是全局的。我的实现路径是这样的商家详情页的商品卡片上点“加号”按钮调用一个store.dispatch(cart/addItem, { goods, shop })购物车浮层底部展示总价和商品数量数据全部从 Vuex getters 里拿购物车 Tab 页是一个独立的列表页展示已经加入的所有商品购物车与商家的关系也需要注意因为不同商家虽然商品不同但购物车数据是全站共享的所以加入第二家店的商品时需要弹窗提示“清空上一个商家的购物车”或者做独立分组。我第一轮做的时候偷懒没处理这个结果购物车里两家店的商品混在一起被测试 mock 数据坑到怀疑人生。第二次我老老实实在 store 里给 cart 记录了一个shopId切换商家加购时主动清空旧数据这才符合真实业务。3.5 订单与个人中心页面订单页的做法其实是用一个列表实例靠状态标签过滤。先设计订单的数据结构{ orderId: xxx, shopName: 某川菜馆, totalPrice: 58, status: pending, // pending | delivering | completed | cancelled goodsList: [ { name: 回锅肉, count: 1, price: 38 } ] }关键的业务细节是“商品快照”下单时要把当时的商品名称、价格、数量复制一份存到订单里而不是去引用商品表的实时数据。因为商品价格会变动如果订单总价存的是“查询商品表实时价格”那用户历史订单的价格显示就会错。这个点在真实开发里非常重要仿写时很多人不留意就直接引用了商品对象。个人中心页则相对简单无非是头像、订单状态入口、地址管理。我做了一个通用 list 组件父组件传配置数组自动渲染图标、标题和跳转路由避免每个列表页都重写一遍 HTML。4. Vuex 状态管理与数据流深挖这一章单独把 Vuex 拎出来讲是因为购物车状态管理是这个项目真正的分水岭。写得好不好直接决定你后续加功能时是顺手还是抓狂。4.1 购物车的数据结构设计不要在商品对象上挂 count最直观的写法是把购物车设计成数组每个元素是一个商品对象加购时往数组里 push。这个方案写起来最快但当你需要修改某个商品的购买数量时你得遍历数组查找复杂度是 O(n)而且更新逻辑散落各组件非常容易出错。我采用的方案是“以 id 为键的对象映射”// store/modules/cart.js const state { shopId: null, items: { // 商品 id 作为 key // 10001: { goodsId: 10001, name: 回锅肉, price: 38, count: 2 } } }用对象映射的好处是加购、减少、改数量都走直接取值或赋值复杂度 O(1)。为什么 Vuex 里可以直接在商品对象上面挂 count从功能上讲也能跑但 Vuex 的“响应式”对后来新增属性非常不友好中途加一个 count 字段很容易变成非响应式导致视图不更新。提前把数据模型设计成扁平结构是最省心的。4.2 mutation 和 action 的边界必须拎清Vuex 的核心规矩是异步操作放 action同步修改放 mutation。一个人购物车为例加购看起来是同步的但真实业务中点击加购可能需要校验商品库存、更新购物车费率等这些逻辑就该写在 action 里。我的写法是这样// actions addItem({ commit }, { shopId, goods }) { commit(ADD_ITEM, { shopId, goods }) return Promise.resolve() // 告诉调用者操作完成 } // mutations ADD_ITEM(state, { shopId, goods }) { if (state.shopId ! shopId) { // 切店清空旧购物车 state.items {} state.shopId shopId } if (state.items[goods.id]) { state.items[goods.id].count 1 } else { state.items[goods.id] { ...goods, count: 1 } } }这里有个细节action 里返回一个 Promise组件里就可以await store.dispatch(cart/addItem)然后做弹窗提示或者动画反馈。这个模式在表单提交、结算跳转等场景非常通用。4.3 状态持久化与未登录状态的处理购物车数据一旦被用户加了好几个商品用户刷新页面全丢了体验很糟。我在 store 里加了一个简单的 localStorage 同步逻辑每次 mutation 提交之后把 cart 模块的 state 序列化存到本地初始化时从 localStorage 读取并恢复。比较好的做法是封装一个小插件而不是在组件里watch数据然后自己 setItemconst localStoragePlugin store { store.subscribe((mutation, state) { localStorage.setItem(cart, JSON.stringify(state.cart)) }) }未登录状态也能加购物车这一点符合真实场景。但是到结算页时就会校验登录态没有登录就弹窗跳转登录页。仿写项目里我用了一个简单 token 字段模拟登录态没有接后端反正逻辑跑通即可。5. 页面性能优化与交互细节打磨你以为页面能跑就完事了吗没有。移动端 H5 的性能要求比 PC 严格得多图片加载、滚动流畅度、异步体验都是用户摸得到的感知。5.1 图标处理少用图片多用字体图标外卖应用的 tab 图标、按钮图标、状态图标如果全部用 png 图片切图切到你血压升高而且图片请求多、单张体积也不小。我的做法是全部用 SVG 或字体图标。如果你用的是 Vue CLI 4/5SVG 图标可以直接放在src/assets/icons下然后用svg-sprite-loader把多个 SVG 打包成雪碧图页面里用use引用。这个方案比字体图标更可控因为字体图标在部分安卓机字体渲染不一致时会出现大小不一甚至乱码。5.2 图片懒加载与骨架屏商家列表一页有几十个商家每个商家都有 logo 和 banner 图如果首屏全部加载用户流量就烧没了页面也卡得一批。我写了一个简单的v-lazy指令基于 IntersectionObserver 实现只让进入视口区域的图片开始加载。Vue.directive(lazy, { inserted(el, binding) { const observer new IntersectionObserver(entries { if (entries[0].isIntersecting) { el.src binding.value observer.unobserve(el) } }) observer.observe(el) } })骨架屏是更进阶的要求其实不用做得很复杂。我在首页请求数据时用 CSS 写了几个占位块接口返回后替换为真实内容配合路由懒加载首屏白屏时间能压到 1 秒以内。这个体验差距用户是能直观感受到的。5.3 better-scroll 与 1px 边框better-scroll 在真机上有一个常见坑图片加载完成、字体加载后scroll 计算的高度过时导致滚动到最底部差一截。解决方法是监听图片的load事件再调用scroll.refresh()this.$nextTick(() { this.scroll.refresh() }) // 如果容器内图片很多可以用图片加载完成后再刷新 Array.from(document.querySelectorAll(.goods-list img)).forEach(img { img.addEventListener(load, this.refreshScroll) })1px 边框也是移动端老生常谈。直接写border: 1px solid #eee在高 DPR 屏幕上会呈粗线商用项目不会这么写。我的统一方案是用伪元素加 transform 缩放.box::after { content: ; position: absolute; top: 0; left: 0; width: 200%; height: 200%; border: 1px solid #eee; transform: scale(0.5); transform-origin: 0 0; pointer-events: none; }这个方案兼容性极好几乎覆盖所有移动浏览器。6. 常见问题与调试经验速查写到这一章基本是把我在两轮开发里反复遇到的疑难杂症全部整理出來。按照下面的问题清单排查能省掉你调试的大半天时间。6.1 跨域与代理配置的坑开发环境明明配置了 proxy但请求就是 404 或者 500。最常见的两个原因target忘加http://代理指向错误pathRewrite写错了接口路径没被正确替换我用的是本地 Mock 方案正确配置如下devServer: { proxy: { /api: { target: http://localhost:8080, changeOrigin: true, pathRewrite: { ^/api: /mock } } } }如果配完还不生效务必清一下浏览器缓存并且确认 vue-cli-service 已经重启。代理配置改了不会热更新这是无数人踩过的坑。6.2 列表滚动与布局塌陷问题商品详情页最容易出现的布局问题是右侧商品列表没有撑起高度better-scroll 没法滚动。原因基本是height: 100%链条断裂。必须保证html、body、#app、.shop-container到内部滚动容器每一层都有明确高度或者滚动容器直接设position: fixedtop/bottom。我建议商家详情页直接用 fixed 定位滚动区域避免父容器高度被忽略。6.3 组件通信混乱与状态不同步症状在商品详情页加了购物车切到首页底部购物车图标数量没变。排查思路确认你调用的购物车数据是不是同一个 store 模块。我见过有的人在组件里this.$store.state.cart有的在this.$store.state.moduleA.cart模块名写错导致数据源不一致。确认 mutation 的类型名没有重复Vuex 模块里namespaced: true没有设置的话同名 type 会被覆盖。组件里有没有正确的mapState/mapGetters引入如果直接this.cartList而组件里没有对应计算属性那视图当然是旧的。6.4 移动端兼容性几个非解决不可的细节微信内置浏览器默认对长列表滚动卡顿给滚动的 viewport 容器加上-webkit-overflow-scrolling: touchiPhone X 等带底部横条机型底部导航栏要加padding-bottom: env(safe-area-inset-bottom)不然结算按钮会被横条遮挡input 聚焦弹出键盘时会遮挡输入框解决方案是监听 window 的 resize 事件把输入框滚动到可视区域flex 布局下子元素宽度塌陷记得给子元素flex-shrink: 0或min-width: 0把这些问题记进我的笔记之后第二轮开发顺得不是一点半点。我再分享一个小技巧如果你的移动端项目需要调试真机页面不要只用浏览器的移动端模拟器手机访问本地开发服务时把vue.config.js里的devServer.host设成0.0.0.0这样同一局域网下手机直接输入电脑 IP 就能打开页面。调试滚动和触摸交互时真机和模拟器的差异会让你深刻理解什么叫“桌面端永远模拟不出手机的物理手感”。两轮做下来我最大的体会是仿写项目最忌讳“一把梭”——一上来就写页面写到哪算哪。先把业务拆解清楚再把技术选型定好最后组件拆分和状态管理决定了项目是 3 天能实现还是 3 周还在加班补 bug。按照这个思路走你应该能比我第一轮少踩一半坑。