1. 项目背景与优化目标
1.1 这个详情页到底卡在哪里
接手新京报商品详情页前端性能优化这个任务时,页面在 4G 网络下首屏要等 6 秒以上才能稳定下来,光首屏的图片请求就有 40 多个,LCP 直接飙到 7.8 秒,CLS 也有 0.32。如果做过电商或者内容电商的前端,应该能感受到这种页面的复杂度:商品主图轮播、价格库存、规格选择、优惠券、长图详情、用户评价、相关推荐,每块内容背后都跟着独立的接口和渲染逻辑。问题不是单点故障,而是整条链路上大大小小的浪费叠在一起。
我把当时的现状拉了一下:技术栈是 Vue 3 + Vite,H5 端优先,接口走统一网关,详情数据由后端拼装返回。页面初始化时,详情、价格、库存、优惠券、推荐、评论一次性并发 6 个请求,其中评论接口返回 50 条数据全部渲染。商品主图原图接近 2MB,详情页里的长图更是有 3MB 多,而且全部是 JPEG 原图直出。第三方脚本包括 IM 客服、埋点、推送,在 HTML head 里同步加载,直接阻塞首次渲染。说白了,首屏链路里每一步都在给用户增加等待时间。
这类页面在做前端性能优化的时候,靠单个 hack 是没有意义的,必须从数据请求、资源体积、渲染链路、缓存策略四个方向同时下手。这次项目的整体思路,就是把详情页当成一条完整的水管:进水口(接口)、管壁(渲染)、水流阻力(JS 执行和图片解码)、蓄水池(缓存)全部疏通一遍,才能看到质的变化。
1.2 用哪些指标衡量优化效果
做性能优化前,我习惯先定基线,否则后面改完不知道是变好了还是只是心理安慰。这次用的是 Lab 数据加 RUM 数据双轨制。Lab 数据用 Lighthouse 固定 4G 网络、Moto G Power 模拟低端机,跑 10 次取中位数;RUM 数据接入现有监控平台,采集真实用户的 Web Vitals 指标。
这里要强调一个点:很多人只看白屏时间,但白屏只是一个中间值,真正影响用户体感的是 LCP、CLS、INP 这三个核心指标。LCP 代表最大内容绘制时间,商品详情页通常就是主图区域;CLS 代表页面布局稳定性,商品详情页图片没有占位时特别容易暴涨;INP 替代了过去的 FID,衡量用户点击、输入到页面响应的延迟,规格选择弹层、加入购物车这类交互是否跟手就看它。
当时定的目标值是:LCP 从 7.8s 降到 2.5s 以内,CLS 从 0.32 降到 0.1 以下,首屏请求数从 40+ 减少到 15 个以内,页面总资源体积从 6.2MB 降到 1.5MB 左右。目标数据不是拍脑袋定的,而是参考了 Core Web Vitals 的阈值和同类电商页面的中位数水平。后面所有优化改动,都以这几个数字作为验收标准。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 优化方案的整体设计
2.1 为什么按“资源—渲染—数据—网络”拆链路
很多前端性能优化的文章喜欢直接堆工具,比如用这个插件、那个 loader,但实战里最重要的是先找到瓶颈分布。这次我没有立刻去改代码,而是先用 Performance 面板和 WebPageTest 把页面加载每个阶段的时间占比记录下来,最后得到的结果是:图片下载和解码占掉 45% 的时间,接口等待占掉 25%,第三方脚本执行占 15%,剩余才是框架渲染和资源解析。
基于这个分布,我把整个项目拆成四条线:资源线主要处理图片体积、格式、懒加载和字体子集化;渲染线处理长列表、规格选择组件、骨架屏和动画性能;数据线处理接口合并、并行请求、数据缓存;网络线处理 CDN 节点、HTTP 缓存策略、资源加载优先级。四条线看起来很多,但实际操作时有先后顺序,我的原则是先处理上游再处理下游:先把图片和接口这类大头压下去,再回来扣渲染细节,因为资源下载和接口等待时间在慢网络下是硬成本,如果你不把它们先降下来,后面渲染优化做得再好,用户还是停留在白屏阶段。
四条线里优先级最高的是数据请求链路的优化,这个结论来自一个很实际的观察:详情页首屏真正要展示的内容是商品名、价格、主图、加入购物车按钮,但原来的代码把这几个数据拆成了 3 个接口,并且是前端串行请求的,这就等于用户必须等第一个接口返回后才知道要请求第二个接口,白白浪费了一次 RTT。后来把三个接口并行化,首屏数据返回时间直接少了 40%。
2.2 方案落地前的关键取舍
优化方案在执行前一定会遇到取舍问题,最典型的就是要不要上 SSR。商品详情页对首屏速度要求高,从纯技术角度看 SSR 确实能更快地输出 HTML,但考虑到项目实际情况:商品详情由后端模板动态生成,前端团队对 Node 层运维能力有限,而且商品信息变更频繁,SSR 缓存策略如果设计不好,反而会导致展示陈旧数据。综合评估下来,这次没有大规模引入 SSR,而是走了 CSR + 接口预加载 + 骨架屏的组合方案,用更小的改造成本换回接近七成的体验提升。
另一个取舍是图片格式。WebP 在大部分现代浏览器上已经普及,AVIF 压缩率更高,但 iOS 低版本 Safari 不支持。当时我定下的策略是:CDN 根据请求的 Accept 头自动输出 AVIF 或 WebP,没有条件接自动协商时,前端用 <picture> 标签做降级,先试 AVIF,不识别就回退 WebP,再不识别就用 JPEG。同时给所有图片加上宽高占位,避免图片加载过程中页面内容跳动。
还有一点取舍是关于接口合并。详情页后端当时提供的接口粒度非常细,有 /detail、/price、/stock、/coupon 等好几个,团队里有人建议直接让后端合并成一个接口,这确实能减少请求数,但坏处是耦合度变高,后续商品模块每次改动都要联动后端重新发版。最后我们选择折中方案:保留 /detail 主接口,把价格、库存、优惠券等依赖商品 ID 的小接口改成可以并行请求的形式,并在网关层面做聚合,而不是改后端业务代码。这样既减少了前端等待时间,又保留了接口的灵活性。
3. 首屏关键链路:图片与数据加载
3.1 图片优化:从 2MB 到 120KB
商品详情页的图片优化是最容易出成绩的部分,因为基数太大了。原来的主图是一张 2MB 的 JPEG,CDN 上没有做任何裁剪,移动端也直接拉原图。优化后我做了三件事:第一,CDN 上开启图片处理管道,根据终端尺寸动态输出 750px 宽度的图片;第二,格式默认输出 WebP,质量参数压到 75,部分图片走 AVIF;第三,首屏主图加上 fetchpriority="high" 和 preload,轮播图只加载第一张,后面的全部懒加载。
这里要说明一下为什么质量参数选 75。图片压缩不是质量越高越好,对电商详情页来说,商品图的纹理和文字边缘是关键,75 这个档位在肉眼几乎无差别的情况下,体积通常能降 60% 到 70%。如果压到 60 以下,商品细节边缘会出现明显噪点,用户放大查看时会觉得图片很糊。我实测了一张原图 2MB 的商品主图,输出 750px WebP 质量 75 后体积是 120KB,这个体积在 4G 网络下的加载时间可以忽略不计。
代码层面的关键改动是给所有大图加上宽高比占位。原来的图片标签基本只写了一个 src,没有 width 和 height,图片加载过程中容器高度从 0 变成真实高度,导致整个页面往下跳,CLS 就是这么来的。修复方式比较统一:要么在接口返回时带上图片宽高,要么在 img 标签上用 aspect-ratio 固定比例。我这里提供一段可以直接用的写法:
html复制<picture>
<source srcset="https://cdn.example.com/goods/1001-750w.avif" type="image/avif">
<source srcset="https://cdn.example.com/goods/1001-750w.webp" type="image/webp">
<img
src="https://cdn.example.com/goods/1001-750w.jpg"
alt="商品主图"
width="750"
height="750"
fetchpriority="high"
decoding="async"
style="aspect-ratio: 1 / 1;"
>
</picture>
注意 decoding="async" 也很重要,它让图片解码不阻塞主线程,对低端机尤其明显。另外详情页里几张大长图,我没有让用户一次性全加载,而是让后端根据锚点把长图切成多段,每段用一个懒加载容器包裹,用户滚动到哪里就加载哪一段。这个改动单独贡献了首屏体积大约 30% 的下降。
3.2 接口数据加载:把串行改成并行,把非关键请求延后
接口层面的优化核心是两个:一个是并行请求,另一个是分级调度。原来的代码里,页面初始化后先请求 /detail 拿商品基本信息,然后在这个回调里再请求 /price 和 /stock,最后才渲染价格库存区域。3G/4G 网络下每个 RTT 都是实打实的等待,这种串行写法会让用户多等一到两秒。
改造后的请求方式是用 Promise.all 把首屏必须的接口一起发出去,同时用 Promise.race 或者超时控制避免某个接口挂了拖慢整体。这里有一份当时改造的示意代码:
javascript复制const [detail, priceStock] = await Promise.allSettled([
fetch('/api/goods/detail', { params: { id } }),
fetch('/api/goods/price-stock', { params: { id } })
]);
// 主数据返回后立即渲染骨架屏中的核心区域
renderMainInfo(detail.value);
// 非首屏数据统一放到空闲时加载
requestIdleCallback(() => {
loadCouponInfo();
loadRecommendList();
});
关于 requestIdleCallback 的使用,需要注意浏览器兼容性,不支持的浏览器我加了一个简单的 fallback,用 setTimeout 代替。另外一个细节是接口返回的数据会做短期缓存:商品详情数据 5 分钟内有效,我用一个 Map 做了内存级缓存,用户从列表页再次进入同一商品详情时,先渲染缓存数据,再静默刷新。价格和库存这类实时性要求高的数据不做缓存,避免展示错误价格。
4. 复杂组件的渲染性能治理
4.1 商品规格选择弹层的渲染优化
商品详情页最常见的交互卡顿点在规格选择弹层。用户点击“选择规格”后,弹层要展示颜色、尺码、套餐等多个维度的选项,同时还要标识哪些组合有货、哪些组合缺货。原来的实现是在弹层打开时一次性渲染所有 SKU 组合,有的商品有几百个组合,每次点击都会重新计算可售状态,高亮选中的逻辑也写得绕,低端安卓机上明显能感觉到点一下弹层要等半秒。
优化方式是把 SKU 数据的计算逻辑前置。进入详情页时就把 SKU 列表转换成扁平化的 Map,键是规格组合的 ID,值是库存和价格信息;弹层打开时只需要从 Map 里查状态,不再做多层数组嵌套遍历。同时用 computed 缓存当前选中状态下的可售结果,避免每次渲染都重新计算。渲染列表时也只渲染当前维度可见的项,比如颜色维度有 10 个选项,就只渲染这 10 个按钮,不会把所有组合全部生成。
弹层内部还做了一个更细的分级:规格选项属于高优先级,打开动画必须流畅;而弹层底部的优惠券、领券提示这类内容属于低优先级,等弹层动画结束后再渲染。这比一次性渲染整块内容要省很多主线程时间。优化后弹层打开时间从 400ms 左右降到 100ms 以内,INP 指标明显好了。
4.2 长列表虚拟渲染:评论区和相关推荐不再拖垮页面
评论列表和相关推荐列表是长列表重灾区。优化前,评论接口返回 50 条数据就渲染 50 个 DOM 节点,相关推荐再渲染 20 个,加上详情页其他内容,整页 DOM 节点数超过 7000。这在 PC 端还好,移动端低端机滚起来能明显感受到页面掉帧,因为每次滚动都会触发大量节点的样式计算和重绘。
这里我选择了虚拟列表方案。很多人觉得虚拟列表一定要引一个大库,其实对于固定高度的卡片列表可以自己写一个很轻量的实现:外层容器固定高度,滚动时计算当前可视区域对应的起始索引和结束索引,只渲染这部分的节点,同时用 padding 或一个撑高度的占位元素保持滚动条总高度不变。评论卡片的高度不是完全固定的,图片数量不同会导致高度变化,所以我又在卡片渲染后测量实际高度,缓存到数组里,滚动时用缓存高度做定位计算。
代码上需要重点注意的滚动性能问题:滚动事件触发频率非常高,不能直接在 scroll 回调里更新渲染状态,我用 requestAnimationFrame 做了节流,只保留每帧最新的一次位置更新。改完之后,详情页的 DOM 节点数量从 7000+ 降到了 300 左右,低端机滚动帧率从不到 30fps 提升到接近 60fps,这个优化在真实用户体感上是质的飞跃。
5. 网络加载与缓存策略细节
5.1 用 preload 和 preconnect 控制资源优先级
很多团队做性能优化会忽略一个细节:资源加载的优先级控制。浏览器虽然有自己的加载策略,但它并不知道一个页面里哪些资源对用户最重要。这次优化前,详情页 head 里引入了一个底部浮层用的字体文件,还有一个 IM 客服的 SDK,这两个文件把加载带宽和连接数占用得死死的,导致首屏主图要排队等很久。
优化方式是让关键资源插队。在 HTML head 里对首屏真正需要的外域域名做 preconnect,提前建立连接;对首屏主图用 preload 标成高优先级;对 IM 客服、埋点这类第三方脚本全部改成异步加载,并且放到 requestIdleCallback 或者 setTimeout 里执行,绝不让它们阻塞首屏。这里有一个典型的上线后效果:主图 LCP 时间从 6.2s 降到了 3.1s,还没有改图片体积就已经有这么大的提升,说明之前资源排队问题非常严重。
具体配置可以参考下面的写法:
html复制<link rel="preconnect" href="https://cdn.example.com" crossorigin>
<link rel="preconnect" href="https://api.example.com">
<link rel="preload" as="image" href="https://cdn.example.com/goods/1001-750w.webp" fetchpriority="high">
需要注意 preload 不能滥用,只对首屏确定会用到的资源使用,否则会造成带宽浪费。像轮播图第二张之后的图片,反而应该用 loading="lazy" 把优先级降下去。另外字体这块,我做了 woff2 子集化,只保留页面中实际用到的字符,并把字体文件从 CSS 默认加载改为 font-display: swap,避免字体文件阻塞文本渲染。
5.2 HTTP 缓存和 CDN 的配置要点
缓存策略是性能优化里性价比极高但容易被忽视的部分。静态资源因为文件名带了 hash,可以直接设置非常激进的缓存:Cache-Control: public, max-age=31536000, immutable,这样用户第二次访问时资源直接从本地缓存读取,不会再发出请求。HTML 文档本身不能缓存,因为它要跟随版本更新,所以设置成 no-cache,每次回源校验。
接口层我们当时遇到一个典型问题:商品详情数据虽然变化频率不高,但前端没有设置任何缓存,用户每次进入页面都要重新请求完整数据。后面在网关层针对 /goods/detail 这个接口做了 5 分钟的缓存,CDN 边缘节点也会缓存一层,回源率大幅下降,接口平均响应时间从 800ms 降到了 400ms 左右。价格库存这类实时接口不做 CDN 缓存,而是通过设置 Cache-Control: no-store 防止代理层错误缓存。
这里分享一个踩坑经历:静态资源 hash 版本更新后,如果 CDN 回源失败或者旧资源被提前清理,很容易出现用户访问 HTML 引用了不存在的 JS/CSS 文件,页面直接白屏。我们的兜底方案是在 Nginx 层对所有静态资源 404 时统一返回 index.html 或者重试最近一个版本的文件,同时在发布流程里保留最近两个版本的构建产物,避免发布过程中出现资源空窗期。
6. 性能监控与线上问题排查实录
6.1 用 PerformanceObserver 建立线上指标采集
优化上线后如果没有监控,你根本不知道真实用户环境里页面表现如何,所以这次我在详情页里接入了一套轻量的性能采集脚本。思路是用浏览器自带的 PerformanceObserver API 监听关键指标事件,然后把数据打点上报到监控平台,采样率控制在 10%,避免上报请求过多影响性能。
采集的核心指标包括:
javascript复制const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.entryType === 'largest-contentful-paint') {
reportMetric('lcp', entry.startTime);
}
if (entry.entryType === 'layout-shift' && !entry.hadRecentInput) {
reportMetric('cls', entry.value);
}
}
});
observer.observe({ type: ['largest-contentful-paint', 'layout-shift'], buffered: true });
实际应用中还需要注意,LayoutShift 条目会持续上报多次,只取页面生命周期里累计的最大值;LCP 也可能在页面加载过程中更新,所以最终上报时用了 PerformanceObserver 的断开时机或者 pagehide 事件来发送最终值。这个采集体系上线后,我们很快就发现了一个自己在模拟环境里完全测不出来的问题:部分低端安卓机上 CLS 依然偏高,后来定位到是商品图下方的促销倒计时组件在数字变化时高度抖动,导致整个页面布局不断变动。这种问题只有在真实用户环境、真实网络速度下才会暴露,所以监控系统对性能优化来说不是可选项,而是必需品。
除了 Web Vitals,我还额外采集了长任务(Long Task)数量和总阻塞时间。长任务是主线程执行超过 50ms 的任务段,它的数量直接反映页面交互卡顿程度。详情页里最容易产生长任务的就是规格弹层初始化时的 SKU 计算。优化后长任务从平均 12 个降到 3 个以下,这个数据在汇报优化成果时也很有说服力。
6.2 三个让人印象深刻的排查案例
第一个案例是图片懒加载导致白屏时间异常。上线图片懒加载后,有用户反馈偶尔首屏主图加载不出来,排查发现是因为轮播图第一张被错误地加上了 loading="lazy",在部分浏览器的加载策略下,首屏图片的懒加载会被延迟到布局完成后才开始,导致主图区域长时间显示空白。修复方法就是给首屏主图显式去掉 loading="lazy",并加上 fetchpriority="high"。
第二个案例是第三方脚本异步加载后,IM 客服按钮样式丢失。原来 IM 脚本在 head 里同步加载时,脚本执行完会立刻往页面上插入客服按钮样式;改成异步加载后,如果脚本执行发生在 DOMContentLoaded 之后,部分样式插入逻辑会异常。最后我把 IM 脚本的初始化时机改成了在首屏渲染完成后再执行,同时把客服按钮改成一个静态的浮动元素,实际需要时再去动态加载完整的 SDK。
第三个案例是低端机滚动卡顿。虚拟列表上线后,反而有用户反馈滚滚会闪一下,后来发现原因不是虚拟列表本身,而是列表项里的图片在快速滚动时触发了懒加载的批量命中,瞬间发起十多个图片请求,占用了滚动线程的带宽。优化方式是把图片懒加载和虚拟列表解耦,虚拟列表只处理节点渲染,图片加载由 IntersectionObserver 单独控制,并且一次最多只加载 3 张图片。这样滚动过程中即使有新图片进入视野,也不会造成明显的掉帧。
7. 优化结果与实际经验复盘
这次详情页性能优化最终的数据是:LCP 从 7.8s 降到 2.3s,CLS 从 0.32 降到 0.06,首屏请求数从 40+ 降到 13,页面资源总大小从 6.2MB 降到 1.4MB,DOM 节点数从 7000+ 降到 300 左右。线上监控显示,支付转化率提升了大约 8%,虽然不能完全归因于性能优化,但至少说明这些改动没有伤害业务。
我个人的体会是,性能优化最容易出错的不是技术本身,而是没有持续维护的机制。一次优化做完后,如果没有人负责监控指标,过了几个月新需求又把大图、同步脚本、复杂组件带回来,成绩很快归零。这次项目后面做了一个硬性约定:新增图片必须走 CDN 压缩管道,新增第三方脚本必须评审加载方式,新增列表必须评估是否用虚拟列表,并且每次发版前跑一次 Lighthouse CI,分数低于阈值就不允许合并。这种工程化约束比任何一次手工优化都管用。
如果让我重新做一次这个项目,我会更早去推动接口层的数据聚合,而不是先纠结图片格式。图片优化带来的收益非常直观,但接口串行造成的等待时间才是用户感知最强的硬伤。好在这次最终把两条线都铺开了,也算是一次完整的前端性能优化闭环。
