前段时间接手了一个老项目做性能优化,Lighthouse 跑出来 TBT(总阻塞时间)将近 2 秒,用户反馈"点了没反应"。
我一层一层往里查,发现问题几乎全出在 JavaScript 上:高频事件没有节流、DOM 操作在循环里反复查询、缓存策略几乎没有......更别提模块化方案了,CommonJS 和 ES Module 混用,打包出来的 chunk 一塌糊涂。
花了将近一周时间做优化,把所有踩过的坑和解法整理成这篇文章,分三个部分:执行效率、缓存策略、模块化加载。
第一部分:执行效率------让 JS 跑得更快

先说一个心态问题
优化之前我想先说一件事:JS 优化不是每次写代码都要做的事。
过度优化有两个代价:一是让代码可读性变差,二是浪费了大量时间在没有瓶颈的地方。我的原则是先用 Performance 面板找到真正的慢点,再有针对性地优化。盲目优化往往是在做无用功。
真正值得花时间做 JS 优化的时机,通常是项目大改版、性能指标出了问题、或者某段代码已经让团队成员看不懂了。
高频事件:节流和防抖救了我
这是我在那个老项目里发现的最严重的问题之一。scroll 和 resize 事件没有任何限制,每次触发都执行一大堆计算逻辑,滚动的时候 CPU 占用直接飙到 80%。
节流(Throttle) :固定时间间隔内只执行一次,适合需要持续触发但不需要每次都响应的场景。
javascript
function throttle(fn, delay = 100) {
let timer = null;
return function (...args) {
if (timer) return;
timer = setTimeout(() => {
fn.apply(this, args);
timer = null;
}, delay);
};
}
// 窗口 resize,100ms 内最多执行一次
window.addEventListener('resize', throttle(() => {
recalculateLayout();
}, 100));
// 滚动加载,200ms 间隔
window.addEventListener('scroll', throttle(() => {
checkLoadMore();
}, 200));
防抖(Debounce) :事件停止触发后等待一段时间再执行,适合"用户停下来再响应"的场景。
javascript
function debounce(fn, delay = 300) {
let timer = null;
return function (...args) {
clearTimeout(timer);
timer = setTimeout(() => {
fn.apply(this, args);
}, delay);
};
}
// 搜索框输入,用户停止输入 300ms 后才发请求
searchInput.addEventListener('input', debounce((e) => {
fetchSearchResults(e.target.value);
}, 300));
两个很容易混淆,记住这句话就够了:节流是"每隔一段时间执行一次",防抖是"停下来之后执行一次"。
事件委托:DOM 节点多了照样丝滑
之前项目里有个列表,几百个 <li> 每个都绑了 click 事件。几百个事件监听器挂在内存里,列表动态增删的时候还要手动加/移除监听,又慢又容易出 bug。
事件委托的原理是利用事件冒泡,把事件绑定到父元素上,通过 event.target 判断是哪个子元素触发的:
dart
// 错误写法:给每个 li 单独绑定
document.querySelectorAll('.list li').forEach(li => {
li.addEventListener('click', handleItemClick);
});
// 正确写法:委托给父元素
document.querySelector('.list').addEventListener('click', (e) => {
const li = e.target.closest('li');
if (!li) return;
handleItemClick(li);
});
改完之后不管列表有多少项,监听器永远只有一个,动态新增的 <li> 也自动享受同样的事件处理,再也不用在增删节点的时候手动管理监听器了。
在 Vue / React 里这个问题通常框架帮你处理了,但如果你在操作原生 DOM 或者用了虚拟滚动,事件委托的思路依然值得记住。
DOM 操作缓存:循环里查 DOM 是大忌
ini
// 这段代码每次循环都查询一次 DOM,极慢
for (let i = 0; i < 1000; i++) {
document.querySelector('.list').appendChild(createItem(i));
}
// 缓存 DOM 引用,只查一次
const list = document.querySelector('.list');
// 用 DocumentFragment 批量操作,最后一次性插入
const fragment = document.createDocumentFragment();
for (let i = 0; i < 1000; i++) {
fragment.appendChild(createItem(i));
}
list.appendChild(fragment);
同理,循环里的列表长度也要缓存:
ini
// 每次循环都读 .length,数组如果是动态的会有额外开销
for (let i = 0; i < list.length; i++) { ... }
// 缓存长度
const len = list.length;
for (let i = 0; i < len; i++) { ... }
动画:用对工具差距很大
做动画时我曾经很喜欢用 setInterval 控制帧率,后来发现这个做法问题很多:setInterval 的回调执行时机和浏览器的渲染节奏是脱钩的,可能导致掉帧或者在用户看不到的帧里浪费计算。
requestAnimationFrame(rAF)才是正确的选择,它会在浏览器下一次重绘之前执行回调,天然和渲染节奏同步:
ini
// 错误:setInterval 和渲染节奏不同步,容易掉帧
let pos = 0;
setInterval(() => {
pos += 2;
element.style.left = pos + 'px';
}, 16);
// 正确:rAF 跟浏览器渲染节奏同步
let pos = 0;
function animate() {
pos += 2;
element.style.transform = `translateX(${pos}px)`; // 用 transform 而不是 left
if (pos < 300) {
requestAnimationFrame(animate);
}
}
requestAnimationFrame(animate);
注意我把 left 换成了 transform------这和上篇 CSS 文章说的是一回事,transform 只触发合成阶段,不会引发 Layout 重排,性能好得多。
复杂动画场景的选择优先级:CSS 动画 > Canvas 动画 > JS 逐帧动画 。能用 CSS transition / animation 解决的坚决不写 JS,GPU 加速、性能调度都是浏览器自动处理的。
彻底消灭 eval
eval 是我见过被滥用最多的 JS 特性之一,常见场景是动态执行字符串代码、或者用来 parse 一些奇怪的数据格式。
javascript
// 绝对不要这么做
eval('console.log("hello")');
// 也不要用 Function 构造器,本质一样
new Function('return ' + userInput)();
eval 的问题有三个:第一,它会阻止 JS 引擎对代码进行优化(因为 eval 执行的代码在编译时是未知的);第二,它破坏了作用域隔离,有安全风险;第三,调试极其困难。
如果你需要动态执行逻辑,99% 的情况可以用函数映射表替代:
javascript
// 用对象映射替代 eval 动态执行
const handlers = {
greet: (name) => `Hello, ${name}`,
farewell: (name) => `Goodbye, ${name}`,
};
const action = 'greet';
handlers[action]('World'); // 安全、可优化、可调试
第二部分:缓存策略------请求能省则省

先搞清楚手里有哪些武器
浏览器端的存储方案有四个,第一次系统梳理的时候我画了一张对比表:
| 方案 | 容量 | 生命周期 | 是否随请求发送 | 适合存什么 |
|---|---|---|---|---|
| Cookie | ~4KB | 可设置过期时间 | 是(自动) | 登录态、会话信息 |
| SessionStorage | 5~10MB | 标签页关闭即清除 | 否 | 页面间临时传参 |
| LocalStorage | 5~10MB | 永久(手动清除) | 否 | 用户偏好、静态资源缓存 |
| IndexedDB | ≥250MB | 永久(手动清除) | 否 | 大量结构化数据、离线应用 |
选错存储方案代价不小。我见过有人把大量数据塞进 Cookie,结果每个请求头都带着几 KB 的冗余数据,白白浪费带宽。
Cookie:只放必须跟着请求走的数据
Cookie 的核心特点是每次 HTTP 请求都会自动带上,这是它存在的意义------让服务器能识别客户端状态。但也正因为如此,Cookie 里的东西要尽量少:
javascript
// 设置 Cookie(带过期时间和安全属性)
document.cookie = 'userId=12345; max-age=86400; secure; samesite=strict';
// 实际项目里一般不手写这个,用库处理
import Cookies from 'js-cookie';
Cookies.set('token', 'xxx', { expires: 7, secure: true });
登录态 token、用户 ID、埋点标识(比如京东的 jda/jdb/jdc)这类"服务器需要认识你是谁"的数据放 Cookie 是合适的。用户偏好设置、界面状态这类纯客户端用的数据,放 Cookie 纯属浪费。
LocalStorage:静态资源缓存的利器
LocalStorage 是我用得最多的缓存方案,主要用在两个场景:
场景一:接口数据缓存。对于变化不频繁的接口数据(城市列表、配置项、用户基本信息),可以缓存在 LocalStorage 里,再次访问直接读本地,不需要等网络:
javascript
const CACHE_KEY = 'city-list-v1';
const CACHE_TTL = 24 * 60 * 60 * 1000; // 24小时
async function getCityList() {
const cached = localStorage.getItem(CACHE_KEY);
if (cached) {
const { data, timestamp } = JSON.parse(cached);
// 检查是否过期
if (Date.now() - timestamp < CACHE_TTL) {
return data;
}
}
// 缓存不存在或已过期,重新请求
const data = await fetch('/api/cities').then(r => r.json());
localStorage.setItem(CACHE_KEY, JSON.stringify({
data,
timestamp: Date.now()
}));
return data;
}
场景二:静态资源缓存(百度移动端就是这么做的)。把 JS / CSS 文件内容存进 LocalStorage,用 md5 哈希值做版本控制:
javascript
const JS_VERSION = 'a3f4b8c2'; // 构建时注入的文件哈希
const CACHE_KEY = `app-js-${JS_VERSION}`;
async function loadScript() {
const cached = localStorage.getItem(CACHE_KEY);
if (cached) {
// 直接从本地执行,不发网络请求
new Function(cached)();
return;
}
const code = await fetch('/js/app.js').then(r => r.text());
localStorage.setItem(CACHE_KEY, code);
new Function(code)();
}
文件内容变了,哈希值就变了,CACHE_KEY 不同,自动走网络请求并更新缓存。旧版本的缓存可以在更新时主动清理:
javascript
// 清理旧版本缓存
Object.keys(localStorage)
.filter(key => key.startsWith('app-js-') && key !== CACHE_KEY)
.forEach(key => localStorage.removeItem(key));
这套方案在弱网环境下效果非常明显,二次访问几乎是秒开。
IndexedDB:离线应用的基础设施
LocalStorage 有个硬伤:只能存字符串,存复杂数据要序列化/反序列化;而且 5~10MB 的容量限制对复杂应用来说很容易不够用。
IndexedDB 是真正的客户端数据库,支持事务、索引查询、存储容量可达几百 MB。石墨文档的离线编辑功能就是基于 IndexedDB 实现的------用户断网时的操作记录全存在本地,联网后再同步到服务器。
原生 IndexedDB API 比较繁琐,实际项目里推荐用 idb 这个轻量封装库:
php
import { openDB } from 'idb';
const db = await openDB('my-app', 1, {
upgrade(db) {
db.createObjectStore('docs', { keyPath: 'id' });
}
});
// 存储文档
await db.put('docs', { id: 'doc-1', content: '...', updatedAt: Date.now() });
// 读取文档
const doc = await db.get('docs', 'doc-1');
// 离线操作队列:断网时把操作存起来,联网后批量同步
await db.put('pending-ops', {
id: crypto.randomUUID(),
type: 'update',
payload: { docId: 'doc-1', delta: [...] },
createdAt: Date.now()
});
不需要做离线应用的话,IndexedDB 适合存储"量大、结构复杂、频繁读取"的数据,比如聊天记录、本地搜索索引、大型配置数据等。
Vue / React 项目的缓存实践
在框架项目里,缓存通常结合状态管理来做,不是直接操作 Storage。我的常用模式是:
kotlin
// Pinia store 里做请求缓存(Vue 项目)
export const useCityStore = defineStore('city', {
state: () => ({
list: null,
loadedAt: null,
}),
actions: {
async fetchList() {
// 内存里有且未超时,直接返回
if (this.list && Date.now() - this.loadedAt < 5 * 60 * 1000) return;
// 先查 LocalStorage
const cached = localStorage.getItem('city-list');
if (cached) {
const parsed = JSON.parse(cached);
this.list = parsed.data;
this.loadedAt = parsed.timestamp;
return;
}
// 发请求并写入两级缓存
const data = await api.getCityList();
this.list = data;
this.loadedAt = Date.now();
localStorage.setItem('city-list', JSON.stringify({ data, timestamp: this.loadedAt }));
}
}
});
内存(Pinia state)作为一级缓存,LocalStorage 作为二级缓存,请求是最后手段。这样同一个 Tab 内的多次调用走内存,刷新页面走 LocalStorage,只有缓存失效了才真正发请求。
第三部分:模块化------从历史包袱到现代方案

快速过一下历史
JS 模块化的发展是一部"问题驱动"的历史,每种规范都是在解决前一种的缺陷。
CommonJS:Node.js 的模块化方案,同步加载,适合服务端(文件在本地,读取几乎没有延迟)。浏览器环境用不了,因为网络加载是异步的,同步等待会阻塞页面。
javascript
// CommonJS
const fs = require('fs');
const { readFile } = require('./utils');
module.exports = {
getData() { return fs.readFileSync('./data.json'); }
};
AMD(RequireJS) :专门为浏览器设计,异步加载,解决了 CommonJS 无法在浏览器用的问题。但语法比较繁琐,需要提前声明所有依赖。
javascript
// AMD
define(['dep1', 'dep2'], function(dep1, dep2) {
return { doSomething() { dep1.init(); } };
});
CMD(SeaJS) :国内玉伯主导,和 AMD 类似但支持"就近依赖"------在用到的地方才 require,而不是顶部全部声明。
javascript
// CMD
define(function(require, exports) {
// 用到了再 require,而不是顶部全声明
exports.doSomething = function() {
const dep1 = require('./dep1');
dep1.init();
};
});
这两种方案现在基本已经是历史了,了解一下背景即可,新项目不会再用。
ES Module:ES6 标准,现代 JS 模块化的终态。
javascript
// ES Module
import { readFile } from './utils.js';
export const getData = () => readFile('./data.json');
export default class MyClass { ... }
为什么 ES Module 赢了?
不只是因为它是"官方标准",它有几个真正的技术优势:
静态分析 :import 语句必须在模块顶层,不能放在条件语句里。这让打包工具(Vite、Rollup、Webpack)可以在编译时就知道哪些代码被用到,哪些没有,从而做 Tree Shaking(摇树优化)------把用不到的代码直接从 bundle 里删掉。
CommonJS 的 require 是运行时动态执行的,打包工具无法在编译时确定哪些代码会被用到,Tree Shaking 效果大打折扣。
javascript
// CommonJS:打包工具不知道你会用哪些,只能全打进去
const utils = require('./utils');
utils[someVariable](); // 运行时才知道用哪个
// ES Module:编译时就知道只用了 readFile,其他可以摇掉
import { readFile } from './utils.js';
循环依赖处理更好 :CommonJS 的循环依赖会拿到未完成的 exports 对象,容易出 bug;ES Module 处理循环依赖时用的是"活绑定",更符合预期。
浏览器原生支持:现代浏览器直接支持 ES Module,不需要打包也能跑(开发阶段),Vite 就是利用这一点实现秒级启动的。
现代项目的模块化实践
开发阶段:直接写 ES Module,Vite / Webpack 会处理兼容性。
python
// 组件里直接 import,不用想太多
import { ref, computed } from 'vue';
import { useUserStore } from '@/stores/user';
import type { Product } from '@/types';
Tree Shaking 要注意的坑:引入第三方库时,尽量用具名导入而不是默认导入整个库:
javascript
// 会把整个 lodash 打进 bundle(几百 KB)
import _ from 'lodash';
_.debounce(fn, 300);
// 只打入 debounce 这一个函数(几 KB)
import debounce from 'lodash/debounce';
// 或者用 lodash-es(支持 ES Module 的版本,Tree Shaking 更彻底)
import { debounce } from 'lodash-es';
动态 import 做按需加载 :上篇文章聊过路由懒加载,其实 ES Module 的动态 import() 可以用在任何地方:
dart
// 点击按钮才加载地图库(通常几百 KB)
button.addEventListener('click', async () => {
const { default: MapGL } = await import('mapbox-gl');
const map = new MapGL({ container: 'map', ... });
});
// Vue 3 异步组件(配合 Suspense 使用)
const HeavyChart = defineAsyncComponent(() => import('./HeavyChart.vue'));
Vite 的分包策略:默认情况下 Vite 把 node_modules 和业务代码打在一起,大项目需要手动配置分包:
arduino
// vite.config.js
export default {
build: {
rollupOptions: {
output: {
manualChunks: {
// 把 Vue 相关库单独打一个 chunk
'vue-vendor': ['vue', 'vue-router', 'pinia'],
// 把 UI 库单独打
'ui-vendor': ['element-plus'],
// 把工具库单独打
'utils': ['lodash-es', 'dayjs'],
}
}
}
}
};
分包之后不同 chunk 可以独立缓存------UI 库版本没变,浏览器直接用缓存;只有业务代码变了,才需要重新下载业务 chunk。
最后
把这三篇内容串起来,JS 性能优化的核心脉络是:
执行层面 ,高频事件加节流/防抖、DOM 操作批量处理、动画用 rAF + transform、远离 eval;缓存层面 ,根据数据特性选对存储方案,利用 LocalStorage + 哈希版本控制减少重复请求;模块层面,拥抱 ES Module,利用 Tree Shaking 减小 bundle,动态 import 按需加载,合理分包让浏览器缓存发挥最大价值。
这些优化做下来,我们项目的 TBT 从将近 2 秒降到了 400ms 以内,JS 相关的 Lighthouse 得分从 48 分涨到了 82 分。
每个项目的瓶颈不一样,建议先用 Performance 面板找到真正慢的地方,再对症下药,而不是把这篇文章的所有建议全部照搬。