JavaScript 性能优化实战指南

27 阅读5分钟

一、先别急着优化:用工具找瓶颈

不要靠感觉优化,先测,再改。

1.1 使用浏览器性能工具

以 Chrome 为例:

  • F12 打开 DevTools

  • Performance 面板 → 点击录制 → 操作页面 → 停止录制
    你可以看到:

    • 哪些函数耗时最长

    • 哪些时间在布局 / 重绘(Layout / Paint)

    • 哪些是长任务(Long Task > 50ms)

1.2 常用几个指标

  • First Contentful Paint (FCP):首屏内容出现时间

  • Largest Contentful Paint (LCP):最大内容块出现时间

  • Total Blocking Time (TBT):主线程被 JS 阻塞的时间

  • Time To Interactive (TTI):页面可交互时间

优化时可以围绕它们做文章:
减少 JS 体积、打碎长任务、延迟不急用的逻辑。

二、减少 JavaScript 体积和执行时间

2.1 按需加载(Code Splitting)

不要一上来就加载全站 JS,可以按页面或功能拆包:

// 只有点击按钮时才加载某模块
button.addEventListener('click', async () => {
  const module = await import('./big-module.js')
  module.run()
})

好处:

  • 首屏 JS 体积减少

  • 加载和解析时间更短

配合 Webpack / Vite / Rollup 等打包工具,可以做到自动按路由拆包。

2.2 减少不必要的 polyfill 和第三方库

  • 只支持现代浏览器时,没必要加载大量 ES5 polyfill;

  • 有些场景完全可以用原生 API 替代:

    // ❌ 引入 lodash 只为了一个 debounce import debounce from 'lodash/debounce'

    // ✅ 自己实现一个简单的即可 function debounce(fn, delay) { let timer = null return function (...args) { clearTimeout(timer) timer = setTimeout(() => fn.apply(this, args), delay) } }

经验法则:能用原生就用原生,实在麻烦再上库,尽量避免为一个小功能引入几十 KB 的依赖。

2.3 减少重复计算和频繁创建对象

  • 如果某个值可以提前算好,就别在循环里一遍遍算:

    // ❌ n 每次循环都从数组长度读取 for (let i = 0; i < arr.length; i++) { // ... }

    // ✅ 缓存长度 for (let i = 0, len = arr.length; i < len; i++) { // ... }

  • 频繁创建临时对象、闭包也会导致 GC 压力升高。
    尽量复用结构或缓冲区,不要在热路径上疯狂 new。

三、优化 DOM 操作:减少重排和重绘

JavaScript 本身很快,真正在拖后腿的往往是 DOM 操作

3.1 批量修改 DOM,而不是一条一条改

// ❌ 每次循环都插入 DOM,触发多次重排
const ul = document.querySelector('ul')
items.forEach(text => {
  const li = document.createElement('li')
  li.textContent = text
  ul.appendChild(li)
})

// ✅ 使用 DocumentFragment 一次性插入
const ul = document.querySelector('ul')
const frag = document.createDocumentFragment()
items.forEach(text => {
  const li = document.createElement('li')
  li.textContent = text
  frag.appendChild(li)
})
ul.appendChild(frag)

3.2 避免读写 DOM 交替

// ❌ 读写交替,会导致浏览器频繁计算布局
div.style.width = (div.offsetWidth + 10) + 'px'
div.style.height = (div.offsetHeight + 10) + 'px'

// ✅ 先读再写
const w = div.offsetWidth
const h = div.offsetHeight
div.style.width = (w + 10) + 'px'
div.style.height = (h + 10) + 'px'

3.3 使用 class 切换代替逐条改样式

// ❌ 多次写 style
el.style.color = 'red'
el.style.fontSize = '14px'
el.style.fontWeight = 'bold'

// ✅ 一次改 class,让 CSS 做剩下的事情
el.classList.add('error')

四、事件与滚动相关优化:防抖与节流

4.1 防抖(debounce)

适合「只在停下来之后触发一次」的场景,比如搜索输入框联想。

function debounce(fn, delay) {
  let timer = null
  return function (...args) {
    clearTimeout(timer)
    timer = setTimeout(() => fn.apply(this, args), delay)
  }
}

window.addEventListener('resize', debounce(() => {
  console.log('窗口改变后 300ms 再执行')
}, 300))

4.2 节流(throttle)

适合持续触发但要限制频率的场景,比如滚动监听:

function throttle(fn, delay) {
  let last = 0
  return function (...args) {
    const now = Date.now()
    if (now - last >= delay) {
      last = now
      fn.apply(this, args)
    }
  }
}

window.addEventListener('scroll', throttle(() => {
  console.log('每 100ms 最多执行一次')
}, 100))

五、避免长任务:让页面“不卡顿”

浏览器主线程如果被一个 JS 任务占用时间太久(> 50ms),就会出现:

  • 点击没反应

  • 滚动卡住

  • 输入延迟

5.1 拆分大循环

// ❌ 一次处理 10 万条数据,卡死 UI
bigArray.forEach(item => heavyCompute(item))

// ✅ 分片处理
function chunkProcess(list, handler, chunkSize = 1000) {
  let index = 0
  function next() {
    const end = Math.min(index + chunkSize, list.length)
    for (; index < end; index++) {
      handler(list[index])
    }
    if (index < list.length) {
      requestIdleCallback(next)  // 或 setTimeout(next, 0)
    }
  }
  next()
}

chunkProcess(bigArray, heavyCompute)

5.2 利用 requestAnimationFrame 做动画

以前我们习惯用 setInterval / setTimeout 做动画,但这会和浏览器的刷新节奏不匹配。

function animate() {
  // 更新位置
  box.style.transform = `translateX(${x}px)`
  x += 2
  if (x < 300) {
    requestAnimationFrame(animate)
  }
}

requestAnimationFrame(animate)

requestAnimationFrame 会在浏览器下一帧绘制前调用,动画更流畅也更省电。

5.3 使用 Web Worker 处理计算密集任务

如果你有复杂的计算(如大数据排序、压缩、加密),可以扔到 Worker:

// main.js
const worker = new Worker('worker.js')
worker.postMessage(bigData)
worker.onmessage = e => {
  console.log('计算结果:', e.data)
}

// worker.js
onmessage = e => {
  const result = heavyCompute(e.data)
  postMessage(result)
}

这样主线程仍然可以保持流畅的交互。

六、内存优化和避免泄漏

内存泄漏会慢慢拖垮页面,严重时浏览器直接崩溃。

6.1 常见泄漏来源

  1. 全局变量 / 挂在 window 上的引用

  2. 定时器 setInterval 没有清理

  3. 事件监听未移除

  4. 闭包中意外持有大对象引用

6.2 解决方案示例

// ❌ setInterval 不清理
setInterval(() => {
  console.log('do something')
}, 1000)

// ✅ 组件销毁或页面离开时清理
const timer = setInterval(...)
window.addEventListener('beforeunload', () => {
  clearInterval(timer)
})


// ❌ 事件监听一直存在
button.addEventListener('click', handleClick)

// ✅ 不需要时移除
button.removeEventListener('click', handleClick)

Chrome DevTools 的 Memory 面板可以帮助你查看堆快照,查找无法被 GC 回收的对象。

七、网络与资源相关优化(与 JS 强相关)

即使 JS 写得很好,如果资源加载慢,首屏一样会很慢。

7.1 按需加载脚本

  • 非首屏必须的脚本使用 deferasync

  • defer: 等 HTML 解析完按顺序执行,适合业务 JS

  • async: 下载完就执行,顺序不保证,适合统计、广告脚本

7.2 懒加载组件 / 路由

前端框架中(React/Vue),对大组件路由做懒加载:

// Vue Router
const PageA = () => import('./pages/PageA.vue')

const routes = [
  { path: '/a', component: PageA }
]

八、数据结构与算法层面的优化

有时候瓶颈不是浏览器,而是你写的算法。

8.1 合理选择数据结构

  • 需要频繁查找:用 Map 或对象字典,而不是 Array.find

  • 需要频繁去重:用 Set

  • 动态增删:避免在数组头部插入删除(unshift/shift 会移动大量元素)。

    // 查找是否存在 // ❌ O(n) const exists = arr.includes(id)

    // ✅ O(1) —— 先构造 Set const set = new Set(arr) const existsFast = set.has(id)

8.2 减少深拷贝

过度使用 JSON.parse(JSON.stringify(obj)) 或各种 deepClone 实现,会很伤性能。
尽量设计数据结构时避免层级过深,必要时只做「局部拷贝」。

九、简单的性能优化 checklist

开发完一个功能,可以按照下面的清单检查一下:

  1. 首屏是否只加载了必需的 JS?

  2. 大模块是否按需加载 / 拆包了?

  3. 高频事件是否加了防抖 / 节流?

  4. DOM 操作是否做了批量处理?是否避免了读写交替?

  5. 是否存在长任务(>50ms)?能否拆分或丢给 Worker?

  6. 定时器、事件监听是否会在不需要时清理?

  7. 是否存在明显的重复计算或深拷贝?

  8. 是否在 DevTools 里看过性能录制和内存快照?

十、结语

JavaScript 性能优化并不是某个“绝招”,而是一套思路:

测瓶颈 → 找最慢的点 → 针对性优化 → 再测