做前端这些年,详情页一直是我最不敢轻易动的模块。它不像列表页那样结构单一,往往集成了主图、SKU、评价、推荐位、营销弹窗、直播挂件等各种复杂组件,稍有不慎就会把线上搞出问题。这次拿到一个内部代号为Target的商品详情页性能优化项目,算是把我印象里关于长列表渲染、图片加载策略、接口并发、首屏指标这些零零散散的经验全部串起来了。这篇文章我不打算写那种"优化了几十个百分点"的场面话,就老老实实复盘一下我从摸底、定指标、动手优化到上线监控的完整过程,以及中间踩过的几个坑。
先说项目背景。这个详情页属于某电商平台的购物主链路,每天UV量很大,移动端占比超过八成。优化前的状况其实已经比较严重了:在 4G 网络和中等价位安卓机下,用户平均要等 3 到 4 秒才能看到首屏主要内容,快速滑动列表时明显掉帧,切换 SKU 后图片闪烁,甚至偶发白屏。业务方反馈"详情页打开慢、滑动卡",但一直说不清楚到底慢在哪、卡在哪。这就是典型的"性能问题变成了体验玄学"。所以这次优化我给自己定的原则是:先量化,再动手,最后用数据说话。
1. 优化前的一轮摸底:先搞清楚慢在哪,再谈怎么快
1.1 先跑一轮真实数据再动手
很多优化方案之所以失败,是因为上来就凭感觉砍需求、调参数,结果连问题根因都没找到。我在这个项目上的第一步,是分别用实验室工具和真实环境把性能数据完整采了一遍。
实验室环境我用的是一台配置偏低的测试机,模拟 4G 网络条件,跑了三遍 Lighthouse。第一遍出来的数据很不好看:FCP 在 2.8 秒左右,LCP 到了 4.5 秒,CLS 高达 0.18,TBT 有 800 多毫秒。这个 CLS 值尤其扎眼,说明页面加载过程中元素发生了明显位移,点错按钮的概率很高。
真实环境我开了浏览器 Performance 面板录制整个加载过程,同时用 Network 面板数了一遍请求。不看不知道,一个详情页首屏居然发出去 36 个请求,光 JS 文件就有 1.8MB 左右,主图请求占了差不多 12 个,还有 3 个第三方 SDK 在首屏加载阶段被初始化。业务接口这块问题也很大,详情、库存、评价、推荐四个核心接口居然是串行请求的,前一个返回之后下一个才开始,首屏接口总耗时接近 1.2 秒。
Performance trace 里暴露出来的问题更典型:主线程上有一段超过 400ms 的长任务,定位后发现是首屏某个营销组件在做数据格式化时卡住了主线程,直接推高了 TBT。往下翻还能看到大量脚本执行和样式重计算耗时,说明运行时层面也有一堆可优化的点。
1.2 把优化目标拆到足够细
数据摸完以后,不要急着给结论。我把问题归了一下类,大致分成了四个方向:资源体积与请求数量、图片加载链路、接口串行、运行时渲染卡顿。基于这四类问题,定了几个量化目标:
- LCP 从 4.5 秒优化到 2.5 秒以内,目标覆盖 75 分位;
- CLS 从 0.18 优化到 0.1 以下;
- INP 控制在 200ms 左右,至少不能出现明显点击延迟;
- 首屏请求数量从 36 个缩减到 20 个以下;
- JS 总体积从 1.8MB 压缩到 1.2MB 以内(gzip 后)。
之所以把这些指标拆这么细,是因为后续每一次改动都能锚定具体数字验证,而不是"感觉快了"。这也算是我做性能优化一贯的笨办法:目标越具体,执行越不容易跑偏。
2. 网络层优化:把请求数量和体积先拉下来
2.1 打开请求瀑布图,先找最扎眼的阻塞点
当年上学时导师常说一句话:网络优化不要只看体积,还要看请求数量和请求时机。这次我把这句话实践了一遍。
优化前我先打开 Network 面板把瀑布图从头到尾看了一遍,发现最大的问题不是单个资源太大,而是请求太多太碎。首屏 36 个请求里,图片占了将近一半,组件样式拆得也很碎,一个详情页下的各个子组件各自引入了独立的样式文件。第三方 SDK 占了 3 个请求,但总量并不大,真正麻烦的是它们的初始化脚本会在首屏同步执行,硬生生把主线程占住了。
于是我把优化顺序定成先合并请求、再压缩体积、再做延迟加载,优先级从收益大小来排。
2.2 拆包与按需加载到底怎么配
Target 详情页用的是类 React 技术栈,构建工具是 Webpack。我这一次没有单纯把代码全部打成一个 bundle 就完事,而是重新设计了分包策略。
公共基础库单独打成一个稳定 chunk,比如 React 相关、路由相关、工具函数库。这个 chunk 因为内容相对固定,文件名不会频繁变化,非常适合做强缓存。业务代码按照路由维度拆分,详情页内部再按组件维度做懒加载。像评价列表这种首屏不需要完整展示的内容,我就改成进入可视区域后再动态 import 加载。
我简单给一下这次用的 splitChunks 配置思路,实际线上配置会根据版本微调,但大概长这样:
splitChunks: { chunks: 'all', cacheGroups: { vendors: { test: /[\\/]node_modules[\\/]/, name: 'vendors', priority: 10, enforce: true, }, commons: { name: 'commons', minChunks: 2, priority: 5, reuseExistingChunk: true, }, }, },配置好之后我还做了一步按需加载的收敛:首屏直接展示的组件走同步引入,藏在下方的弹窗、评价列表、直播挂件全部改成懒加载。这一步整个 JS 体积从 1.8MB 降到了 1.3MB 左右,按需拆出来的子 chunk 占了几百 KB,但是都不会阻塞首屏。
2.3 CDN 与缓存策略:让回访用户真正吃到缓存
资源打包完了,还需要让浏览器更聪明地缓存它们。我们做了几件事。
第一,所有静态资源统一走 CDN,并且在 CDN 上为带 hash 的静态文件开启了一年的强缓存。这个做法的逻辑是:文件名一旦变化就代表内容更新,服务器只要让浏览器对带 hash 文件使用 Cache-Control: max-age=31536000, immutable 即可,完全不回源验证。不带 hash 的 HTML 页面则使用 no-cache,每次请求都去服务端校验,保证业务更新能及时拿到最新页面。
第二,把页面里几个公共依赖抽成独立 chunk 后,它们的缓存命中率明显提高。以前整个 bundle 只要有一行代码变更,所有用户都要重新下载全量脚本;现在业务代码更新只会让业务 chunk 失效,vendors 和 commons 仍然命中缓存。
第三,对第三方 SDK 做了延迟加载处理。首屏真正用到的只有埋点 SDK,其他推荐、客服、直播等 SDK 全部改到空闲时加载,用 requestIdleCallback 加一个兜底定时器处理。
2.4 一次性把体积做干净
除了拆包,我还顺手做了一轮体积清理。Source Map 只保留在独立监控环境,生产环境不输出;Brotli 压缩和 gzip 同时配置,让 CDN 根据请求头协商返回,压缩率比单一 gzip 平均能再低 15% 到 20%。
这些操作都不复杂,带来的收益却很实在。优化后的首屏静态资源总大小从 2.1MB 降到了 1.1MB 左右,请求数降到 19 个。到这里,网络层面的优化基本完成了。
3. 首屏渲染链路:从图片到接口的逐段提速
3.1 图片优化:不同尺寸、裁剪、占位和懒加载
商品详情页最耗资源的几乎都是图片,Target 也不例外。主图、详情长图、评价晒图一屏接一屏,如果全都按原图加载,再快的网络也扛不住。
我先做的是图片服务裁剪。后端图片服务本身就支持按宽高裁剪和格式转换,我只需要把 URL 拼接参数这件事做对。原来代码里很多地方直接用原图地址,现在改成了统一封装的图片组件,根据容器尺寸生成对应 URL,并且优先使用 WebP 格式,不支持 WebP 的浏览器自动回退到 JPEG。
关于响应式图片,移动端详情页里我用 HTML 的 srcset 和 sizes 做了一套适配。比如主图区域,给最小屏、普通屏、高清屏分别提供不同宽度的图片地址,让浏览器按当前视口宽度自己选。这一套对安卓碎片化环境特别有用,不至于让中低端机也去下载超大图。
首屏折叠线以下的长图改成懒加载,以滚动进入视口附近 200px 作为触发条件。懒加载组件会先用一个固定宽高比的占位容器撑住位置,避免图片加载完成后页面往下跳。
还要注意一个细节:图片解码不要阻塞主线程。我第一次做懒加载时直接在滚动回调里同步创建大量 Image 对象,结果滚动依然掉帧。后来把 decoding="async" 加上了,再配合 content-visibility 对屏外区块做渲染跳过,滚动帧率才恢复正常。但 content-visibility 这个属性在部分浏览器上对可访问性有影响,上线前一定要做键盘和屏幕阅读器的回归检查。
3.2 字体优化:保证文字可见又压缩体积
详情页用了好几套自定义字体,旧的加载方式是 CSS 里直接 @font-face,没有处理加载时机,导致页面文字在字体下载完成前一直是不可见的,白白拖慢了 FCP。
我做了三件事。第一,字体文件子集化,只保留页面用到的字形,文件体积减少了 60% 以上。第二,在 CSS 里全局设置 font-display: swap,让文字先用系统字体显示,自定义字体加载完再替换,这样用户至少能第一时间看到内容。第三,对首屏区块用 preload 预加载关键字体,对非关键字体延迟到空闲再加载。
这个优化本身不复杂,但对 CLS 有微妙影响。字体切换可能导致文字宽高变化,所以我把关键文字容器都预留了尺寸,实测 CLS 数据没有因为字体替换产生波动。
3.3 数据请求:并行、缓存与弱网兜底
接口这块是首屏时长的重头。优化前详情、库存、评价、推荐四个接口串行请求,总等时接近 1.2 秒,但仔细看会发现库存接口其实根本不依赖详情返回,评价和推荐也只是部分依赖用户基础信息。我直接把不依赖前置数据的请求用 Promise.all 并发发出,详情接口回来后立刻渲染首屏主要内容,评价和推荐等数据回来后再异步填充。首屏接口总耗时从 1.2 秒降到了 650ms 左右。
另外给详情接口加了一层本地缓存。商品详情页的数据时效性相对较低,同一用户短时间重复访问同一个商品,完全可以直接用上次的数据渲染,等静默更新拿到新数据再做比对合并。这个策略对回访路径的体验提升非常明显,用户从金刚位进入商品页、切走、再回来,首屏基本是秒开。
缓存不是随便做,需要控制失效时间和删除策略。我用的方案是把缓存数据存在内存里,以商品 ID 加一个时间戳作为 key,默认有效期 10 分钟,同时监听页面隐藏事件在后台时清掉部分缓存,防止数据过旧。
弱网兜底也要想好。原来详情接口超时后页面会一直白屏,我加了一个首屏骨架屏兜底:接口超过 3 秒没有返回就先展示静态的商品名和主图占位,避免用户面对一片空白。这里还隐藏着一个容易忽略的坑,超时后需要主动 abort 旧请求,否则返回的数据可能在页面其他状态下被渲染,产生状态错乱。
3.4 骨架屏与布局稳定性
视觉上的快,有时候比数据上的快更重要。做首屏优化的同时,我同步调整了骨架屏方案。
过去的骨架屏是等接口返回后一次性渲染整个内容,布局、文案全部就位。这个方案有两个问题:骨架屏本身的出现和消失会造成大面积重绘,接口慢时用户盯着空白骨架也容易焦虑。
我把骨架屏按区块拆分,每个区块的宽高和真实内容完全对齐。主图区、标题区、价格区、加购按钮区各自占位,接口返回哪块就填哪块。这样用户感知到的是"页面在逐步变清晰",而不是"白屏了一段时间然后突然变清晰"。更重要的是,由于占位高度与真实内容一致,CLS 没有因布局变化产生波动,整个视觉过程非常平滑。
为了固定布局,我给所有异步渲染区域都预设了 min-height。评价列表区域、推荐楼层区域在内容没加载前就保持固定的占位高度,加载中不塌陷,加载后不溢出。这是 CLS 优化里最笨但也最有效的做法。
4. 运行时性能:交互卡顿的深层原因
4.1 重温渲染流程,找到卡顿源头
做完加载性能,再来处理卡顿。浏览器的主线程既要执行 JS,又要做样式计算、布局、绘制和合成,任何一环占用过高都会让页面变卡。
Performance trace 显示,滑动商品长图时存在一种隐蔽的性能问题:每次滚动都触发了大面积的样式重计算,其中涉及一些对绝对定位元素的 left、top 修改。这类属性一旦被修改,浏览器无法复用上一帧的布局结果,只能重新计算,又被强制要求同步反馈到当前帧,就会进入连续 Layout Thrashing 状态。
我对滚动场景做了一次全面排查,把所有滚动事件里的直接样式操作全部替换为 transform 和 opacity 动画,因为这些属性走合成线程,不会触发主线程重排。改动后滑动长图时 Performance 面板里的 Frames 区块从大量红色长帧恢复到了绿色短帧。
4.2 长列表、状态管理与大数据量的处理
详情页的 SKU 列表和评价列表都是典型长列表。原先一屏评价可能有几百条,全部渲染 DOM 节点,节点数量多了以后,每次交互都要和大量节点做协调,掉帧是必然的。
这里我用到了虚拟列表。把可视区域和缓冲区域内的数据映射成真实 DOM,其他数据只占一个空的高度占位。具体实现上我自己维护了一个不需要额外依赖的虚拟滚动容器:
const visibleCount = Math.ceil(containerHeight / itemHeight) + bufferSize; const startIndex = Math.max(0, Math.floor(scrollTop / itemHeight) - bufferSize); const endIndex = Math.min(total, startIndex + visibleCount); const visibleData = list.slice(startIndex, endIndex);实际项目里如果数据量不大,不一定非要上完整的虚拟列表库,这种手写方案可控性最好。但要注意用 transform 位移整体内容区,不要频繁操作每一项的 top 值,否则性能又会回去。
状态管理方面也做了一次收敛。详情页早期把详情、SKU 选择、库存、评价等状态全部放在一个全局 store 里,任何字段更新都会让大量组件重新渲染。我改成按业务域拆分状态,详情数据、交互状态、埋点状态分离,各自组件只订阅自己关心的片段。渲染次数肉眼可见地降下来了。
组件层面,我在重复渲染严重的列表项组件上用了 memo 包裹,父组件重新渲染时,如果列表项 props 没有变化,直接跳过子组件渲染。回调函数用 useCallback 稳定引用,避免每次渲染生成新函数导致子组件缓存失效。
4.3 独立于渲染的复杂任务处理
运行时还有一个隐藏的问题:很多复杂计算直接放在了渲染流程里。比如营销组件生成优惠文案时,会对一堆规则做遍历匹配,这个计算每次渲染都执行,毫无必要。我把这些纯计算逻辑改成计算一次后缓存结果,根据输入参数判断有没有必要重新计算。
埋点上报也做了一层优化。原来每个事件都即时发送请求,在滑动场景下会不断创建网络请求。现在统一收集事件,定时批量上报,减少空闲时主线程被网络任务打断的频率。
另外,页面里频繁使用的 tooltip、弹窗这类低成本组件,我没过度优化,它们对性能影响不大,保持代码可读性更重要。优化不做无谓的复杂化,这是我踩了很多坑换来的教训。
5. 上线后的监控与回归防线
5.1 性能监控埋点的完整设计
优化做得再漂亮,没有监控就是凭感觉。我们上线前就搭了一套相对完整的性能监控,核心做法是用 PerformanceObserver 在浏览器真实环境中采集 Web Vitals,并上报到内部监控平台。
初始化代码很简单,但要注意兼容降级:
new PerformanceObserver((list) => { for (const entry of list.getEntries()) { if (entry.name === 'largest-contentful-paint') { reporter.send('lcp', entry.startTime); } } }).observe({ type: 'largest-contentful-paint', buffered: true }); new PerformanceObserver((list) => { for (const entry of list.getEntries()) { if (entry.hadRecentInput) continue; reporter.send('cls', entry.value); } }).observe({ type: 'layout-shift', buffered: true });CLS 上报时需要过滤掉用户主动交互产生的布局偏移,否则数据会干扰判断。我第一次上线时没写 hadRecentInput 判断,结果用户点击按钮触发的布局变化全部被记进去了,数值高得离谱。
除了 Web Vitals,我们还采集了资源加载耗时、接口请求耗时、JS 错误、长任务统计,并且按页面路径、网络类型、设备型号做了维度拆分。这样业务方再报"页面卡",至少能直接定位是页面弱网、低端机还是代码异常导致的。
5.2 性能预算与 CI 卡点
光有监控还是事后处理,我更想把问题挡在上线前。所以我们在 CI 流程里加入了一个脚本,对资源体积做硬性预算:入口 chunk 超过 350KB 直接报警,任意图片超过 300KB 直接构建失败。预算值是根据优化后线上数据的 75 分位定的,不会太紧导致日常迭代寸步难行。
另外在灰度发布阶段,我们会拿新版本的 Web Vitals 跟老版本做自动对比,LCP 涨幅超过 15% 会触发告警,需要优化后才能继续放量。这种做法初期会有点麻烦,但效果是长期的,它能防止性能问题随着业务迭代慢慢回潮。
5.3 上线后的数据验证
光有监控还是事后处理,我更想把问题挡在上线前。所以我们在 CI 流程里加入了一个脚本,对资源体积做硬性预算:入口 chunk 超过 350KB 直接报警,任意图片超过 300KB 直接构建失败。预算值是根据优化后线上数据的 75 分位定的,不会太紧导致日常迭代寸步难行。
另外在灰度发布阶段,我们会拿新版本的 Web Vitals 跟老版本做自动对比,LCP 涨幅超过 15% 会触发告警,需要优化后才能继续放量。这种做法初期会有点麻烦,但效果是长期的,它能防止性能问题随着业务迭代慢慢回潮。
上线一周后,我们从监控后台拉了一版全量数据。LCP 中位数从 4.2 秒降到了 2.1 秒,75 分位在 2.8 秒左右,CLS 降到 0.06,INP 在 180ms 上下波动。整体转化率这块自然有波动,不能全归功于性能,但跳出率确实降了一些。
6. 优化结果与踩坑实录
6.1 核心指标的前后对比
直接放一下我们记录的前后对比,所有数据都是线上监控的 75 分位,不是实验室数据。
| 指标 | 优化前 | 优化后 |
|---|---|---|
| FCP | 2.8s | 1.6s |
| LCP | 4.5s | 2.8s |
| CLS | 0.18 | 0.06 |
| TBT | 820ms | 210ms |
| INP | 不稳定,偶发 400ms+ | 180ms |
| 首屏请求数 | 36 | 19 |
| gzip 后 JS 总体积 | 约 1.8MB | 约 1.1MB |
这份对比说明两个问题。第一,网络层和渲染层的优化叠加起来,加载速度确实有质的变化。第二,真正让体验感知变得"平稳"的,其实是 CLS 和 INP 这两项。页面不太跳了、点击不卡了,用户才觉得操作顺手。
6.2 线上踩过的几个坑
优化过程中踩的坑也不少,我挑几个印象最深的写出来,给后面继续做这类改造的同学当参考。
第一个坑是骨架屏刚上线时 CLS 不降反升。排查了很久发现问题出在占位符高度不准确上。有些区块文案是两行,某些场景下只有一行,我预留的高度不够,真实内容渲染后把下方内容往下顶,就产生了布局偏移。后来改成所有异步区块渲染前,由 JS 根据接口返回的数据量动态计算并设置容器高度,CLS 才真正降下来。
第二个坑是接口并行改造后,某个依赖失败导致整个详情页崩溃。详情接口和评价接口都通过 Promise.all 一起发,评价接口万一超时,catch 被统一接到外层,直接中断了详情渲染。后来我改成每类请求独立处理错误,详情失败走骨架屏兜底,评价失败只是那个区块显示重试占位,不影响主内容。
第三个坑是图片裁剪参数做过一次调整后,商品图颜色明显失真。查下来发现是图片服务参数里把质量压缩到了 35,导致色带严重。后来又开放了一档更合理的质量配置,并且上线前对所有模板做了肉眼抽查才放量。性能优化不能只盯着字节数,画质底线要守住。
第四个坑是懒加载组件在滚动容器和 window 同时滚动时失效。详情页里部分楼层自己内部有滚动容器,监听器没有做统一管理,有时触发不加载。后面改成用 IntersectionObserver 统一观察,设好 root 和 rootMargin,才彻底解决。
6.3 一点关于复盘的体会
这次做下来的一个体会是,性能优化本质上是一个"测量-定位-改造-验证"的闭环,而不是靠一两个奇技淫巧就能一步到位。一开始我特别想找到某个"银弹",比如换一个框架或者重写几个组件,后来发现绝大多数收益都来自日拱一卒的基础工作:请求减少几个、图片体积降一点、长任务拆短一点。
另外,优化不是一次性的工作。业务需求每添一个组件,第三方 SDK 每多一个,性能都会缓慢回退。预算机制和监控告警能在回退发生时尽早发现,这比再搞一次大规模优化要省力得多。
这次改完之后我还留了个念想:把虚拟列表和图片组件继续抽象到整个前端基建层,让其他业务页也能直接复用。这个东西目前还在推进中,等做完以后有机会再单独写一篇分享。
最后再分享一个顺手发现的小技巧:调试滚动类性能问题时,光看 Performance 面板里的长任务不够直观,可以打开浏览器开发者工具的 Rendering 面板,勾选 Paint flashing 和 Layout Shift Regions,页面加载和滚动时所有发生重绘、布局移动的区域都会实时高亮。配上手机模拟,几乎一眼就能看出哪个组件在悄悄浪费性能。