1. 性能优化先想清楚:你是在优化“感受”还是在优化“数字”
先说个我踩过好几次的坑:拿到一个JS性能问题,直接就开始看代码、找循环、改算法,折腾大半天,最后发现用户该卡还是卡。为什么?因为大多数性能问题根本不在代码本身,而在加载路径和渲染时机上。
JavaScript性能优化这件事,本质上不是“让代码跑得更快”,而是“让用户觉得更快”。这俩有本质区别。用户感知到的性能由几个关键节点决定:首屏能看到的等待时间、交互后的响应延迟、滚动和动画的流畅度。如果你只盯着V8引擎里某个函数的执行时间,那基本属于用错了力。
我之前接手过一个移动端H5项目,首屏加载要4秒多,产品天天催。我一开始也是先去看JS有没有性能瓶颈,结果发现压缩后的业务代码才100多KB,根本不至于这样。后来一查,真正的问题是:页面里有8个独立第三方SDK的<script>标签全部同步加载,而且图片资源没做尺寸裁剪,一张首图2MB。把这些东西理顺之后,首屏直接进1.5秒以内,JS代码一行没改。
这就是我想说的第一件事:性能优化第一步是建立正确的问题定位方式,而不是急着写优化代码。你得先搞明白瓶颈在哪一层——网络层、渲染层、还是纯计算层。网络层慢,你优化算法没用;渲染层卡,你优化网络也没用。
另外要注意的是,性能优化不是一锤子买卖。代码在不断演进,依赖在不断升级,性能也会随之波动。所以科学的做法是把性能优化做成一个可持续的流程:建立基线、持续监控、回归对比。很多团队做了第一轮优化就完事了,三个月后性能又烂回去了,就是因为缺少监控和兜底机制。
所以这篇文章我不会只讲技巧,我会把完整的优化思路、工具链、实战套路都过一遍。你跟着这个路径去做,至少能把80%的JavaScript性能问题都解决掉,剩下的20%才需要靠极致的底层调优去拼。
2. 先测量再动手:没有数据支撑的优化都是瞎折腾
2.1 性能基线怎么建立,别靠“感觉卡了”去立项
做性能优化最容易犯的错就是:靠感觉判断哪里卡。你觉得这个页面卡,但卡的是什么?是加载慢?是滚动掉帧?是按钮点击没反应?每种卡顿的成因完全不同,解决方案也完全不同。
一个合格的性能优化项目,开工前必须建立量化基线。我最常用的组合是以下几个工具,覆盖不同维度:
| 工具 | 作用 | 使用场景 |
|---|---|---|
| Lighthouse | 生成性能评分和关键指标 | 整体评估、定基线 |
| Performance面板 | 录制或实时查看调用栈和耗时 | 定位CPU瓶颈和长任务 |
| Network面板 | 分析资源加载时间和顺序 | 定位网络层瓶颈 |
| Bundle分析器 | 看打包产物的体积构成 | 定位依赖过重和冗余代码 |
| Performance API | 在真实用户环境采集指标 | 线上监控、长期追踪 |
Lighthouse测出来的评分只是一个参考维度,我更关注它下面的几个具体指标:First Contentful Paint、Largest Contentful Paint、Speed Index、Time to Interactive。这些指标直接对应了用户能感知的加载体验。第一次跑Lighthouse的时候,一定要记录所有数据,这就是你的基线。
业内常用的基线整理方式是这样的:固定一个测试设备(比如一台中端Android真机),固定网络环境(比如用DevTools的Slow 4G模拟),然后跑三次取中位数。不能用高端PC和Wi-Fi去测,那样测出来的数据对真实用户没有参考意义。我之前给一个项目做优化,用Mac上的Chrome测出来Lighthouse能打95分,结果拿同事的小米手机一测,首屏要5秒。设备的性能差距在JS处理上放大得非常明显。
Performance面板则是定位运行时问题的核心工具。录制一段操作过程,重点看几个地方:有没有长任务(Long Tasks,超过50ms的任务都会在Timeline上标红)、脚本执行时间在整个耗时里占比多少、有没有明显的强制同步布局(Forced Reflow)标记。我之前排查一个表格页卡的滚动问题,就是用Performance面板录了三秒钟的滚动,发现几乎每一帧都有一个紫色高亮的Layout标记,说明是在滚动过程中反复改样式导致的强制重排。这问题用肉眼是不可能看出来的。
2.2 必看的Core Web Vitals:别再盯着PV和UV做性能了
从用户角度来度量性能,Google推行的Web Core Vitals是你绕不开的框架。它包含三项指标:LCP最大内容绘制、INP交互到下一次绘制的延迟、CLS累积布局偏移。这三项分别对应:加载体验、交互体验、视觉稳定性。
LCP衡量的是页面最大元素(通常是首屏图或者大标题)渲染出来的时间。超过2.5秒就算差。优化LCP的核心思路就一条:让最大的那个元素尽早开始加载并渲染。通常手段包括:预加载关键图片(<link rel="preload">)、避免用JS动态插入首屏内容、服务端渲染或者预渲染(SSR/SSG)、去掉阻塞渲染的同步脚本。
INP是2024年Google用Interaction to Next Paint替代了原来的First Input Delay,用来衡量用户交互后页面响应的快慢。这个指标考察的是从用户点击到页面画出视觉反馈的时间。要优化INP,核心就是减少事件处理中的主线程阻塞时间,把复杂的计算拆开做或者挪到Web Worker中,避免在事件回调里做重型操作。
CLS衡量的是页面加载过程中元素突然跳动的程度。这个往往是前端容易忽略的点:没有给图片和广告位预留尺寸、字体加载后导致文字位置变化、异步插入的DOM把下面的内容挤下去——这些都是CLS的元凶。修起来其实不复杂,给媒体元素加上宽高属性,用aspect-ratio预留空间,字体用font-display: swap,但就是需要你有这个意识。
提示:如果你们团队有条件,可以用
web-vitals这个npm包把真实的指标数据上报到自己的监控平台,这是我从“靠工具测”升级到“线上持续观测”的关键一步。
2.3 用Performance API做线上真实性能监控
本地测Lighthouse只能代表理想环境,真实用户手里的手机千奇百怪,网络环境更是复杂。所以我建议任何有点规模的项目,都要接线上性能监控。不用非得买商业产品,自己用Performance API就能搭一个轻量的方案。
PerformanceObserver可以监听largest-contentful-paint和layout-shift等性能条目。我分享一个简单的采集代码思路:
// 一个极简的线上性能数据采集示例 const observer = new PerformanceObserver((list) => { for (const entry of list.getEntries()) { if (entry.entryType === 'largest-contentful-paint') { // 这里把数据发送到你的监控服务器 sendBeacon('/api/perf', JSON.stringify({ type: 'LCP', value: entry.startTime, url: location.href })); observer.disconnect(); } } }); observer.observe({ type: 'largest-contentful-paint', buffered: true });navigator.sendBeacon这个方法很适合发送性能日志,因为它在页面卸载时也能可靠地把数据发出去,不会因为跳转丢数据。采集到的数据存下来之后,按设备、网络、页面维度去聚合,就能看出哪些页面在哪些场景下性能差,再针对性去优化。这里我就不展开讲监控平台的搭建了,核心思路就是:真实用户数据才是最终裁判。
3. 加载阶段提速:让页面更早可交互
3.1 JS体积控制:从依赖和构建产物下刀
JavaScript性能优化的第一刀,应该砍在不需要执行的代码上。你让浏览器下载并解析的每一KB代码,都在消耗用户的流量和CPU。尤其是移动端,低端手机的JS解析速度比桌面端慢4到5倍。
我处理过一个项目,打包后的JS有800KB,压缩后也有近250KB。用Bundle分析器一看,好家伙,moment.js一个库就占了50KB,但整个项目只用到了它的format功能。换成dayjs之后,直接少了45KB。后来又发现,lodash全量引入,但实际只用了debounce和get两个函数,改成按需引入后又省了30KB。
这里要给大家说一个具体可操作的原则:任何依赖库,能按需引入就按需引入,能用轻量替代品就果断替换。引入依赖前问自己两个问题:这个库的功能我能不能用原生API实现?如果能,节省的代码量值不值得我用一个库?原生fetch已经很好用了,非要为了兼容引入axios?Array.prototype.flat、Object.fromEntries这些ES2019+能力都已经普及了,没必要为了它们引入工具库。
构建层面的配置也很重要。如果你是Webpack 5,注意开启moduleConcatenation提升作用域,配合terser-webpack-plugin做极致压缩。如果你用的是Vite,构建时用的是Rollup,天然会做tree shaking。但不管哪个构建工具,都需要你自己去检查两件事:有没有把整个库的dist文件直接import进来,导致tree shaking失效;有没有在入口文件里import了一个有很多副作用的模块。
还有一点,就是按需加载和动态import。把首屏不需要的代码(比如弹窗组件、详情页逻辑、图表库)拆成独立的chunk,用户用到时才加载。这个在Webpack和Vite里都支持,语法就是const { default: Chart } = await import('./Chart.vue')这种方式。拆完之后要注意检查chunk体积,如果一个异步chunk还是很大,就要继续拆。
实操心得:拆代码之后,我一般会同时做两件事:给chunk起可读性强的名字(Webpack的
magic comments),方便在Network面板里定位;在代码分割点上加错误边界逻辑,异步加载失败时提示用户刷新而不是白屏。
3.2 用对缓存策略:HTTP缓存和Service Worker两手都要硬
缓存这个环节经常被低估。很多团队觉得上线就完事了,结果老用户每次打开页面都要重新下载所有资源。这体验能不慢吗?
HTTP缓存的核心原则是:内容不变的资源,让浏览器永远用缓存;内容变化的资源,让URL跟着内容变。典型的做法:给静态资源文件名加上内容哈希(app.8f7d2c.js),然后设置Cache-Control: max-age=31536000, immutable。文件名变了,URL就变了,用户自然加载新文件;文件名不变,就直接用缓存,连网络请求都不发。
但HTTP缓存的局限在于:首次访问没有缓存,还是要走完整的下载流程。要突破这个局限,就得靠Service Worker做App Shell缓存。Service Worker的思路是:在你第一次访问页面的时候,把框架代码、基础样式、公共图片这些“外壳”资源全部预缓存到本地。第二次访问的时候,直接从Service Worker缓存里读取,几乎可以做到秒开。
我参与的一个项目,加了Service Worker预缓存之后,老用户从二次进入到页面完全渲染,时间从1.8秒降到了0.4秒。用户体验的提升是质变级别的。当然,Service Worker的坑也不少:更新策略、缓存版本管理、白屏风险。一个稳妥的方案是:采用stale-while-revalidate策略,页面永远先用缓存的旧版本渲染,后台静默拉取新版本,拉到了就更新缓存并在下次加载时切换。这样用户永远能看到内容,不会因为缓存更新而白屏。
还有一个大家问得多的问题:图片和视频这类大资源要不要进Service Worker?我的建议是:大部分不要。图片走HTTP缓存就够了,Service Worker缓存图片容易撑爆存储配额。除非是用户最高频使用的页面配图,否则别做这个,收益有限还增加复杂度。
3.3 图片和字体是性能隐形杀手,别只盯着JS
JS是主线程的计算负担,但页面上绝大多数体积来自图片。如果图片没优化好,JS优化得再彻底,加载体验实际也提升不了多少。
图片优化的核心就三件事:格式、尺寸、解码方式。格式上,能上WebP就上WebP,甚至可以考虑AVIF,大部分现代浏览器都支持。尺寸上,上一套响应式图片方案,用srcset让移动端只加载480px宽的图,桌面端加载1920px宽的图,别让手机用户下载一张2MB的高清大图。解码方式上,开启loading="lazy"做懒加载,优先加载首屏的图。图片懒加载这个属性现在是原生支持的了,不用再引库。
字体这块是个隐性痛点。中文字体动辄几MB,如果直接用@font-face包裹一整份中文字体文件,那是一场灾难。我现在处理中文字体的套路是:只加载页面上实际出现的文字对应的子集字体,通过字体子集化工具(比如fontmin)只保留需要的字形;或者直接用unicode-range把字体切分成多个子集,浏览器按需加载。如果条件实在有限,就把字体优先级调低,优先保证系统字体渲染,项目字体加载完再替换,这就是font-display: swap的作用,代价是有FOIT(无样式文字闪烁),但总比白屏好。
注意:图片懒加载也不是无脑加的,首屏图片千万别加
loading="lazy",因为浏览器可能不知道哪张图优先,导致首屏图被推迟到很晚才加载。这也是加了懒加载反而首屏变慢的常见原因。
4. 运行时流畅度:从主线程手里抢时间
4.1 主线程的长任务拆分,从“一次干完”到“分次干完”
JS是单线程语言,所有DOM操作、事件回调、计算逻辑都挤在主线程上。如果某个任务的执行时间超过50ms,浏览器就有可能出现掉帧、卡顿。这就是为什么我们做运行时优化时,核心目标就是消灭长任务,或者至少把它们拆短。
拆分长任务的最简单方案是让出主线程。思路是:把一个需要几百毫秒才能执行完的逻辑,拆成多个小块,每个小块之间用setTimeout或者MessageChannel让浏览器有空闲时间插入渲染。这里我推荐优先用MessageChannel,因为setTimeout在浏览器里有一个最小4ms的延迟限制(嵌套层数较多时),MessageChannel能更快地把任务交还给主线程。
// 用MessageChannel做任务分片调度的示例 function scheduleYield() { return new Promise(resolve => { const channel = new MessageChannel(); channel.port1.onmessage = () => resolve(); channel.port2.postMessage(null); }); } async function processLargeArray(items) { const CHUNK_SIZE = 1000; for (let i = 0; i < items.length; i += CHUNK_SIZE) { // 处理当前分片 const chunk = items.slice(i, i + CHUNK_SIZE); processChunk(chunk); // 让出主线程,让浏览器有机会渲染和响应输入 await scheduleYield(); } }另一个更现代、更优雅的方案是Scheduler API里的await scheduler.yield()。虽然目前它的浏览器兼容性还没有全覆盖,但在Chrome 119+里已经可以用了,而且它是由浏览器原生调度上下文和优先级,比手写MessageChannel方案更可靠。如果项目对浏览器内核版本要求高(比如必须支持老版本WebView),使用MessageChannel方案做降级即可。
还有一类任务是“拆了也白拆”的——比如用户在滚动中,scroll事件高频触发,你有大量逻辑在处理。这时候除了拆任务,更关键的是防抖和节流,以及把部分逻辑挪出主线程。
4.2 防抖和节流:高频事件的保命手段
前端性能优化的基础功就是防抖和节流。防抖适合处理搜索输入这类“只关心最后一次输入结果”的场景:用户连续输入时,只有在停止输入多少毫秒后才会执行真正的逻辑。节流适合处理滚动、鼠标移动、窗口缩放这些“在一定时间内最多执行一次”的场景,保证逻辑不会因为事件高频触发而崩溃。
但很多人的误解在于:用了防抖就一定能提升性能。其实不是的,防抖和节流对浏览器的scroll事件提升有限,因为scroll的触发频率本身就很高而且无法取消。真正的关键其实是:在事件回调里尽量少做DOM读写操作,以及在下一帧渲染之前批量更新样式。
我举个例子:一个列表页,滚动时需要在导航栏顶部显示滚动距离。如果你在scroll回调里直接读取scrollTop然后设置某个元素的transform,这本身问题不大。但如果你在scroll回调里还做了getBoundingClientRect()、读取offsetTop这种布局信息,再设置样式,那就容易触发强制同步布局,导致掉帧。因为浏览器会为了拿到最新的布局信息,强制同步计算一次Layout。
正确姿势是做一波读写分离:先把所有需要的数据读出来,攒到requestAnimationFrame回调里统一执行写操作(比如设置样式、插入DOM)。下面是一个简易的读写分离实现:
// 读写分离:读操作立即执行,写操作延迟到下一帧 const state = { scrollY: 0, rects: [] }; let rafId = null; function onScroll() { // 读操作:立即执行,不需要等渲染 state.scrollY = window.scrollY; const items = document.querySelectorAll('.item'); state.rects = Array.from(items, el => el.getBoundingClientRect().top); // 写操作:交给requestAnimationFrame统一执行 if (rafId === null) { rafId = requestAnimationFrame(() => { applyChanges(state); rafId = null; }); } }这个模式的收益在元素数量多或者操作频率高的场景下非常明显。我处理过一个图片瀑布流项目的滚动卡顿,通过读写分离加requestAnimationFrame批量更新,掉帧率从45%降到了8%。
4.3 DOM操作是性能黑洞:批量更新、文档片段、虚拟列表
前端性能书上说了无数遍的“减少DOM操作”,但实际操作起来大家总觉得无从下手。这里给出一条清晰的行动路径,按优先级从高到低。
第一优先级是批量更新,减少布局抖动。不要一个数据一个数据地改样式,把多次DOM写操作合并到一次里执行。Vue和React这类框架天然帮你做了一部分批处理工作,但如果你写的是原生JS,就需要自己控制。插入大量节点时,用DocumentFragment先组织好再一次性插入;修改样式时,优先用style.cssText或者一次性修改class,不要逐条改style.width、style.height。
第二优先级是减少节点数量和层级。DOM节点太多,浏览器维护节点树本身就要消耗内存和计算,布局计算也会更慢。有一次我接手一个聊天页面,节点数量超过5000个,滚动时每帧都要做布局计算,卡得没法用。后来做了虚拟列表,只渲染可视区域加缓冲区的节点,节点数量降到100个以内,性能问题直接消失。虚拟列表是长列表场景的必选项,市面上有成熟的库(比如react-virtualized、vue-virtual-scroller),也可以自己实现,核心就是:计算可视区的起始索引和结束索引,上下各留几行缓冲,然后根据滚动位置裁剪或复用DOM节点。
第三优先级是事件代理。如果一个列表里有1000个按钮,每个按钮都绑定一个click事件,那就有1000个监听器。事件代理的思路是只给列表容器绑定一个监听器,通过event.target判断用户点击的是哪个按钮,然后分发逻辑。这不仅减少了内存占用,还简化了后续新增节点的处理——新增按钮不用再单独绑定事件了。原生JS的事件代理语法很简单,而且Vue和React也在底层帮你做了类似的事情,但对原生JS项目来说,这件事依然重要。
4.4 requestAnimationFrame和requestIdleCallback的正确打开方式
很多前端只知道requestAnimationFrame可以用来做动画,其实它也是跟帧同步的最佳工具。动画的本质是:每一帧渲染之前,浏览器要把你设置的样式变化计算并绘制出来。所以样式更新放在requestAnimationFrame里,能保证更新跟渲染同步,不会错位导致跳帧。
但要注意:requestAnimationFrame回调里的代码如果执行时间过长,依然会拖垮帧率。因为它是在帧的开头执行的,如果你的回调执行了100ms,那这一帧就没法在16.7ms内完成,掉帧就是必然的。所以在requestAnimationFrame里,你只应该做轻量级的更新,重计算应该在它之前用普通异步完成。
而requestIdleCallback是用来处理低优先级任务的:在浏览器的空闲时间段执行不紧急的工作。典型场景:上报日志、预处理可能用得上的数据、懒加载的资源和缓存预热。它的执行优先级很低,不会影响动画和交互。
// 用requestIdleCallback做空闲时间的预处理 if ('requestIdleCallback' in window) { requestIdleCallback(() => { precomputeUserData(); }, { timeout: 2000 }); } else { // 兼容性兜底:不给不支持的低版本浏览器增加负担 setTimeout(() => precomputeUserData(), 2000); }这里有一个兼容性提醒:requestIdleCallback在Safari上长期不支持,所以适应性处理还是必须的。其次,别在requestIdleCallback里做任何会触发布局或绘制的操作,否则它就不再是“空闲”了。
5. 内存管理:别让页面越用越卡
5.1 内存泄漏是流畅度的隐形杀手
用户打开你的页面,用着用着越来越卡,最后直接闪退——这大概率是内存泄漏。内存泄漏的意思是:你创建了对象、注册了事件、启动了定时器,但在不需要的时候没有及时释放,导致内存占用只增不减。
常见的JS内存泄漏场景集中在几个地方:
- 全局变量挂载:无意间把对象挂到了
window上,进程结束前都不会被回收。 - 定时器未清理:
setInterval启动后,组件销毁时忘了clearInterval,回调还在引用着旧的DOM和数据。 - 事件监听器未移除:尤其是把匿名函数当作事件监听器绑在全局对象或DOM上,移除的时候
removeEventListener用的却是另一个函数,等于没移除。 - 闭包引用:一个被全局变量引用的函数,它的闭包作用域里还链接着一大堆本来早就可以释放的数据。
- 持续增加的缓存:自己实现的数据缓存Map,只增不减,没有做LRU淘汰或容量限制。
排查内存泄漏最直观的方式是Chrome的Memory面板,做三次“操作→快照”的对照:先记录一个堆快照,执行某个操作(比如打开弹窗再关闭),再记录一次快照,然后重复几次。如果堆大小在单调上升且没有回落,基本就是泄漏了。然后用Comparison视图看新增的对象里有哪些是意外的——那帮你锁定了泄漏源。
5.2 数据结构和闭包的隐藏成本
内存泄漏之外,还有一个更隐蔽的问题:不合理的对象结构导致GC压力过大。这里要引出V8引擎的隐藏类(Hidden Class)和内联缓存(Inline Cache)概念。简单来说,V8为了快速读取对象属性,会为对象创建隐藏类。如果你在代码里给一个对象不断添加新属性(obj.a = 1; obj.b = 2; obj.c = 3;),V8就得不断更新隐藏类,导致属性读取速度下降。
更常见的GC压力来源是“频繁创建对象但不长期使用”。比如在循环体里const temp = { a: i, b: i * 2 },这个对象在下一轮循环就没人引用了,但每循环一次就创建一次,短时间内创建大量短生命周期的对象会迫使GC频繁执行。解决思路就是对象复用:在循环外只创建一次,循环里只改属性值。这和C++里循环外分配内存的做法异曲同工。
闭包的问题在于,只要闭包函数还被引用,它捕获的所有变量都会一直存活。我见过一个项目,因为一个简单的useCallback依赖数组写错,导致回调返回新的闭包,这个闭包又引用了大量的数据,结果导致组件一遍遍重新渲染加累积内存。这种现象在React项目里尤其常见。不是不能用闭包,而是要知道:闭包会持有外部变量直到闭包本身被回收,所以不要让长生命周期的闭包轻易捕获大对象。
5.3 Web Worker:把计算负担从主线程挪走
JS是单线程的,但浏览器的多线程能力其实一直摆在那里——Web Worker就是那个能帮你把主线程计算压力转移出去的工具。任何CPU密集型任务,比如图像处理、数据解析、加密解密、大规模数组运算,都适合挪进Worker里做。Worker在后台线程执行,不占用主线程,所以不会影响页面渲染和交互。
举个实战场景:页面上传一个几十MB的文件,需要做内容预览(比如生成hash、解析元数据)。这些操作在主线程做会让页面卡死几秒甚至十几秒——用户在这个期间没法滚动,也没法点击。把逻辑挪到Worker之后,主线程只是发个消息过去,用户该干嘛干嘛,Worker算完再PostMessage回来更新UI。
// 主线程中的调用 const worker = new Worker('/worker.js'); worker.postMessage({ type: 'process', payload: fileData }); worker.onmessage = (e) => { updatePreview(e.data.result); }; // worker.js 内部逻辑 self.onmessage = (e) => { const { payload } = e.data; const result = heavyProcessing(payload); self.postMessage({ result }); };对于“不能改造成Worker”的计算(因为要操作DOM),可以退而求其次用前面讲的任务分片方案,在主线程的间隙里做完。两者思路互补,共同目标都是:把主线程留给渲染和交互,降低掉帧和点击延迟的概率。
Worker的限制也提一下:它不能访问DOM,所以数据需要PostMessage序列化传输,大对象的拷贝成本可能吃掉优化收益。新版浏览器支持Transferable Objects,可以把ArrayBuffer的所有权移交给Worker,实现零拷贝传输。在处理大型二进制数据时,用这个方案可以避免结构克隆的开销。
6. 实操避坑总结:这些坑我替你先踩了
6.1 JavaScript性能误区自查清单
写做性能优化这些年,我踩过不少坑,也有过很多“原来如此”的时刻。最后整理几个高频误区,给大家做自查用。
第一个误区:只看Lighthouse分数而不看具体指标项。Lighthouse总分高不代表体验好。有次我优化一个项目,Lighthouse打到了98分,但真实用户在微信内置浏览器里打开还是很慢。原因在于,微信内置浏览器有一些自己的缓存和加载策略,Lighthouse模拟的环境覆盖不到。这类问题只能靠真实设备和真实网络环境去测,再结合线上监控数据才能暴露。
第二个误区:把所有代码都拆成异步加载。异步加载能减少首屏阻塞,但异步chunk太多也有代价——更多网络请求、更复杂的加载依赖、更长的加载链路。拆分的粒度应该以“首屏所需之外的东西才拆”为原则,为拆而拆反而可能更慢。
第三个误区:只优化代码,不优化网络和服务器。JS由服务器返回,服务器的响应速度、CDN覆盖、HTTP/2/3的开启情况,直接影响JS的加载速度。有时候你代码写得再干净,源站服务器在海外,用户在国内,无论如何都慢。
第四个误区:忽视低版本设备和WebView。在Chrome上测出60fps,不等于所有用户都60fps。现在很多H5页面运行在各种品牌的Android WebView里,它们的内核版本差异极大。优化完一定要做设备矩阵实测——一台千元Android机跑一遍你的页面,你就知道什么叫现实了。
6.2 高频性能问题的排查套路速查表
下面把性能问题按现象归类,给出对应的排查入口和首选方案,供大家定位问题时直接翻阅。
| 现象 | 优先排查方向 | 常用手段 |
|---|---|---|
| 页面加载慢 | 资源体积、请求数量、缓存 | 代码分割、压缩、CDN、HTTP缓存 |
| 首屏长时间空白 | 渲染路径、同步脚本 | defer/async、内联关键CSS、SSR |
| 滚动/动画掉帧 | 主线程长任务、强制重排 | 读写分离、防抖节流、Worker |
| 点击响应延迟 | 事件处理中的重计算 | 逻辑拆分、减负回调、避免同步布局 |
| 越用越卡 | 内存泄漏、对象失控 | 堆快照、事件清理、定时器清理 |
| 低端机白屏 | 代码兼容性、内存溢出 | 降级策略、动态import、压缩尺寸 |
这套速查表的特点是按“现象”来组织,而不是按“工具”来组织。遇到问题先归类,再对症下药。实战中90%以上的性能问题都能在这张表里找到对应的处理路径。
6.3 给自己设一条性能预算红线
最后一个建议是把性能优化制度化:给项目设一条性能预算的红线。比如:首屏JS总体积不超过200KB(压缩后),LCP不超过2.5秒,CLS不超过0.1,任何单次构建新增依赖的体积增量超过5KB必须review。把这条红线写进CI流程里,构建时检查,超出就报警。这样性能问题才能被拦截在发布之前,而不是上线后靠用户投诉来驱动。
我实际执行过一版简单的体积预算检查脚本:构建完成后解析打包产物,读取每个chunk的gzip后大小,和预算文件里设定的阈值对比,超了就让构建失败。这个机制大概花了半天时间来搭建,之后的收益是持续的。从此再也没有出现过“加了个80KB的图表库没人发现”这种事后尴尬。
性能优化从来不是某个“大版本”的事,而是一种持续工程习惯。基线数据、监控日志、预算红线,一个都不能少。这些东西搭好之后,后面的手段都是锦上添花了。