我自己的个人网站之前首屏加载要 2.8 秒,Lighthouse 性能评分长期在 60 分上下徘徊。后来花了一周时间专门做 JavaScript 性能优化,把加载时间压到了 1.2 秒以内,评分稳定在 95 以上。这个过程中踩了不少坑,也总结出一套可以复用的方法论,今天一次性掏出来分享给你。
先说结论:JavaScript 性能优化从来不是某一行代码的魔法,而是一套从测量、网络加载、解析执行、渲染交互到内存管理的系统性工程。这篇文章我会完整拆解这套优化链路,每个环节都配上可以直接抄作业的代码和验证方法,无论你是在做企业级 Web 项目,还是个人站点,都能从中找到适合自己的优化点。
1. 性能问题到底出在哪:先别急着优化,先学会测量
1.1 打开 Performance 面板,让数据代替感觉
很多开发者来找我聊性能问题时,第一句话都是"页面很卡""加载很慢"。但"卡"和"慢"是两个完全不同层面的问题,前者通常是渲染帧率不足,后者往往是网络和资源加载瓶颈。凭感觉猜病因,大概率会做无用功。
我接手任何一个性能优化项目,第一步永远是录一段 Performance 面板的 trace。操作方式很简单:打开 DevTools 切到 Performance 面板,点击录制按钮,手动刷新页面或者复现操作流程,然后停止录制。这时候你会得到一份完整的时间线,包括网络请求、HTML 解析、JavaScript 执行、样式计算、布局、绘制、合成,每一帧发生了什么都能看到。
这里有一个重点:不要只看红色长条,要关注 Long Task。浏览器主线程上任何超过 50ms 的任务都会标记为 Long Task,这是导致交互卡顿的元凶。Performance 面板里标红的区域基本就是它们。你还需要注意 FPS 曲线,如果操作过程中 FPS 掉到 30 以下,说明主线程被密集计算或者频繁布局阻塞了。
还有一个非常实用的 API 可以在代码里精确计算耗时:
javascript复制function measure(fn, label) {
performance.mark(`${label}-start`);
fn();
performance.mark(`${label}-end`);
performance.measure(label, `${label}-start`, `${label}-end`);
}
// 调用
measure(() => processLargeArray(data), 'processLargeArray');
// 在控制台查看结果
performance.getEntriesByType('measure').forEach(entry => {
console.log(`${entry.name}: ${entry.duration.toFixed(2)}ms`);
});
这是定位"哪一段代码慢"的最快方式,也是后续所有优化的基线。
1.2 用 Core Web Vitals 建立可量化的性能预算
光知道哪里慢还不够,你需要一套大家都认可的指标来衡量优化效果。Google 提出的 Core Web Vitals 是目前行业的事实标准,重点看三个:
| 指标 | 含义 | 良好阈值 | 测量方式 |
|---|---|---|---|
| LCP | 最大内容绘制,衡量加载感知速度 | ≤ 2.5s | Lighthouse / web-vitals |
| INP | 交互到下一次绘制的延迟,衡量响应性 | ≤ 200ms | web-vitals / 实验室 |
| CLS | 累积布局偏移,衡量视觉稳定性 | ≤ 0.1 | Lighthouse / web-vitals |
Lighthouse 内置了这些指标的评分逻辑,跑一遍就能看到主要扣分项。我习惯用浏览器插件的 Lighthouse 做一次快速检测,然后再用 Chrome 的"性能洞察"面板做更细粒度的分析。
更重要的是,把性能目标转成硬性约束。比如我给自己的站点定了几条规矩:LCP 不超过 2s、总包体积 Gzip 后控制在 180KB 以内、单个 JavaScript chunk 不超过 100KB。超了就不允许合入主干,这比任何口头强调都管用。
1.3 先建基线再动手,避免优化了个寂寞
一个非常容易犯的错误是没有量化起点就开干。你花了两天把某个函数从 100ms 优化到 20ms,结果页面依然卡,因为瓶颈根本不在这里。正确顺序是:先录制基线数据,确定一两个真正影响 Core Web Vitals 或用户体验的指标,再围绕它们展开优化,最后重新测量对比。
我在做一个后台管理系统的时候,最初优化了表格组件的 render 函数,自我感觉良好。结果一测,LCP 没变化,INP 还是 300ms,真正的问题出在一个没有被懒加载的图表库上,它在首屏就会被解析执行。这就是不建基线的代价,方向错了,努力白费。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 网络层优化:让资源更快到达浏览器
2.1 压缩、缓存与 CDN:最基础也最容易被忽视的三板斧
JavaScript 性能优化的第一步其实发生在服务器端。任何一段 JS 都要经过网络传输才能到达浏览器,在传输环节做优化,收益远大于代码层面的微调。
压缩是第一优先级。现代服务器基本都支持 gzip,而 Brotli 在压缩率和解压速度上更优。以我常写的 React 应用为例,一个 300KB 的 bundle,gzip 后大约 85KB,Brotli 后能压到 75KB 左右。配置起来很简单,比如在 Nginx 里开启:
nginx复制gzip on;
gzip_comp_level 5;
gzip_min_length 1k;
gzip_types application/javascript text/css application/json;
brotli on;
brotli_comp_level 5;
brotli_types application/javascript text/css application/json;
压缩级别不是越高越好,5 左右是性价比比较高的档位,再往上压缩率提升有限,但 CPU 消耗成倍增长。
缓存策略同样关键。持久化缓存配合文件名指纹是最常见的组合:文件内容变了文件名就变,文件名没变浏览器直接用缓存。这样第二次访问时 HTTP 响应会直接命中本地缓存,真正加载的 JavaScript 可能只有几十字节的 304 响应。
http复制Cache-Control: public, max-age=31536000, immutable
CDN 的价值不仅是快,它还能分担源站压力,让不同地域的用户从就近节点拉取资源。如果项目用户分布在全国甚至全球,这一步省不了。
2.2 代码分割与懒加载:按需加载才是正道
很多页面首屏慢,不是因为代码难写,而是因为它加载了用户根本用不到的东西。一个管理后台把编辑器、图表库、导出 Excel 的依赖全部打包进一个 bundle,首页加载能不慢吗?
解决思路是代码分割(Code Splitting)。用 Vite 或 webpack 的动态导入语法,把代码切成按需加载的 chunk:
javascript复制// 路由级懒加载
const Dashboard = lazy(() => import('./pages/Dashboard'));
const Reports = lazy(() => import('./pages/Reports'));
const Settings = lazy(() => import('./pages/Settings'));
function App() {
return (
<Routes>
<Route path="/" element={<Dashboard />} />
<Route path="/reports" element={<Reports />} />
<Route path="/settings" element={<Settings />} />
</Routes>
);
}
组件级的懒加载也有非常典型的场景。比如一个弹窗里才用到的富文本编辑器,完全可以在打开弹窗时才加载;一个只在用户导出时才会用到的 XLSX 库,可以在点击导出按钮时才加载。
构建工具里还可以做更精细的分包配置。Vite 提供了 manualChunks,webpack 有 splitChunks,把体积大、更新频率低的第三方库(React、lodash 等)单独拆出来,利用长缓存让用户只下载有变化的业务代码,而不是每次都被迫重新拉取一个大包包。
2.3 请求合并与 HTTP/2 的时代变化
以前性能优化的老经验是"把多个 JS 合并成一个文件,减少请求次数",这个说法在 HTTP/1.1 时代完全正确,但在 HTTP/2 时代已经不适用了。HTTP/2 支持多路复用,多个小请求可以并行传输,不再受浏览器同域名并发连接数限制。
所以现在更推荐的做法是:按业务逻辑拆小文件,利用 HTTP/2 的并行能力,配合 CDN 的缓存粒度,反而能让首屏只加载必要的代码。为了合并而合并,反而让缓存失效的代价变高——改一个小按钮,整包都要重新下载。
另外两个容易被忽略的资源提示标签是 preload 和 preconnect:
html复制<link rel="preload" href="/js/index.js" as="script">
<link rel="preconnect" href="https://cdn.example.com">
preload 用来提前加载当前页面马上要用的关键资源,preconnect 用来提前建立与第三方域名的连接。它们不是万能的,用多了会占用带宽,建议只对最核心的资源使用。
3. 解析与执行:JavaScript 是怎么拖慢页面的
3.1 解析为什么会阻塞渲染,阻塞的到底是什么
默认情况下,HTML 解析器遇到 <script> 标签时会停下来,先下载脚本,再交给 JavaScript 引擎执行,等执行完了才继续解析后面的 HTML。这意味着脚本多大,首屏可能就卡多久。
你可以把浏览器解析 HTML 的过程想象成一条流水线,脚本标签就像一个必须全神贯注才能完成的质量检验站,它一开工,整条流水线都停在那里等。如果这个脚本恰好是 2MB 的第三方库,用户看到首屏的时间就会被严重推后。
JavaScript 的执行还会影响 LCP 和 INP。LCP 要等页面最大元素渲染出来,如果关键内容在脚本后面的 DOM 里,脚本不执行完,这个元素就一直出不来。所以要想让页面"飞"起来,第一步就是尽可能减少首屏必须要执行的 JavaScript。
3.2 async 和 defer:脚本加载的正确姿势
标准做法是给外部脚本加 async 或 defer 属性。两者都不阻塞 HTML 解析,但行为有细微差别:
| 方式 | 下载时机 | 执行时机 | 适用场景 |
|---|---|---|---|
| 普通脚本 | 阻塞解析,立即下载 | 下载完立即执行 | 首屏必需、内联逻辑 |
| async | 异步下载,不阻塞解析 | 下载完立即执行,谁先下载完谁先执行 | 独立第三方分析脚本 |
| defer | 异步下载,不阻塞解析 | HTML 解析完成后按顺序执行 | 依赖 DOM 的业务脚本 |
我自己的准则是:凡是需要在 DOM 加载后操作的脚本,一律用 defer;凡是与主业务无关、且执行顺序不敏感的(比如统计代码、A/B 测试脚本),用 async。注意,有 defer 的脚本如果包含 addEventListener 且监听 DOM 事件,执行时机在 DOM 解析之后,就没有问题。
对于首屏渲染依赖的关键逻辑,无论加不加 async/defer 都不合适,更好的做法是把最关键的代码内联进 HTML 的 <head>,保证它在没有任何网络依赖的情况下快速执行。
3.3 V8 引擎的 hidden class、内联缓存与去优化
进入执行层面,JavaScript 引擎的运行时优化对性能的影响往往被开发者严重低估。V8 引擎有两个关键机制:hidden class(隐藏类)和 inline cache(内联缓存)。
hidden class 可以理解成 V8 给对象建的"结构模板"。当你创建两个形状完全一样的对象时,它们会共享同一个 hidden class,属性访问速度最快。但如果两个对象属性结构不同,或者同一个对象在不同阶段被动态添加/删除属性,V8 就得不断地"改造"hidden class,访问速度会下降一到两个数量级。
实际编码中这表现为:构造函数里初始化所有属性,保持一致的属性顺序,避免删除属性。比如:
javascript复制// 不推荐
function Point(x, y) {
this.x = x;
this.y = y;
}
const p = new Point(1, 2);
p.z = 3; // 动态添加属性,触发 hidden class 变化
delete p.z; // 动态删除属性,也会降低性能
// 推荐:一次性定义完整结构
function Point(x, y) {
this.x = x;
this.y = y;
this.z = 0;
}
const p = new Point(1, 2);
p.z = 3;
还有一个常见问题是函数参数类型不稳定。V8 默认会对同一个函数优化出针对特定类型的字节码,如果你某次调用传了数字,另一次传了字符串,引擎会做"去优化"(deoptimization),性能和热路径完全不在一个量级。所以写业务代码时,一个函数内部尽量保持参数类型一致,不要在数字和字符串之间反复横跳。
4. DOM 操作与渲染层优化:减少主线程的工作量
4.1 DOM 操作为什么贵:回流与重绘的完整链路
JavaScript 操作 DOM 本身不贵,贵的是操作之后浏览器要做的一系列布局和绘制工作。当你修改某个元素的样式、尺寸、位置时,浏览器需要重新计算布局(Layout/Reflow),然后重新绘制(Paint),涉及大量后代元素时成本会急剧上升。
触发回流的操作非常常见:读取 offsetWidth、修改宽高、增删 DOM 节点、改变字体大小、滚动页面等。有个特别容易踩的坑是"强制同步布局"——在循环里反复读写几何属性,浏览器被迫每次都重新走一遍布局流程。
推荐的做法是:批量读取,批量写入。用 requestAnimationFrame 把 DOM 写入操作放在同一帧集中执行:
javascript复制// 不推荐
const boxes = document.querySelectorAll('.box');
boxes.forEach((box, i) => {
box.style.transform = `translateX(${i * 20}px)`;
console.log(box.offsetWidth); // 每次写入后立即读取,触发同步布局
});
// 推荐
const boxes = document.querySelectorAll('.box');
const widths = [];
boxes.forEach(box => widths.push(box.offsetWidth)); // 先统一读取
requestAnimationFrame(() => {
boxes.forEach((box, i) => {
box.style.transform = `translateX(${widths[i]}px)`;
});
});
4.2 批量更新与 DocumentFragment:别让 DOM 节点一个个"挤"进去
往页面里循环添加成百上千个节点时,每 append 一次,浏览器就会触发布局,这是性能灾难。解决思路先合并到内存里,再一次挂载。DocumentFragment 就是为这个场景设计的轻量节点容器:
javascript复制const fragment = document.createDocumentFragment();
for (let i = 0; i < 10000; i++) {
const item = document.createElement('div');
item.textContent = `Item ${i}`;
fragment.appendChild(item);
}
container.appendChild(fragment); // 只触发一次回流
这里有一个衡量点:文件数量巨大时,即便用了 DocumentFragment,一次性渲染 1 万个节点也依然会造成长任务。这种情况下,虚拟列表通常是更合理的方案。
4.3 虚拟列表:渲染上千条数据时应该怎么处理
虚拟列表的核心思想:无论数据有多少条,只渲染用户可视区域内的一小部分 DOM 节点。窗口滚动时,动态替换可见区域的节点内容。
一个最简单固定高度虚拟列表的思路是这样的:
- 外层容器固定高度,开启 overflow-y: auto。
- 内容区域的高度设置为 总条数 * 每条高度,用来撑出滚动条。
- 监听滚动事件,根据 scrollTop 计算出起始索引,截取当前需要渲染的数据子集。
- 将这部分数据映射为 DOM 节点后绝对定位到正确位置。
javascript复制function VirtualList({ items, itemHeight, viewportHeight }) {
const [scrollTop, setScrollTop] = useState(0);
const visibleCount = Math.ceil(viewportHeight / itemHeight);
const startIndex = Math.max(0, Math.floor(scrollTop / itemHeight) - 5); // 多渲染几个做缓冲
const endIndex = Math.min(items.length, startIndex + visibleCount + 10);
const visibleItems = items.slice(startIndex, endIndex);
return (
<div
style={{ height: viewportHeight, overflowY: 'auto' }}
onScroll={e => setScrollTop(e.target.scrollTop)}
>
<div style={{ height: items.length * itemHeight, position: 'relative' }}>
{visibleItems.map((item, index) => (
<div
key={startIndex + index}
style={{
position: 'absolute',
top: (startIndex + index) * itemHeight,
height: itemHeight,
width: '100%',
}}
>
{item}
</div>
))}
</div>
</div>
);
}
如果列表项高度不固定,需要在做完渲染后测量每个 item 的实际高度,用偏移量数组来定位,复杂度高一个档次。如果不是特别复杂的数据,优先尝试固定高度的做法,或者统一限制最大行高,用省略号处理内容溢出。
4.4 防抖节流与 requestAnimationFrame:控制高频事件的执行节奏
用户高频触发的事件(scroll、resize、input、mousemove)如果每次响应都去操作 DOM,主线程会被瞬间打满。防抖(debounce)和节流(throttle)是控制执行频率的两个基础工具:
javascript复制// 防抖:停止触发后 delay 毫秒才执行
function debounce(fn, delay = 300) {
let timer = null;
return function (...args) {
clearTimeout(timer);
timer = setTimeout(() => fn.apply(this, args), delay);
};
}
// 节流:每 delay 毫秒最多执行一次
function throttle(fn, delay = 300) {
let last = 0;
return function (...args) {
const now = Date.now();
if (now - last >= delay) {
last = now;
fn.apply(this, args);
}
};
}
滚动类动画场景,更推荐用 requestAnimationFrame。它能保证函数执行节奏和浏览器刷新频率同步(通常 60fps),而且不会像 setTimeout 一样被事件队列阻塞:
javascript复制function onScroll() {
requestAnimationFrame(() => {
// 更新布局
header.style.transform = `translateY(${window.scrollY}px)`;
});
}
window.addEventListener('scroll', onScroll, { passive: true });
关于动画有一个重要原则:能用 CSS 的 transform 和 opacity 实现的效果,就不要用 JavaScript 去改 top 和 left。前者由合成器单独处理,不会触发回流和重绘,性能表现远优于后者。
5. 内存管理与长任务优化:让页面不卡顿不崩溃
5.1 内存泄漏的常见来源与排查
JavaScript 有自动垃圾回收机制,但这不等于你可以对内存管理掉以轻心。内存泄漏最常见的原因有四个:
- 被遗忘的全局变量。可能是意外把变量挂到了 window 上,也可能是函数内出现了 "use strict" 缺失导致的隐式全局变量。
- 闭包持有的对象。闭包引用外部变量是正常的,但如果闭包被长期保留,并且引用了 DOM 元素,这个 DOM 元素永远无法被回收。
- 事件监听器没有移除。SPA 里频繁切换页面会反复创建和销毁组件,如果组件销毁时没有移除 addEventListener,监听器连同被引用的对象都会驻留内存。
- 定时器没有被清理。setInterval 创建的定时器,组件卸载后如果没有 clearInterval,回调会一直执行,回调里引用的所有东西也都无法释放。
排查方法:打开 DevTools 的 Memory 面板,先录制一次堆快照,然后进行多次页面操作,比如打开关闭弹窗、切换路由、滚动列表,最后再录制一次快照。对比两次快照,重点看 Detached DOM nodes(脱离文档的 DOM 节点)数量和整体堆大小的增长趋势。如果节点数量持续上升且没有回落,基本可以断定有泄漏。
For example,Vue 或 React 项目里最容易出的一个泄漏是:事件总线(Event Bus)上注册了监听但没有在 beforeUnmount/unmount 里移除:
javascript复制// 组件销毁时一定要移除
onMounted(() => {
eventBus.on('refresh', handleRefresh);
});
onBeforeUnmount(() => {
eventBus.off('refresh', handleRefresh);
});
5.2 Web Worker:把计算挪到后台线程
JavaScript 的主线程既要做事件处理,又要跑布局和渲染,如果同时塞进一个几百毫秒的耗计算任务,页面基本就是"卡死"状态。Web Worker 能创建真正的后台线程,把重计算放进 Worker,主线程只负责发起任务和接收结果。
一个 JSON 数据解析和字段聚合的典型场景:
javascript复制// main.js
const worker = new Worker('/worker.js');
worker.postMessage({ data: largeJsonData, type: 'process' });
worker.onmessage = (event) => {
renderTable(event.data.result);
};
// worker.js
self.onmessage = (event) => {
const { data, type } = event.data;
if (type === 'process') {
const result = data.map(item => aggregate(item)); // 耗时操作
self.postMessage({ result });
}
};
Worker 不是万能的,它不能访问 DOM,也不适合处理需要频繁与主线程通信的任务(通信本身有开销)。适合的场景是图片处理、数据解析、复杂计算、加密解码之类的主线程重负载任务。
5.3 长任务的拆分与时间切片
另一个处理长任务的思路是时间切片:把一个长任务拆成多个短任务,每段之间让出主线程给用户交互。最简单的方式是 setTimeout 分批:
javascript复制function processInBatches(items, batchSize = 1000) {
let index = 0;
function processNextBatch() {
const end = Math.min(index + batchSize, items.length);
for (let i = index; i < end; i++) {
processItem(items[i]);
}
index = end;
if (index < items.length) {
setTimeout(processNextBatch, 0);
}
}
processNextBatch();
}
这个方案的问题在于 setTimeout 的最小精度和帧对齐,更好的选择是 requestIdleCallback,它能在浏览器空闲时执行回调:
javascript复制function processWithIdle(items) {
let index = 0;
function processIdle(deadline) {
while (index < items.length && deadline.timeRemaining() > 10) {
processItem(items[index]);
index++;
}
if (index < items.length) {
requestIdleCallback(processIdle);
}
}
requestIdleCallback(processIdle);
}
不过 requestIdleCallback 的兼容性和触发频率在不同浏览器上差别还挺大的,生产环境建议测一下真实表现。无论如何,核心思想是"让出主线程",这比一股脑算完更能留住用户的耐心。
6. 常见问题与排查技巧实录
6.1 性能问题速查表
整理一份我平时排查时的对照表,遇到问题直接按图索骥:
| 症状 | 可能原因 | 排查方向 | 解决方案 |
|---|---|---|---|
| 首屏白屏时间长 | bundle 过大/未压缩/CDN 慢 | Network 面板看耗时构成 | 压缩、代码分割、CDN、走 HTTP 缓存 |
| 滚动卡顿 | 每帧频繁触发回流 | Performance 看 Layout 闪烁 | 用 transform 代替位置属性,加 will-change |
| 点击按钮响应慢 | 主线程在跑长任务 | 看 Long Task 和 CPU 占用 | Worker 迁移任务,时间切片 |
| 页面越用越卡 | 内存泄漏 | Memory 快照对比 | 清理定时器、事件监听器、闭包引用 |
| 列表渲染几千条卡 | DOM 节点过多 | Elements 面板数节点 | 虚拟列表或分批渲染 |
| 动画掉帧 | 重绘/重排开销大 | FPS 指标 | 使用合成属性,降低帧率或改用 CSS 动画 |
6.2 我在实际项目中踩过的几个坑
第一个坑是关于"javascript:void(0)"的。早年很多项目会用 <a href="javascript:void(0)" onclick="..."> 这种方式来做可点击的链接,限制事件逻辑绑定在行内。它的问题不仅是代码混乱不好维护,而且 javascript: 协议会创建新的执行上下文,在某些情况下反而增加额外的解析开销和安全隐患。后来我统一改成给 <a> 写 href="#!" 配合 preventDefault,或者直接用 <button>。void 运算符本身没有问题,但用在 href 里不是好习惯。
第二个坑和 Content-Security-Policy 有关。有些项目开启了严格 CSP,禁止 javascript: 协议的内联代码,如果线上环境突然出现 javascript:void(0); 相关报错,多半是安全策略升级导致的兼容问题。排查时先检查 CSP 头,再看有没有遗留的行内 JS。
第三个坑是在移动端做动效时随手改了 left 和 top。移动端低端机对重排的敏感度比桌面端高得多,有时候一个 100 多个节点的动画就能把帧率打到 20 以下。后来全部换成 transform 之后,帧率直接稳定在 60。
第四个坑来自混合应用场景,比如 OC 和 JavaScript 互相调用。原生和 Web 交互时,WebView 环境的 JavaScript 性能往往比桌面浏览器弱不少。优化策略在纯 Web 项目里可能已经足够,但在 WebView 上还得额外注意:减少大字符串传递,能用 JSON 就别用 base64 图片,原生桥接调用尽量批量处理,避免频繁跨线程通信。
6.3 浏览器工具之外的辅助手段
除了 DevTools 自带的 Performance、Memory 和 Network 面板,我还会用 web-vitals 库把真实用户的性能数据采集上来。Core Web Vitals 的实验室数据只能代表你本地环境的表现,真实用户在不同网络、不同设备上的体验差异非常大。
在页面中引入采集代码:
javascript复制import { onLCP, onINP, onCLS } from 'web-vitals';
onLCP((metric) => {
sendToAnalytics('LCP', metric.value);
});
onINP((metric) => {
sendToAnalytics('INP', metric.value);
});
onCLS((metric) => {
sendToAnalytics('CLS', metric.value);
});
拿到这些数据以后,你就不需要再猜用户到底卡不卡了。
另一个辅助手段是自动化性能预算。很多项目的 CI 流程里已经集成了 Lighthouse CI,每次合代码之前跑一遍,分数低于阈值就禁止合并。这能有效防止性能回归,长期下来比什么会议号和口头要求都可靠。
根据我个人的经验,JavaScript 性能优化最忌讳的就是"什么都想马上优化"。真正高效的路径是:先用数据定位痛点,确定要优化的核心指标,针对性地做一种优化,然后立刻回测对比。一次只动一个变量,才能知道到底哪一步起了作用。不要指望一次大改把所有问题都解决,性能是持续迭代出来的。把这个循环跑起来,你的页面离"飞起来"就不远了。
