简介移动Web案例课《美团外卖》完整项目源码包面向具备HTML/CSS基础、希望进阶jQuery实战的初中级前端开发者。资源共86个文件以CSS、JS、HTML为主辅以JSON配置与图片素材压缩包仅213KB覆盖首页、订单、菜单、个人中心等核心页面模块及搜索、分类导航等常见交互组件。已有79人学习浏览。借助该套源码可直观学习CSS3弹性盒布局与媒体查询实现不同屏幕的响应式适配并通过jQuery简化DOM操作、处理移动端触摸事件以及商品列表的动态加载。目录按common、lib等功能拆分结构清晰适合对照课程逐模块拆解实践也有助于理解前端性能优化与代码结构组织可作为移动端Web界面开发的快速参考模板。1. 移动web案例课美团外卖到底在练什么“移动web案例课美团外卖”不是一门让你照着敲一遍的练习课它真正要解决的问题是把App的交互密度压进手机浏览器里。同样是点餐、加购、结算PC网页可以随意铺开移动端却要面对375px宽、网络不稳、触摸不准这三座大山。用jQueryCSSHTML三件套重做美团外卖首页恰好能把移动web的核心矛盾集中暴露出来尺寸适配、触控交互、性能边界。这套案例适合两类人。一类是刚学完HTML/CSS、想拿一个“能上真机”的完整页面证明自己的前端新人另一类是常做H5活动页、但总被rem换算和真机兼容性反复折腾的开发者。下面按我自己的落地路径走先解决适配再搭骨架然后做交互最后把真机上才会遇到的坑一个个摊开。2. 移动web适配viewport和rem先把设计稿“换算”这件事做对2.1 为什么美团外卖这类App页面首选rem而不是rpx或em移动web页面拿到设计稿通常是750px宽也就是iOS的2倍尺寸但手机逻辑宽度只有375px左右。做适配本质上就是在做“按比例缩放”把一个750px的设计稿等比塞进375px的屏幕里所有尺寸都砍半。rpx是小程序专用的单位在纯HTML页面里根本不存在em是相对父级字体大小一层嵌套一层很容易乱套只有rem是相对根元素font-size的改html标签一个值就能控制全站尺寸这正是移动web常用的做法。css字体在移动端怎么调也走同一套给它设成rem就行别单独用px。单位参考对象移动端适配表现pxCSS像素固定不变每台机型都要单独适配em父元素字体层级嵌套会累积容易失控rem根元素字体改html的font-size即可全站缩放rpx小程序专用仅微信小程序环境有效HTML里不存在viewport那段标配如下meta nameviewport contentwidthdevice-width, initial-scale1.0, maximum-scale1.0, user-scalableno这行代码的意思是widthdevice-width让布局宽度等于设备宽度initial-scale1.0避免浏览器默认缩放maximum-scale和user-scalableno锁死用户缩放操作。外卖下单页这么写是常规操作因为误触缩放会直接影响点击准确率。但要注意它同时也禁掉了无障碍缩放如果做的是内容型页面不建议照抄。2.2 从750px设计稿到rem换算脚本与根字号取值常见做法是采用flexible方案的思想按屏幕宽度动态改html的font-size把750px设计稿定为根字号100px。也就是说1rem100px设计稿里750px宽的区块换算出来正好7.5rem运算非常直观。(function (doc, win) { var docEl doc.documentElement; var resizeEvt orientationchange in window ? orientationchange : resize; function recalc() { var clientWidth docEl.clientWidth; if (!clientWidth) return; // 以750px设计稿为基准把viewport宽度等比映射到根字号 docEl.style.fontSize 100 * (clientWidth / 750) px; } if (!doc.addEventListener) return; win.addEventListener(resizeEvt, recalc, false); doc.addEventListener(DOMContentLoaded, recalc, false); })(document, window);这段脚本干什么只要屏幕宽度变化就重新计算根字号rem布局跟着整体缩放。为什么基准是100px因为设计稿上标多少px你写CSS时除以100就是多少rem比如设计稿40px写0.4rem心智负担最小。也有人在生产环境把基准定为50px理由是部分Android机对根字号有限制但50px换算时数值容易出小数点我习惯用100px再配合后面的边界处理一起用。2.3 rem方案的两个边界桌面端与老WebViewrem不是万能的。桌面端浏览器窗口一拉宽整个外卖页面会被等比放大成“巨人版”要处理得加上限media screen and (min-width: 750px) { html { font-size: 100px !important; } body { max-width: 750px; margin: 0 auto; } }这个限制的逻辑是当设备宽度超过设计稿宽度不再跟随等比放大而是把页面固定在750px宽并居中视觉上接近App的平板布局。老WebView是另一个坑Android 4.4之前的WebView对viewport的支持不完整flexible方案在部分国产浏览器里会计算出负数或NaN所以生产环境里我会在recalc里加一个下限保护docEl.style.fontSize Math.max(12, 100 * (clientWidth / 750)) px;Math.max(12, ...)保证根字号不会小于12px因为部分浏览器对小于12px的font-size会拒绝渲染直接导致所有rem尺寸错乱。这条是我在真机上被坑过之后学会的血泪经验调试时如果整个页面突然“缩成一团”先检查根字号有没有被系统强行改掉。2.4 样式引入顺序与全局公共样式css样式引入方式常见有三种外链link、import、内联style。移动web页面我一般只用外链link。import会阻塞渲染且在部分老浏览器里加载时序不可控内联样式留给动态计算的临界值。CSS文件里先放reset再放公共样式* { margin: 0; padding: 0; box-sizing: border-box; -webkit-tap-highlight-color: transparent; } html { font-size: 100px; } body { font-family: -apple-system, BlinkMacSystemFont, PingFang SC, Helvetica Neue, sans-serif; background: #f5f5f5; } a { text-decoration: none; color: inherit; } ul, ol { list-style: none; }box-sizing: border-box让padding和border算进宽度里移动端布局不会因为加了内边距而撑破flex容器。-webkit-tap-highlight-color: transparent去掉点击时的灰色高亮这是移动端触控体验的入门细节不写的话每次点按钮都会闪一下灰块。字体栈里放PingFang SC和Helvetica Neue是因为iOS和Android的中文字体渲染差异很大你不显式指定某些国产机就会回退到一套很难看的默认字体。到这里适配链路就完整了meta viewport管缩放、rem管换算、公共样式管基础。接下来可以真正开始搭页面骨架。3. 美团外卖首页骨架用HTML5语义标签和flex布局搭出App感3.1 页面结构设计头部搜索、金刚区、商家流的三个区块页面结构常见的做法是拆成三层顶部定位搜索栏中部金刚区也就是外卖分类入口底部商家列表。先写HTML骨架div classpage header classapp-header div classlocation北京 · 五道口 span classarrow/span/div div classsearch-bar input typetext placeholder搜索商家或商品 /div /header nav classcategory-grid a classcategory-item href#span classicon icon-1/spanspan classlabel美食/span/a a classcategory-item href#span classicon icon-2/spanspan classlabel甜点/span/a a classcategory-item href#span classicon icon-3/spanspan classlabel超市/span/a /nav main classshop-list div classshop-item img srcplaceholder.png alt classshop-img div classshop-info h3 classshop-name麻辣香锅五道口店/h3 p classshop-desc月售 2000 · 45分钟送达/p /div /div /main /div每个区块职责单一header负责定位和搜索nav承担入口分发main里的shop-item是信息载体。这样拆分的好处后面接数据时体现得出来——用jQuery渲染商家列表只需要往.shop-list里append完全不动其他区块。header用固定定位时main要预留padding-top否则列表会被盖住。3.2 头部与金刚区的flex布局图片和文字一行怎么排“图片和文字一行css”是移动端布局里最常见的诉求美团外卖这类App的消息流、商品列表、tab栏用的都是同一套布局模板核心就三个属性.app-header { position: fixed; top: 0; left: 0; right: 0; z-index: 100; background: #fff; padding: 0.08rem 0.1rem; display: flex; align-items: center; } .location { flex-shrink: 0; font-size: 0.14rem; margin-right: 0.1rem; } .search-bar { flex: 1; display: flex; align-items: center; background: #f5f5f5; height: 0.32rem; border-radius: 0.16rem; padding: 0 0.12rem; } .search-bar input { border: none; outline: none; background: transparent; width: 100%; font-size: 0.14rem; } .category-grid { margin-top: 0.5rem; background: #fff; display: flex; justify-content: space-around; padding: 0.1rem 0; } .category-item { display: flex; flex-direction: column; align-items: center; } .category-item .icon { width: 0.4rem; height: 0.4rem; border-radius: 50%; background: #ffd54f; } .category-item .label { font-size: 0.12rem; margin-top: 0.06rem; color: #333; }flex一行排开的原理父容器display:flex之后子项默认沿主轴排成一行align-items:center控制垂直居中这样图片和文字天然对齐不需要再靠vertical-align或line-height硬调。position:fixed的头部要特别注意页面滚动时fixed元素盖在内容上所以main要加padding-top。金刚区icon数量在6到8个之间比较常见。建议用justify-content:space-around而不是space-between前者在子项成奇数时依然居中分布不会出现最后一个贴边的问题。3.3 商家列表的卡片式布局价格划线价和删除线的一次实践商家列表这一块每个卡片要同时承载图片、店名、评分和价格信息布局层级一多就乱。我一般用这样的结构div classshop-item div classshop-imgimg src... alt/div div classshop-info h3 classshop-name汉堡王/h3 p classshop-rating评分 4.8 · 月售 3000/p p classshop-price span classcurrent¥25/span del classorigin¥32/del span classtag满减/span /p /div /div.shop-list { padding-top: 0.5rem; } .shop-item { display: flex; background: #fff; padding: 0.12rem; margin-bottom: 0.12rem; } .shop-img { width: 0.9rem; height: 0.9rem; border-radius: 0.06rem; overflow: hidden; flex-shrink: 0; margin-right: 0.1rem; } .shop-info { flex: 1; overflow: hidden; } .shop-name { font-size: 0.15rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } .shop-rating { color: #999; font-size: 0.11rem; margin: 0.04rem 0; } .current { color: #ff6600; font-size: 0.16rem; font-weight: 600; } .origin { color: #bbb; font-size: 0.1rem; } del { text-decoration: line-through; }del元素默认就带删除线样式CSS里加text-decoration: line-through只是把颜色和粗细调对不需要自己画线。版权价格这一行是“图片和文字一行”的变体全部元素都是inline级别天然在一行父级加flex反而会把行为改掉所以这里我用自然排列。商家数量多时要防止图片加载闪烁常见做法是给.shop-img固定宽高并设置背景色#eee图片加载前先占位加载后再覆盖。这一步不起眼但真机弱网环境下差别非常大。4. jQuery交互落地tab切换、购物车加减与滚动加载4.1 用事件委托接管动态生成的商家列表jQuery在移动web里的位置业内吵过很多轮新框架层出不穷但现有大量H5页面仍是jQuery在跑。原因也简单它把DOM操作和事件绑定压缩到一行对非重型SPA的页面来说学习和维护成本都低。用jQuery做动态列表时最需要记住的是事件委托——直接bind(.shop-item)只对页面初始存在的元素生效动态append进来的节点不会响应必须委托到父级。$(.shop-list).on(click, .shop-item, function () { var shopId $(this).data(id); location.href shop.html?id shopId; });这里的逻辑是事件绑定在.shop-list上click发生时jQuery用冒泡机制找到目标元素再判断它是不是.shop-item或它的子元素。这样无论后续怎么append响应都不会丢。data(id)是HTML5自定义属性的习惯用法商家ID放在data-id里比拼在class名里可维护得多。4.2 jQuery手写tab切换时要注意hover在移动端的失真外卖分类金刚区、商家筛选栏这类交互本质是tab切换。CSS里的:hover在移动端根本不会触发必须用click驱动这也是移动web和桌面web之间最容易被忽略的行为差异。$(.category-tab).on(click, li, function () { var index $(this).index(); $(this).addClass(active).siblings().removeClass(active); $(.tab-panel).eq(index).addClass(active).siblings().removeClass(active); });代码逻辑index拿到点击的tab序号eq(index)定位到对应面板。这种“兄弟互斥”的写法是tab切换的经典范式比if-else逐项判断简单得多。要注意addClass和removeClass成对出现漏一个会出现两个tab同时高亮的穿帮效果。真机上判断高亮是否生效先看class有没有加上再看CSS里.active的优先级够不够。关于css鼠标移入事件在触摸设备上想在手指滑过时给反馈正确的事件是touchstart/touchend。但不建议在tab上绑touch事件一是会跟click形成重复触发二是滑动页面时手指经过tab也会误触发。移动端tab就老老实实用click把hover样式当作桌面端的渐进增强不要反过来。4.3 购物车加减逻辑把状态和DOM分开管理美团外卖的精华在购物车。点击加号要判断库存、更新数量、重算价格点击减号要判断是否归零、删掉商品或保留占位。jQuery实现这个逻辑有个常见坑就是每个商品各写一套函数页面一多就爆炸。我的做法是集中用一个状态对象驱动DOMvar cart {}; function addToCart(shopId, itemId, price) { if (!cart[itemId]) { cart[itemId] { count: 0, price: price }; } if (cart[itemId].count 99) return; cart[itemId].count; renderCartBadge(); renderItemCount(itemId); } function reduceFromCart(itemId) { if (!cart[itemId]) return; cart[itemId].count--; if (cart[itemId].count 0) { delete cart[itemId]; } renderCartBadge(); renderItemCount(itemId); } function renderItemCount(itemId) { var count cart[itemId] ? cart[itemId].count : 0; $(.item-count[data-item itemId ]).text(count); $(.item-count[data-item itemId ]).toggleClass(hidden, count 0); }这里把数量和价格放在cart对象里DOM只是它的投影。好处是后续要做购物车弹层、结算页直接遍历cart对象生成数据即可不用再从DOM里text()抠数字——后者很容易拿到带着“¥”符号的脏数据。99的上限是防连点移动端双指操作或连点器很容易把数量刷上去服务端也会拒本地先挡一层。加号按钮点击时给它一个涟漪光圈扩散效果用CSS动画配合jQuery的removeClass/addClass重新触发.ripple { position: relative; overflow: hidden; } .ripple:after { content: ; position: absolute; width: 0.2rem; height: 0.2rem; border-radius: 50%; background: rgba(255, 255, 255, 0.6); transform: scale(0); opacity: 1; } .ripple.go:after { animation: ripple 0.4s ease-out; } keyframes ripple { to { transform: scale(3); opacity: 0; } }$(.btn-add).on(click, function () { var $this $(this); $this.addClass(go); setTimeout(function () { $this.removeClass(go); }, 400); });用法是点击时加临时class触发动画结束后移除以便下次点击再次触发。这个效果适合加号、减号这类高频操作按钮用它抵消移动端没有hover状态的生硬感。注意动画结束后一定要移除class否则第二次点击不会有涟漪。4.4 回到顶部与滚动加载的两段式处理用户滑到列表底部时常见的处理是“回到顶部”按钮。html一键返回顶部算法听起来玄学实际就是滚动距离的计算$(window).on(scroll, function () { var top $(window).scrollTop(); $(#back-top).toggleClass(show, top 600); }); $(#back-top).on(click, function () { $(html, body).animate({ scrollTop: 0 }, 300); });scrollTop()读的是当前滚动距离600px这个阈值按需调通常是一屏半的高度。回到顶部的动画用animate({scrollTop:0})时要同时选中html和body两个元素——为什么是两个因为iOS Safari对body的scrollTop响应不一致写成$(html, body)是兼容保底真机上能省去一次“点了没反应”的翻车。滚动加载的常见做法是检测滚动到底部前200px就发请求注意要用节流$(window).on(scroll, $.throttle(function () { if ($(window).scrollTop() $(window).height() $(document).height() - 200) { loadMoreShops(); } }, 200));$.throttle是jQuery自带的节流方法没引入的话用setTimeout自己实现也行。不加节流时滚动事件每秒会触发几十次接口会被瞬间打爆。这个问题在移动端比桌面端更明显因为触摸滚动的惯性事件是连续触发的。5. 真机调试与避坑5个让页面翻车的高频问题5.1 1px边框在Retina屏上变粗视觉直接穿帮现象在iPhone上美团外卖这类页面里写了border-bottom: 1px的元素看起来有2px粗和设计稿明显不符。原因Retina屏的物理像素与CSS像素是1:2或1:3的关系1px CSS像素在物理屏上被放大成2个或3个物理像素点视觉上自然变粗。桌面屏通常没这个问题所以很多开发者只在真机阶段才发现。解决用transform: scaleY(0.5)配合伪元素画细线这是最常见的做法写成一个工具类.hairline-bottom { position: relative; } .hairline-bottom:after { content: ; position: absolute; left: 0; bottom: 0; width: 100%; height: 1px; background: #e5e5e5; transform: scaleY(0.5); transform-origin: 0 0; }transform-origin: 0 0是必须的不写的话缩放会以元素中心为原点线条位置会偏移。如果有全等边框需求则用border-image或者把四个方向都做成伪元素原理一样。这个坑没有框架级解法只能靠工具类统一收口。5.2 点击有300ms延迟用户觉得“不跟手”现象在部分Android机或旧版iOS WebView上点击按钮后会明显延迟约300ms才响应连点几次还会触发两次事件。原因早期移动端浏览器为了区分“单击”和“双击缩放”在touchend之后会等待300ms再决定是否触发click。这导致click事件天然带延迟。另一个连带现象是input聚焦时页面会自动放大同样是双击缩放机制在起作用。解决viewport里加user-scalableno可以解除这个等待这也是第2章那行meta的另一个价值。如果锁了缩放还延迟看是不是全屏遮罩层的touchmove把滚动吞了。fastclick库曾经是主流方案但它已经停止维护在新版Chrome里反而引入额外问题不建议再引。排查时打开浏览器开发者工具的Network面板对比click事件触发时间与触摸时间差值稳定在300ms左右基本就是这个问题。5.3 iOS页面滚动卡顿尤其是列表滚动时掉帧现象商家列表很长时iOS上滑动明显掉帧甚至出现一整块白屏滚动。原因常见原因有两个一是滚动时频繁触发重排比如不断改元素的top值或宽度二是本身没有开启GPU合成。移动端浏览器对overflow:auto的列表处理比桌面端弱很多。解决给滚动容器加上-webkit-overflow-scrolling: touch这是iOS的惯性滚动开关。同时把滚动事件里频繁改DOM的代码全部拆出去只留节流后的处理逻辑。另一个更稳的做法是把长列表分页渲染一次只渲染一屏的量而不是一次性append几百个DOM节点。jQuery的clone和append虽然快但DOM量大了之后WebView的排版引擎会吃力。生产环境里我一般一次加载20条左右滚动到底再追加。5.4 rem根字号在部分手机浏览器里被系统限制现象按100px基准换算在高分屏某些浏览器里页面字体和间距突然不成比例偏大或偏小。原因部分WebView对html根字号做了最大/最小限制超过系统设定值时font-size会被强制收敛。这会导致基于它的所有rem尺寸一起错乱属于典型的“换算没错、运行环境出错”。横竖屏切换时也会触发重新计算如果计算时机不对同样会出现尺寸跳变。解决第2章里那个Math.max(12, ...)下限可以防太小建议在recalc里再套一层Math.min比如限制到100 * 2。另一种做法是改用vw作为单位但因为vw兼容性和四舍五入问题老旧机型仍不稳。我的习惯是rem负责整体缩放关键按钮和文字用px兜底。调试时遇到“为什么只有这台手机不对”先手动在浏览器控制台打印document.documentElement.style.fontSize对比一下就知道了。5.5 图片占位和懒加载在弱网下导致布局抖动现象商家列表滚动时图片一张张加载出来每次图片加载完都把下方内容往下顶列表一直跳动。原因图片没有固定宽高或者父容器没有预先占位。移动端弱网环境下图片加载时间从几百毫秒到几秒每次加载都触发一次reflow。解决给.shop-img设定固定宽高就是为此。进一步可以做懒加载把img的src先换成占位图滚动到可视区再换成真实地址$(img[data-src]).each(function () { var $img $(this); if ($(window).scrollTop() $(window).height() $img.offset().top) { $img.attr(src, $img.data(src)).removeAttr(data-src); } });懒加载的判断核心是offset().top与滚动距离的比较注意页面缩放或横竖屏切换后要重新跑一次否则会出现滚动到位置却不加载的情况。另一个容易踩的问题是scroll和resize事件绑得太多导致加载器本身成为性能瓶颈。用节流把检查频率降到每200ms一次就够了。6. 进阶原子类收口样式再用三条线过一遍真机验收功能跑通之后接下来是维护层面的升级把高频样式收成原子类然后按一条清单做真机验收。原子性css的做法是把重复出现的单条样式抽成类名比如flex、隐藏、单行省略.flex { display: flex; } .flex-center { display: flex; align-items: center; justify-content: center; } .flex-1 { flex: 1; } .hidden { display: none; } .ellipsis { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }注意原子类不要无节制地抽。像商家列表里那些一次性的复杂组合抽出来反而让HTML变得臃肿。我的习惯是只抽五类布局类、间距类、文字类、显示类、动效类并且控制总量配合语义类共存。比如shop-name既需要字体大小又需要省略号我会在HTML里写classshop-name ellipsis而不是把所有样式都塞进.shop-name。这个方案适合案例课、活动页这类中短期项目项目规模大到几百个原子类时命名冲突和维护成本会反噬届时就该考虑样式方案的重构了。真机验收建议按三条线来检查。交互线重点看点击反馈和购物车状态是否同步加号减号连点几次再刷新页面看状态是否恢复适配线重点看不同机型下rem换算结果和safe area是否正常iPhone X以后的机型底部home指示条会盖住结算按钮CSS里要补上.settle-bar { padding-bottom: constant(safe-area-inset-bottom); padding-bottom: env(safe-area-inset-bottom); }constant是旧版iOS语法env是新语法两条都写是为了兼容老设备。性能线重点看滚动掉帧和图片懒加载是否到位用vConsole这类调试面板在真机上抓console报错和网络请求做完这只移动web案例才真正“能交付”。我自己的收尾习惯是每次改完样式都用手机浏览器过一遍首页、列表、购物车三个页面滚到底再滚回来把滚动卡顿和图片抖动问题当成最后一道关卡。这套流程看着朴素但比任何框架层的花活都更能避免交付前的临门翻车。希望帮到你。本文还有配套的精品资源点击获取 SEO 优化官网定制响应式建站教育培训建站