jQuery+CSS复刻移动外卖H5:从DOM操作到事件委托的完整实践 简介移动Web前端案例课“美团外卖”配套源码包面向具备HTML/CSS入门基础、期望掌握jQuery交互和移动端适配的学习者可据此还原外卖类应用的典型界面与功能模块。压缩包共86个文件大小约213KB内含4个HTML页面、16个CSS样式、10个JS脚本、9个JSON数据文件及29张PNG图片素材分别承担页面结构、响应式布局、交互逻辑、本地数据模拟与视觉资源等角色目录组织清晰便于按模块拆解学习。该资源已有79人学习下载。通过分析源码可以深入学习Flexbox弹性布局、媒体查询断点设计、jQuery简化DOM操作与触摸事件绑定同时参考搜索栏、分类导航、商品列表、订单管理等场景的代码实现进而迁移到自己的移动Web项目中提升前端工程化与调试能力。1. 移动外卖H5为什么还值得用jQuery来写美团外卖这类移动Web项目在React、Vue满天飞的今天看起来有点复古但恰恰因为它的业务链路足够典型——定位、搜索商家、商品列表加购、购物车结算、订单回显——这套信息架构几乎覆盖了一个移动端H5电商需要处理的全部核心场景。用jQuery加CSS加HTML去复刻它不是为了怀旧而是这套组合能把DOM操作、事件绑定、数据渲染的底层过程暴露得最彻底。对于还要维护老项目、或者要面对千元安卓机性能瓶颈的前端来说这个案例就像一份标准化解剖样本那些被现代框架帮你隐藏掉的细节在这儿都得自己亲手料理。下面把目录结构、CSS布局和jQuery交互分开拆每个环节都带参数设置和踩坑点。2. 源码目录与页面骨架从index到menu的信息架构2.1 目录结构webpage、lib、common三者的分工解压“源码 (1).zip”根目录下会出现webpage、lib、common几个文件夹外加一个.DS_Store文件。DS_Store是macOS的文件夹索引文件部署到Linux或Windows服务器后没有任何作用可以直接删掉不要因为它在压缩包里就塞进发布包。真正参与页面的只有webpage、lib和common。目录职责说明webpage业务页面层index、order、menu、my 四个HTML文件lib第三方依赖库jQuery 及所需依赖的压缩文件common公共代码与静态资源复用的CSS、工具JS、图片这种把四个页面平铺在同一层、公共资源统一收进common的做法在中小型H5项目里很常见。页面之间互相跳转时用相对路径比绝对路径稳得多项目部署到二级目录也不会因为写死/webpage/开头而出现404。我一般会把common里的CSS拆成base.css和widgets.css两份一份管reset和基础标签样式一份管组件复用这样后面改主题只用动widgets。# 在项目根目录下查看真实文件清单 find . -type f -not -name .DS_Store | sort逻辑说明find命令列出所有非.DS_Store文件并按路径排序能帮你快速确认哪些文件是真正要发布的。常见情况是压缩包里混入了几个vim交换文件或者临时备份文件一并排除掉再打包发布体积会小不少。2.2 index页的DOM骨架头部、金刚区、商品列表打开webpage/index.html结构走的是移动端外卖页最经典的“三段式”header放定位和搜索入口nav放分类金刚区section放推荐商品流最后footer固定底部tab栏。注意input用的是typesearch而不是typetext这样在iOS Safari里会有原生的清空按钮键盘回车键也会变成“搜索”。!doctype html html langzh-cn head meta charsetutf-8 meta nameviewport contentwidthdevice-width, initial-scale1.0 title美团外卖/title /head body div idapp header classheader span classlocation望京SOHO/span div classsearch input typesearch placeholder搜索商家或商品 /div /header nav classcategory a href# classcat-item美食/a a href# classcat-item甜点饮品/a a href# classcat-item快餐便当/a /nav section classgoods-list div classgoods-card.../div /section footer classtab-bar a hrefindex.html classactive首页/a a hreforder.html订单/a a hrefmenu.html菜单/a a hrefmy.html我的/a /footer /div /body /html逻辑说明idapp是整个页面的作用域容器后续CSS选择器和jQuery查询都收窄在它下面避免跟别的页面、别的插件产生全局选择器冲突。category里每个cat-item都带href只是示例页面没有真实跳转实际项目如果做SPA式切换可以把href改成javascript:void(0)并把点击交给事件绑定但这样会丢掉右键新开链接的能力权衡点在于你需不需要保留原生链接语义。2.3 menu和order页的布局差异menu.html和order.html虽然业务不同但布局有个共同点页面只有主内容区在滚动底部tab栏完全固定。menu页的难点在于左侧类目栏和右侧商品区是两个独立的滚动容器左侧宽度设定在80px右侧用flex:1撑满剩余宽度各自的overflow-y设置为auto。如果左右滚动手感不一致可以用-webkit-overflow-scrolling: touch开启iOS的惯性滚动但注意这个属性在部分安卓WebView上会失效并不会报错需要真机验证。order.html相对简单订单列表按时间倒序渲染每条订单卡片里需要把“再来一单”的按钮放在卡片右下角而不是左下角这是为了避免用户单手操作时大拇指碰到屏幕边缘误触。这个细节没什么技术含量但产品评审时经常会被挑出来说。2.4 localStorage同步购物车数据跳转页面传参少量数据可以直接拼URL但购物车这种结构化的数据走URL就太笨了中文商品名还会产生一长串百分号编码。案例里用的是localStorage统一管理购物车menu页写入、index页读来渲染角标、order页读来生成订单卡片页面刷新数据不丢。function saveCart() { try { localStorage.setItem(cartItems, JSON.stringify(cartArr)); } catch (e) { console.warn(localStorage写入失败, e); } } function loadCart() { try { return JSON.parse(localStorage.getItem(cartItems)) || []; } catch (e) { return []; } }逻辑说明saveCart先try/catch包裹因为浏览器隐私模式或存储已满时setItem会抛QuotaExceededError不拦截会直接把后续购物车渲染逻辑中断。loadCart同理JSON.parse遇到损坏数据会抛异常catch里返回空数组让页面至少不白屏。这个细节是真实的有些老WebView在无痕模式下所有localStorage操作都会抛异常不加保护页面里的购物车角标渲染会整体失效。3. rem适配与FlexboxCSS3布局在美团外卖里的具体应用3.1 viewport与rem的换算基准页面里meta标签的写法直接决定移动端排版质量。美团外卖这版用了meta nameviewport contentwidthdevice-width, initial-scale1.0, maximum-scale1.0, user-scalableno参数说明widthdevice-width让布局视口等于设备宽度避免iPhone默认按980px宽渲染导致字小如蚁initial-scale1.0保证CSS像素和设备像素1比1对应防止页面放大错位maximum-scale1.0配合user-scalableno禁掉双指缩放。点餐场景里用户单手操作多禁缩放能降低误触率代价是牺牲了无障碍体验。提示viewport禁掉手势缩放后页面里的双击缩放也会一并失效。如果产品要求保留部分缩放能力可以监听gesturestart事件并调用preventDefault局部处理但各安卓WebView行为不一致不建议在这上面花太多时间。rem换算的核心是给html设置动态font-size让页面里所有rem单位随屏幕宽度等比变化。常见做法是以设计稿宽度375px为基准把font-size设为100px这样设计稿上的px值除以100就是rem值。(function(doc, win) { var docEl doc.documentElement; function recalc() { var width docEl.clientWidth; if (!width) return; docEl.style.fontSize 100 * (width / 375) px; } win.addEventListener(resize, recalc); doc.addEventListener(DOMContentLoaded, recalc); })(document, window);逻辑说明这段代码在DOM加载完成时和窗口尺寸变化时各执行一次把html的font-size设置成屏幕宽度除以375再乘以100。屏幕越宽字号越大页面里所有使用rem单位的宽高、字号、边距都被等比放大。这个方案在iPhone 6、6s、7、8这一代机型上表现非常稳但到iPad这类超宽设备上要把基数设上限否则字会大得离谱我一般会在recalc里加一句Math.min限制最大值比如iPad上最多按414px折算。3.2 Flexbox做底部导航和商品行等分底部tab栏和分类金刚区的核心都是弹性布局。tab栏需要固定在视口底部用position: fixed加display: flex渲染每个tab-item设置flex:1自适应等分。flex:1是flex-grow、flex-shrink、flex-basis三个属性的简写在等分场景里它等价于flex: 1 1 0%意思是按剩余空间等比放大、等比缩小、初始宽度为0。.tab-bar { position: fixed; bottom: 0; left: 0; right: 0; display: flex; height: 50px; background: #fff; border-top: 1px solid #eee; z-index: 100; } .tab-item { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; font-size: 12px; color: #666; } .tab-item.active { color: #ffb800; } .tab-item .icon { width: 22px; height: 22px; }参数说明.tab-item内部再一次用flex-direction: column把图标和文字沿主轴垂直排列align-items: center和justify-content: center让它们在交叉轴和主轴上同时居中。.tab-item.active是伪类选择器的典型用法通过匹配class为active的元素改变颜色。如果某个tab-item的文字被系统字体缩放撑成两行flex:1并未约束高度底部栏会被顶高所以还要给.tab-item加固定line-height和overflow: hidden做兜底。3.3 优惠券圆切radial-gradient模拟缺口“优惠券圆切”效果在美团外卖红包、店铺优惠券里很常见。券面左、右各有一个内陷的半圆缺口常规做法是直接切一张带透明通道的PNG背景图但用CSS渐变画能省去图片请求还方便在运行时改颜色。.coupon { position: relative; width: 300px; height: 96px; border-radius: 8px; background: radial-gradient(circle at 0 50%, transparent 8px, #ffb800 8px), radial-gradient(circle at 100% 50%, transparent 8px, #ffb800 8px); }逻辑说明两个radial-gradient是并排叠加的第一个把圆心放在x0即左边缘的垂直中心半径8px内为透明8px之后填充#ffb800第二个把圆心放在右边缘做同样的透明缺口。background图像声明里的每一项都会被叠加绘制前面的层盖住后面的层所以两个椭圆的起始方向不用考虑覆盖顺序。这里如果缺口半径跟卡片高度不匹配比如高度只有60px而半径为8px圆形会显得很瘪一般建议半径取卡片高度的1/12到1/8之间。搜索热词里还有“css 删除线”价格标签里原价加删除线是常见的视觉降权写法直接text-decoration: line-through即可但如果要设计上更克制的删除线可以改用一个带1px下边框的伪元素覆盖效果更好。而“css 打点”其实就是text-overflow: ellipsis实现单行文本省略商品描述截断的代码会在下一节遇到。3.4 原子化CSS与伪类选择器案例里商品卡片反复出现三行文字店铺名、商品名、价格。这三行样式如果全部写在一个.goods-card span选择器里后面想单独调整价格颜色时就必须再精确匹配span或增加class。所以更合理的做法是给每行独立类名一个类只负责一个可控的属性集合这种写法跟“三行模式的css文件”里提到的原子化CSS思路一致也让伪类选择器有更明确的挂钩对象。比如价格用.price类搭配.active状态展示选中效果选择器只需要.price.active而不是去数这是第几个span。.goods-name { font-size: 14px; color: #333; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .goods-desc { font-size: 12px; color: #999; } .goods-price { font-size: 16px; color: #ff5339; font-weight: bold; } .goods-price .origin { font-size: 12px; color: #ccc; text-decoration: line-through; margin-left: 4px; }参数说明.goods-name的white-space: nowrap配合overflow: hidden和text-overflow: ellipsis形成单行省略号也就是“打点”效果缺任何一个属性都不生效。.goods-price里嵌套一个.origin原价标签用line-through画删除线视觉上压低原价存在感。价格数字如果想做渐变字色给.goods-price加background: linear-gradient(...)再加background-clip: text并设color为transparent但移动端记得写-webkit-前缀Safari的兼容性和字体渲染要单独测。4. jQuery事件委托链从商品列表到购物车结算的交互闭环4.1 动态列表渲染与click失效问题商品列表的DOM是从数组渲染出来的直接$(.add-btn).on(click)在页面初始化时执行那时候列表还是空的绑定自然失效。案例里用的是事件委托把监听挂到#goodsList容器上让冒泡到容器的事件来判断源元素是否匹配。$(#goodsList).on(click, .add-btn, function() { var $btn $(this); var id $btn.data(id); var name $btn.data(name); var price parseFloat($btn.data(price)); addToCart(id, name, price); });参数说明.data(id)读取的是按钮data-id属性值HTML里写作>var cart []; function addToCart(id, name, price) { var target null; for (var i 0; i cart.length; i) { if (cart[i].id id) { target cart[i]; break; } } if (target) { target.count 1; } else { cart.push({ id: id, name: name, price: price, count: 1 }); } renderCart(); } function renderCart() { var total 0; var count 0; cart.forEach(function(item) { total item.price * item.count; count item.count; }); $(#cartBadge).text(count).toggle(count 0); $(#cartTotal).text(¥ total.toFixed(2)); saveCart(); }逻辑说明addToCart先遍历cart数组找已有商品找到就把count自增找不到就push一个新对象。这是典型的先改数据再重新渲染而不是直接改DOM上的数字保证UI始终由数据推导不会出现页面角标和localStorage里存的数值不一致。renderCart每轮全量重算total和count再一次性写入两个DOM节点几十条购物车记录的性能开销可以忽略。toFixed(2)保留两位小数是避免浮点数乘法出现29.900000000000002这种尾巴的简单手段。4.3 上拉加载与scroll节流移动端列表滚动到接近底部时触发下一页加载需要监听scroll事件。但scroll在部分千元机上一秒触发几十次每次都不加限制地做DOM渲染和请求页面会卡顿严重。常见做法是加节流。var isLoading false; $(window).on(scroll, function() { if (isLoading) return; var remain $(document).height() - $(window).scrollTop() - $(window).height(); if (remain 50) { isLoading true; loadMoreGoods().always(function() { isLoading false; }); } });参数说明isLoading作为锁在请求没回来前直接拦截后续滚动事件。remain计算的是“当前滚动位置离底部还有多少像素”公式是文档总高度减去已滚动高度再减去视口高度小于50px即触发。loadMoreGoods返回的是$.ajax的Promise对象.always在请求成功或失败后都会释放锁避免失败后永远无法再次加载。这里如果把锁放在请求结束后释放用户快速连续滚动到底部时会发出重复请求接口没有做幂等就会回消息错乱。需求里如果带一个返回顶部的悬浮按钮实现也很直接$(window).scrollTop(0)配合animate就能平滑回顶要点是每次点击前先取当前scrollTop作为起点避免用户停在半路时出现跳变。4.4 CSS3过渡与鼠标移入事件的移动端差异搜索热词里“css 鼠标移入事件”在桌面端很常见hover伪类在移动端却基本不会触发因为它没有鼠标指针。给商品卡片写hover效果时要用媒体查询从平板以上的屏幕才启用避免在移动端误判media (min-width: 768px) { .goods-card:hover { transform: translateY(-2px); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08); transition: transform 0.2s ease, box-shadow 0.2s ease; } }参数说明media (min-width: 768px)把hover行为限制在宽度大于等于768px的设备上手机和平板竖屏不会触发卡片浮起效果避免点击屏幕时卡片瞬间出现跳变。transition写在hover里而不是元素默认样式里会让“悬停进入”有过渡而“悬停离开”立即恢复这个差异在桌面端效果上能感知到按产品喜好调整就行。移动端交互反馈更适合用:active伪类按下瞬间背景色变化松手即恢复。4.5 请求报错排查SyntaxError与接口层拦截搜到过“jq ajax syntaxerror: failed to execute open on xmlhttprequest: invalid url”本质是$.ajax请求的url字段不是合法地址最常见于拼接数据时某个参数是undefined最后拼出了相对路径浏览器无法创建请求。排查思路在ajax发起前打印url看是否以http开头或至少是/开头。更稳的做法是把接口地址统一放到配置对象里页面代码不直接拼URL只传params。$.ajax({ url: config.apiBase /goods/list, method: GET, data: { page: pageNo, size: pageSize }, dataType: json, success: function(res) { if (res.code 0) { renderGoods(res.data.list); } }, error: function(xhr, status, err) { console.error(请求失败: , status, err); } });逻辑说明config.apiBase是一个全局配置对象里的基础路径上线时只需改一处。data里的page和size是页码和每页条数后端按这个分页。success里先判断业务状态码res.code再取res.data.list交给渲染函数。移动端联调时如果后端还没就绪可以先在本地mock一个json文件把url临时指到那个文件数据字段结构保持一样等接口好了再切换回真实地址。定位问题时可优先看Chrome DevTools的Network面板请求标红通常是CORS或404而Console里的SyntaxError才考虑URL非法。5. 真机调试与性能挖坑字体回退、懒加载与inspect实战5.1 Chrome DevTools的设备模拟与网络节流F12进入DevTools后按CtrlShiftM切到设备工具栏选一台iPhone 6/7/8的设备配置再在Network面板里把网络切换成Fast 3G、CPU降速4x能把用户在弱网下的首屏体验模拟得八九不离十。要看rem的基准设置是否正确在Console执行document.documentElement.style.fontSize输出的值不应该是空字符串或16px相关的默认值。如果为空说明适配脚本没跑起来页面里所有rem都失效回退到了浏览器默认字号布局会直接崩。5.2 Android与iOS的真机USB调试安卓用户需要打开手机开发者模式的USB调试开关连上数据线后在Chrome地址栏访问chrome://inspect/#devices勾选Discover USB devices手机上弹出的授权确认点允许桌面端就能看到页面、Console和网络瀑布流。iOS用户则需要在iPhone的“设置 Safari 高级”里打开Web Inspector再用macOS的Safari菜单“开发”里就能看到已验证的设备。真机调试最常查出开发工具模拟不出来的问题比如某些WebView对ES6 Promise不支持、CSS滤镜渲染异常这些都要靠真机Log才能定位。5.3 中文字体回退与图片懒加载移动端中文字体被系统字体覆盖是常事字体声明最好从系统字体开始逐级回退。商品列表里图片是最占流量的部分懒加载是必做的。body { font-family: -apple-system, BlinkMacSystemFont, PingFang SC, Microsoft YaHei, sans-serif; }$(function() { var $imgs $(img[data-src]); function lazyLoad() { var winH $(window).height(); $imgs.each(function() { var $img $(this); if ($img.attr(src)) return; var top $img.offset().top - $(window).scrollTop(); if (top winH 100) { $img.attr(src, $img.data(src)); } }); } $(window).on(scroll.lazy, lazyLoad).trigger(scroll.lazy); });逻辑说明lazyLoad遍历所有带data-src的图片通过offset().top和scrollTop计算出图片顶部相对视口的位置小于视口高度加100px时把data-src的真实地址写入src图片才开始下载。100px是预加载缓冲防止用户滚到底时图片才触发加载留下白块。命名空间放在scroll.lazy里是为了后续如果页面还有其他滚动逻辑可以只解绑这个lazy而不影响别人。字体回退那条font-family里-apple-system优先在iOS上命中SF字体BlinkMacSystemFont是Chrome和Android WebView的对应字体都不存在时落到PingFang SC或微软雅黑这样在不同设备上视觉基调不会差太多。本文还有配套的精品资源点击获取