摘要:本文以一个仿外卖点餐应用为完整案例,从项目架构与环境初始化讲起,依次拆解网络请求封装、商家首页焦点图、菜单联动滚动、购物车数量增减、满减优惠计算、模拟支付流程、订单列表分页刷新、消费记录统计、数据同步排查与性能优化等十个关键环节。全文聚焦真机运行中高频出现的"坑",配合可复制的代码片段与避坑思路,帮助新手从零跑通一个闭环项目,也适合准备面试的同学作为移动端商业应用开发的实战参考。
关键词:外卖点餐应用;移动端开发;状态管理;联动滚动;购物车算法;满减优惠;模拟支付;分页加载;虚拟列表;性能优化
在开发一款仿外卖点餐应用时,很多开发者容易陷入“功能堆砌”的误区,认为把页面上能点的按钮都做完就算完工。但实际真机运行后才发现,菜单和商品列表的滚动不同步、购物车数量计算出现精度误差、或者下拉刷新时数据重复加载等问题,才是真正影响用户体验的痛点。这些问题往往不是单一模块的失误,而是架构设计初期对数据流和状态管理考虑不周所致。
对于正在准备面试或希望提升移动端实战能力的同学来说,单纯阅读文档很难建立起对复杂业务场景的直观感受。我们需要通过一个完整的闭环项目,从环境搭建到最终的性能调优,亲手处理那些在 Demo 中容易被忽略的细节。比如如何优雅地处理网络请求的取消与重试,如何在长列表中实现高效的联动滚动,以及如何在本地模拟支付流程而不依赖后端接口。
本文将基于一个真实的外卖点餐项目案例,拆解从架构初始化到真机调试的全过程。我们会重点聚焦于那些在实际开发中高频出现的“坑”,并提供可落地的解决方案。无论你是想复现一个完整的项目作品集,还是希望深入理解移动端商业应用的开发逻辑,接下来的内容都将提供具体的代码思路和避坑指南,帮助你构建出既稳定又流畅的应用体验。
① 项目架构解析与环境初始化配置
启动任何商业级项目之前,确立清晰的目录结构和依赖管理策略是至关重要的。在这个外卖项目中,我们采用了模块化分层架构,将网络层、数据模型层、UI 组件层和业务逻辑层严格分离。这种设计不仅便于多人协作,更能有效防止代码耦合度过高导致的维护困难。
在环境初始化阶段,除了常规的安装依赖包之外,还需要配置统一的网络请求基类和全局状态管理容器。建议使用 TypeScript 进行强类型约束,特别是在定义商家信息、菜品数据结构时,明确的接口定义能减少大量运行时错误。同时,为了适配不同屏幕尺寸,需要在项目入口处引入响应式布局方案,设定好根字体大小基准,确保后续开发中 rem 单位的换算准确无误。
此外,别忘了配置 ESLint 和 Prettier 规则,统一团队的代码风格。对于图片资源,建议建立专门的静态资源管理模块,区分图标、背景图和商品图,并预设好加载占位图和错误兜底图,避免在网络波动时界面出现空白或破裂。
为了更直观地理解这套分层设计,下面用一张 Mermaid 图展示四层之间的依赖关系与数据流向:
图注:UI 组件层只负责渲染与用户交互,不直接发起请求;业务逻辑层承接交互事件,完成购物车、优惠与订单等计算;数据模型层以 TypeScript 接口统一各层的数据结构,是层间通信的契约;网络层封装请求、超时与重试,向上提供稳定的数据获取能力。依赖方向自上而下单向传递,数据则沿调用链回流,从而保证各层职责边界清晰、可独立替换与测试。
1.1 从零初始化项目
理论讲完,我们动手把项目跑起来。这里用 Vite 的 vanilla-ts 模板,几行命令就能得到一个带 TypeScript 支持的干净工程,不需要额外配置打包器:
# 创建项目(选择 vanilla-ts 模板)npmcreate vite@latest takeout-demo ----templatevanilla-tscdtakeout-demo# 安装依赖并启动npminstallnpmrun dev启动后浏览器会打开一个默认页面,说明环境已经就绪。接下来把src/下的示例文件清空,按上一节的分层思路建立目录:
takeout-demo/ ├── src/ │ ├── types.ts # 数据模型层 │ ├── store.ts # 全局状态管理 │ ├── request.ts # 网络层 │ ├── cart.ts # 业务逻辑层:购物车 │ ├── promotion.ts # 业务逻辑层:满减 │ ├── order.ts # 业务逻辑层:订单 │ └── main.ts # UI 组件层入口 ├── index.html └── package.json1.2 配置 TypeScript 与代码规范
打开tsconfig.json,确认开启了严格模式。新手最容易忽略的是strict相关选项,一旦关掉,很多空值、类型不匹配的问题会拖到运行时才暴露:
{"compilerOptions":{"target":"ES2020","module":"ESNext","moduleResolution":"bundler","strict":true,"noUnusedLocals":true,"noUnusedParameters":true,"noFallthroughCasesInSwitch":true},"include":["src"]}再补上 ESLint 与 Prettier,让团队代码风格保持一致。安装依赖后新增.prettierrc:
npminstall-Deslint prettier eslint-config-prettier{"semi":true,"singleQuote":true,"printWidth":100,"trailingComma":"es5"}1.3 响应式布局基准
移动端适配的核心是让rem与屏幕宽度挂钩。在index.html的<head>中加一段脚本,按设计稿宽度(这里以 375px 为基准)动态设置根字体大小:
<script>(function(){constbaseWidth=375;functionsetRootFontSize(){constwidth=Math.min(document.documentElement.clientWidth,750);document.documentElement.style.fontSize=(width/baseWidth)*16+'px';}setRootFontSize();window.addEventListener('resize',setRootFontSize);})();</script>这样写样式时,设计稿上 32px 的间距直接写成2rem即可,在不同机型上会自动等比缩放,不用再为每个屏幕单独写媒体查询。
1.4 静态资源与占位图约定
最后统一图片资源的管理方式。建议在src/下建一个assets目录,按用途分子目录,并在types.ts里约定好占位图常量,供各层复用:
// src/types.ts(片段)exportconstPLACEHOLDER_IMG='data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciLz4=';exportconstFALLBACK_BANNER='/assets/images/banner-fallback.png';图片加载失败时统一回退到占位图,避免网络波动时出现破图。到这里,目录结构、类型约束、代码规范、适配基准和资源约定都已就位,下一节就可以正式进入网络请求封装与首页焦点图的开发了。
② 网络请求封装与商家首页焦点图实现
网络请求是应用的生命线,一个健壮的封装层能够极大降低后续业务的开发成本。我们需要创建一个单例模式的请求类,统一处理 HTTP 头信息、Token 注入、错误码拦截以及请求取消逻辑。针对外卖场景,特别要注意处理弱网环境下的超时重试机制,避免因一次短暂的网络抖动导致用户无法加载菜单。
2.1 先写一个能用的请求封装
上一节我们已经建好了src/request.ts,这里把它补完整。核心思路只有三条:超时用AbortController掐断、失败按指数退避重试、所有错误统一抛成同一种结构,方便上层判断:
// src/request.tsexportinterfaceApiError{code:number;message:string;}constBASE_URL='/api';exportasyncfunctionrequest<T>(path:string,options:{timeout?:number;retry?:number;headers?:Record<string,string>}={}):Promise<T>{const{timeout=8000,retry=2,headers={}}=options;for(letattempt=0;attempt<=retry;attempt++){constcontroller=newAbortController();consttimer=setTimeout(()=>controller.abort(),timeout);try{constres=awaitfetch(`${BASE_URL}${path}`,{signal:controller.signal,headers:{'Content-Type':'application/json',...headers},});clearTimeout(timer);if(!res.ok){throw{code:res.status,message:`HTTP${res.status}`}asApiError;}return(awaitres.json())asT;}catch(err){clearTimeout(timer);if(attempt===retry){throwerrasApiError;}// 指数退避:300ms、600ms、1200ms……awaitnewPromise(r=>setTimeout(r,300*Math.pow(2,attempt)));}}throw{code:-1,message:'unreachable'}asApiError;}这里有个新手常踩的坑:clearTimeout一定要放在try和catch里各写一次。只写在成功分支里,请求失败时定时器不会被清理,页面切走后仍会触发一次abort,控制台就会冒出莫名其妙的报错。
2.2 商家首页焦点图的数据加载
商家首页的焦点图(Banner)是实现视觉吸引的关键。在实现时,不要简单地使用原生轮播组件,而应结合自动播放、手动滑动指示器以及无限循环逻辑。考虑到图片加载性能,务必实施懒加载策略,仅当图片进入视口附近时才发起网络请求。
// 简化的轮播图数据加载示例asyncfunctionloadBannerData(shopId){try{constresponse=awaitrequest.get(`/api/shop/${shopId}/banners`);// 预处理图片链接,添加 CDN 加速参数returnresponse.data.map(item=>({...item,imageUrl:`${item.imageUrl}?x-oss-process=image/resize,w_800`}));}catch(error){console.warn('Banner 加载失败,使用默认占位图');returngetDefaultBanners();}}注意catch里返回的是兜底数据而不是继续抛出异常。首页 Banner 属于「锦上添花」的模块,它挂了不应该让整个页面白屏,用上一节约定的FALLBACK_BANNER顶上即可。
2.3 轮播图的自动播放与无限循环
数据拿到后,渲染层只需要关心三件事:当前是第几张、什么时候自动翻页、用户手动滑动时如何接管。下面这段实现用「首尾各复制一张」的技巧做无限循环,避免滑到边界时出现突兀的回弹:
// src/banner.tsexportfunctioncreateBanner(container:HTMLElement,images:string[],interval=3000){// 首尾各补一张,实现无缝循环constlist=[images[images.length-1],...images,images[0]];letindex=1;lettimer:number|null=null;container.innerHTML=list.map(src=>`<div><imgtoken interpolation">${src}" loading="lazy" /></div>`).join('');consttrack=container.querySelector('.banner-track')asHTMLElement;functiongoTo(i:number,animate=true){track.style.transition=animate?'transform 0.3s ease':'none';track.style.transform=`translateX(-${i*100}%)`;index=i;}functionnext(){goTo(index+1);}// 过渡结束后,若停在复制出来的首/尾,则瞬间跳回真实位置track.addEventListener('transitionend',()=>{if(index===0)goTo(images.length,false);if(index===list.length-1)goTo(1,false);});functionstart(){timer=window.setInterval(next,interval);}functionstop(){if(timer)window.clearInterval(timer);timer=null;}container.addEventListener('touchstart',stop);container.addEventListener('touchend',start);goTo(1,false);start();}loading="lazy"让浏览器自己决定何时加载图片,配合 CDN 的resize参数,首屏只需要下载几张压缩后的小图,弱网下也不会卡住。
2.4 首屏信息的并行加载与骨架屏
在 UI 层面,焦点图下方通常紧接着展示商家公告、配送时间和评分信息。这些数据的渲染需要等待焦点图加载完成后并行执行,以保证首屏内容的完整性。如果某个接口响应较慢,应采用骨架屏(Skeleton)技术先行占位,提升用户的感知速度。
// src/shop.tsexportasyncfunctionloadShopHome(shopId:string){// 三个接口互不依赖,用 allSettled 并行,单个失败不影响其他模块const[banners,notice,rating]=awaitPromise.allSettled([loadBannerData(shopId),request(`/shop/${shopId}/notice`),request(`/shop/${shopId}/rating`),]);return{banners:banners.status==='fulfilled'?banners.value:[],notice:notice.status==='fulfilled'?notice.value:null,rating:rating.status==='fulfilled'?rating.value:null,};}用Promise.allSettled而不是Promise.all,是因为公告接口挂了不该连带 Banner 一起消失。骨架屏则只需要在请求发出前给容器加上一个skeleton类,数据回来后移除即可,CSS 里用渐变动画模拟内容块,成本极低但体感提升明显。
到这里,网络层和首页焦点图就都跑通了。下一节我们进入菜单页,处理左侧分类与右侧商品列表的联动滚动——那是整个项目里最容易写出卡顿的地方。
③ 菜单列表布局设计与联动滚动逻辑
菜单页面是用户停留时间最长的区域,其核心难点在于左侧分类列表与右侧商品列表的双向联动。当用户点击左侧“热销榜”时,右侧视图应平滑滚动到对应位置;反之,当用户手动滑动右侧列表时,左侧选中的分类也应实时高亮更新。
3.1 先把布局骨架搭出来
布局方面,推荐使用固定左侧宽度、右侧自适应的方案。左侧分类栏高度固定,内部可独立滚动;右侧商品区占据剩余空间。用 flex 两栏就能搞定,关键是给右侧容器设overflow-y: auto,让它成为滚动容器——后面所有偏移量计算都基于它,而不是window:
<divclass="menu"><asideclass="menu-side"id="side"></aside><sectionclass="menu-main"id="main"></section></div>.menu{display:flex;height:100vh;overflow:hidden;}.menu-side{width:5.5rem;flex:0 0 5.5rem;overflow-y:auto;background:#f6f6f6;}.menu-main{flex:1;overflow-y:auto;-webkit-overflow-scrolling:touch;/* iOS 惯性滚动 */}.side-item{padding:0.75rem 0.5rem;font-size:0.875rem;color:#333;}.side-item.active{background:#fff;color:#ff5339;font-weight:600;border-left:3px solid #ff5339;}这里有个新手常踩的坑:如果给.menu或body也加了overflow: auto,滚动事件会落到外层容器上,scrollTop永远是 0,联动自然就失效了。记住「谁滚动,谁监听」。
3.2 渲染分类与商品列表
数据来自上一节的接口,结构沿用types.ts里的Category和Dish。渲染时按分类分组,每个分类前放一个锚点元素,方便后面用offsetTop取位置:
// src/menu.tsimport{Category,Dish}from'./types';exportfunctionrenderMenu(sideEl:HTMLElement,mainEl:HTMLElement,categories:Category[],dishes:Dish[]){sideEl.innerHTML=categories.map((c,i)=>`<divtoken interpolation">${i===0?' active':''}"token interpolation">${c.id}">${c.name}</div>`).join('');mainEl.innerHTML=categories.map(c=>{constlist=dishes.filter(d=>d.categoryId===c.id);return`<divtoken interpolation">${c.id}"> <h4>${c.name}</h4>${list.map(d=>`<div> <span>${d.name}</span> <span>¥${(d.price/100).toFixed(2)}</span> </div>`).join('')}</div>`;}).join('');}3.3 预先计算每个分类的锚点偏移
实现联动的关键在于精确计算每个分类锚点在右侧列表中的偏移量。我们可以预先遍历商品数据,记录每个分类第一个商品距离顶部的距离。注意要减去右侧容器自身的offsetTop,否则在页面有头部导航时会整体偏移:
// src/menu.ts(续)exportfunctioncollectAnchors(mainEl:HTMLElement){constgroups=Array.from(mainEl.querySelectorAll<HTMLElement>('.group'));returngroups.map(g=>({cid:g.dataset.cid!,top:g.offsetTop-mainEl.offsetTop,}));}offsetTop是相对最近的定位祖先计算的,所以.menu-main最好设position: relative,这样g.offsetTop就是相对滚动容器的距离,直接可用。如果商品数据是异步加载的,记得在数据渲染完成后再调用一次collectAnchors,否则拿到的全是 0。
3.4 点击左侧:滚动到对应分类
点击左侧分类时,把右侧容器滚到对应锚点即可。用scrollTo的behavior: 'smooth'就能拿到平滑动画,不用自己写缓动:
// src/menu.ts(续)exportfunctionbindSideClick(sideEl:HTMLElement,mainEl:HTMLElement,anchors:{cid:string;top:number}[]){sideEl.addEventListener('click',e=>{consttarget=(e.targetasHTMLElement).closest<HTMLElement>('.side-item');if(!target)return;constcid=target.dataset.cid!;constanchor=anchors.find(a=>a.cid===cid);if(!anchor)return;// 标记为「程序滚动」,避免滚动监听把高亮又改回去isProgrammaticScroll=true;mainEl.scrollTo({top:anchor.top,behavior:'smooth'});setActive(sideEl,cid);});}3.5 滑动右侧:反向高亮左侧
当用户手动滑动右侧列表时,左侧选中的分类也要实时更新。思路是判断当前scrollTop落在哪两个锚点之间,取前一个锚点对应的分类。滚动事件触发频率极高,必须配合节流(Throttle)函数优化,避免频繁的状态更新引发渲染卡顿:
// src/menu.ts(续)letisProgrammaticScroll=false;functionsetActive(sideEl:HTMLElement,cid:string){sideEl.querySelectorAll('.side-item').forEach(el=>{el.classList.toggle('active',(elasHTMLElement).dataset.cid===cid);});}functionthrottle<Textends(...args:any[])=>void>(fn:T,wait=100){letlast=0;return(...args:Parameters<T>)=>{constnow=Date.now();if(now-last>=wait){last=now;