去年年中,我带同事做了一件事:把“新京报商品详情页”的前端性能从首屏 4 秒级别拉到 2 秒以内。那个页面本身不算复杂,但它的问题特别典型——图片大、脚本重、接口串行、缓存策略约等于没有、移动端流量占比高。如果你也在做内容电商、媒体自营商城、或者任何以图文为主又有交易闭环的落地页,大概率会遇到一模一样的情况。
这篇文章我按自己的实操顺序来写,先讲怎么定目标和诊断,再拆优化手段,最后给出可以直接抄走的配置和代码。顺便把我在这个项目里踩过的坑、查出过的隐藏 bug、以及“性能优化到底怎么落地到日常迭代”的经验一起说了。内容涉及前端、性能优化这两个关键词,目标读者是 1-3 年经验的前端同学,也适合准备前端面试题时想找真实案例的人参考。
1. 项目背景:一张商品详情页的处境
1.1 页面形态与业务特点
“新京报商品详情页”从业务上看,是典型的媒体电商场景。用户通常是从一篇资讯、一个专题页或者一条站外广告点进来的,落地后看到商品主图、标题、价格、规格、详情大图、推荐位、评论区等信息。
这种页面有几个天然特点,直接决定了性能优化的方向:
- 图片密度极高。单品详情页普遍有 10-20 张高清大图,很多运营同学直接上传几 MB 的原图;
- 首屏要求高。进入页面后的 3 秒内,用户能不能看到主图、标题和价格,直接决定转化;
- 会话深度浅。很多用户看一眼就走,回访率中等,需要考虑首访体验,也要兼顾回访用户的秒开;
- WebView 环境复杂。大量流量来自 App 内嵌 WebView 和微信公众号菜单内置浏览器,这些环境的性能和标准支持度参差不齐。
我验收项目时特意拿中端安卓机 + 4G 网络、iPhone SE + 弱 Wi-Fi 两种组合做基线,而不是在 MacBook 上开着 DevTools 模拟移动端。后者只能看到实验室数据,前者才接近真实用户感受。
1.2 性能问题的表象与影响
优化前,我用真实用户监控(RUM)做了一周数据采集,拿到的典型表现是:
- 首屏时间(LCP)中位数接近 4.1s,最差场景能到 6s 以上;
- 首次内容绘制(FCP)2.8s 左右,白屏时间体感明显;
- 布局偏移(CLS)0.18,主图加载完成时会把标题和价格往下顶,用户经常点错;
- JavaScript 长任务频繁,滚动详情图时卡顿,评论区加载时主线程被占满 300ms 以上;
- 详情接口串行请求,商品信息、价格、库存、评价是四个独立接口依次返回,总耗时接近 1.2s。
业务侧的反馈更直接:跳出率高、加购转化低,运营同学在群里喊“页面是不是坏了,我手机打开一片白”。
这些现象放在一起,已经足够触发一次专项优化。但触发之前,我要求团队先做一件事:定目标,而且必须用数字说话。
1.3 优化目标怎么定才合理
我们定了两组目标,一组是真实用户指标,一组是资源层面的硬指标:
| 指标类型 | 指标名称 | 优化前 | 优化目标 |
|---|---|---|---|
| 真实用户 | LCP 中位数 | 4.1s | ≤ 2.5s |
| 真实用户 | CLS 中位数 | 0.18 | ≤ 0.1 |
| 真实用户 | INP 中位数 | 350ms | ≤ 200ms |
| 资源硬指标 | 首屏 JS 传输体积 | 1.8MB | ≤ 800KB |
| 资源硬指标 | 首屏图片体积 | 1.5MB | ≤ 600KB |
为什么目标这么定?因为 Web Vitals 行业基线就是 LCP 2.5s、CLS 0.1、INP 200ms,这不是拍脑袋,而是 Google 大量统计后认为影响用户感知的阈值。资源硬指标是为了把优化落到可执行的研发动作上,不能只盯着浏览器面板。
注意:性能目标不要用“提升 50%”“优化一点”这种模糊说法,一定要绑定到具体指标。否则后面研发、测试、产品扯皮时没有客观依据。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 性能诊断:定位瓶颈的完整路径
2.1 诊断工具的选择与实测数据
性能优化最忌讳“瞎改”。我一般先用一套固定的工具组合,把问题量化,再进去改代码。
这个项目我用了四类工具,各干各的活:
- Chrome DevTools 的 Performance 面板:录制页面加载到可交互的完整过程,看长任务、主线程时间线、渲染阻塞;
- Lighthouse:快速打分,看性能总分和诊断建议;
- WebPageTest:选真实的移动设备 + 4G 网络,拿到 TTFB、LCP、CLS 以及请求瀑布图;
- 线上 RUM 数据:接入性能监控 SDK,观察真实用户分布,而不是只看我自己这台机器。
我自己的经验是:DevTools 和 Lighthouse 帮你看“你这台电脑上的问题”,WebPageTest 帮你看“中端手机上的问题”,RUM 帮你看“所有用户的问题”。 三者缺一不可。
跑完第一轮,拿到的关键数字很典型:
- HTML 文档 210KB(携带大量内联脚本和样式,且没有被 gzip)
- 首屏引用的 8 张主图共 1.5MB,最大一张 820KB
- 首屏 JS 共 1.8MB(压缩后),其中近 1MB 来自第三方依赖
- 详情接口 4 个串行,总等待时间 1.2s
- 字体文件两个,共 240KB,且阻塞渲染
2.2 从指标倒推瓶颈:一份问题清单
诊断的目的不是看数字,而是把数字转化成一个个可以执行的优化项。我拿到上面的数据后,按“指标 → 可能原因 → 验证手段”拆了一遍:
| 核心症状 | 可能原因 | 验证方式 |
|---|---|---|
| LCP 慢 | 主图 820KB、首屏脚本同步执行阻塞渲染、字体阻塞 | 在 Performance 里看 LCP 触发时机,看网络瀑布流 |
| CLS 高 | 图片无固定宽高、详情区内容加载后高度变化 | 观察 Layout Shift 记录,查找发生偏移的节点 |
| 长任务多 | 大组件同步打包、详情接口返回后一次性渲染大量 DOM | 主线程录制里看长任务归属脚本 |
| 接口慢 | 4 个接口串行、后端处理慢、TCP 连接未复用 | Network 面板看请求瀑布,看是否有阻塞等待 |
那一周我先列出约 15 条问题,再逐一验证,最后保留确定存在的 8 条。不必要的猜测不要进迭代,否则就是团队陪着你做实验。
2.3 量化优先级:哪些优化先做
问题清单出来之后,真正难的是排序。不是每一条优化都值得做,也不是每一条都能在下一次发版前完成。
我当时的优先级是按“预估收益 / 实施成本”来排的:
| 优化项 | 预估收益 | 实施成本 | 优先级 |
|---|---|---|---|
| 主图压缩 + WebP 转换 | 首屏传输减 40%-60% | 低,改图或加构建插件 | P0 |
| 首屏脚本拆包 + 动态加载 | TBT 显著下降,LCP 提前 | 中,需要动构建配置 | P0 |
| 图片固定宽高比 | CLS 降到 0.1 以内 | 低,加样式和尺寸属性 | P0 |
| 接口聚合 | 首屏完整时间减少 300-500ms | 中,需要后端配合 | P1 |
| 字体加载优化 | 首屏文本更快可见 | 低 | P1 |
| 缓存策略 + Service Worker | 回访用户秒开 | 中,基础设施改造 | P1 |
| 虚拟滚动 | 滚动卡顿缓解 | 中,组件改造 | P2 |
这个表看起来简单,但在项目里起着“统一意见”的作用。产品、后端、前端谁也别凭感觉争优先级,拿收益和成本说话。
3. 五大优化手段逐个拆解
3.1 图片链路:从源头减重
商品详情页最重的资源一定是图片,所以图片优化是第一个重点。
第一步是改格式和压缩。原图从运营后台出来是 JPG,平均单张 300-800KB。我们先把图片统一转成 WebP,再按不同展示位做尺寸裁剪。实测下来,同样视觉质量下 WebP 比 JPG 小 40%-60%,单张主图直接从 800KB 降到 200-300KB。
第二步是响应式加载。不同的终端用不同尺寸的图,通过 srcset + sizes 让浏览器自己选,而不是所有用户都加载同一张大图。
code复制<img
src="https://cdn.example.com/p/goods/1001-m.jpg"
srcset="
https://cdn.example.com/p/goods/1001-s.jpg 480w,
https://cdn.example.com/p/goods/1001-m.jpg 800w,
https://cdn.example.com/p/goods/1001-l.jpg 1280w
"
sizes="(max-width: 480px) 90vw,
(max-width: 960px) 50vw,
800px"
width="800"
height="900"
alt="商品主图"
fetchpriority="high"
/>
这里有几个细节容易被忽略:
width和height必须写。即便最终用 CSS 做了响应式,浏览器也可以根据这两个属性提前算出宽高比,避免 CLS;- 首屏里的关键主图不要加
loading="lazy",它应该被浏览器优先加载,甚至可以加fetchpriority="high"; - 非首屏的详情大图统一加
loading="lazy",让浏览器在接近视口时才加载; - 如果图片本身还有更好的格式 AVIF,可以继续做
<picture>多源回退,但优先级没有 WebP 高,兼容性和 CDN 支持要提前确认。
我们还用了图片懒加载的自定义实现,而不是完全依赖原生属性。原因是部分老版本 WebView 对 loading="lazy" 支持并不好,后面踩坑实录里我会细说。
3.2 首屏渲染:把浏览器第一次绘制的成本降下来
图片减重后,下一个核心矛盾变成了 JavaScript。
我们当时的页面是一个 SPA,所有的业务组件、第三方库、甚至部分后端返回的内联数据,都被打包进了同一个 bundle。这直接导致脚本传输大、解析执行时间长,浏览器主线程被无谓的工作占满,首屏迟迟无法绘制。
针对首屏渲染,我做了四件事:
第一,拆掉渲染阻塞脚本。 把非关键的第三方脚本全部加上 defer 或 async。所谓渲染阻塞,就是浏览器在解析 HTML 时遇到 <script> 必须停下来等它下载和执行完,加上了 defer 才能把脚本延后到 DOM 解析完成之后执行。
第二,首屏静态化/骨架屏。 商品标题、价格、主图这些核心信息不再等 JS 跑完才显示。我们在 HTML 模板里直接内联了首屏最小化数据结构,配合骨架屏,用户进入页面立刻能看到结构和核心文案,JS 加载完成后再“注水”成可交互状态。这一步对 LCP 和 FCP 的效果非常直观。
第三,关键资源预连接。 在 <head> 里加上:
code复制<link rel="preconnect" href="https://cdn.example.com" />
<link rel="dns-prefetch" href="https://cdn.example.com" />
这样浏览器在解析到 CSS/JS 请求之前,就已经提前完成了 DNS 查询和 TCP 连接。对于 CDN 域名,收益很明显,尤其是弱网环境。
第四,字体加载优化。 原页面引用了一种品牌定制字体,单个 woff2 文件 200 多 KB,而且是通过阻塞同步方式引入的。实际上商品详情页里大部分文字都是系统中文字体就能覆盖的,品牌字体只需要在小范围使用。我们改成 font-display: swap + 按需子集化,只保留数字和特殊字符部分,体积降到 30KB,同时文本可以在字体文件加载过程中先用系统字体渲染。
3.3 拆包与按需加载:让代码按需取货
这是前端性能优化里最“硬核”的一块,也是我后来和团队反复强调的“必做项”。
项目原本的构建产物是一个巨大的 app.js,压缩后 1.8MB。这个体积摊到中端安卓机上,光下载就要 2-3 秒,更不用说解析执行。
我们的拆法分了三层:
- 路由级拆包:商品详情页虽然只有一张页面,但内部的评价模块、推荐位模块、直播入口组件,都是独立路由或独立区块。改成动态
import()后,首屏只加载真正需要的模块; - 第三方库分离:把体积大、更新频率低的依赖单独打成 vendor 包,例如图片查看器、播放器组件。利用浏览器缓存,用户二次访问时这些包直接命中缓存,不用重复下载;
- 按需引入 UI 库和工具库:项目里引入了完整的 UI 组件库,实际只用其中 5 个组件;又引入了 lodash 全家桶,大部分函数用不到。换成按需导入后,少下载了约 200KB 压缩后的 JS。
拆包后的效果:首屏压缩后 JS 体积从 1.8MB 降到 780KB,减少了 56% 左右。 主线程长任务从平均 380ms 降到 120ms,滚动卡顿基本消失。
这里给一个 Vite 下的拆包配置参考:
javascript复制// vite.config.ts
export default defineConfig({
build: {
rollupOptions: {
output: {
manualChunks(id) {
if (id.includes('node_modules')) {
if (id.includes('lodash') || id.includes('dayjs')) {
return 'vendor-utils';
}
if (id.includes('swiper') || id.includes('player')) {
return 'vendor-media';
}
if (id.includes('vue') || id.includes('pinia')) {
return 'vendor-vue';
}
}
}
}
},
chunkSizeWarningLimit: 300
}
});
拆包不是拆得越碎越好,还要考虑 HTTP/2 下的多请求开销和浏览器缓存命中率。我一般按“核心运行时、UI 库、业务公共、高频第三方”四类来分,而不是每个包都独立成 chunk。一个 chunk 300KB 左右是相对合理的规模,过小会导致请求数膨胀,过大会重新产生长任务。
3.4 缓存与离线能力:让回访用户秒开
首访体验做到位之后,我开始处理回访用户。对一个媒体电商场景来说,用户很可能在一个专题里连续看多个商品,或者在公众号里反复点回来,回访体验直接决定了“第二单”。
缓存策略在性能优化里属于投入产出比极高的部分,但也是最容易踩坑的部分。
我们最后落地的缓存体系分三层:
| 层级 | 对象 | 策略 | 说明 |
|---|---|---|---|
| HTTP 层 | 带 hash 的静态资源 | Cache-Control: public, max-age=31536000, immutable |
文件名带 hash 的 JS/CSS/图片,缓存一年,永不过期 |
| HTTP 层 | HTML 文档 | Cache-Control: no-cache |
HTML 每次都回源验证,保证拿到最新页面 |
| Service Worker 层 | 静态资源 + GET 接口 | 预缓存 + 运行时缓存 | 离线可用,回访加速 |
Service Worker 我单独说一句。它对回访首屏的提升是“降维打击”级别的:静态资源全部走本地缓存,接口数据做 30 秒内存缓存和 5 分钟磁盘缓存。实测优化后,回访用户首屏时间中位数降到 0.8s 左右,基本是冷启动即见。
Service Worker 注册逻辑要特别注意版本管理,不然会出现“页面已经更新,缓存还是旧版”的尴尬问题。我们在线下环境用 skipWaiting 加 clients.claim,线上版本则用更新后主动清理旧缓存的策略。
3.5 接口层优化:前端也不能只改前端
前端性能优化做到后期,瓶颈往往不在浏览器里,而在接口层。商品详情页原本要等四个接口串行返回,每次都等上一个请求结束才发下一个,白白多花 3 个往返时间。
我们和后端协商后,做了一件事:聚合接口。由后端提供一个 detail/batch 接口,一次性返回商品信息、价格、库存、头部评价数、运营配置等首屏数据。前端只发一次请求,首屏数据完整时间从 1.2s 降到 400ms 左右。
这个动作原理很简单,就是减少 HTTP 往返次数。移动端一个完整请求在弱网环境下可能耗 100-200ms,串行四次就是 400-800ms,加上后端处理时间,体感差异非常大。
聚合接口不是简单地把几个接口拼起来,还要注意三点:
- 超时时间单独配置。聚合接口涉及多个数据源,用时比单个接口长,前端给它单独设置 3s 超时,不能和普通接口共用超时时间;
- 做好降级方案。如果聚合接口失败,前端自动回退到原来的四个接口分别请求,保证功能可用;
- 数据裁剪。详情页真正首屏需要的数据字段不超过 30 个,但原来的接口单次返回 80 多个字段,改掉后每个包体减少一半以上。
前端这侧还需要处理并发请求的竞态问题。尤其是用户飞快切换商品时,上一个详情接口返回结果可能覆盖当前商品的数据。后来我们在请求层加了一个简单的递增 ID 机制,每次切换商品生成新 ID,只有请求返回的 ID 和当前 ID 一致时才允许更新页面状态。
4. 实操落地:关键配置与代码
4.1 前端构建与代码层面的关键改动
这一节我直接给可复用的方案。如果你也在做类似的页面,可以按顺序操作。
第一步:图片压缩与响应式改造。
构建时我用 imagemin-webp-webpack-plugin(Webpack 项目)或直接让 CDN 提供图片处理能力。推荐可以直接在图片 URL 上追加参数,让 CDN 实时输出指定宽高的 WebP 图片,这样不用改构建脚本,运营同学上传原图也能自动生效。
实际项目中我用 CDN 的图片处理接口比较方便,例如:
- 原图 URL:
https://cdn.example.com/p/goods/1001.jpg - 限制宽度 800、转 WebP:
https://cdn.example.com/p/goods/1001.jpg?x-oss-process=image/resize,w_800/format,webp
配合前端 srcset,不同设备可以直接选不同处理参数。
第二步:路由和组件级动态引入。
把原本在入口文件同步引入的模块改成动态导入:
javascript复制// 原写法(同步,全部打进首屏 bundle)
import GoodsComment from '@/views/GoodsComment.vue';
import GoodsRecommend from '@/views/GoodsRecommend.vue';
// 改后(动态导入,按需加载)
const GoodsComment = () => import('@/views/GoodsComment.vue');
const GoodsRecommend = () => import('@/views/GoodsRecommend.vue');
如果组件出现在首屏,只是体积较大,可以配合 defineAsyncComponent 加一个 loading 状态:
javascript复制import { defineAsyncComponent } from 'vue';
const HeavyPlayer = defineAsyncComponent({
loader: () => import('@/components/HeavyPlayer.vue'),
loadingComponent: PlayerSkeleton,
delay: 100,
timeout: 5000
});
第三步:图片占位防抖动。
原本商品主图加载完成后会把标题和价格往下顶,这是 CLS 的最大来源。改法很简单:给图片容器一个固定的宽高比。
css复制.goods-main-img {
aspect-ratio: 4 / 5;
background-color: #f5f5f5;
}
.goods-main-img img {
width: 100%;
height: 100%;
object-fit: cover;
}
aspect-ratio 在 Safari 15+ 和最新 WebView 都支持得很好,老系统可以用 padding-top 百分比来实现同样的效果,原理是百分比 padding 基于父容器宽度计算。
第四步:接口聚合和内存缓存。
detail/batch 聚合接口返回后,前端在内存里缓存一份,30 秒内再次进入同一商品直接走缓存:
javascript复制const detailCache = new Map();
async function fetchGoodsDetail(goodsId) {
const cached = detailCache.get(goodsId);
if (cached && Date.now() - cached.time < 30 * 1000) {
return cached.data;
}
const res = await request.post('/api/goods/detail/batch', {
goodsIds: [goodsId]
});
detailCache.set(goodsId, {
time: Date.now(),
data: res.data
});
handleRequestRace(goodsId, res.data);
return res.data;
}
4.2 Nginx 与 CDN 层的配套配置
前端代码优化只是一半,服务端配置能决定这些优化到底能不能发挥出来。我们上线前把 Nginx 的配置统一梳理了一遍,关键项如下:
nginx复制# 开启 HTTP/2
listen 443 ssl http2;
# 静态资源缓存一年
location /static/ {
expires 365d;
add_header Cache-Control "public, max-age=31536000, immutable";
access_log off;
}
# HTML 不缓存,每次回源
location / {
add_header Cache-Control "no-cache, max-age=0";
try_files $uri /index.html;
}
# 开启 gzip
gzip on;
gzip_types text/css application/javascript application/json image/svg+xml;
gzip_min_length 1024;
# 开启 brotli(如果编译时带了)
brotli on;
brotli_types text/css application/javascript application/json;
immutable 这个指令经常被漏掉。它告诉浏览器,这个带 hash 的文件只要被缓存过,就永远不要发条件请求回源验证,直接使用缓存,可以省掉一次网络往返。
CDN 侧我们配了两条规则:
- HTML 缓存 5 秒,因为首页可能随时有运营配置变更;
- 静态资源和图片缓存 1 年,带 hash 的文件不存在内容变更后的覆盖问题。
另外,所有静态资源统一走 CDN 域名,不发到业务域名上,既方便做 HTTP/2 多路复用,也避免携带不必要的 Cookie。
4.3 上线与回滚预案
性能优化类改动最怕“上线后出问题,原因都找不到”。所以我们在上线流程上格外做了几件事。
首先是灰度方案。我们先把新版静态资源发布到 CDN,但 HTML 里引用新版资源只对 10% 的流量生效,观察真实用户监控的 Web Vitals 和错误率稳定后,再逐步放量到 50%、100%。
其次是资源预热。静态资源上线前,先用 CDN 的预热接口把核心 JS/CSS/图片全部推送到边缘节点,避免上线后第一批用户“高并发回源”把服务器打挂。
最后是回滚预案。回滚不是把 HTML 换回旧版就完了,还要注意 CDN 缓存。老版本的 HTML 如果被边缘节点缓存了,用户可能拿不到新内容。我们的做法是:每次发布前都先批量刷新 CDN 上的 HTML 缓存,再调整 Nginx 指向。
注意:回滚前必须确认 Service Worker 的缓存策略也在可控范围,否则浏览器里跑的还是旧版 Service Worker,回滚了线上 HTML 也无济于事。我们给 Service Worker 设置了一个“自毁开关”,下架策略能通过配置中心临时停用。
5. 踩坑实录:常见问题与排查技巧
5.1 图片懒加载导致的首屏白屏问题
第一次把首屏主图加上 loading="lazy" 后,线上出现了一个诡异的现象:部分安卓 WebView 用户首屏白屏时间反而变长了。
排查后发现,这些 WebView 的 loading="lazy" 实现有 bug——图片被浏览器判定为“首屏之外”而延迟加载,但实际视觉位置在首屏内。这通常和 WebView 快速初始化时的视口计算有关。
解决方案没有用原生 loading="lazy",而是自己写了一个轻量懒加载指令,规则是:首屏前 3 张图片强制立即加载,剩下的才走 IntersectionObserver。
javascript复制const observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
const img = entry.target;
img.src = img.dataset.src;
observer.unobserve(img);
}
});
}, {
rootMargin: '200px 0px',
threshold: 0.01
});
const lazyImages = document.querySelectorAll('img[lazy]');
lazyImages.forEach((img, index) => {
if (index < 3) {
// 首屏前 3 张不懒加载
img.src = img.dataset.src;
return;
}
observer.observe(img);
});
后来我在 WebPageTest 上对比了一次,自定义懒加载的 LCP 比原生 loading="lazy" 在安卓 WebView 上快了近 400ms,这个差异在高端机型上不明显,但中端安卓机上非常突出。
5.2 拆包后公共依赖重复与体积反弹
拆包阶段我一度遇到一个很无语的问题:把多个 vendor 包拆出来后,总体积反而变大了。
用 webpack-bundle-analyzer 一看,发现原因是 lodash 被同时引用进了不同 chunk,每个 chunk 都抽出了自己的副本;另外 dayjs 因为版本不兼容,被安装了两份,一个 1.9.x,一个 2.0.x,累计体积多出近 100KB。
解决方法是统一依赖版本 + 在构建配置里显式声明公共依赖应该单独成包:
javascript复制// webpack 情况下
resolve: {
alias: {
'dayjs': require.resolve('dayjs'),
'lodash': require.resolve('lodash-es')
}
}
另外,UI 组件库的按需引入也要检查是否真的“按需”了。经历过一次 Babel 插件配置错误,导致所有图标被全量引入,构建产物多了 180KB。这类问题不看体积分析报告很难发现。
5.3 缓存策略太激进导致老用户看不到新版本
这是缓存优化里最容易出事故的点。我们给静态资源加了 immutable 后,有一次运营修改了价格和促销文案,但用户的手机上始终显示旧价格。
排查结果是:那次运营改的是页面 HTML 里的内联数据,而 HTML 的缓存策略被我们误设成了 max-age=86400,导致 CDN 和浏览器都缓存了旧 HTML 一整天。
改法很简单:HTML 统一设置 no-cache,所有非静态资源请求不要在后端显式设置长时间缓存。带 hash 的脚本和样式可以肆无忌惮地缓存,但 HTML 是“入口文件”,必须每次回源验证。回源验证不是每次都要重新生成页面,Nginx 可以配合 ETag 和 Last-Modified 做 304 响应,成本很低。
5.4 内存泄漏排查实录
页面优化后期,有用户反馈“页面开着开着就卡了”,滚动和点击越来越不跟手。用 Chrome DevTools 的 Performance 面板录制,发现主线程上有持续增长的长任务,内存占用从 80MB 一路涨到 400MB,典型的内存泄漏。
排查思路大概分三步:
- 复现:在真机上连续滑动商品详情页、切换多个商品、打开关闭直播组件,拍一段 Performance 录制和内存快照;
- 对比快照:先拍一个操作前的 Heap Snapshot,执行重复操作后再拍一个,用 Comparison 视图查看新增对象来自哪里;
- 定位引用链:从新增对象往上追溯保留树,找到被哪个全局对象或者闭包持有引用。
最后定位到一个具体问题:商品评论区组件卸载时,没有移除给 window 添加的滚动监听器,并且监听器闭包里引用了已经销毁的 DOM 节点。每一次进入评论模块,就多留下一个引用链,页面长期不关闭就持续累积。
修复代码很简短:
javascript复制onMounted(() => {
window.addEventListener('scroll', handler, { passive: true });
});
onUnmounted(() => {
window.removeEventListener('scroll', handler);
});
但排查过程花了一整天。这类问题的最佳解决方式是预防,团队后来约定:所有组件在 onUnmounted 里必须清理定时器、事件监听器、以及大型对象引用,并在代码评审里专门检查这一点。
最后再说一个实战体会。性能优化做完一轮后,最大的挑战不是技术,而是防止回归。我们后来在 CI 里加了一条性能预算检查,任何超过预算增量的改动都会自动触发提醒,同时把 Lighthouse 跑分和资源的总体积作为发布准入条件。优化不是一次性的“专项”,而应该是每个迭代都要守住的底线。这也让后来团队再做新功能时,天然会去想“这个资源是否可以不加载”“这段代码是否可以再拆小一点”。这才是性能优化真正沉淀下来的东西。
