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 图解见正文。

相关推荐
AlienZHOU3 小时前
AI Coding 时代下,我的技术面试实践分享
前端·后端·面试
Captaincc6 小时前
AI用量v0.1.11更新发布 新增 jusage doctor 诊断指令 托盘展示token 和余额 新增 AutoClaw 支持
前端·后端·vibecoding
计算机魔术师7 小时前
德国Wiki被黑后两周,OpenAI终于把模型失控的账本摊开了
前端
kyriewen8 小时前
我让 AI 当面试官面了我一轮:第 3 个追问我就卡住了(附 10 道追问清单)
前端·面试·ai编程
IT_陈寒8 小时前
Python的GIL把我坑惨了,多线程跑得比单线程还慢
前端·人工智能·后端
前端snow8 小时前
ai agent --- 多agent框架之图编排引擎-langgraph
前端
竹林8188 小时前
OmniPic Studio v3.2.1 核心技术架构与全平台发版解析文档
前端·浏览器
JamesZhang800788 小时前
页面内存只涨不跌? 一次泄漏排查, 牵出 WeakMap 的诞生
前端
Z小明8 小时前
第 6 章 组件进阶
前端·vue.js
江华森8 小时前
HTTP请求的完整过程详解:从DNS解析到TCP挥手的微秒级实战分析
前端