前端开发··2 阅读·预计 13 分钟

JavaScript 性能反模式清单:从闭包泄漏到数组误用的 6 个高频陷阱

引言

JavaScript 性能优化的文章,大多在讲 V8 的隐藏类和内联缓存。这些底层知识很有价值,但日常开发中 80% 的性能问题其实来自更朴素的代码习惯——一个不经意间在循环中创建的函数闭包、一行随手 someArray.filter().map() 的链式调用、一个忘记移除的事件监听。本文不聊引擎内部实现,只聚焦你明天就能在 PR 里改掉的 6 个高频性能反模式。

反模式一:循环内的函数闭包

❌ 错误示范

function processUsers(users) {
  const results = [];
  for (let i = 0; i < users.length; i++) {
    // 每次迭代创建一个新函数闭包,且闭包捕获了整个 processUsers 的词法环境
    const handler = (user) => {
      const enriched = { ...user, timestamp: Date.now() };
      results.push(enriched);
      return enriched;
    };
    handler(users[i]);
  }
  return results;
}

每次循环迭代都在创建一个新的 handler 函数对象,1000 个用户就是 1000 个函数实例。更糟的是,闭包还引用了 results 数组,阻止了潜在的内存优化。

✅ 正确写法

function processUsers(users) {
  const now = Date.now();
  // 函数提升到循环外,只创建一次
  function enrichUser(user) {
    return { ...user, timestamp: now };
  }
  return users.map(enrichUser);
}

⚙️ 更进一步的工厂模式

当你确实需要带参数的闭包时(比如事件绑定的柯里化),用工厂函数在循环外预创建:

// ❌ 错误:循环内 new Function / bind
for (const item of items) {
  element.addEventListener('click', () => handleClick(item));
}

// ✅ 正确:事件委托 + data 属性
element.addEventListener('click', (e) => {
  const index = e.target.dataset.index;
  if (index !== undefined) handleClick(items[index]);
});

反模式二:数组方法链式滥用

❌ 错误示范

// 3 次全量遍历,内存分配了 3 个中间数组
const report = users
  .filter(u => u.isActive)          // Array(n)
  .map(u => ({ name: u.name, age: u.age }))  // Array(m)
  .sort((a, b) => b.age - a.age);   // Array(m) —— sort 原地排序但仍需 GC 前两个

// 对 10 万条数据:~30ms,峰值内存 ~8MB

✅ 用 reduce 一次遍历

const report = users.reduce((acc, u) => {
  if (u.isActive) {
    acc.push({ name: u.name, age: u.age });
  }
  return acc;
}, []).sort((a, b) => b.age - a.age);

// 对 10 万条数据:~12ms,峰值内存 ~3MB

⚙️ 性能基准对比(10k → 100k 元素)

| 数据量  | filter+map+sort (ms) | reduce+sort (ms) | 内存差 |
|---------|----------------------|-------------------|--------|
| 1,000   | 0.9                  | 0.6               | ~40KB  |
| 10,000  | 4.2                  | 2.8               | ~0.8MB |
| 100,000 | 28.7                 | 11.3              | ~12MB  |

决策法则:链式调用 ≤ 2 次且数据 < 1000 条时,可读性优先;超过此阈值使用 reducefor...of

反模式三:字符串拼接的代价

❌ 错误示范

function buildHtml(items) {
  let html = '';
  for (const item of items) {
    html += `<li class="${item.className}">${item.content}</li>`;
  }
  return html;
}
// 每次 += 操作在 V8 中可能触发字符串复制(cons string ≥ 13 字符时裂变)

✅ 数组 join 方案

function buildHtml(items) {
  const parts = [];
  for (const item of items) {
    parts.push(`<li class="${item.className}">${item.content}</li>`);
  }
  return parts.join('');
}
// O(n) 复杂度,只需一次最终分配

现代 V8 对短字符串的 += 有一定优化,但拼接 1000+ 片段时 join 的领先幅度可达 3-5 倍。

反模式四:对象属性访问退化

❌ 错误示范

function computeScore(data) {
  const name = data.person.name;
  const a = data.person.scores.math;
  const b = data.person.scores.english;
  const c = data.person.scores.science;
  // 每次 data.person 都要重新解析原型链
  return a * 0.4 + b * 0.3 + c * 0.3;
}

✅ 局部引用缓存

function computeScore(data) {
  const person = data.person;
  const scores = person.scores;
  // 或用解构一次性提取
  const { math, english, science } = scores;
  return math * 0.4 + english * 0.3 + science * 0.3;
}

虽然现代 JIT 会尝试内联属性访问(inline caching),但在深层嵌套和热点循环中,显式缓存仍然是最稳定的优化——尤其在对象形状(hidden class)可能不一致时。

反模式五:事件监听残留

❌ 错误示范

class InfiniteScroll {
  constructor(container, onLoadMore) {
    this.container = container;
    this.onScroll = () => {
      if (container.scrollTop + container.clientHeight >= container.scrollHeight - 100) {
        onLoadMore();
      }
    };
    container.addEventListener('scroll', this.onScroll);
  }
  // 忘记提供 destroy() 方法
}

// SPA 路由切换后,旧组件的事件监听仍然活着
// 且闭包持有 container 和 onLoadMore 的引用 → 无法 GC

✅ 生命周期闭环

class InfiniteScroll {
  #container = null;
  #onScroll = null;

  constructor(container, onLoadMore) {
    this.#container = container;
    this.#onScroll = () => {
      const { scrollTop, clientHeight, scrollHeight } = container;
      if (scrollTop + clientHeight >= scrollHeight - 100) onLoadMore();
    };
    container.addEventListener('scroll', this.#onScroll, { passive: true });
  }

  destroy() {
    this.#container?.removeEventListener('scroll', this.#onScroll);
    this.#container = null;
    this.#onScroll = null;
  }
}

关键点

  • 使用 { passive: true } 告诉浏览器你不会调用 preventDefault(),允许浏览器并行处理滚动
  • destroy() 中置 null 断开引用链,帮助 GC

反模式六:微任务堆积

❌ 错误示范

async function processQueue(items) {
  for (const item of items) {
    await heavyAsyncWork(item);
    // 每个 await 后的代码都是微任务
    // 1000 个 item = 主线程被 1000 个微任务连续占用
  }
  updateUI(); // 迟迟得不到执行
}

✅ 让出控制权

async function processQueue(items, batchSize = 10) {
  for (let i = 0; i < items.length; i += batchSize) {
    const batch = items.slice(i, i + batchSize);
    await Promise.all(batch.map(heavyAsyncWork));
    // 每批完成后让出主线程
    if (i + batchSize < items.length) {
      await new Promise(r => setTimeout(r, 0));
    }
  }
  updateUI();
}

setTimeout(fn, 0) 将后续执行推到下一个宏任务,浏览器得以在间隙中处理 UI 更新和用户交互。

总结

这 6 个反模式并非玄学——每一个都对应明确的 JavaScript 语义和可观测的性能差异:

反模式根因检测工具
循环闭包函数对象重复创建Chrome DevTools Memory → Allocation sampling
链式遍历中间数组分配Performance 面板火焰图
字符串拼接不可变字符串复制大循环内 .length 的递增耗时
属性退化原型链重复查找console.time/timeEnd 包裹热点
事件残留引用未断开Heap Snapshot → Detached DOM tree
微任务堆积饥饿宏任务Performance → 超长的 microtask 段

性能优化的真正价值不在于扣那几毫秒——而在于你把这些模式内化后,CR(Code Review)阶段就能拦截掉 90% 的性能退化。记住:好的性能不是靠火焰图修出来的,是靠好习惯写出来的。

0 评论

评论区

登录 后参与评论