仿写一款像饿了么这样体量的外卖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 class="home-page"> <header class="home-header"> <div class="location-picker"> <span>某市</span> <i class="icon-caret"></i> </div> <div class="search-input" @click="$router.push('/search')"> <i class="icon-search"></i> <span>搜索商家、商品</span> </div> </header> <main class="home-scroll"> <CategoryNav :categories="categories" /> <SellerList :sellers="sellers" :loading="loading" /> </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 class="stars"> <span v-for="star in 5" :key="star" class="star" :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 class="inner-seller-detail"> <div class="left-menu"> <div class="menu-item" v-for="(cat, index) in categories" :key="cat.id" :class="{ active: currentIndex === index }" @click="clickCategory(index)" > {{ cat.name }} </div> </div> <div class="right-list" ref="rightList" @scroll="handleRightScroll"> <div class="food-block" v-for="(cat, index) in categories" :key="cat.id" :ref="'foodBlock'" > <h3>{{ cat.name }}</h3> <div class="food-item" v-for="food in cat.foods" :key="food.id"> <img :src="food.pic" :alt="food.name" /> <div class="food-info"> <span class="food-name">{{ food.name }}</span> <span class="food-price">¥{{ food.price }}</span> </div> <AddButton :food="food" /> </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赋给scrollTop:
clickCategory(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.js:
const 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 的工程化流程完整走通。数据流动、组件边界、状态管理、路由组织,这些骨架思考才是几十个页面背后真正稳定的那部分。做完这个项目之后最明显的变化是,我再写任何移动端业务,都会下意识先问一句:这条交互路径跨了几个页面,哪些状态需要全局共享,哪些数据应该属于接口模型。能把这个问题想清楚,仿写就算真正到位了。