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

前段时间接手了一个老项目做性能优化,Lighthouse 跑出来 TBT(总阻塞时间)将近 2 秒,用户反馈"点了没反应"。

我一层一层往里查,发现问题几乎全出在 JavaScript 上:高频事件没有节流、DOM 操作在循环里反复查询、缓存策略几乎没有......更别提模块化方案了,CommonJS 和 ES Module 混用,打包出来的 chunk 一塌糊涂。

花了将近一周时间做优化,把所有踩过的坑和解法整理成这篇文章,分三个部分:执行效率、缓存策略、模块化加载。


第一部分:执行效率------让 JS 跑得更快

先说一个心态问题

优化之前我想先说一件事:JS 优化不是每次写代码都要做的事

过度优化有两个代价:一是让代码可读性变差,二是浪费了大量时间在没有瓶颈的地方。我的原则是先用 Performance 面板找到真正的慢点,再有针对性地优化。盲目优化往往是在做无用功。

真正值得花时间做 JS 优化的时机,通常是项目大改版、性能指标出了问题、或者某段代码已经让团队成员看不懂了。

高频事件:节流和防抖救了我

这是我在那个老项目里发现的最严重的问题之一。scrollresize 事件没有任何限制,每次触发都执行一大堆计算逻辑,滚动的时候 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 面板找到真正慢的地方,再对症下药,而不是把这篇文章的所有建议全部照搬。

相关推荐
用户21816970493020 小时前
Flutter(三)Dart语法 String int/num/double bool List map dynamic
前端
用户5268356779021 小时前
IaC 运维实战:基于 OpenTofu / Terraform Hook 的云基础设施变更物理现场声光反馈架构
前端
雪隐21 小时前
个人电脑玩AI-14让5060 Ti给你打工——给 Whisper 字幕工具加上说话人分离:pyannote.audio 实战与踩坑记
前端·人工智能·后端
小当家.10521 小时前
Taste Skill:88KB 提示词如何让 AI 写的 UI 不再像流水线罐头
前端·人工智能·ui·skill
罗超驿21 小时前
3.SpringBoot快速上手:从零搭建你的第一个Web应用
前端·spring boot·后端
时光不负努力21 小时前
tailwind 速记
前端
醉城夜风~21 小时前
HTML列表标签学习博客:有序列表、无序列表、自定义列表详解
前端·学习·html
muddjsv21 小时前
CSS 盒模型完全指南:content-box 与 border-box 尺寸计算原理
前端·css
米码收割机21 小时前
【前端】html+css 甘肃天水旅游(源码+文档)【独一无二】
前端·css·html
ITmaster07311 天前
从前端到AI工程师:一场跨越鸿沟的真实蜕变之旅
前端·人工智能