首页/文章/八股文

JavaScript 闭包

2026-08-17
526418 分钟
...

摘要:闭包(Closure)是函数与其词法环境(Lexical Environment)的组合——一个函数可以"记住"并访问它被创建时所处的外部作用域中的变量,即使它在该作用域之外被执行。一句话记忆:函数打包了出生地的变量,走到哪都能用。核心要点:① 内部函数引用外部变量即形成闭包;② 闭包实现变量私有化/数据封装/柯里化;③ 潜在风险是内存泄漏和循环引用;④ 避免泄漏需手动解除引用和及时移除监听器。

一、什么是闭包

1.1 定义

一个函数可以"记住"并访问它被创建时所处的外部词法作用域,即使它在该作用域之外被执行,这种现象就叫做闭包。

// === 最简单的闭包示例 ===
function test() {
  const name = 'nana';        // 外部变量

  function t2() {             // 内部函数 —— 这就是闭包!
    console.log(name);         // 访问了外部变量 name
  }

  return t2;                  // 把内部函数返回到外部
}

const fn = test();            // test() 执行完毕,它的作用域本该销毁
fn();                         // 但 t2 仍然能访问到 name → "heyi"
// 为什么?因为 t2 形成了闭包,把 name "打包带走"了

1.2 本质:词法作用域 + 函数作为一等公民

闭包的产生需要两个条件同时满足:

条件说明
① 内部函数引用了外部变量必须有变量被"捕获"
② 内部函数在外部被调用超出了定义时的作用域范围
// 不是闭包的情况:
function foo() {
  const x = 1;
  console.log(x);   // 只是普通的作用域查找,没有形成闭包
}
foo();

// 是闭包的情况:
function bar() {
  const x = 1;
  return function() {   // 内部函数引用了 x
    return x;           // 并且被返回到外部使用
  };
}
const fn = bar();       // fn 持有对 x 的引用 → 闭包形成

二、闭包的核心应用场景

2.1 变量私有化(数据封装)

这是闭包最经典的应用——通过闭包创建"私有变量",外部无法直接修改:

// === 计数器:变量私有化 ===
function createCounter() {
  let count = 0;                    // 私有变量,外部无法直接访问!

  // 返回一个访问器函数(闭包)
  return function() {
    count++;
    return count;
  };
}

const counter1 = createCounter();   // 创建第一个计数器实例
const c1 = counter1();              // 1
const c2 = counter1();              // 2
const c3 = counter1();              // 3

const counter2 = createCounter();   // 创建第二个计数器实例(独立!)
console.log(counter2());            // 1(从 0 开始,不受 counter1 影响)

// 关键点:
// 1. count 是私有的,外部无法直接改写 counter1.count = 999 ❌
// 2. 每次 createCounter() 都创建新的闭包,count 互不影响
// 3. 只有通过返回的函数才能间接操作 count

2.2 模块模式(Module Pattern)

在 ES6 Module 普及之前,闭包是实现模块化的核心手段:

// === 用闭包实现模块 ===
const UserModule = (function() {
  // 私有变量
  let _users = [];
  let _idCounter = 0;

  // 私有函数
  function _generateId() {
    return ++_idCounter;
  }

  // 公共 API(通过闭包暴露)
  return {
    add(name) {
      const user = { id: _generateId(), name };
      _users.push(user);
      return user;
    },
    getById(id) {
      return _users.find(u => u.id === id);
    },
    getAll() {
      return [..._users];          // 返回副本,防止外部篡改
    },
    get count() {                  // getter
      return _users.length;
    }
  };
})();

UserModule.add('Alice');
UserModule.add('Bob');
console.log(UserModule.count);     // 2
console.log(UserModule._users);    // undefined(私有!)

2.3 函数柯里化(Currying)

柯里化是闭包的经典应用——将多参数函数转化为一系列单参数嵌套函数:

// === 普通多参数函数 ===
function add(a, b, c) {           // Identifier 'add' has already been declared
  return a + b + c;
}
add(1, 2, 3);                     // 6

// === 柯里化版本:add(1)(2)(3) ===
function add(a) {
  return function(b) {
    return function(c) {
      return a + b + c;           // a 和 b 通过闭包被"记住"
    };
  };
}

add(1)(2)(3);                     // 6

// 实际应用:预设参数
const add10 = add(10);            // 预设 a = 10
add10(5)(3);                      // 18
const add10And20 = add10(20);     // 预设 a=10, b=20
add10And20(5);                    // 35

2.4 其他常见场景

// === 场景 1:防抖 / 节流 ===
function debounce(fn, delay) {
  let timer = null;               // 闭包保存 timer 状态
  return function(...args) {
    clearTimeout(timer);
    timer = setTimeout(() => fn.apply(this, args), delay);
  };
}

// === 场景 2:React Hooks 的本质就是闭包 ===
function useState(initialValue) {
  let state = initialValue;        // 闭包持有状态
  function setState(newValue) {
    state = typeof newValue === 'function' ? newValue(state) : newValue;
    render();                     // 触发重新渲染
  }
  return [state, setState];       // 返回状态和更新函数
}

// === 场景 3:迭代器 / 生成器模拟 ===
function createIterator(arr) {
  let index = 0;                  // 闭包保存游标位置
  return {
    next() {
      return index < arr.length
        ? { value: arr[index++], done: false }
        : { value: undefined, done: true };
    },
    reset() { index = 0; }
  };
}

三、闭包的底层原理

3.1 执行上下文与作用域链

理解闭包需要先理解 JS 的执行模型:

┌─────────────────────────────────────────────┐
全局执行上下文 (Global EC)                   │
│  ┌───────────────────────────────────────┐  │
│  │  函数 createCounter 的执行上下文         │  │
│  │  ┌─────────────────────────────────┐  │  │
│  │  │  匿名返回函数的执行上下文 (闭包)     │  │  │
│  │  │                                 │  │  │
│  │  │  变量查找顺序:                    │  │  │
│  │  │  自身外层(createCounter) → 全局│  │  │
│  │  └─────────────────────────────────┘  │  │
│  │                                       │  │
│  │  Variable Environment: { count }      │  │
│  └───────────────────────────────────────┘  │
│                                             │
Variable Environment: { createCounter, ...}│
└─────────────────────────────────────────────┘

3.2 闭包的本质:[[Environment]] 引用

每个 JS 函数对象内部都有一个隐藏属性 [[Environment]],指向其定义时的词法环境

// 伪代码表示闭包的内部结构
function createCounter() {
  let count = 0;

  // 返回的匿名函数内部大致长这样:
  // {
  //   code: function() { count++; return count; },
  //   [[Environment]]: {           // ← 词法环境引用
  //     count: 0,                 // ← 被捕获的变量
  //     outer: <GlobalEnv>
  //   }
  // }
  return function() {
    count++;
    return count;
  };
}

关键点:只要存在对闭包函数的引用,[[Environment]] 就不会被垃圾回收,里面的变量就一直存活。


四、闭包可能导致的问题

4.1 内存泄漏(最常见问题)

闭包会阻止外部变量的垃圾回收——如果不再需要的闭包没有被释放,内存就会持续增长:

// === 泄漏示例 1:超大闭包 ===
function leakyHandler() {
  const hugeData = new Array(100000).fill('x');  // ~400KB 数据
  // hugeData 被闭包捕获,只要 handler 存在就不会被回收

  return function handler() {
    console.log('clicked');   // 根本没用 hugeData,但它还是被闭包持有!
  };
}

const handler = leakyHandler();
document.getElementById('btn').addEventListener('click', handler);
// 结果:hugeData (~400KB) 在页面生命周期内永远无法释放
// === 泄漏示例 2:循环中的闭包(经典面试题)===
// ❌ 错误:所有回调共享同一个 i(var 的作用域问题)
for (var i = 0; i < 3; i++) {
  setTimeout(function() {
    console.log(i);    // 输出:3, 3, 3(不是 0, 1, 2!)
  }, 100);
}
// 原因:var i 是函数作用域,三个闭包引用的是同一个 i,循环结束后 i=3

// ✅ 修复方式 1:let 块级作用域(每次循环创建新绑定)
for (let i = 0; i < 3; i++) {
  setTimeout(function() {
    console.log(i);    // 输出:0, 1, 2 ✅
  }, 100);
}

// ✅ 修复方式 2:IIFE 创建独立作用域
for (var i = 0; i < 3; i++) {
  (function(j) {
    setTimeout(function() {
      console.log(j);  // 输出:0, 1, 2 ✅
    }, 100);
  })(i);
}

4.2 循环引用(IE 旧时代的噩梦)

// === 循环引用导致 DOM 无法回收 ===
function setup() {
  const element = document.getElementById('myDiv');

  element.onclick = function() {
    // 这个闭包引用了 element
    // element 又通过 onclick 引用了这个函数
    // → 循环引用!
    console.log(element.id);
  };
}

// 在现代 V8/SpiderMonkey 中,GC 能处理这种循环引用
// 但在旧版 IE(<9)中会导致 DOM 元素永远不被回收

4.3 性能开销

闭包不是免费的:

| 开销项 | 说明 | | --- | --- | --- | | 额外内存 | 每个闭包都携带 [[Environment]] 引用链 | | 查找耗时 | 变量访问需要沿作用域链逐层查找(V8 有优化但非零成本) | | 优化限制 | V8 对闭包变量的某些优化(如内联缓存)受限 | ```js |

// 性能敏感场景下避免不必要的闭包
// ❌ 循环中反复创建闭包
function slow(arr) {
  const result = [];
  for (let i = 0; i < arr.length; i++) {
    result.push(() => arr[i] * 2);   // 每次迭代创建新闭包
  }
  return result;
}

// ✅ 如果不需要闭包,就不要用
function fast(arr) {
  return arr.map(x => x * 2);        // 无闭包,更轻量
}

五、如何避免闭包导致的内存泄漏

5.1 手动解除引用(= null)

function setupLeakFree() {
  const element = document.getElementById('myDiv');
  const hugeData = new Array(100000).fill('x');

  element.onclick = function handleClick() {
    console.log(element.id);         // 只用到 element,不用 hugeData
    // hugeData 不小心被闭包捕获了
  };

  // ✅ 解决:手动切断不需要的外部变量引用
  // 方法 A:赋值 null
  hugeData = null;                   // 释放大对象的引用

  // 方法 B:用局部变量缩小闭包范围
  // const id = element.id;           // 只提取需要的值
  // element.onclick = () => console.log(id);
}

// 更彻底的方式:用完后主动置空
let handler = setupLeakFree();
// ... 页面卸载或组件销毁时
handler = null;                      // 断开引用 → GC 可以回收

5.2 及时移除事件监听器

// === 最佳实践:成对出现(添加 ↔ 移除)===
class Component {
  constructor(el) {
    this.el = el;
    this.handleClick = this.onClick.bind(this);

    el.addEventListener('click', this.handleClick);
  }

  onClick() {
    console.log('clicked');
  }

  destroy() {
    // ⚠️ 关键:移除监听器!否则 this.el 和 this 永远不释放
    this.el.removeEventListener('click', this.handleClick);
    this.el = null;                  // 断开 DOM 引用
    this.handleClick = null;         // 断开函数引用
  }
}

// React 中对应的是 useEffect cleanup
useEffect(() => {
  const handler = () => { /* ... */ };
  window.addEventListener('resize', handler);
  return () => window.removeEventListener('resize', handler);  // 清理!
}, []);

5.3 避免不必要的闭包

// === 原则:不需要访问外部变量就不要放在闭包位置 ===

// ❌ 不必要的闭包
function processData(items) {
  const config = { debug: true };    // 被闭包捕获但不一定需要

  return items.map(function(item) {
    if (config.debug) {              // 闭包引用了 config
      console.log(item);
    }
    return item * 2;
  });
}

// ✅ 把不变量提到参数或常量
const DEBUG = true;                  // 模块级常量,不在闭包链上
function processData(items) {
  return items.map(item => {
    if (DEBUG) console.log(item);
    return item * 2;
  });
}

5.4 WeakMap 解决强引用问题

// === WeakMap:弱引用,不阻止 GC ===
const privateData = new WeakMap();

class User {
  constructor(name) {
    this.name = name;
    privateData.set(this, {         // 以实例为 key(弱引用)
      createdAt: Date.now(),
      token: generateToken()
    });
  }

  getToken() {
    return privateData.get(this)?.token;
  }
}

//  User 实例被 GC 回收时privateData 中对应的条目自动清除
// 这是闭包 + 私有数据的现代化替代方案

六、方案对比

6.1 闭包 vs 其他实现私有变量的方式

| 方案 | 私有性 | 继承共享 | 内存效率 | 复杂度 | | --- | --- | --- | --- | --- | --- | | 闭包(工厂函数) | ✅ 真正私有 | ❌ 每实例独立副本 | 一般(每实例一套闭包) | 低 | | # 私有字段(ES2022) | ✅ 真正私有 | ✅ 原型共享 | 高(最优) | 极低 | | _ 约定(下划线) | ❌ 假私有(可访问) | ✅ 原型共享 | 最高 | 最低 | | WeakMap | ✅ 真正私有 | ✅ 外部 Map 管理 | 高 | 中 | | Symbol | ❌ 可通过 Reflect.ownKeys 发现 | ✅ | 高 | 中 | ```js |

// ES2022 # 私有字段(推荐现代项目使用)
class Counter {
  #count = 0;                       // 真正私有,外部完全不可访问

  increment() { return ++this.#count; }
  get value() { return this.#count; }
}

const c = new Counter();
c.#count;                          // SyntaxError! 私有字段
c.increment();                     // 1
维度详情
优点① 变量私有化 / 数据封装 ② 实现柯里化 / 偏函数 ③ 模块模式 ④ 防抖节流 / 状态保持 ⑤ React Hooks 的基础
缺点① 内存泄漏风险(外部变量无法被回收)② 循环引用(旧 IE 问题)③ 变量查找性能开销 ④ 调试困难(闭包变量不易观察)
适用场景需要保持状态的工厂函数、事件回调、异步回调、模块封装
避免场景简单工具函数、性能热点代码、不需要外部变量的纯函数

七、完整流程串联

下面用一个完整的例子走通「闭包创建 → 使用 → 泄漏 → 修复」的全过程:

步骤 1定义外部函数
  function factory() {
    let secret = 'xxx';            // 将被闭包捕获的变量

    return {
      get() { return secret; },    // 闭包 A:读取 secret
      set(v) { secret = v; },      // 闭包 B:写入 secret
      reset() { secret = null; }   // 闭包 C:清空 secret
    };
  }

步骤 2调用工厂获得闭包实例
  const instance = factory();
  // factory() 执行完毕,但其内部的 secret 被 3 个闭包引用
  // → secret 不会随 factory 的执行上下文销毁而消失

步骤 3通过闭包操作私有数据
  instance.get();                  // 'xxx'
  instance.set('new value');       // 修改成功
  instance.get();                  // 'new value'

步骤 4不再需要时手动释放
  instance = null;                 // 3 个闭包函数全部断开引用
  // → secret 现在没有任何引用可达
  // → GC 下一次运行时回收 secret

步骤 5如果忘记释放泄漏!)
  // instance 被全局变量 / DOM 事件 / 定时器长期持有
  // → secret 永远不释放
  // → 如果 secret 是大对象 → 内存持续增长

最小可运行的完整 Demo

<!DOCTYPE html>
<html>
<body>
  <button id="btn">点击计数</button>
  <span id="display">0</span>

  <script>
    // 1. 用闭包创建一个安全的计数器
    function createSafeCounter(displayEl) {
      let count = 0;                // 私有变量

      function increment() {        // 闭包函数
        count++;
        displayEl.textContent = count;
        console.log(`count = ${count} (私有变量,外部无法直接改)`);
      }

      return increment;             // 返回闭包
    }

    // 2. 绑定到按钮
    const btn = document.getElementById('btn');
    const display = document.getElementById('display');
    const counter = createSafeCounter(display);   // 创建闭包实例

    btn.addEventListener('click', counter);

    // 3. 演示安全性和独立性
    const counter2 = createSafeCounter(Object.assign({}, display));
    // counter 和 counter2 各自拥有独立的 count

    // 4. 页面卸载时清理(防泄漏)
    window.addEventListener('unload', () => {
      btn.removeEventListener('click', counter);
      // counter 的引用在这里断开 → GC 可回收
    });

    // 验证私有性:
    // console.log(count);          // ReferenceError! 外部无法访问
    // counter.count = 999;         // 无效!count 不在 counter 上
  </script>
</body>
</html>

八、面试高频 Q&A

Q1:什么是闭包?用一句话解释。

A:闭包是函数 + 它被定义时的词法环境的组合。当一个函数能够"记住"并访问它所在作用域的变量,即使在其定义的作用域之外执行,就形成了闭包。本质是函数内部的 [[Environment]] 指向了外部词法环境。

Q2:闭包有哪些实际用途?

A:五大用途——① 变量私有化(createCounter 工厂函数);② 模块模式(IIFE 封装私有 API);③ 函数柯里化(add(a)(b)©);④ 状态保持(防抖/节流/React setState);⑤ 回调函数(事件处理/异步操作中访问外层变量)。

Q3:闭包为什么会导致内存泄漏?如何解决?

A:因为闭包保持了对外部变量的引用,使得这些变量无法被垃圾回收。解决方案:(1) 手动置空 variable = null;(2) 及时移除事件监听器 removeEventListener;(3) 组件销毁时清理(React useEffect return 清理函数);(4) 避免不必要的闭包(不需要外部变量就不放在闭包位置);(5) 用 WeakMap 替代强引用

Q4:循环中的闭包为什么输出全是一样的值?(经典题)

A:var函数作用域,循环中的 var i 只有一个,所有 setTimeout 回调共享同一个 i,当回调执行时循环已结束,i 已是最终值。修复方法:(1) let 替代 var(let 是块级作用域,每次迭代创建新绑定);(2) 用 IIFE (function(j){...})(i) 为每次迭代创建独立作用域。

Q5:ES2022 的 # 私有字段和闭包实现的私有变量有什么区别?

A:# 私有字段是语言级别的真私有(语法层面禁止外部访问),且存储在原型上(所有实例共享一份方法),内存效率更高;闭包私有变量是运行时层面的(通过作用域隐藏),每实例各自持有一份闭包副本。新项目优先用 # 字段,需要动态私有数据或兼容旧环境时用闭包。


九、记忆口诀

  • 函数打包出生地:闭包 = 函数 + 出生时的词法环境,走到哪都能用
  • 私有变量靠它藏:createCounter 让 count 成私有,外部改不了
  • 循环 var 要注意:var 共享同一个 i,改 let 或 IIFE 就行
  • 不用赶紧断引用removeEventListener + = null,别让大对象赖着不走
  • 能用 # 号就用 #:ES2022 私有字段更省内存,闭包留给需要动态封装的场景

如果您觉得这篇文章有帮助,请点个赞吧~

分享文章

相关文章

更多文章 →
八股文2026-08-27
定时器按顺序播放多个音频,切后台音频会乱
本身不会补触发,但 它会被系统级冻结 ——iOS Safari 后台完全停止计时,切回前台后 只补执行一次 (不是不补,而是"缺失的中间状态补不上")。间隔短的不会出大问题, 间隔长的会出现"音频流被压缩" ——切回来后本该播 5 分钟的间隙,实际只过了 3 分钟,结果整段对不上。 核心原则 :定时器只能用来"提醒一次", 真正决定"该不该播"的是绝对时间戳 。 setTimeout 在后台会发生什么(按平台) | 平台 | 后台行为...
面试
八股文2026-08-27
线上项目白屏的原因
白屏的本质是 渲染管线某一环断了 ——可能是资源、JS、接口、路由、样式、兼容性任一环节出问题。排查按"控制台 → 网络 → DOM → 环境"四步定位。 本质(前端类比) 把网页想成一栋楼: 白屏 ≠ 一定是同一种原因——这是面试想听的层次。 六大类原因(按出现频率) | 类别 | 典型表现 | 真实案例 | | : | : | : | | ① 资源加载失败 | DOM 是空的 | 入口 JS 404、CDN 挂了、CSS 阻塞 |...
面试
八股文2026-08-27
背景图就是 1MB 大图,怎么优化
1MB 大背景图优化分 三步 :压缩体积(10 30x)、按需加载(按设备/视口/网速)、渲染期优化(GPU 合成)。背景图跟 不同——它是 CSS,不走浏览器的原生懒加载机制,得手动处理。 背景图 vs 的关键区别(先讲清楚这个) 这是面试官想听的"针对性认知"——背景图不是普通图片,不能套通用方案。 三步优化(按优先级) 第 1 步:压缩体积(最重要,立竿见影) 1MB 的来源一般是这几种 ,对应解决方案: | 原始问题 | 体积来...
面试
八股文2026-08-27
项目里很多图片和视频,怎么优化
图片视频优化分 四层 :网络层(CDN/格式)、加载层(懒加载/预加载)、渲染层(解码/缓存)、业务层(按需/降级)。面试要把这四层都讲到位才算有体系。 四层优化模型 第 1 层:网络层(省钱、省时间) 核心目标:让资源体积小、让用户拿到资源快 | 手段 | 作用 | 关键点 | | : | : | : | | 图片格式 | WebP/AVIF 比 JPEG 小 25 50% | 兼容 fallback | | 视频格式 | H.265...
面试
八股文2026-08-20
Embedding 向量模型:从语义表示到相似度计算
前言 大模型「读懂」文字靠的是 token,但 token 之间只有离散的编号关系,模型并不知道「苹果」和「李子」在语义上很近。要让程序能「理解」两段文字的相似程度,必须先把文本映射成一个 高维向量 ,再用几何方法比较。这一步就是 Embedding。 本篇基于我本地 今天的真实代码,从语义表示讲到余弦相似度,并复盘几个踩过的真实坑。 一、为什么需要 Embedding 传统关键词检索是「字面匹配」: Embedding 做的是「语义匹...
面试
八股文2026-08-19
前端面试100题
前端面试 100 题 适用方向:中高级前端 / React / Vue / Next.js / Nuxt / TypeScript / 工程化 / 实时通信 / Electron / Node.js / AI 应用前端 使用方式:优先掌握“标准回答”,再练“面试官追问”,最后把“结合你的简历怎么答”组织成自己的项目故事。 说明 “结合你的简历怎么答”只使用你简历中已经出现的项目与技术事实。 “标准回答 / 追问”属于通用前端知识总结,用...
面试

评论

请登录后发表评论

去登录
加载评论中...

目录