资讯动态

仿写饿了么外卖App:Vue CLI+Vuex+路由实战完整教程

发布时间:2026/10/17 5:47:57 来源:尧图企业网站定制
仿写一款像饿了么这样体量的外卖App对很多学完 Vue 基础但找不到练手项目的人来说是一条非常合适的进阶路径。我自己拿 Vue CLI 做这个项目时目标很简单把 Vue Router、Vuex、组件通信、移动端布局这些平时分散的知识点全部放进一个足够复杂的真实场景里串一遍。页面可以不用做得一模一样但整个“进店、选菜、加购、结算”的用户链路必须完整跑通。下面就把我从搭脚手架到页面联动、再到项目复盘的完整过程写出来希望能让准备动手的你少走一些弯路。1. 仿写前先拆需求外卖App里到底有哪些前端考点1.1 功能范围做到什么程度算“能交差”很多新手拿到仿写题的第一反应是打开目标 App 挨个页面看然后感慨“这也太复杂了”。其实最该做的第一件事是把需求缩小到可控范围。我在动工前先在纸上列了一份功能清单首页顶部定位与搜索框、分类图标导航、商家滚动列表、加载状态。商家详情页店铺头图与基础信息、左右两栏商品菜单左侧分类、右侧菜品、底部购物车浮层。购物车增减商品、展示清单、总价与总件数计算、清空操作、跨商家状态隔离。订单列表页与我的页面先用静态数据和少量交互占位最后再决定要不要补。这份清单里没有登录、没有支付、没有后台系统、没有真实搜索。这非常关键仿写项目的死亡率往往不是难度而是需求无限膨胀今天想加搜索框联想明天想加订单追踪后天想搞积分签到每加一个都会把核心链路的打磨时间拖掉一两周。我自己定的标准是核心闭环能顺畅演示非核心功能宁缺毋滥。1.2 为什么外卖场景比后台管理更锻炼人后台管理系统的页面大多是表单、表格、弹窗的循环前端要处理的信息流比较稳定。外卖类业务不一样它由前端驱动的“用户状态”非常稠密当前定位、选中的商家、购物车内容、结算金额、订单状态全都需要跨页面共享和同步。换句话说它不是在做静态展示而是在推演一套完整的业务流程。我列过一张对照表方便你在动手前想清楚这个项目到底练什么练习点在仿写里的具体体现做完能掌握什么组件化首页 6~8 个业务组件拆分组件边界与 props/events 通信路由体系底部 Tab 详情页跳转嵌套路由、懒加载、参数传递全局状态购物车跨页面共享Vuex 模块化与 mutation 设计滚动与性能商品列表、分类联动滚动容器高度、事件节流移动端适配全站 vw/rem 布局响应式方案、真机调试思维1.3 需要的前置知识清单这个项目不建议完全零基础来写。你至少得先写过几个能跑的 Vue 组件配置过 vue-router并且已经知道 Vuex 的 state、mutations、actions 大概各管什么事。如果这些基础还没到位先花两三天过一遍文档再回来否则写代码时很容易陷入“明明在报错但不知道是哪部分基础没学会”的挫败感。2. 用 Vue CLI 搭工程创建命令、目录结构与依赖取舍2.1 创建项目的操作步骤脚手架部分我用的命令非常简单vue create eleme-demo如果本机还没装 Vue CLI先把命令工具装上。创建时在交互面板里选择 Manually select features然后勾上 Babel、Router、Vuex、CSS Pre-processors。我不建议一上来就开 TypeScript不是说它不好而是仿写项目的主要矛盾是把业务逻辑和工程组织跑通语言层面的额外心智负担可以放到下一个项目再加。创建完成以后第一件事不是急着写页面而是做清理和目录重排。默认生成的 HelloWorld 组件、示例图片和乱七八糟的 assets 全部删掉把 src 改成下面这样src ├── api # 接口封装层 ├── assets # 静态图片、字体图标 ├── components # 通用组件 ├── mock # 假数据 ├── router # 路由配置 ├── store # Vuex 模块 ├── styles # 全局样式 └── views # 页面组件这个目录在团队项目里很常见单人仿写同样值得遵守。把 mock 和 api 分开有一个现实好处将来接真实后端接口时只需要动api目录的内部实现页面的调用方式完全不用改。2.2 依赖选择加什么、不加什么我最后只加了两个运行依赖vue-router和vuex脚手架默认会带再加一个 CSS 预处理器。没有引入任何 UI 组件库。理由很简单饿了么这类偏电商、偏交互的产品组件自定义程度非常高。你引入 Vant 或者 Element 之后还要花大量精力去覆盖它们自带样式覆盖到一半反而会被框架的习惯拖着走。仿写项目本来就是为了吃透组件原理从头写一遍的收益大得多。滚动库better-scroll我也没有第一步就引入而是先把原生 overflow 滚动方案跑通后面发现真机体验不够好再考虑。这个决定让我在联调阶段省了不少时间详见第 5 章。2.3 全局基础样式和项目配置先处理一件容易忽略但非常重要的事html、body、#app 的高度必须设置好。移动端长列表滚动的核心就是“滚动容器要有明确高度”如果根节点高度塌陷后面所有overflow-y: auto都会失效。我在src/main.js里挂载styles/index.scss顶部放上一段基础重置*, *::before, *::after { box-sizing: border-box; } html, body, #app { height: 100%; margin: 0; padding: 0; font-family: -apple-system, PingFang SC, Microsoft YaHei, sans-serif; }同时要确认vue.config.js里指向src否则大量/api/xxx的引用到了后面会全部崩掉。这个别名默认就是配好的但如果你以前复制过别人的配置最好还是检查一下。3. 数据先行Mock 数据结构设计比页面更重要3.1 先定义核心字段别让组件里各写各的我见过不少人把 mock 数据直接写在单个页面里最后两三个页面各拿一份字段名对不上的数据联调时非常痛苦。更合理的做法是先建 mock 目录把商家、分类、商品的核心结构定义清楚。我用的是一份最小但完整的模型// mock/sellers.js export const sellers [ { id: 1, name: 街角小炒店, rating: 4.8, monthlySales: 278, deliveryTime: 28, minPrice: 20, deliveryFee: 4, categories: [ { id: 101, name: 热销, foods: [ { id: 1001, name: 尖椒回锅肉, price: 32, pic: /img/food-01.png, }, ], }, ], }, ]这里有几个约定要提前统一商家和商品用id关联分类用name商品展示用pic价格用price整数存储。字段一旦定下来整个项目里就不要再突然改叫img、image、pictures这种命名混乱是后期大量 bug 的温床。3.2 把 mock 接口封装成异步loading 和骨架屏才有意义只导出静态数据会导致页面瞬间渲染完永远看不到网络请求时的 loading 状态。我把所有 mock 数据都用 Promise 加 setTimeout 包了一层模拟网络延迟// api/seller.js import { sellers } from /mock/sellers export function getSellerList() { return new Promise((resolve) { setTimeout(() resolve(sellers), 300) }) } export function getSellerDetail(id) { return new Promise((resolve, reject) { const found sellers.find((item) item.id Number(id)) setTimeout(() { found ? resolve(found) : reject(new Error(商家不存在)) }, 300) }) }组件里统一用const res await getSellerList()来取数据。以后换成 axios 时只需把函数内部改成return axios.get(/sellers)页面调用层完全不用动。3.3 购物车数据不能混进商家对象商品属于“商家资源”购物车里的数量和勾选状态属于“用户会话状态”两者性质完全不同。如果把count直接挂在 food 对象上两个页面共用同一个 food 对象时一个地方改了数量另一个地方也会变化很容易产生隐蔽 bug。我有意识地分了两层seller.categories.foods只存商品自己的信息Vuex 的 cart 模块只存{ goodsId, goods, count }这种购买行为。这个分层会在第 6 章详细展开先记住结论接口模型和用户行为状态永远分开。4. 首页实现定位、分类导航与商家列表的组件化套路4.1 外壳布局上固定、下滚动首页模板部分我写得很克制就是上方 header、下方 content 两个兄弟节点template div classhome-page header classhome-header div classlocation-picker span某市/span i classicon-caret/i /div div classsearch-input click$router.push(/search) i classicon-search/i span搜索商家、商品/span /div /header main classhome-scroll CategoryNav :categoriescategories / SellerList :sellerssellers :loadingloading / /main /div /template这里 header 没有用position: fixed而是让外层home-page设一个明确高度header 作为 flex 的第一个子元素自然固定home-scroll用flex: 1; overflow-y: auto承担滚动。这么做的优势是顶部不会遮挡内容底部安全区也不用手动计算。4.2 商家卡片把评分、配送信息封装成内部小组件商家卡片我单独放在components/SellerCard.vue接收一个 seller 对象。卡片内部包括头像、名称、评分、月销量、起送价/配送费、推荐标签其中评分部分又拆出了一个Star.vue小组件。星级展示是最基础也很典型的循环渲染div classstars span v-forstar in 5 :keystar classstar :class{ active: star Math.round(rating) } /span /div把评分拆成组件之后以后想做半星、展示评分数值、控制星星尺寸只需要改这一个文件。这里我踩过一个小坑商家卡片作为子组件最开始接收的是seller-name、seller-rating这种打散的 props后来想加一个字段就要从父组件到模板全部过一遍。正确的做法是整块接收对象内部自行解构。4.3 加载状态与图片兜底从接口拿数据后我在 Home 里维护了loading和error两个状态。loading时展示骨架屏占位error时显示重试按钮。这个模式非常通用代码量也不大但能覆盖真实场景里“接口失败、超时、重试”这一整条链路。图片字段是新人最容易翻车的地方。我最初没有给每条 mock 数据配置pic字段页面渲染出一排裂图。后来统一在数据层给每个商家和商品都加了pic字段没有图片就填/img/placeholder.png。这样模板永远不用写v-if去绕开缺失字段页面结构会干净很多。5. 商家详情页商品分类联动滚动的完整解决思路这一章是整个项目里仅次于购物车的第二个硬骨头也是最值得深入理解的部分。面试官如果问“你怎么实现双列联动的”答得清不清楚基本能看出对 DOM 和滚动原理的掌握程度。5.1 左右两栏必须有稳妥的高度约束页面结构是一个左侧分类菜单加右侧商品列表的左右分栏。两侧都要能独立滚动所以先保证父容器有明确高度div classinner-seller-detail div classleft-menu div classmenu-item v-for(cat, index) in categories :keycat.id :class{ active: currentIndex index } clickclickCategory(index) {{ cat.name }} /div /div div classright-list refrightList scrollhandleRightScroll div classfood-block v-for(cat, index) in categories :keycat.id :reffoodBlock h3{{ cat.name }}/h3 div classfood-item v-forfood in cat.foods :keyfood.id img :srcfood.pic :altfood.name / div classfood-info span classfood-name{{ food.name }}/span span classfood-price{{ food.price }}/span /div AddButton :foodfood / /div /div /div /div对应的样式里两侧容器都要设height: 100%和overflow-y: auto。如果父级高度没确定overflow-y就只是摆设根本不会出现滚动条。5.2 右侧滚动时左侧高亮用 offsetTop 做区间判断右侧整体是一大块内部按分类切成foodBlock。右侧滚动时遍历每个 block 的offsetTop再对照当前scrollTop判断应该把左侧第几个菜单点亮handleRightScroll(e) { const scrollTop e.target.scrollTop const blocks this.$refs.foodBlocks let current 0 for (let i 0; i blocks.length; i) { const top blocks[i].offsetTop - 48 const bottom blocks[i].offsetTop blocks[i].offsetHeight - 48 if (scrollTop top scrollTop bottom) { current i break } } this.currentIndex current }我留了一个 48px 的缓冲区间。如果不加缓冲在两个分类交界处滚动时左侧菜单会在相邻分类之间疯狂跳变体验非常差。这 48px 不是固定规律你可以根据分类头部高度调整但思路是一样的给每个分类“设定一个高亮生效区间”而不是用offsetTop scrollTop这种过窄的判断。5.3 点击左侧菜单右侧滚动到对应分类反过来联动时我需要拿到右侧容器 DOM再把目标分类块的offsetTop赋给scrollTopclickCategory(index) { const blocks this.$refs.foodBlocks const block blocks[index] if (!block) return this.currentIndex index this.$nextTick(() { this.$refs.rightList.scrollTop block.offsetTop }) }这个实现最需要注意的是$refs只有在模板渲染后才能拿到。如果你在created里马上操作 refs拿到的一定是空对象。我一般会等 mock 数据返回并把分类渲染完成后再注册滚动事件和读取 refs或者在$nextTick回调里统一处理。5.4 用不用 better-scroll取决于你遇到什么瓶颈网上大部分仿写教程会直接告诉你用 better-scroll但我自己的实践体会是先基于原生 overflow 滚动把功能跑通再按需引入库。原生方案在 PC 端浏览器和大部分移动端浏览器里足够稳定而且不需要额外维护依赖体积。当你发现真机滚动惯性不够、滚动事件响应太慢、或者需要锁定滚动方向时再引入 better-scroll 才有明确目标。这时候你能清楚说出自己是在解决哪一层问题而不是被一个封装库牵着走。6. 购物车与 Vuex从数据结构到跨商家边界6.1 用模块化 Vuex 管理购物车而不是堆在根 store外卖购物车是典型的全局状态详情页在改、底部浮层在显示、首页角标要同步、订单页还要汇总。如果靠 event bus 或者父组件一层层传项目到后期会变成一团乱麻。我在 store 里单独建了一个modules/cart.jsconst state { items: {}, // key: goodsId, value: { goods, count } } const mutations { ADD_ITEM(state, goods) { if (state.items[goods.id]) { state.items[goods.id].count 1 } else { state.items[goods.id] { goods, count: 1 } } }, REDUCE_ITEM(state, goodsId) { const item state.items[goodsId] if (!item) return item.count - 1 if (item.count 0) { delete state.items[goodsId] } }, CLEAR_CART(state) { state.items {} }, }这里我把goods对象完整存进 items 里看似冗余但有意为之购物车列表需要独立展示商品信息如果只存 goodsId打开购物车时还要回查商家数据接口层、路由层全都要跟着绕。直接冗余一份商品快照组件拿数据的路径是最短的。6.2 getters 集中计算不要在模板里堆逻辑总件数和总价这类派生数据放在 getters 里最合适const getters { totalCount: (state) Object.values(state.items).reduce((sum, item) sum item.count, 0), totalPrice: (state) Object.values(state.items).reduce( (sum, item) sum item.goods.price * item.count, 0 ), }组件里用 mapGetters 直接取computed: { ...mapGetters(cart, [totalCount, totalPrice]), }这里强烈建议使用模块化命名空间不然等以后 store 里再放定位、订单等模块getters 名字很容易撞车。还要提醒一句不要在模板内部写Object.values(items).reduce(...)这种逻辑所有计算收敛到 getters既能复用又可测试。6.3 跨商家购物车进入新商家前先判断真实产品里A 商家加购的商品在进入 B 商家时会被拆成独立订单。仿写阶段我做了一个简化版进入新商家时如果购物车里有上一个商家的商品就弹窗提示“是否清空购物车”。实现上靠路由参数变化来触发判断watch: { $route.params.id: function (newId) { if (this.totalCount 0 newId ! this.currentSellerId) { this.$dialog.confirm(切换商家后购物车将被清空是否继续).then(() { this.$store.commit(cart/CLEAR_CART) }) } }, }有些复杂但值得提倡的版本是把“当前商家 id”也存进 Vuex在执行ADD_ITEM时检查商品是否属于当前商家。后者更贴近真实设计但实现成本会高一些仿写阶段至少要先做“进入新商家时判断”这个动作避免用户一不留神就在两家店混着下单。6.4 弹层、清空与锁定背景滚动购物车清单浮层点击清空时调用CLEAR_CART会直接把 items 整体重置保证 Vuex 能感知引用变化。浮层显隐我维护了一个局部状态cartVisible并在打开时锁住 body 滚动openCart() { this.cartVisible true document.body.classList.add(overflow-hidden) }, closeCart() { this.cartVisible false document.body.classList.remove(overflow-hidden) }在一个完善的项目里建议把这个逻辑抽成独立的lockScroll工具函数统一在几个弹层组件里使用防止每个组件各写一套导致混乱。7. 路由缓存与移动端适配易踩却很少被提到的坑7.1 首页要不要 keep-alive体验完全不同路由默认情况下从首页跳到详情页再返回首页会重新渲染滚动位置直接丢失。用户滑到商家列表第 20 个位置再返回看到自己回到顶部体验会很糟。我给首页路由加了meta: { keepAlive: true }并在根组件里配合const routes [ { path: /home, component: () import(/views/Home.vue), meta: { keepAlive: true } }, ]template keep-alive router-view v-if$route.meta.keepAlive / /keep-alive router-view v-if!$route.meta.keepAlive / /template开启 keep-alive 后有一个隐蔽坑created只会执行一次。我最初把“重新拉取商家列表”的逻辑放在created里发现详情页返回后数据不会刷新。正确答案是把这类每次进入都需要的逻辑挪到activated钩子里同时把不需要重复执行的放回created。7.2 尺寸适配vw 方案比 rem 更省心以前做移动端适配常用 rem 加 flexible 脚本现在更推荐直接用 vw。以 375 设计稿为例写一个 SCSS 函数function vw($px) { return #{$px * 100 / 375}vw; }然后宽度直接用width: vw(188)简单直接。不用引入任何运行时脚本兼容性也能覆盖主流移动端。需要注意1px 边框这类极细线条不要整体用 vw否则不同设备上可能出现边界模糊建议用伪元素加 scale 来单独处理。7.3 iOS 高度 bug、滚动穿透和底部安全区我在购物车浮层级联测试时遇到过 iOS 上100vh不可信的问题地址栏伸缩会让页面底部忽高忽低。解决方式是根容器用height: 100%并保证html, body高度为 100%而不是无脑100vh。滚动穿透问题就按第 6.4 节的方案处理浮层打开时锁 body 滚动。最后是底部安全区商品底部浮层的按钮高度要加上这样一行.settle-bar { padding-bottom: env(safe-area-inset-bottom); }不然 iPhone 底部横条会挡住结算按钮。代码只有三行但对移动端真实体验的影响非常大。7.4 图片资源放在静态目录别全塞进 assets 用 require 包这是我在项目中途吃过的一次亏。最初的商品图片放进了assets组件里到处都是require(../assets/pics/a.png)。后来把 mock 数据改成 URL 形式大量 require 路径就崩了。于是我把商品图片全部挪到public/img下直接在数据里填/img/a.png。这样路径可读、页面迁移方便不用为大量静态图维护一条 require 链路。只有少数需要构建时处理的资源才值得保留在 assets 里。8. 复盘与进阶仿写项目怎么才算真正学到东西8.1 一个验收演示路径核心闭环要无缝项目做完以后我用一条非常土但有效的验收方式检查成果打开项目完整演示一遍“定位选择 - 浏览首页商家 - 进入某商家 - 分类菜单切换 - 加购两件 - 打开购物车 - 减少数量 - 清空 - 返回首页看角标 - 切换商家”。走完这条链路重点检查这几件事返回首页时滚动位置是否保留购物车角标是否跨页面同步跨商家清空逻辑是否正常触发购物车弹层是否阻断了背景滚动在 iOS 真机上底部按钮是否被安全区遮挡。每次演示暴露出的问题比写代码时自己“感觉良好”要真实得多。8.2 后续可以继续加的方向如果这个版本已经跑通可以考虑做一次升级我按优先级排了这样几个方向用IntersectionObserver实现图片懒加载提升超长商家列表的滚动性能用 localStorage 持久化购物车让刷新后数据不丢把 mock 接口换成本地小服务或真实后端框架体验请求拦截和错误处理的真实链路给评分组件增加半星能力并支持不同粒度的评分展示给订单列表页加筛选状态让第二个 Tab 不再是纯静态占位。任何一个方向做完都比再开一个全新项目更能积累经验。做这一类仿写项目最常见的误区是把“像素级还原”当成目标。页面的相似度当然是加分项但真正值钱的东西是你有没有借这个问题把 Vue 的工程化流程完整走通。数据流动、组件边界、状态管理、路由组织这些骨架思考才是几十个页面背后真正稳定的那部分。做完这个项目之后最明显的变化是我再写任何移动端业务都会下意识先问一句这条交互路径跨了几个页面哪些状态需要全局共享哪些数据应该属于接口模型。能把这个问题想清楚仿写就算真正到位了。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑