
提到“煤炉Mercari二手商品详情页前端性能优化”这个项目我先交代一下背景这是海外二手交易平台Mercari的移动Web端商品详情页站内通常昵称“煤炉”。详情页可以说是整个平台用户决策链路里最重的一环买家在这页上看图、比价、看卖家信誉、读评价、判断是否下单每一步都是在和时间赛跑。性能不好用户不会给你第二次机会。我接手这个项目的时候线上数据已经不太乐观详情页LCP在4G模拟环境下平均3.8秒在3G环境下直接飙到7秒以上交互响应也经常被用户反馈“点了收藏半天没反应”。更麻烦的是这个页面承载的内容形态很多——商品图片动不动就十几张评论列表能拉到上千条还要兼容不同型号的老旧安卓机。市面上讲性能优化的文章很多但大多说的是理论真正围绕一个具体业务页面做的完整实战记录反而少。这篇文章就把我们项目里做过的拆解、改造、上线、踩坑全过程写出来给正在做电商详情页、二手交易类产品或者移动Web优化的同学一个能直接参考的副本。适合谁看一是刚接手电商类前端项目、准备系统做一轮性能治理的同学二是面试前端岗位前想积累真实优化案例的候选人三是团队里被LCP、CLS、INP折磨得够呛的负责人。下面按我的实际操作顺序来聊。1. 详情页的性能欠账到底在哪里接手项目第一件事不是上来就改代码而是先把问题量化清楚。没有准确数据打底后面做任何优化都像是在盲人摸象。1.1 二手商品页的首屏链路长在哪普通电商详情页的链路通常是“进入页面 - 请求商品接口 - 渲染主图 - 渲染标题价格 - 渲染详情”。但二手交易平台的详情页比普通新品页复杂得多因为它必须同时回答买家的好几个问题东西长什么样、成色如何、卖家的信用靠不靠得住、这个价格值不值。具体到我们页面首屏渲染依赖的数据源就有四五个商品基础信息接口标题、价格、成色、所在地卖家信息和信用评价摘要商品主图列表注意是列表通常8到20张平台推荐策略的“猜你喜欢”区域价格走势与降价提醒状态这里面任何一个接口慢都会拖整体渲染。而且我们早期是纯客户端渲染CSR页面先加载一个巨大的JS Bundle再在浏览器里发请求拿数据最后才拼出画面。换句话说用户网络再快也得等脚本下载完、解析完、数据回来才能看到东西。这个架构本身就是性能天花板。更隐蔽的问题是图片处理策略。商品主图原来的做法是原图直出没有压缩、没有分辨率分级、没有格式转换。一张单反拍的实拍图动辄2MB到5MB首屏装3张主图光图片就10MB以上。这在WiFi环境下尚可忍受在移动网络下基本等于劝退。1.2 先定目标再谈优化没有目标的性能优化是没法验收的。我们团队在项目启动时把核心指标卡在了四个维度上指标优化前目标值说明LCP最大内容绘制3.8s2.5s以内首屏主体内容可见速度CLS累积布局偏移0.280.1以下页面错位、抖动程度INP交互响应延迟280ms150ms以内点击收藏、滑动等交互反馈速度TBT总阻塞时间420ms200ms以内主线程被长任务卡住的时间这里多说一句INP是2024年Google Web Vitals里替代FID的正式指标它比FID更严格因为它统计的是整站所有点击、键盘、触摸操作的响应延迟而不只是首次输入。所以我们做交互优化时把INP当成和LCP同等重要的指标来盯后面会发现这个选择非常关键。确定目标以后我们把整个详情页生命周期分成三段来治理首屏渲染链路、长列表与交互流畅度、网络层与缓存。每段都有自己的主要矛盾和优化空间逐段突破不要混在一起改否则出了问题根本定位不到是哪一步引起的。2. 首屏渲染链路优化先把第一眼的钱花在刀刃上首屏是所有优化的重中之重。二手商品详情页的买家有个行为习惯他们会在列表页滑动浏览大量商品点进详情页后通常只会停留几秒做快速判断。如果前两秒看不到核心信息很多人直接返回跳出率就是这样堆高的。2.1 图片分级与懒加载别让首屏为整页图片买单我们做的第一件事是给图片资源建一套完整的分级消费体系。具体来说图片按用途分成三档主图缩略图宽度480px用于首屏轮播区目标体积控制在60KB以内详情配图宽度1080px用于用户下滑后查看目标体积控制在200KB以内原图不裁剪仅在用户主动点开大图预览时加载同时CDN侧启用AVIF和WebP格式自动转换根据浏览器请求头中的Accept字段返回对应格式。实测下来同一张图片从JPEG转为WebP能省30%左右的体积转为AVIF能省50%以上。对于不支持AVIF的低版本浏览器回退到WebP不支持WebP的回退到JPEG。兼容性判断交给CDN去处理前端不用写一堆判断逻辑。图片加载策略也从“进入页面全量加载”改成“首屏关键图片预加载 非关键图片懒加载”。首屏轮播区只加载第一张主图剩余图片进入视口后再通过IntersectionObserver触发加载。这里有个细节不要给所有图片都设置原生loadinglazy因为首屏图片如果被浏览器误判为延迟加载反而会拖慢LCP。首屏图片应该用fetchpriorityhigh显式声明高优先级视口外的图片再用loadinglazy。做完这轮改造首屏图片传输体积从平均12MB降到了1.5MB以内这是一个数量级的差距。2.2 SSR 直出与数据分片首屏数据能多早到就多早到图片只是第一层数据链路是更大的瓶颈。我们决定把详情页从CSR改造成SSR直出。这里说的SSR不是简单用Vue的同构渲染把所有内容都吐到浏览器而是针对详情页场景做了“分片直出”。核心思路是同一页面里不同模块的数据时效性、重要性不一样没必要等最慢的接口再一次性返回全部HTML。我们把接口拆成三层首屏关键数据商品标题、价格、首图、卖家名、成色信息随SSR直出TTFB要求在500ms内次屏数据全部主图、详细描述、发货信息数据到达后异步补充渲染但这部分不影响首屏关键内容的展示延后数据评论列表、猜你喜欢、价格走势等首屏可交互之后再拉取技术实现上用React的renderToPipeableStream我们当时服务端渲染层用的是React客户端路由框架是Vue的混合架构但原理是通用的做流式渲染。首屏数据一到服务端立刻把能渲染的HTML推给浏览器不需要等所有接口响应完毕。这里给个实用建议在做SSR改造时要分清哪些内容属于“首屏视觉稳定内容”哪些属于“可渐进增强内容”。我们最初踩过一个坑试图把评论列表也做成SSR直出结果因为评论接口里面有大量富文本和图片链接服务端处理耗时直接翻倍反而拖累了LCP。后来把评论列表彻底放到客户端异步加载首屏速度又快了不少。SSR不是全量直出而是把最该先到的内容优先放到HTML里。2.3 骨架屏不是遮羞布它是渲染节奏的一部分很多人觉得骨架屏只是视觉层面的小优化其实它在性能优化里扮演的角色比想象中大它可以稳定CLS避免页面内容加载完成后突然发生大面积跳动。我们页面里所有异步模块都做了骨架屏但骨架屏的尺寸不是随便画的必须和真实内容的宽高保持一致。比如商品标题区域的信息密度较高骨架屏就做两行渐变条评价列表每条的高度固定在88px骨架屏也按这个高度渲染。这样做好处是真实数据到达后DOM结构的宽高变化很小CLS数值自然就降下来了。另外骨架屏的渲染也做了优先级控制。首屏上方区域主图区、标题区、价格区的骨架屏随HTML直出用户一打开页面就能看到结构下方区域评论、推荐的骨架屏在数据请求发起的同时再插入避免一次性渲染太多无意义的占位元素拖累DOM解析速度。首屏链路这轮优化做完LCP从3.8s降到了2.2s左右TTFB从之前的1.2s降到600ms以内。但首屏只是开始真正考验耐心的战场在中后段。3. 长列表与交互流畅度详情页下半场的体验决胜点二手详情页有一个被很多人低估的性能杀手评论列表。热门商品的评论能到上千条每条评论里还附带买家的返图。如果全部渲染成真实DOM节点整个页面的节点数能到几万个。浏览器解析这些节点、计算样式、布局绘制每一步都在消耗主线程时间。3.1 评价列表虚拟滚动改造记录我们改造评价列表时第一版方案选的是社区里很成熟的虚拟滚动库但实际接入后遇到不少兼容性问题后来干脆自己写了一个定制版。虚拟列表的核心逻辑其实不复杂只渲染可视区域内需要的条数滚动时动态替换渲染内容同时用padding撑起整个滚动区域的高度。我们按每条评价固定高度88px头像40px 两行文字来计算可视区域高度假设为800px那么同时只需要渲染约12条评价。上下各预留几条约半屏的缓冲防止快速滚动时出现白屏。这里有一个关键参数缓冲条数不能太小。我们最初设的是上下各6条结果在高速滚动时DOM创建跟不上滚动速度会出现闪空和抖动。最后调到上下各15条同时让滚动容器的transform开启GPU加速这个问题才解决。评价列表的文字内容多每一条都需要做文本截断。我们统一用CSS的line-clamp限制两行而不是在JS里做字符串截断这样能减少一次文本计算的成本。3.2 收藏按钮与价格走势模块的交互优化详情页里收藏按钮是使用频率最高的交互用户会在浏览过程中反复点击“收藏”和“取消收藏”。我们优化前点击收藏按钮要发一个异步请求等服务器返回后再更新按钮状态。弱网环境下这个操作可能会卡顿一秒以上用户通常会认为点漏了紧接着再点一次结果就是重复提交体验极差。改成乐观更新点击后前端立刻把按钮状态切换为“已收藏”同时显示一个轻量的toast提示请求失败时再回滚状态并提示用户。这样视觉反馈是瞬间的INP指标里的点击响应时间从平均280ms降到了80ms以内。价格走势模块也有类似问题。原来的实现是页面初始化时拉一次历史价格数据然后一次性绘制整个折线图。问题在于如果用户不往下滑到价格区域这笔数据请求和图表渲染就是纯浪费。我们给这个模块加了懒挂载逻辑只有滚动到该区域且停留超过300ms时才真正请求数据和初始化图表。这里插一句很多前端性能问题并不是单个操作慢而是大量低优先级的工作抢占主线程。我们通过Performance面板统计过旧版本页面加载后会有一次持续800ms的主线程长任务主要就是图表初始化、大数据量列表渲染、埋点脚本执行这些事堆在一起导致的。后续我们专门做了任务拆分把非关键操作放到requestIdleCallback或者setTimeout里给用户点击交互腾出主线程时间片。3.3 长任务拆分与用户感知优化INPINP这个指标本质上衡量的就是主线程“忙于干活顾不上响应用户操作”的时间。我们要把超过50ms的长任务拆成小任务让浏览器有机会在任务间隙处理点击、滚动等输入事件。举一个具体例子详情页历史价格图表的渲染原先是一整个同步过程从数据格式化到Canvas绘制一条龙做完耗时约120ms。这期间如果用户点击收藏按钮浏览器只能等图表渲染完再响应用户感知就是“卡了一下”。我们的做法是分片处理先把数据切成长度不超过200的小块每次通过requestAnimationFrame或者scheduler.postTask处理一块处理完就给浏览器一个交还主线程的机会。这样图表总渲染时间可能反而多了几十毫秒但用户点击收藏的响应不再被长时间阻塞感知上的流畅度提升非常明显。另外我们还给整个页面加了一层极简的“点击反馈”机制任意按钮在被点击的瞬间先触发一个CSS状态变化比如轻微的颜色改变再等待业务逻辑执行结果。这样即使后面有几百毫秒的异步等待用户也先看到了“系统收到指令”的反馈不会觉得页面没响应。4. 网络层与缓存移动端弱网下的最后一公里前面做的优化再彻底在弱网条件下也会被网络延迟抵消大半。移动端用户的地铁、电梯、地下室场景网络质量波动远比我们想象得剧烈。这部分的优化决定了你在差网络下是“勉强能用”还是“直接劝退”。4.1 Service Worker 静态资源缓存与版本策略按照常规方案我们注册了Service Worker来缓存静态资源。但这里值得展开讲的是缓存策略的选择而不是注册本身。对于带版本号的静态资源JS、CSS、图片我们采用Cache First策略命中缓存就直接返回完全不走网络。这类文件内容被内容哈希标识版本更新时请求新的URL自然就能拿到新文件不会有缓存污染问题。对于HTML文档比如详情页本身我们采用Network First策略优先请求网络拿到新HTML就用最新的网络失败时才回退到缓存。这样能保证商品价格、库存这类关键时刻的数据尽量是新鲜的同时断网时用户依然能看到之前打开过的页面骨架。具体实现代码如下// Service Worker 缓存策略示例 const CACHE_STATIC mc-static-v3; const CACHE_PAGE mc-page-v2; self.addEventListener(install, (event) { self.skipWaiting(); }); self.addEventListener(activate, (event) { event.waitUntil( caches.keys().then((keys) { return Promise.all( keys .filter((key) ![CACHE_STATIC, CACHE_PAGE].includes(key)) .map((key) caches.delete(key)) ); }) ); }); self.addEventListener(fetch, (event) { const url new URL(event.request.url); const isStaticAsset url.pathname.match(/\.(js|css|jpe?g|webp|avif|png|woff2?)$/); const isPage event.request.mode navigate; if (isStaticAsset) { // 缓存优先命中即返回 event.respondWith( caches.match(event.request).then((cached) { if (cached) return cached; return fetch(event.request).then((response) { const clone response.clone(); caches.open(CACHE_STATIC).then((cache) cache.put(event.request, clone)); return response; }); }) ); return; } if (isPage) { // 网络优先失败回退缓存 event.respondWith( fetch(event.request) .then((response) { const clone response.clone(); caches.open(CACHE_PAGE).then((cache) cache.put(event.request, clone)); return response; }) .catch(() caches.match(event.request)) ); } });上线后我们还顺手把Service Worker的更新检查时机从前台切回时提前到了注册时配合skipWaiting和clients.claim避免用户守着老版本页面迟迟不更新。不过Service Worker的版本更新需要谨慎我们曾经因为缓存了旧的图片尺寸参数导致某次CDN改造后详情页图片大面积变形排查了大半天才发现是新旧缓存混用导致的。4.2 数据接口缓存分级价格和库存不能乱缓存很多做前端缓存的同学容易走入一个误区把接口响应一律缓存缓存了就快。但详情页的数据是有时效性分级的商品标题、描述、卖家信息等几乎不变的内容CDN缓存可以设置到5到10分钟商品价格可能被卖家随时调整又必须保证买家看到的是准的所以这类接口的CDN缓存只设置30到60秒库存状态直接关系到能否下单在全流程里都不应该被前端缓存我们给数据接口做了一套分级缓存策略按照响应头的Cache-Control来区分商品基础信息Cache-Control: public, max-age300 价格信息Cache-Control: public, max-age30 库存与是否可购买Cache-Control: no-store这样做有时候会出现一个页面里不同数据“新鲜度不一致”的情况比如价格已经涨了但标题缓存的还是旧版本。但从用户体验上看标题本身不会引起购买决策冲突而价格必须尽量正确所以这种分级是值得的。4.3 弱网降级方案把“请求失败”变成“控制失败感”弱网下用户最大的痛点倒不是加载慢而是加载失败。我们页面里评论列表如果请求失败原来的表现是整块区域空白用户不知道是没加载出来还是这里没有内容。整改后所有异步模块都加了“错误占位 重试按钮”的设计。评论列表请求失败时显示“评论加载失败点击重试”点击后重新发起请求而不是整页刷新。图片加载失败时同理。二手商品图是关键信息如果图片挂了用户基本不会下单。我们对所有商品图都加了onerror处理加载失败先自动重试一次备用尺寸再失败则显示占位提示。这里的关键是不要让一个坏图阻塞整个页面的图片流。对于首屏主图我们还加了一层“缩略图抢先显示”的逻辑原图还没加载完成时先用刚才提到的480px缩略图顶上去让用户先看到商品轮廓原图到达后再无缝替换。这种体验上的“先看到后看清晰”比一张大图加载几秒钟的等待感好得多。5. 上线后的踩坑与排查笔记性能优化做得再完善上线后的灰度阶段也会暴露一堆奇怪问题。这部分我整理成笔记形式给准备动手做同类优化的同学排几个雷。5.1 图片抖动和CLS回弹我们第一版上线后LCP和TTFB数据都很好但CLS却比预期高回弹到了0.18左右。排查下来发现是图片懒加载导致的虽然主图区域设置了固定的宽高比但详情配图没有统一设置图片从占位状态切换到真实图片时高度发生变化把下方内容往下推。这个问题的修复方案是给所有详情配图容器设置aspect-ratio属性宽度是100%自适应高度由宽高比计算得出。同时给图片设置object-fit: cover确保图片在容器内保持比例不拉伸这一个改动让CLS从0.18直接降到了0.06。这块的经验是任何懒加载图片都必须在HTML或CSS里预留好宽高空间否则懒加载省下来的性能会以布局偏移的形式加倍还回去。5.2 虚拟滚动与iOS橡皮筋的兼容问题虚拟滚动上线后安卓端一切正常但iOS微信内置浏览器上出现了滚动卡顿和漂移。排查发现是iOS的橡皮筋回弹效果和虚拟列表的滚动位置计算冲突橡皮筋滚动时scrollTop会变成负数虚拟列表计算出错整块内容就空白了。修复方式是在滚动容器上设置overscroll-behavior: none禁止回弹效果同时给虚拟列表的计算逻辑里加了一层边界处理scrollTop小于0时强制归零。iOS的橡皮筋效果在某些场景下确实是“feature”但在虚拟滚动列表里就是bug源不用犹豫直接禁掉。5.3 “指标变好但用户没感觉”的陷阱这是我最想提醒的一点。我们第二版优化上线后LCP降到了1.8秒CLS几乎为零但线上数据却显示详情页跳出率没有明显改善用户反馈也没有变好。团队当时很困惑数据明明全面变好了为什么用户不买账后来我们用录屏工具回放了一批真实用户会话发现了真正的问题很多用户从列表页进入详情页后第一眼看到的是主图但主图的加载速度虽然快了商品标题和价格区域却因为SSR直出时接口返回慢仍然在骨架屏状态多停留了将近一秒钟。也就是说用户第一眼看到的仍然是一个“没加载完”的页面LCP标记的“最大内容绘制”虽然完成了但用户觉得重要的信息并没有完整呈现。问题根源是LCP的定义和用户真实感知存在偏差LCP标记的是最大可见元素的渲染完成时间但在我们页面里最大元素是主图它渲染完成不代表用户关心的信息都出来了。我们后来把标题和价格的接口优先级提到主图前面强制要求价格信息必须在HTML中直出即使标题晚到一点也没关系然后用户体验才真正开始好转。这里给所有人一个忠告性能指标是导航图不是终点。每个页面的业务语义不同用户真正关心什么需要结合页面场景重新定义“快”的含义。最后再说一个从整个项目里沉淀下来的体会性能优化不是一次性的项目它是一个持续性的监控和回归过程。我们上线后搭建了基于真实用户监控的看板把LCP、CLS、INP按机型、操作系统、网络类型分别统计每次发版前都要对比看板数据看是否存在回退。这样做的好处是性能和功能一样成了常规迭代的一部分而不是一个“优化完了就走”的临时任务。如果你也想给负责的页面做一轮性能治理我建议先花一周时间把现有指标摸清楚再对照本文的改造顺序逐项推进不要想着一口气全改完每步验证、每步回归效果反而会更扎实。