1. 先给性能优化立个规矩:不量化就是瞎忙
先说句大实话:标题里的“提升10倍速度”确实能让人兴奋,但如果你不知道页面现在慢在哪个环节,后面二十九招全都是盲人摸象。我接手过一个老后台项目,首屏白屏接近 5 秒,所有人都在骂接口慢,结果我用 Performance 面板一看,光是全量引入的 moment 和 lodash 就占了 800 多 KB 的解析执行时间,接口才花了 200ms。那一刻我才明白,性能优化首先要做的不是“改代码”,而是“量体温”。
1.1 为什么“10倍”必须有基线
没有基线的优化全是玄学。同一个页面,在 M1 笔记本上跑和在公司老旧 Windows 台式机上跑,体感完全不一样;开了一堆浏览器扩展的 Chrome 和全新无痕窗口,数据也完全不同。所以我接手任何项目的第一件事,都是先定基线:
- 用无痕模式打开页面,关掉所有扩展,保证环境干净。
- 用 Performance 面板录制一次完整的页面加载过程,记录 FCP、LCP、TTI 以及长任务数量。
- 把不少于 10 次的采样数据合并,去掉最高和最低,取中位数。
这里的关键不是追求“和线上完全一致”,而是给自己一个可复用的对照标准。没有这个对照,你改完一个代码块,根本说不清到底是优化起了作用,还是今天网络心情好。
1.2 三个工具组合,比单个工具更有用
我常用的组合是三件套:Performance 面板、Lighthouse、以及代码里的 performance.mark / performance.measure。
Performance 面板适合看主线程火焰图,能直观看到哪个函数占用时间长,哪些长任务阻塞了用户交互。Lighthouse 适合出报告,它能给出 FCP、LCP、TBT 等指标,还能列出“消除阻塞渲染的资源”这类具体建议。但 Lighthouse 有个问题:它模拟的是中低端设备,有时候得分太苛刻,你只需要把它当参考,不用追求满分。
真正定位到业务瓶颈的,还得靠自己在代码里埋点。比如某个接口返回数据后要做一层很重的字段映射,你可以这样测:
performance.mark('transform-start'); const list = rawData.map(normalizeItem); performance.mark('transform-end'); performance.measure('data-transform', 'transform-start', 'transform-end');然后在 Performance 面板的“Measured”里看具体耗时。这个方式能把“业务代码慢”和“框架渲染慢”区分开,很多优化动作就是靠这种埋点才找到真正目标的。
1.3 从性能面板找到真正瓶颈的顺序
性能面板打开后,先别急着看 JS 函数,我习惯按这个顺序排查:
一是看网络请求瀑布图,找最大的 JS/CSS 文件以及阻塞的请求。二是切到“主线程”视图,看有没有超过 50ms 的长任务。三是看“渲染”相关的指标,确认是不是频繁发生布局抖动。
整体来说,加载阶段的优化优先级是:网络传输 > 解析执行 > 渲染 > 内存。因为一个文件少 100KB,可能换来 100ms 的下载时间;一个死循环少跑 1 次,可能换来 1s 的交互恢复。后面的 30 招,我也会按这个顺序展开。
2. 加载阶段 10 招:把首屏时间打骨折
加载阶段是最容易出效果的地方,因为很多成本发生在浏览器还没开始运行业务代码之前。这个阶段的目标很明确:让浏览器更快拿到关键代码,并且更少地阻塞解析。
2.1 第1招:gzip 或 Brotli 压缩必须开
这一招属于“零代码改动”的优化,但很多项目居然没做。服务器上给静态资源开启 gzip,很多时候能把 JavaScript 文件压缩到原来的三分之一左右;条件允许就上 Brotli,压缩率比 gzip 还要再高 5% 到 15%。
我试过把同一个 350KB 的 bundle 分别用 gzip 和 Brotli 压缩,结果 gzip 后约 90KB,Brotli 后约 70KB。这还只是单文件,如果一个项目里有几十个 chunk,累计传输体积差异非常明显。
有一点很多人容易忽略:API 接口的 JSON 也应该开压缩。如果接口响应体特别大,开启压缩后,移动端网络体验能提升一大截。别只盯着静态资源。
2.2 第2招:代码分割,别一个 bundle 走天下
老项目最典型的问题就是“一个 app.js 装下全世界”。路由级别的懒加载是最容易落地的分割方式:
const routes = [ { path: '/dashboard', component: () => import('./views/Dashboard.vue') }, { path: '/reports', component: () => import('./views/Reports.vue') } ];这样首屏只加载当前路由的代码,其他路由等进入时再拉。实测一个系统从单 bundle 切到路由懒加载后,首屏 JS 体积从 1.2MB 降到了 300KB 左右,加载时间立竿见影。
但注意,分割后要合理配置公共模块的抽取逻辑,否则会出现多个路由 chunk 反复加载同一份公共代码的情况。webpack 的 splitChunks 会把 node_modules 里的核心依赖抽到一个 commons chunk 里,这个配置值得花时间研究。
2.3 第3招:tree shaking 要确认真的生效
很多项目都以为自己开启了 tree shaking,实际上并没有。关键是你的源码必须用 ES Module 的静态 import/export 语法,不能使用 CommonJS 的 require,否则大部分打包工具没法静态分析出哪些代码没被用到。
比如从 lodash 里引入函数:
// 这样整库可能被打包进去 import _ from 'lodash'; _.chunk(list, 2); // 这样只打包 chunk 相关代码 import chunk from 'lodash/chunk'; chunk(list, 2);检查方法很简单:把打包后的产物丢到编辑器里,搜一下某个你没用过的函数名是否出现在产物中。或者直接用 webpack-bundle-analyzer 看依赖占比。很多“莫名的大体积”其实就是 tree shaking 没生效。
2.4 第4招:大依赖换轻量替代品
这是“降本增效”效果最猛的一招。我曾经把一个管理后台里的 moment.js 换成 day.js,包的体积直接少了接近 300KB。为什么?moment.js 为了兼容性内置了大量规则,而大多数业务只用到了日期格式化、加减天数这几个功能,day.js 几乎零成本替代。
类似的还有:直接用原生 Array.prototype.flat 代替 lodash flatten;用 URLSearchParams 代替 qs;用 Intl.DateTimeFormat 代替复杂的日期格式化库。每换掉一个,都记得看打包体积和实际功能测试,别让隐蔽的边界问题影响业务。
2.5 第5招:缓存策略别让浏览器每次都重新下载
静态资源改完后,你的服务器如果还在给同一个文件名返回新内容,那前面所有体积优化都白费。正确做法是:
- 带指纹的文件名,例如
app.8f3k2.js,设置Cache-Control: public, max-age=31536000, immutable。 - 不带指纹的
index.html,设置no-cache,保证发布后能拿到新的资源列表。
另外注意,浏览器在“弱网”或“过期”情况下会发起协商缓存。你需要在服务端配置好 ETag 和 Last-Modified 的返回策略,避免 200 响应变成常态。缓存不是越多越好,而是“该持久就持久,该失效就失效”。
2.6 第6招:defer 和 async 别用错
<script>标签默认会阻塞 HTML 解析,脚本下载和执行期间,页面剩余部分得等着。如果你把非关键脚本直接放在<head>里,首屏就会被白白拖延。
- 普通脚本且需要等待 DOM 解析完成,用
defer。 - 独立、不依赖其他脚本的统计类代码,可以用
async。
其实,在现代浏览器里,<script type="module">默认就有 defer 的行为。所以写原生 HTML 嵌入脚本时,别偷懒把一堆<script src="...">裸放进去。这个改动虽然小,但在低端机上对 FCP 的影响很明显。
2.7 第7招:preload / preconnect 关键资源
preload 可以提前加载首屏必需的字体、图片或脚本,preconnect 可以提前建立第三方域的连接。我最常做的是:
<link rel="preload" href="/font/main.woff2" as="font" type="font/woff2" crossorigin> <link rel="preconnect" href="https://api.example.com">这里要克制,别把十几张首屏图片全部 preload,否则会把网络带宽耗尽,反而拖慢关键请求。preconnect 主要用在你确定会立即请求的跨域接口或 CDN 上,比如日志上报域名。DNS 解析、TCP 连接、TLS 握手的时间叠加起来,在移动端常有几百毫秒的损耗,preconnect 能把这部分提前到后台完成。
2.8 第8招:图片压缩和现代格式切换
很多页面的性能瓶颈不是 JavaScript,而是巨大的 PNG/JPG。我见过一个列表页,光图片总大小 8MB,接口数据才 200KB。这种情况把图片换成 WebP/AVIF,体积直接减少 60% 以上。
浏览器兼容性用<picture>标签处理:
<picture> <source srcset="banner.avif" type="image/avif"> <source srcset="banner.webp" type="image/webp"> <img src="banner.jpg" alt="banner"> </picture>另外,纯色背景和简单图案优先用 CSS 渐变或者 SVG,而不是图片。UI 素材里的装饰性图标,能合成就合成雪碧图,或者直接用字体图标。虽然现代 HTTP/2 对多个请求的容忍度高了,但图片解码依然是新能成本,能少则少。
2.9 第9招:消除阻塞渲染的 CSS 和 JS
CSS 媒体查询不对,也会阻塞渲染。比如一个只有打印时才需要的样式表,如果不加media="print",浏览器也会认为它需要加载完才能渲染。
对于首屏,把关键样式内联到 HTML 里,非关键的 CSS 用异步方式加载。例如:
<link rel="stylesheet" href="non-critical.css" media="print" onload="this.media='all'">这个技巧能让首屏更快出内容,但要注意别把超大段 CSS 全部内联,否则 HTML 本身变得很臃肿。内联只针对首屏骨架样式,其他仍然走外链缓存。
2.10 第10招:配合 IntersectionObserver 做图片和组件懒加载
懒加载不仅可以用在图片上,也可以用在整个组件上。当一个组件出现在视口附近时才去加载它的 JS 和渲染 DOM,这个模式对长页面非常有效。
const observer = new IntersectionObserver((entries) => { for (const entry of entries) { if (entry.isIntersecting) { const el = entry.target; el.src = el.dataset.src; observer.unobserve(el); } } }); observer.observe(imgElement);注意不要对首屏关键内容做懒加载,那样会适得其反。判断标准是:这个内容如果不出现在首屏,用户会感知到吗?不会的话,才适合懒加载。
3. 运行时 10 招:让代码跑得更快
首屏加载优化完了,接下来要解决的是用户操作时的卡顿。运行时性能优化的核心不是“少写代码”,而是“减少无效计算”。
3.1 第11招:循环里避免重复读取全局变量和重复计算
很多新手写循环是这么写的:
const list = getList(); for (let i = 0; i < list.length; i++) { doSomething(list[i], window.config.xxx); }这个写法虽然没问题,但每次循环都要走一遍list.length和window.config.xxx的属性查找。尽管 V8 引擎已经很聪明,能把这些优化掉一部分,但遇到复杂情况仍然会有作用域链查找的成本。
我更建议这样:
const len = list.length; const cfg = window.config.xxx; for (let i = 0; i < len; i++) { doSomething(list[i], cfg); }把长度和配置项缓存到局部变量里,既符合直觉,也能避免高维作用域查找。这个改动在性能敏感的大数组遍历里,能直观观察到一丁点提升,积累起来就是流畅度。
3.2 第12招:用 Set/Map 代替数组 includes/find 做高频查找
这是非常经典的一个性能坑。数组中includes和find的时间复杂度是 O(n),如果你在一个循环里面对每个元素都执行一次includes查找,整体就成了 O(n²)。数据量小的时候没感觉,数据量到 10 万以上,页面基本就卡死了。
看这个例子:
const blacklist = ['bad', 'worse', 'terrible']; const result = list.filter(item => !blacklist.includes(item.category));如果list有 10 万条,blacklist有 1 万条,最坏要做 10 亿次比较。换成Set:
const blacklistSet = new Set(['bad', 'worse', 'terrible']); const result = list.filter(item => !blacklistSet.has(item.category));Set.has是 O(1) 级别的操作,整个流程瞬间降为 O(n)。我在一个关键词过滤功能里试过,优化前耗时 2.3 秒,优化后不到 30ms,这就是“10倍”的典型来源之一。
3.3 第13招:避免嵌套循环,用 Map 做一次遍历
两个数组互相找出交集或差异的场景,特别容易出现嵌套循环。正确的套路是:先遍历其中一个数组,建立一个 Map 或 Set,再去遍历另一个数组做查询。
const a = getLargeList(); const b = getAnotherList(); const bMap = new Map(b.map(item => [item.id, item])); const common = a.filter(item => bMap.has(item.id));这样最多只需要遍历两遍,而不是两两比较。我第一次把这个模式用在订单列表与退款列表匹配上,耗时从 8 秒降到 0.1 秒,当时同事还以为是数据量变小了。
3.4 第14招:缓存重复计算结果(memoization)
只要函数是纯函数(同样的输入永远得到同样的输出),就可以缓存结果。最经典的场景是复杂数据转换:
const memoize = (fn) => { const cache = new Map(); return (...args) => { const key = JSON.stringify(args); if (cache.has(key)) return cache.get(key); const result = fn(...args); cache.set(key, result); return result; }; }; const expensiveTransform = memoize((raw) => { // 这里有一大段很重的字段处理逻辑 return raw.map(transformItem); });注意缓存的 key 如果包含对象,需要谨慎使用 JSON.stringify,因为对象序列化可能不稳定。另一个常见位置是 React 里的 useMemo 和 useCallback,它们本质也是缓存机制,但要记住缓存本身有开销,只有计算成本明显高于缓存成本时才值得用。
3.5 第15招:选对遍历方式
不同遍历方式虽然逻辑等价,但引擎优化级别不同。性能敏感的热点代码,建议用最基础的 for 循环配合索引。for...of在没有迭代器开销的数组上也很快,而forEach每次循环都会回调函数,会额外产生调用帧。
比如:
// 性能最好 for (let i = 0; i < len; i++) { process(arr[i]); } // 也可接受 for (const item of arr) { process(item); }但千万别用for...in遍历数组,它会遍历所有可枚举属性,包括原型链上的属性,速度慢且行为容易出问题。这段经验适合那些数据量极大的算法场景,普通几十个元素的列表,你完全不用在意这种差距。
3.6 第16招:防抖和节流要分场景,别混用
防抖和节流是处理高频事件最常用的招,但很多人用错。
- 防抖:一定时间内没有再触发事件,才执行一次。适合输入框搜索、textarea 校验。
- 节流:一定时间内最多执行一次。适合滚动事件、窗口 resize、鼠标移动。
简单实现节流:
function throttle(fn, delay = 100) { let last = 0; return (...args) => { const now = Date.now(); if (now - last >= delay) { last = now; fn(...args); } }; } window.addEventListener('scroll', throttle(handleScroll, 100));如果你做的事情是触发布局或样式更新,那么推荐用requestAnimationFrame节流,这样能和浏览器渲染帧对齐,避免不必要的中间态绘制。
3.7 第17招:长任务交给 Web Worker
主线程一旦被一个大循环或复杂计算占用,用户就无法点击按钮、滚动页面,体验直接“假死”。遇到纯计算密集型任务,比如大数组排序、数据加密、JSON 序列化/反序列化、数据清洗,都应该考虑丢给 Web Worker。
// worker.js self.onmessage = (e) => { const result = e.data.map(heavyProcess); self.postMessage(result); }; // 主线程 const worker = new Worker('./worker.js'); worker.postMessage(rawData);这里有个性能细节:postMessage传对象默认是结构化克隆,对大对象拷贝成本很高。如果数据是 ArrayBuffer,可以用 transferable 对象把所有权转移给 Worker,这样没有拷贝开销。实际项目中,我曾经把一个日志清洗场景从主线程阻塞 1.8 秒降到几乎无感。
3.8 第18招:大数据处理用时间切片分批执行
有些计算不方便放到 Worker 里,而是必须依赖主线程上的 DOM 或框架状态。这时就用“时间切片”思路,把一个大任务切成多个小段,每段执行完让出主线程。
const chunkSize = 1000; let index = 0; function processChunk() { const end = Math.min(index + chunkSize, list.length); for (; index < end; index++) { process(list[index]); } if (index < list.length) { requestIdleCallback(processChunk); } }通过requestIdleCallback把剩余任务分配到浏览器空闲时执行,用户在看页面时不会感到卡顿。当然,兼容性上有一定问题,可以回退到setTimeout(fn, 0)。
3.9 第19招:延迟初始化非关键对象
如果一个类实例很重,但用户可能一直不会用到,就别在页面初始化时 new 出来。比如图表组件、代码编辑器、权限配置解析器,都应该做懒加载。
let editorInstance = null; function getEditor() { if (!editorInstance) { editorInstance = new HeavyEditor(element); } return editorInstance; }这样做的好处是减少首屏的主线程计算和内存占用。代价是首次调用会有一点延迟,但通常用户第一次点开编辑器时能接受加载。配合 loading 状态,体验会比页面一进来就卡顿好得多。
3.10 第20招:避免高频循环里创建函数和闭包
闭包虽然好用,但它在每次创建时都会捕获当前作用域。如果在循环里创建函数,GC 会面临大量的垃圾回收压力。
// 不好:每次循环都生成一个箭头函数 for (const item of items) { btn.addEventListener('click', () => handle(item)); } // 相对好:定义在循环外,通过 data 属性传参 function handle(e) { const item = e.target.dataset.item; process(item); }对于动画循环、拖拽、实时计算这类高频场景,函数复用尤其重要。这个优化很难在指标上直观看到,但长期运行时,内存波动会明显变缓,GC 停顿变少,页面就更跟手。
4. DOM 与渲染 5 招:别让浏览器做无用功
JavaScript 性能再好,如果不停地操作界面,浏览器照样会卡。因为 DOM 操作背后的 layout、paint、composite 都是成本。
4.1 第21招:批量插入节点,用 DocumentFragment 或字符串拼接
很多人写动态列表:
list.forEach(item => { const li = document.createElement('li'); li.textContent = item.name; ul.appendChild(li); });这样每插入一个节点都会触发一次布局更新。优化后:
const fragment = document.createDocumentFragment(); list.forEach(item => { const li = document.createElement('li'); li.textContent = item.name; fragment.appendChild(li); }); ul.appendChild(fragment);DocumentFragment 不会占用真实 DOM 树的位置,把所有节点先挂到它里面,最后一次性插入父节点,这样只触发一次重排。我试过一次插入 3000 个节点,前者耗时 80ms,后者只有 15ms 左右。
4.2 第22招:动画用 requestAnimationFrame 驱动
setTimeout到 16ms 不代表会在那一帧渲染,真正绘制要看浏览器的刷新率。用requestAnimationFrame可以保证动画逻辑在浏览器下一次重绘之前执行,避免出现掉帧和中间状态的闪烁。
let top = 0; function step() { top += 2; el.style.transform = `translateY(${top}px)`; requestAnimationFrame(step); } requestAnimationFrame(step);同时把动画属性改成transform而不是top,这背后涉及浏览器渲染管线的知识。改top会触发 layout,而transform只触发合成,合成通常在 GPU 上进行,代价低很多。这是页面动画流畅的最关键一招。
4.3 第23招:一次性渲染上千条列表,用虚拟列表
不可避免地要渲染大量数据时,千万不要把所有节点都挂到 DOM。虚拟列表的思路是:只渲染视口内看得见的那些节点。
实现上就是固定一个外层容器高度,监听滚动位置,计算应该渲染的起始索引和结束索引,只给这几十个节点创建真实 DOM。其余数据通过 padding 的方式模拟高度,撑起滚动条。
自己实现并不复杂,但要处理滚动偏移、行高变化、异步数据等边界情况。生产项目里我更推荐直接沿用成熟库方案,比如基于@tanstack/react-virtual或vue-virtual-scroller这样的库,能省大量时间。
4.4 第24招:使用合成属性,避免频繁触发布局
除了动画之外,高频事件处理器里也要特别注意。比如拖拽时实时修改元素left和width,可能会导致每一帧都整页重排。改用transform: translate3d(...)和scale()之后,元素进入合成层,布局不会受影响。
但别把所有元素都塞进合成层,因为一个页面的合成层数量太多会占用 GPU 内存,反而变慢。这个度要自己观察 Performance 面板,看是 layout 时间高还是 paint 时间高,再决定应该优化哪个方向。
4.5 第25招:缓存 DOM 查询结果,别反复 querySelector
在事件监听器里频繁document.querySelector('.item'),看起来没什么,但要是个复杂选择器,每次都会进行递归查找。把常用节点缓存到变量里:
const navLists = document.querySelectorAll('.nav-item'); function updateNav() { // 直接使用缓存的 navLists }尤其是那些在每次 resize、scroll 里都会执行的逻辑,查询结果缓存后能明显减少调用成本。注意,如果 DOM 结构发生变化,缓存可能失效,所以可以在插入节点后手动重新查询一次。
5. 内存与长期运行 5 招:不泄漏才能持久快
前面解决的是“跑得快”,内存这块解决的是“长期跑不慢”。很多 SPA 应用刚打开挺流畅,用过十分钟后越来越卡,就是因为内存泄漏堆积。
5.1 第26招:事件监听器用完要解绑
这是最常见的泄漏来源。你给一个 DOM 元素绑定了事件,随后把元素移除,但绑定并没有解除,于是这个元素连同事件闭包里的变量都被保留在内存里,无法被 GC 回收。
React 和 Vue 在组件卸载时通常会帮你清理,但如果你手动 addEventListener 到 window 或 document,框架是不管的。
function handleResize() { ... } window.addEventListener('resize', handleResize); // 组件销毁时必须有 window.removeEventListener('resize', handleResize);我在一个旧项目里排查卡顿,发现就是每个页面都往 window 上绑了 resize 事件,切路由后旧监听依然存在,十个页面就产生了上百个无效监听。解绑之后,内存稳定下降。
5.2 第27招:定时器和观察器及时清理
setInterval 如果没被 clear,会一直执行回调,即使页面已经销毁。同样,IntersectionObserver、MutationObserver 这些观察器如果没 disconnect,也会持有目标元素的引用。
一个稳妥的习惯是:在 Vue 的 beforeUnmount 或 React useEffect 的 cleanup 函数里,统一清理所有“异步资源”。
useEffect(() => { const timer = setInterval(tick, 1000); const observer = new IntersectionObserver(callback); observer.observe(domRef.current); return () => { clearInterval(timer); observer.disconnect(); }; }, []);只要你维护一份“需要清理的资源清单”,并在销毁时逐个关闭,内存泄漏的问题能规避掉 80%。
5.3 第28招:用对象池避免反复创建销毁大对象
游戏引擎里有一个老概念叫对象池:你不直接丢弃对象,而是把用过的对象放回池子里,下次需要时再取出来。JavaScript 里的垃圾回收机制会自动处理,但如果创建和销毁特别频繁,GC 的 STW 停顿就会影响到渲染。
前端业务里比较典型的是 Canvas 粒子动画、拖拽辅助层、复杂数据结构包装器。比如:
const pool = []; function getParticle() { return pool.pop() || createNewParticle(); } function recycleParticle(p) { pool.push(p); }这种模式会让内存占用稳定在一个水位线附近,而不是忽高忽低。优化监控面板里,GC 次数和耗时会明显下降。
5.4 第29招:防止全局变量和缓存容器无限增长
很多人喜欢把数据丢到全局变量里,比如:
window.cachedList = [];这个数组如果只增不减,内存就会持续上涨。即使不是一个全局变量,在组件里声明的“缓存 Map”也可能因为 key 不清理而无限变大。
建议给任何缓存容器设定上限,或者定期清理。比如:
if (cache.size > 10000) { cache.clear(); }排查内存泄漏时,用 Chrome 的 Memory 面板做两次 heap snapshot,分别在页面稳定后和交互若干次后,比较 Retained Size 变化。如果某个自定义对象数量持续上涨,基本就找到了泄漏点。
5.5 第30招:建立性能监控,防止优化效果回退
优化不是一次性工作。今天把包体积从 1MB 降到 300KB,下个月某位同学加了一个重型依赖,马上又回到 800KB。所以一定要把关键指标监控起来。
简单方式是使用 PerformanceObserver 收集运行时指标:
const observer = new PerformanceObserver((list) => { for (const entry of list.getEntries()) { reportMetric(entry.name, entry.value); } }); observer.observe({ entryTypes: ['largest-contentful-paint', 'first-input'] });构建层面也可以加入 CI 检查:如果某次构建的产物体积超过阈值,就提醒团队确认原因。我见过最惨的情况是优化完半个月后,线上性能跌回原点而没人发现,直到用户投诉。监控的意义,就是给性能优化一个长期生命线。
6. 最后说点实战结果和我的体会
回到开头那个后台系统。我把整个优化过程走下来,实际用到的招数没有 30 个那么多,真正起决定作用的只有 5 个:上 Brotli 压缩、路由懒加载、换掉 moment.js、用 Set 替代数组查找、批量插入 DOM。首屏从 5 秒降到 0.5 秒,交互卡顿几乎消失。
这种结果说明一个道理:“提升10倍”不是平均用力,而是找到最大的那个瓶颈,用最合适的一招把它击穿。多数项目的性能问题都集中在少数几个点上,你花 80% 精力去优化那 20% 的热点,效果远比均匀修炼好。
我个人还有一个小习惯:每次优化完,都会把优化前后的基线数据截图存下来。不是为了写报告,而是为了下一次有人质疑“你改这段代码有什么用”的时候,直接甩出对比数据。这种实打实的依据,比任何说辞都有说服力。
最后再分享一个建议:别把 30 招背下来就结束,而是在日常开发里按“加载 > 运行 > 渲染 > 内存”的顺序去形成检查清单。遇到一个新页面卡顿,先用 Performance 面板定位,再对照清单挑几招去实测。时间久了,这些招数就会变成肌肉记忆。到那时候,你看一个页面的加载瀑布图,基本就能猜出它的问题在哪个环节了。