我把 JS 性能优化踩过的坑,全写在这里了

前段时间接手了一个老项目做性能优化,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 面板找到真正慢的地方,再对症下药,而不是把这篇文章的所有建议全部照搬。

相关推荐
SameX3 小时前
HarmonyOS ArkTS 把组件截图保存成 PNG 再调系统分享面板的做法
前端
Swot3 小时前
SvelteKit SSG 发布后,为什么老访客非要强刷才能看到新内容?
前端
鸿蒙开发3 小时前
小程序存档写到一半怎么办?双槽保存与损坏恢复实战
前端·javascript·react.js
知忆_IS4 小时前
TerriaMap 开发实战04:MVT数据自定义符号化 - terriajs源码修改
javascript·webgis·terriamap·terriajs
卸任5 小时前
AI 编程工具越用越多,模型配置越来越乱:用 Magpie 把它们管起来
前端·ai编程
前端一课5 小时前
凝神诀
前端
前端双越老师5 小时前
程序员 1v1 访谈系列第十三期:大龄已育空窗一年如何破局
前端·ai编程·全栈
恋猫de小郭6 小时前
Flutter 大小核优化又被回滚,然后又开新提案?
android·前端·flutter
MyBili6 小时前
Chrome扩展开发入门:从零打造实用浏览器插件
前端·chrome·谷歌浏览器·效率工具·浏览器插件·前端入门·扩展开发
FungLeo6 小时前
成为全栈·Vue 管理后台篇·Vue3 + Vite + Naive UI:后台工程基座怎么搭
前端·vue.js·ui·typescript·vite·前端工程化·biome