前两周一个朋友找到我,说他们公司的后台管理系统“越用越卡”,客户投诉快把客服淹没了:页面打开要转圈好几秒,表格滚动掉帧,切个Tab都能卡出菊花。我远程看了一眼,发现这个项目的首屏JS bundle压缩后居然有1.8MB,其中光是一个JSON解析库就占了200多KB,还有十几个第三方脚本在启动阶段全量执行。这种故事我见过太多次了。实际上,前端性能优化从来不是“锦上添花”的环节,而是直接影响用户留存和转化率的基础工程。
这篇文章我不打算讲什么高深理论,就结合我这些年做前端性能专项、带团队做性能治理的真实经历,把优化的整个链路拆开聊一聊:从指标怎么定义、瓶颈怎么定位,到资源加载、运行时性能、工程化陷阱、移动端弱网,再到怎么让优化成果不“反弹”。如果你正准备给项目做一次性能优化,或者正被前端面试题里的“从输入URL到页面渲染”折磨,这篇文章应该能给你一个比较完整的框架。
1. 先搞清楚:你的页面到底慢在哪——指标与度量手段
1.1 Core Web Vitals不是KPI装饰,而是用户体感的数字化
很多人一提性能就说“首屏加载速度”,但这个说法太模糊。是DNS解析慢?还是JS执行慢?还是图片资源太大?没有统一的度量口径,优化就是无头苍蝇。Google推的Core Web Vitals,本质上就是把“用户体感”拆成了三个可量化的维度:
- LCP(Largest Contentful Paint):页面最大内容元素的渲染时间。它衡量的是用户看到主要内容有多快。电商的Banner、文章的标题图、后端的表格首屏,都算LCP元素。
- INP(Interaction to Next Paint):用户交互到页面响应的延迟。它在2024年全面取代了FID,衡量的不光是首次输入延迟,而是所有交互的响应速度。
- CLS(Cumulative Layout Shift):页面加载过程中内容的偏移量。比如图片没有预留尺寸,加载完突然把文字挤下去,这种“跳动感”就是CLS得分差的表现。
这三个指标对应的优化手段完全不同:LCP差可能是资源体积和加载优先级问题,INP差可能是主线程长任务问题,CLS差往往是布局稳定性问题。所以优化的第一步不是动手改代码,而是先把当前状态测准。
1.2 用Performance面板和Lighthouse定位瓶颈的实战路径
我自己的习惯是“Lab数据 + 真实数据”双轨并行。
先用Lighthouse做一轮快速体检。在Chrome DevTools的Lighthouse面板跑一遍,或者在命令行执行:
bash复制npx lighthouse https://your-site.com --view --preset=desktop
Lighthouse会给出性能分、各项指标数据,还会直接告诉你哪些资源是阻塞渲染的、哪些图片可以换格式。但它有个问题:它是在模拟环境里跑的,数据干净、稳定,不代表真实用户的环境。它的价值更像“健身房体检”,能发现问题,但没法覆盖真实世界的复杂性。
所以还要看真实用户监控(RUM)数据。最简单的方式是往页面里埋一段性能采集逻辑,用浏览器的Performance API拿到关键时间点:
javascript复制// 用PerformanceObserver监听LCP
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
console.log('LCP:', entry.startTime, entry.element?.tagName);
}
});
observer.observe({ type: 'largest-contentful-paint', buffered: true });
// 获取导航时间指标
const nav = performance.getEntriesByType('navigation')[0];
console.log('TTFB:', nav.responseStart);
console.log('DOMContentLoaded:', nav.domContentLoadedEventEnd);
console.log('Load:', nav.loadEventEnd);
把采集到数据上报到后端或第三方监控平台,你会看到真实用户在第50百分位、第95百分位的表现。这里有一个关键经验:不要只看平均值。平均值会被极端值拉偏,P75、P95更能反映大多数用户的真实体验。
1.3 指标选错,优化白做:不同业务形态该盯哪几个数
我在给不同项目做性能治理时,发现指标选择一定要跟业务形态匹配。
- toC内容型产品(资讯、视频、电商):盯LCP和CLS。用户进来第一眼看到的内容快不快、页面稳不稳,直接决定跳出率。这类产品对首屏性能极其敏感。
- toB后台管理系统:更要盯INP和内存占用。后台用户每天8小时都泡在系统里,操作反馈慢半秒都是折磨,页面越用越卡(内存泄漏)更是致命伤。
- 移动端H5:除了Core Web Vitals,还要关注JS堆内存峰值和总传输体积。低端机内存小、CPU弱,过度设计很容易把页面拖垮。
很多团队刚开始做性能优化时容易犯一个错误:试图“全指标优化”,结果资源分散,哪个都没做好。我的建议是:一个阶段只定一个核心指标,比如Q1死磕LCP到2.5秒以内,Q2专项治理内存泄漏。性能优化最怕什么都想做,最后什么都做不深。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 加载链路的优化:从请求发出到首屏渲染的每一毫秒
2.1 带宽有限,先算清楚你的首屏“账面资产”有多重
拿到一个项目,我第一件事是打开Network面板,把页面在“禁用缓存 + Slow 4G”条件下重新加载一遍,看看首屏一共发起了多少个请求、总共传输了多少字节。这个“账面资产”决定了优化空间。
一个健康的首屏,请求数一般控制在30个以内,传输体积(gzip后)控制在1MB以内。如果首屏请求超过了80个,那你压根不用分析什么渲染性能,光是请求排队和DNS解析的时间就已经把预算吃光了。
然后我会用构建产物分析工具看打包体积:
bash复制# webpack项目
npx webpack-bundle-analyzer dist/statistics.html
# vite项目
npx vite-bundle-visualizer
通过这个工具可以一眼看出哪些第三方库在“偷吃”你的体积预算。我记得有一个项目,首屏bundle里塞了一个巨大的Excel解析库,只为了第一版“导入导出”功能,但这个功能90%的用户根本没用到——这种就是典型的优化切入点。
2.2 代码分割和懒加载的合理粒度,不是所有异步都好
代码分割(Code Splitting)的核心目标是:首屏只加载首屏需要的代码,其他代码等用到时再加载。
按路由分割是目前最基础的做法。在Vue和React里,路由级的懒加载已经非常成熟:
javascript复制// React Router 6
const routes = [
{
path: '/dashboard',
element: lazy(() => import('./pages/Dashboard'))
}
];
// Vue Router 4
const routes = [
{
path: '/dashboard',
component: () => import('./views/Dashboard.vue')
}
];
但这里有个常见的坑:分割粒度太细反而会坏事。有些开发者把每个路由下的每个弹窗、每个工具函数都拆成一个独立的chunk,结果首屏还没渲染完,已经并行发起了十几个小请求。在HTTP/1.1时代这是灾难,因为浏览器对同一域名有连接数限制;在HTTP/2时代并行请求不是大问题,但每个chunk都有额外的解析和执行开销,请求数太多仍然会拖慢首次渲染。
我的建议是:以“业务模块”为分割单位,比如“导入导出模块”合成一个chunk,“图表报表模块”合成一个chunk,而不是把一个页面里的每个组件都单独拆开。同时,对于权重较低、触达率不高的功能(比如用户协议弹窗、帮助中心),可以放心懒加载;但那些高权重、核心链路上的组件(比如电商的购物车、登录框),反而应该随着主包一起加载,因为用户大概率会立刻用到。
2.3 图片与字体的隐藏开销,往往吃掉一半加载预算
图片是首屏体积的“隐形杀手”。一张1920宽的Banner,如果原图直接丢上去,动辄2-3MB。对图片做体积控制,收益比优化代码高得多。
我常做的一套组合拳:
- 响应式图片:用
srcset和sizes让浏览器按照屏幕宽度加载合适尺寸的图,而不是手机也加载一张4K大图。
html复制<img
src="banner-800.jpg"
srcset="banner-400.jpg 400w, banner-800.jpg 800w, banner-1600.jpg 1600w"
sizes="(max-width: 480px) 100vw, 800px"
alt="banner"
width="1600"
height="600"
/>
-
现代格式优先:AVIF和WebP的压缩率远高于JPEG和PNG,视觉效果损失很小。用
<picture>元素做降级。 -
原生懒加载:非首屏图片加
loading="lazy",让浏览器自己决定什么时候加载。 -
图片尺寸稳位:给
img设置width和height属性,或者在CSS里用aspect-ratio占好位置。这是解决CLS最有效的手段。很多前端特别喜欢不给图片设尺寸,结果图片加载出来后把整个布局往下挤——CLS分数直接被拉爆。
字体也是容易忽略的一环。一个完整的中文字体包好几MB,但实际用到的字符可能只有几百个。我的建议是:
- 用
font-display: swap,避免字体加载期间文字不可见。 - 用
unicode-range做字体子集化,只加载页面上实际会用到的字符范围。 - 如果只是图标,优先用SVG或字体图标,不要把整个图标字体文件全量引入。
2.4 CDN与缓存策略:让第二次访问快得像本地
加载优化并不只是“第一次访问”的问题。一个用户在周一访问过一次,周二再来,如果你的缓存策略合理,应该是秒开的。
我总结了一套比较稳的缓存方案:
| 资源类型 | 缓存策略 |
|---|---|
| index.html | no-cache,每次回源验证 |
| 带hash的JS/CSS | Cache-Control: public, max-age=31536000, immutable |
| 图片/字体 | Cache-Control: public, max-age=31536000, immutable |
| 接口响应 | no-cache,由业务层控制 |
这套策略的逻辑是:带内容hash的文件,文件名变了说明内容变了,所以可以放心让浏览器和CDN永久缓存;而index.html是入口文件,它要随时能拿到最新的文件列表,所以必须每次回源验证。
CDN的收益大家都很清楚,但很多团队在部署时忽略了“CDN节点是否真的生效”这个问题。我在优化一个项目时发现,静态资源走了CDN,但HTML里引用的资源URL还是相对路径(/static/js/app.js),导致CDN压根没被访问到。正确的做法是让构建工具把资源URL替换为CDN域名:
javascript复制// vite.config.js
export default {
base: 'https://cdn.example.com/your-project/'
}
另外提一个细节:Service Worker可以作为“第二层缓存”。对于已经有复杂业务逻辑的项目,用Service Worker预缓存首屏资源和核心路由,能让用户第二次进站时几乎零等待。这个方案不是必须的,但如果你的团队有精力维护,体验提升非常明显。
3. 运行时卡顿与内存泄漏的系统排查法
3.1 “运行缓慢”不等于网络问题:先看主线程在忙什么
很多时候用户说“页面卡”,不是网络慢,而是主线程被占满了。浏览器的JS是单线程的,如果主线程上有一个长时间运行的任务(Long Task),它就会阻塞用户的所有交互——点击没反应、滚动掉帧、动画卡顿。
定位这种问题,最直接的工具是Chrome DevTools的Performance面板。操作方式很简单:打开Performance面板,点击录制,然后在页面上执行会让它卡顿的操作(比如打开某个弹窗、滚动某个列表),点击停止。然后看Main轨道上的火焰图:
- 如果有一块非常长的、颜色的Task,点击它可以看到具体是哪个函数在执行,耗时多少。
- 如果看到连续不断的紫色Layout任务,说明布局被频繁触发,可能是强制同步布局(layout thrashing)的问题。
- 如果看到大量的黄色Scripting任务,说明JS执行逻辑有问题。
我在一个项目里就遇到过典型场景:一个下拉筛选组件,每次筛选都会同步遍历数万个数据项并重新渲染一个超大的树形组件,导致火焰图里出现一个800ms的长任务。用户点一次筛选,整个页面冻结将近一秒,这就是INP恶化的根源。后来改成“先展示加载态,把遍历计算放到异步任务里分片执行”,交互卡顿感完全消失。
3.2 内存泄漏排查:从Performance Monitor到Heap Snapshot
内存泄漏是“越用越卡”类问题的主要元凶。很多后台系统刚打开很快,用两个小时之后开始卡顿、闪烁、甚至自动崩溃,大概率就是内存泄漏。
排查路径我建议分三步:
第一步:用Performance Monitor确认有没有泄漏。 勾选Performance Monitor面板里的JS heap size和DOM Nodes,然后正常使用页面一段时间。如果JS堆内存持续上升,而且无论怎么操作都不会下降到一个稳定基线,那基本可以确定存在泄漏。
第二步:用Memory面板拍Heap Snapshot。 先拍一张快照,然后做几次你怀疑会泄漏的操作(比如打开弹窗再关闭、切换Tab页),再拍一张快照。在第二张快照里选择“Allocation comparison”,看哪些对象的数量异常增长。
第三步:分析引用链,找到泄漏源头。 在快照里选中一个可疑对象,查看它的Retainers链——它会告诉你这个对象为什么没有被回收。大多数情况下泄露都有一个共性:某个被生命周期更长的对象(比如全局变量、定时器、事件监听器、闭包)仍然持有它的引用。
我梳理了前端最常见的几种泄漏模式:
- 定时器未清理:组件销毁时忘了
clearInterval或clearTimeout,定时器回调引用了组件内部的状态或DOM。 - 全局变量和模块级缓存:数据不断往一个全局Map或对象里塞,但永远不删除旧数据。
- 事件监听器未解绑:给全局对象(window、document)绑定的事件在组件卸载时没有移除。
- 闭包引用:异步操作(比如请求回调)持有大对象的引用,即使组件已经销毁了。
- DOM引用残留:把DOM元素存到变量里,但DOM已经被移出了文档,变量仍然持有引用。
举一个我踩过的真实案例:一个聊天页面用setInterval每隔5秒去拉取未读消息数,然后把结果setState。问题在于:这个定时器是在某个页面级组件里创建的,但开发者在写代码时把同一个定时器ID存在了一个模块级的静态变量里。页面销毁时clearInterval清的是另一个定时器。结果每次打开聊天页就会新增一个定时器,旧的永远不销毁,定时器回调又闭包引用了整个组件实例。项目上线两个月后,线上反馈“聊天页越来越卡”,一查内存,每次进入聊天页都泄漏几十MB。修复方法很简单:在组件卸载的生命周期里清掉定时器,同时保证定时器ID不被全局共享变量覆盖。
3.3 长列表和频繁更新的性能陷阱
长列表在toB系统里非常常见——表格一次性渲染几百上千行数据,下拉列表动辄几千个选项。
遇到这种场景,第一反应是上虚拟滚动(Virtual Scrolling)。虚拟滚动的核心思路是:不管数据有多少条,只渲染可视区域内的那几十条,其他数据用占位符替代。当你滚动时,用transform: translateY()把可视区域的内容移动到对应位置,同时动态替换数据。市面上比较成熟的方案有react-window、vue-virtual-scroller,或者直接用各大组件库自带的虚拟表格。实测下来,一个10000行的列表,普通渲染需要几百毫秒到一秒,虚拟滚动可以把首屏渲染压到几十毫秒。
但我必须提醒一句:虚拟滚动不是万能的。如果列表只有一二百行,或者每行高度不固定而且需要全文搜索,强行上虚拟滚动反而会引入额外复杂度。需求决定方案,不是方案决定需求。
对于“频繁更新”的场景,另一个常见问题是:状态管理库的依赖收集粒度太粗,本来只改了一个小组件的数据,结果整个页面都重新渲染。React里用React.memo、useMemo、useCallback可以减少子组件的重复渲染;Vue里要注意响应式数据的层级——一个对象如果被reactive包裹,内部任何属性的变化都会触发依赖它的组件更新,setter越深层性能开销越大。
3.4 降低交互延迟:从事件委托到requestIdleCallback
INP优化有一个核心原则:用户操作后,浏览器要尽快先画一帧反馈,再去做昂贵的工作。
什么意思呢?比如用户点击了“保存”按钮,你要做的是:
- 立刻更新按钮状态(比如变成“保存中……”)。
- 把耗时计算和数据请求放到后面异步执行。
但很多人的写法是:点击之后,先同步执行一连串数据校验和转换逻辑,这些逻辑加起来可能要200ms,浏览器在那段时间里无法画新帧,用户就会觉得“点了没反应”。
这里有几个实用的优化手段:
- 事件委托:用事件冒泡机制,在父容器上统一处理子元素的事件。既减少事件监听器的数量,也方便动态新增的元素自动获得事件处理能力。
- 防抖和节流:搜索输入、滚动监听这类高频触发的事件,需要加防抖(debounce)或节流(throttle)。一个容易弄混的点是:防抖适合“等用户停止输入再出发”的场景,节流适合“按固定频率执行”的场景(比如滚动时计算位置)。
- requestIdleCallback:把非紧急任务(比如上报日志、预计算下一屏数据)放到浏览器空闲时执行,避免抢占关键渲染路径。
- 优先响应视觉反馈:监听
pointerdown而不是click去出发视觉状态变更,因为click在移动端有300ms左右的延迟(现代浏览器已经通过width=device-width的viewport规避了这个问题,但不同机型还是可能有差异)。如果你动画第一帧要跟手,用pointerdown更稳。
4. 工程化陷阱与架构层面的性能取舍
4.1 JSON.stringify成为性能瓶颈的典型场景
JSON.stringify本身不是一个慢操作,但它在某些场景下会成为“量变引发质变”的元凶。前一阵有个热搜词是“json.stringify 前端性能优化”,说明踩到这个坑的人不少。
最常见的两个场景:
场景一:把整个store或超大对象塞进localStorage/sessionStorage做持久化。 某个项目的做法是:每次store变化,就把整个Vuex/Redux状态树JSON.stringify后写入localStorage。store里的数据一多,这个操作能轻松跑到几十毫秒甚至上百毫秒。而且JSON.stringify是同步的,它会阻塞主线程。我那时候给的建议是:不要持久化整个store,只挑选必要的字段,用JSON.stringify(state, ['token', 'userInfo', 'someConfig'])白名单方式裁剪。
场景二:在高频事件里做序列化。 比如在mousemove或scroll回调里对复杂对象做JSON.stringify再上报埋点。高频触发意味着这个同步操作被反复执行,页面自然卡顿。优化方案是把埋点上报放到一个队列里批量处理,而不是每条都即时序列化。
还有一个容易被忽略的细节:JSON.stringify遇到Date对象会转成ISO字符串,字符串体积比原始时间戳大得多。如果你在跑大数据量接口且数据里有大量时间字段,可以考虑在序列化前把时间戳统一转成数字。对于循环引用的对象,直接调用JSON.stringify会抛异常,这也是需要提前处理的边界场景。
4.2 大文件上传为什么需要Web Worker——一次真实性能治理
“前端使用worker上传大文件”能成为热搜词,说明很多人已经意识到主线程计算大文件hash有多痛苦。
先讲背景:大文件上传(比如几百MB的视频)要做“秒传”和“断点续传”,通常会对文件分片,然后计算每个分片的hash,以及整个文件的hash。计算hash需要读取并处理文件数据,这是一个纯CPU密集型任务。如果这个计算放在主线程,你会看到什么?整个页面卡死,用户没法滚动、没法点击,只能盯着进度条干等。文件越大,等待越久。
解决方案就是把它丢给Web Worker,让它在后台线程里跑,主线程保持流畅。
javascript复制// worker.js(在Worker线程中执行)
importScripts('https://cdn.jsdelivr.net/npm/spark-md5@3.0.2/spark-md5.min.js');
self.onmessage = function (e) {
const { fileChunks } = e.data;
const spark = new SparkMD5.ArrayBuffer();
let count = 0;
function processNextChunk() {
const reader = new FileReader();
reader.onload = (event) => {
count++;
spark.append(event.target.result);
self.postMessage({ type: 'progress', percent: count / fileChunks.length });
if (count < fileChunks.length) {
processNextChunk();
} else {
self.postMessage({ type: 'done', hash: spark.end() });
}
};
reader.readAsArrayBuffer(fileChunks[count]);
}
processNextChunk();
};
主线程这边:
javascript复制const worker = new Worker('/worker.js');
worker.postMessage({ fileChunks: chunks });
worker.onmessage = (e) => {
if (e.data.type === 'progress') {
// 更新UI进度条
} else if (e.data.type === 'done') {
console.log('文件hash:', e.data.hash);
// 继续后面的秒传/断点续传逻辑
}
};
需要注意的一个细节是:如果你用了Vite或Webpack,直接new Worker(new URL('./worker.js', import.meta.url)),构建工具会自动帮你处理Worker的打包。另外SharedArrayBuffer在跨源隔离环境之外是受限的,如果要做多线程共享内存,需要先保证站点设置了正确的Cross-Origin-Embedder-Policy响应头,这个比较麻烦,一般场景用postMessage传值就够了。
4.3 框架选型与状态管理的隐性性能成本
前端框架本身不是性能瓶颈,但如果用法不对,框架的响应式机制会成为放大器。
举个例子,Vue 3的reactive会把一个对象变成深度响应式。如果你把一个包含几千个元素的大数组塞进reactive,并且在某个组件里读取了整个数组的某个属性,那么当数组里任意一项的数据变化时,所有依赖这个数组的组件都会被触发更新。Vue 3的依赖追踪已经很优化了,但数据量上去之后成本依然可观。
React这边也有类似的问题:状态放在Context里,只要Context的值一变,所有消费这个Context的组件都会重新渲染,不管它们实际用到了哪一部分数据。这个问题在React 18之后依然存在,只是有useSelector之类的库帮你做局部订阅,但用的原生useContext多了,还是会有性能隐患。
我的经验是,工程层面的性能取舍要前置——在项目初期就约定好:
- 状态粒度:能放在子组件里的局部状态,不要提升到全局;能拆成多个Context/Store的,就不要合成一个巨大的全局对象。
- 数据不可变:更新复杂对象时,尽量生成新对象而不是mutate原对象,这样能利用引用相等性优化跳过不必要的重新渲染。
- 计算下沉:复杂的计算逻辑不要写在render/render函数里,优先放到
useMemo或computed里,并且准确填写依赖数组。
4.4 构建工具的优化配置:不是把所有东西都塞进vendor就行
构建层面的性能优化,最容易出现一个误区——把太多的第三方库合并到一个vendor chunk里。有些团队的思路是“反正公共代码抽出来做个大vendor文件,利用缓存”,但结果是首屏要加载一个2MB的vendor文件,用户第一次进来慢得无法忍受。缓存策略本身没错,但打包体积不控制,缓存再强也没用。
有几个方向值得优先做:
- 按需引入:用ESModule版本替代CommonJS版本。
lodash按需引lodash-es,Element Plus和Ant Design都支持按需样式加载,这些都能让构建产物体积大幅缩减。 - Tree-shaking的副作用处理:在
package.json里正确声明sideEffects: false,并在构建配置里开启tree-shaking。但要注意,一些库(比如CSS文件、polyfill)是有副作用的,不能盲目声明sideEffects: false,否则样式会被错误删除。 - Babel和TS编译的缓存:开启
cacheDirectory和transpileOnly,减少重复编译耗时。Vite项目一般不用太操心这个,Webpack项目收益明显。 - 使用现代构建工具:Vite的开发体验是真的好,因为它是基于esbuild预构建依赖,冷启动几秒就完成。如果你还在用Webpack做大型项目,线上构建时间动辄几分钟,可以考虑渐进迁移到Vite或者至少启用Webpack的持久化缓存。
我去年代一个传统Webpack项目做技术改造,仅仅是把构建持久化缓存开启,产线构建时间从9分钟降到3分钟。虽然不是用户的直接体验,但构建快了,团队才愿意在性能优化上持续投入,否则每次验证一个优化手段都要等10分钟,谁都没动力继续优化。
5. 移动端与弱网环境的专项优化
5.1 移动端的硬件现状决定了你的优化策略
做过移动端H5的都知道,你在MacBook的Chrome里调试一切完美,到了用户的安卓千元机上,页面可能像PPT一样卡。移动端的性能优化,首先要接受一个现实:你面对的不是“高性能设备 + 稳定网络”,而是“参差不齐的设备 + 不稳定的弱网环境”。
低端安卓机的CPU单核性能和内存都是有限的,手游性能优化里常用的“降低Draw Call、减少主线程CPU占用”思路,在H5里同样适用——只是把“Draw Call”换成“DOM节点数”。
我一般会在移动端重点检查这几项:
- DOM节点总数:首屏如果超过3000个节点,低端机解析和布局就会明显变慢。
- 主线程长任务:监控页面生命周期里的Long Task,把耗时超过50ms的任务尽可能切分、延迟或移到Worker。
- 总传输体积:移动网络下,每多传1KB都是有感知的,即便4G/5G很快,弱信号场景下多1MB也意味着多几秒的白屏时间。
5.2 弱网策略:分级加载+离线包+智能请求策略
弱网优化是移动端H5特别重要但经常被忽视的一环。很多团队只在“高性能 + 高速网络”下测试,等到地铁上用户打不开页面,才开始找问题。
我总结几个实用性很强的策略:
图片分级加载:弱网上不要加载原图。业内常用的是“先加载模糊小图,再按需加载高清图”。实现方式不复杂:爬取图片URL时带上质量参数,默认给个低质量版本;等用户点击图片或页面空闲时,再请求高质量版本替换。
请求合并与批量接口:移动端一个页面如果发出十几个细碎的接口请求,每个请求都有TCP建连、TLS握手、DNS解析开销。与其让用户等待十几次往返,不如后端合并成一个或者两三个聚合接口。GraphQL和BFF层都能很好地解决“过度请求”的问题,但如果没有这些基础设施,简单的做法是让后端提供批量接口。
Service Worker离线缓存:对静态资源和服务入口做预缓存。在弱网时,用户至少能打开页面框架和基础数据,体验要比“无限转圈”好得多。Service Worker要注意更新策略,我建议用“stale-while-revalidate”:先返回缓存的旧版本,再在后台拉取新版本替换,下次访问生效。
骨架屏:弱网下白屏最容易让用户流失。骨架屏的本质是“用结构占位图告诉用户界面正在加载,不是死了”。现代前端框架做骨架屏都不难,关键是不要等到数据回来了才渲染,而是把骨架屏当作首屏渲染的一部分。我见过有团队直接在后端接口返回前把骨架屏HTML拼出来返回前端,效果也一样好。
5.3 移动端渲染优化:减少布局抖动,给主线程留出余量
移动端的卡顿,很大一部分来自“无意识的布局抖动”。比如在滚动回调里频繁读取offsetHeight、offsetTop等强制同步布局属性,然后再写样式——这会导致每次滚动都要重新计算布局,整体帧率直接崩掉。
推荐确保以下几条原则:
- 动画只操作transform和opacity。CSS的
top、left、width、height动画会触发整棵布局树重算,而transform只触发合成线程,完全不影响主线程布局计算。GPU能帮你干活,就别让CPU来。 - 用
contain属性隔离渲染范围。对于独立的模块(比如轮播图、弹窗),加contain: layout paint,告诉浏览器这个子树的布局变化不影响外部。 - 谨慎使用
will-change。很多人以为给元素加will-change: transform就能让动画变流畅,但一个页面加几十个will-change会导致浏览器为每个元素都建立合成层,内存消耗暴增,低端机反而更卡。它是“锦上添花”,不是“雪中送炭”。 - 滚动监听改成
passive: true。addEventListener('scroll', handler, { passive: true })告诉浏览器你不需要在这个监听器里阻止默认滚动行为,浏览器就能在滚动线程里直接处理,不阻塞主线程。
5.4 移动端内存限制:别等标签页被系统回收才醒过来
iOS Safari和部分安卓浏览器在内存压力下会强制回收后台标签页。用户切出去回个消息,再切回你的H5页面,发现页面已经重新加载了。这个体验很伤,但可以通过“页面可见性”感知提前保存状态:
javascript复制document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') {
// 保存关键表单数据、滚动位置、临时状态到sessionStorage
}
});
另外,移动端的长列表更容易触发崩溃。如果你在移动电商首页一次渲染了上百张商品卡片,低端机很容易被内存和GPU压力打崩。虚拟列表在移动端不是“优化”,是“必需品”。
6. 让优化成果不“回退”的工程机制
6.1 性能预算:把优化固化成CI门槛
做性能优化最怕的不是过程难,而是“辛辛苦苦优化了半年,一个需求上线后全部回到解放前”。原因很简单:没有人把关,新功能永远会带来新体积、新请求、新任务。
我之前带领团队落地的一个机制是“性能预算”。核心做法是在CI流水线里加一个检查任务:每次构建完成后,自动统计产物的总体积、首屏关键资源的数量、gzip后的实际字节数,跟预先设定的预算值对比,超出就构建失败。
一份比较简单的性能预算配置长这样:
json复制{
"bundles": {
"main.js": { "maxSize": "250KB", "compression": "gzip" },
"vendor.js": { "maxSize": "300KB", "compression": "gzip" }
},
"totalSize": { "maxSize": "800KB", "compression": "gzip" }
}
如果某个版本想临时打破预算,开发者必须在MR描述里写清楚理由和上线后预计什么时候回收。这个机制刚落地时团队觉得“烦”,但坚持两个月后,所有人都习惯了在设计阶段就考虑性能成本,效果比任何事后优化都好。还可以集成Lighthouse CI,把LCP、INP、CLS的分数作为PR的门禁条件,跑不到及格线就不让合代码。团队文化不是靠倡导建立的,靠机制。
6.2 线上监控与报警:别被平均值“骗”了
性能优化上线后,怎么知道它真的有效?答案是线上监控。
监控平台有很多选择,自研埋点上报到自己的日志系统,或者接入第三方的APM平台(比如阿里云ARMS、听云、自建Grafana + Prometheus等)。但不管用什么工具,有几个数据口径上的经验教训要分享:
- 分位值比平均值重要。平均值会被1%的极端慢用户拉高,你看平均值感觉很稳,其实P95用户已经卡到想卸载。我一般监测P50、P75、P95三个档位。
- 新用户和老用户要分开看。新用户没有缓存,首屏性能会差一些;老用户命中了缓存,数据会好看很多。如果混在一起统计,缓存的收益会被新用户的差数据稀释,你无法判断优化决策到底对哪部分用户有效。
- 性能数据要和业务数据关联。做性能优化的终极目标不是“LCP降到2秒”,而是“跳出率降到15%”。要建立性能指标到业务指标的漏斗关系,让老板和产品经理看到性能优化的商业价值,团队才有持续投入的理由。
- 监控不能只看不报。设置合理的告警阈值,比如P75 LCP超过3秒持续5分钟就报警,让性能问题在上线后半小时内被发现,而不是等用户投诉才被动响应。
6.3 性能文化:把性能意识装进需求评审和代码评审
最后一个建议,可能听起来有点“软”,但我觉得它比任何技术方案都重要:性能优化必须变成团队的文化,而不是某个人或某个小组的“兼职”。
具体做法是几个小习惯:
- 需求评审时,问一句“这个功能的代价是什么”。新增一个第三方库、引入一个重型组件、加一个全局遮罩层,这些会不会吃掉性能预算。
- 代码评审时,重点关注“渲染路径上的重活”。我记得有一次评审,看到新人把一个大对象在
render里直接JSON.stringify后写入DOM属性——那条代码放到生产环境,分分钟拖垮首屏。评审时有人及时指出,省去了后面的线上事故。 - 新人入职时,花两小时教他们怎么用Performance面板和Memory面板。新人越早掌握性能定位的工具,团队后期的“性能债”就越少。
- 建立性能复盘机制。每次线上出现性能事故,不要只是修完bug就结束,要复盘根因、排查过程、优化手段,沉淀成文档和知识库。
我经历过不止一次“汲汲忙忙优化,又默默回退”的循环,所以特别强调机制和文化。没有持续的工程机制做保障,一次性能优化做得再出色,也只是“一次性救火”,不是长期的健康状态。把这些成本放到项目早期,远比在线上事故时被用户推着走高效得多。
写到这,我复盘了一下自己做过的比较成功的一次性能治理:那个项目初始首屏加载耗时6秒,优化后稳定在1.8秒以内,内存泄漏问题清零。整个周期大概是两个月,实际改代码的时间不到一半,另一半花在定指标、搭监控、推预算门槛上。所以我最后想说的是:性能优化更像是一个“工程治理”问题,值钱的不只是一个两个优化技巧,而是你能否构建一套很快发现瓶颈、优化瓶颈、阻止瓶颈复现的完整闭环。希望这篇文章里分享的路径,能让你少走一些我当年走过的弯路。
