JavaScript 闭包

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

一、什么是闭包

1.1 定义

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

js 复制代码
// === 最简单的闭包示例 ===
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 本质:词法作用域 + 函数作为一等公民

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

条件 说明
① 内部函数引用了外部变量 必须有变量被"捕获"
② 内部函数在外部被调用 超出了定义时的作用域范围
js 复制代码
// 不是闭包的情况:
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 变量私有化(数据封装)

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

js 复制代码
// === 计数器:变量私有化 ===
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 普及之前,闭包是实现模块化的核心手段:

js 复制代码
// === 用闭包实现模块 ===
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)

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

js 复制代码
// === 普通多参数函数 ===
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 其他常见场景

js 复制代码
// === 场景 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 的执行模型:

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

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

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

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

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

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


四、闭包可能导致的问题

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

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

js 复制代码
// === 泄漏示例 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) 在页面生命周期内永远无法释放
js 复制代码
// === 泄漏示例 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 旧时代的噩梦)

js 复制代码
// === 循环引用导致 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)

js 复制代码
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 及时移除事件监听器

js 复制代码
// === 最佳实践:成对出现(添加 ↔ 移除)===
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 避免不必要的闭包

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

// ❌ 不必要的闭包
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 解决强引用问题

js 复制代码
// === 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

6.2 闭包的优缺点总结

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

七、完整流程串联

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

javascript 复制代码
【步骤 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

html 复制代码
<!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)(c));④ 状态保持 (防抖/节流/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 私有字段更省内存,闭包留给需要动态封装的场景

本笔记由「前端面试学习笔记」skill 生成,配套 SVG 图解见正文。

相关推荐
xiaominlaopodaren1 小时前
three.js地图数学基础(一)
前端·three.js
何时梦醒1 小时前
🎯 从零彻底搞懂 React Context API —— 一篇带你穿越"组件树"的状态共享方案
前端·javascript·react.js
渣波1 小时前
拒绝页面假死!React 并发编程实战:Web Worker + useRef 深度解析与高性能计算架构
前端·javascript
用户2930750976691 小时前
Web Worker:让 JavaScript 拥有"多线程"能力
前端
BreezeJiang1 小时前
不要把 Context 当万能状态库:主题共享和鼠标 Hook 应该这样拆
javascript·react.js
渣波1 小时前
赋予 AI “灵魂”:LangChain.js 中临时与长期记忆的终极实战指南
前端·javascript
想要成为糕糕手1 小时前
🧵 浏览器里的第二大脑:Web Worker
javascript·react.js·浏览器
逍遥德2 小时前
ECMAScript 各个版本的语法列表
前端·javascript·ecmascript·es6
用户059540174462 小时前
把AI聊天机器人记忆存储测试从3小时压到5分钟,我用Pytest + Docker搭了一套自动化回归
前端·css