提到“煤炉(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.8s | 2.5s以内 | 首屏主体内容可见速度 |
| CLS(累积布局偏移) | 0.28 | 0.1以下 | 页面错位、抖动程度 |
| INP(交互响应延迟) | 280ms | 150ms以内 | 点击收藏、滑动等交互反馈速度 |
| TBT(总阻塞时间) | 420ms | 200ms以内 | 主线程被长任务卡住的时间 |
这里多说一句,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触发加载。这里有个细节:不要给所有图片都设置原生loading="lazy",因为首屏图片如果被浏览器误判为延迟加载,反而会拖慢LCP。首屏图片应该用fetchpriority="high"显式声明高优先级,视口外的图片再用loading="lazy"。
做完这轮改造,首屏图片传输体积从平均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 长任务拆分与用户感知优化(INP)
INP这个指标,本质上衡量的就是主线程“忙于干活,顾不上响应用户操作”的时间。我们要把超过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-age=300 价格信息:Cache-Control: public, max-age=30 库存与是否可购买: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按机型、操作系统、网络类型分别统计,每次发版前都要对比看板数据看是否存在回退。这样做的好处是,性能和功能一样,成了常规迭代的一部分,而不是一个“优化完了就走”的临时任务。如果你也想给负责的页面做一轮性能治理,我建议先花一周时间把现有指标摸清楚,再对照本文的改造顺序逐项推进,不要想着一口气全改完,每步验证、每步回归,效果反而会更扎实。