彻底搞懂 JavaScript 闭包:从作用域链到内存泄漏的完全指南

先看一段几乎每个 JavaScript 面试都会出现的代码,如果你能一眼说清它的输出,以及为什么,那这篇文章对你而言是复习;如果你还会犹豫,那它会帮你把"闭包"这件事彻底钉死在脑子里。

for (var i = 0; i < 3; i++) {
  setTimeout(function () {
    console.log(i);
  }, 0);
}
// 输出:3 3 3 —— 而不是 0 1 2

很多人知道"答案是 3 3 3",但说不清机制。讲清楚它,需要把作用域、作用域链、执行上下文、闭包这四个概念串成一条线。本文就沿着这条线走一遍,然后落到真实工程里:闭包能帮你做什么,又会在哪里悄悄吃掉你的内存。

作用域:变量"住"在哪里

JavaScript 采用词法作用域(lexical scope,也叫静态作用域)。"词法"的意思是:一个变量的作用域,在你写代码的那一刻就已经确定了,跟函数在哪里被调用无关,只跟它在哪里被定义有关。这一点是理解闭包的地基。

var value = 'global';

function outer() {
  var value = 'outer';
  function inner() {
    console.log(value); // 'outer',不是 'global'
  }
  return inner;
}

var fn = outer();
fn(); // 即使在全局调用,inner 找的依然是 outer 里的 value

inner 在查找 value 时,先看自己的作用域(没有),再看它定义时所在的外层作用域 outer(找到了)。这条"自己 → 外层 → 再外层 → 全局"的查找链路,就是作用域链。注意:它是按代码书写的嵌套结构连起来的,不是按调用栈连起来的。

函数作用域 vs 块级作用域

ES6 之前,JavaScript 只有函数作用域和全局作用域,var 声明的变量会被提升到所在函数的顶部。ES6 引入 let / const 带来了块级作用域 —— 一对花括号 {} 就是一个独立作用域。这正是文章开头那段代码的关键。

function f() {
  console.log(a); // undefined(var 提升了声明,没提升赋值)
  var a = 1;
  {
    let b = 2;
  }
  console.log(b); // ReferenceError: b is not defined
}

执行上下文与变量对象

函数每次被调用,引擎都会为这次调用创建一个"执行上下文"(Execution Context),里面包含:这次调用的变量环境(局部变量、参数)、对外层作用域的引用、this 绑定等。函数执行完,这个上下文通常会被销毁,里面的局部变量随之被垃圾回收。

"通常"两个字是重点。如果一个内层函数在外层函数执行完之后仍然能被访问到(比如被返回出去、被赋值给外部变量、被注册成回调),那么它依赖的那部分外层变量环境就不能被销毁 —— 引擎必须把它"留住"。这个"被留住的、内层函数 + 它引用的外层变量环境"的组合,就是闭包

function createCounter() {
  let count = 0;          // 本该随 createCounter 调用结束被回收
  return function () {
    count += 1;           // 但这里还在用它,所以它被"留住"了
    return count;
  };
}

const next = createCounter();
console.log(next()); // 1
console.log(next()); // 2
console.log(next()); // 3 —— count 一直活着,且外部无法直接访问

所以一句话定义:闭包是函数和其词法环境的捆绑。只要一个函数"记得"并能访问它出生时所在的作用域,哪怕在别处执行,它就带着一个闭包。

回到开头:为什么是 3 3 3

现在拆解开头那段代码。var i 是函数作用域(这里是全局),整个循环只有一个 i。三个 setTimeout 的回调都是闭包,它们捆绑的是同一个 i。循环跑完,i 变成 3。等到事件循环把回调取出来执行时,它们读到的自然都是 3。

修复方式有三种,本质都是"为每次迭代制造一个独立的绑定":

// 方案 1:用 let,块级作用域,每次迭代是一个新的 i
for (let i = 0; i < 3; i++) {
  setTimeout(() => console.log(i), 0); // 0 1 2
}

// 方案 2:用 IIFE 立即执行函数,手动制造一层作用域
for (var i = 0; i < 3; i++) {
  (function (j) {
    setTimeout(() => console.log(j), 0); // 0 1 2
  })(i);
}

// 方案 3:利用 setTimeout 的第三个参数把当前 i 传进去
for (var i = 0; i < 3; i++) {
  setTimeout((j) => console.log(j), 0, i); // 0 1 2
}

方案 1 之所以有效,是因为 letfor 循环里有特殊处理:每一次迭代,引擎都会创建一个新的词法环境,并把上一轮的 i 值拷贝进来。于是三个回调捆绑的是三个不同的 i

闭包在工程里到底拿来干什么

1. 模块模式与私有变量

ES6 模块出现之前,闭包是 JavaScript 实现"私有成员"的唯一手段,至今在很多库里还能见到。

const userStore = (function () {
  let users = [];               // 私有,外部拿不到
  let nextId = 1;

  return {
    add(name) {
      const u = { id: nextId++, name };
      users.push(u);
      return u.id;
    },
    remove(id) {
      users = users.filter((u) => u.id !== id);
    },
    count() {
      return users.length;
    },
  };
})();

userStore.add('Alice');
userStore.add('Bob');
console.log(userStore.count()); // 2
console.log(userStore.users);   // undefined —— 真正的封装

2. 函数工厂与柯里化

闭包让函数可以"记住"配置,从而批量生产定制化的函数。

function makeMultiplier(factor) {
  return (n) => n * factor;
}
const double = makeMultiplier(2);
const triple = makeMultiplier(3);
console.log(double(10), triple(10)); // 20 30

// 柯里化:把多参函数变成一串单参函数
function curry(fn) {
  return function curried(...args) {
    if (args.length >= fn.length) return fn.apply(this, args);
    return (...rest) => curried.apply(this, args.concat(rest));
  };
}
const add3 = curry((a, b, c) => a + b + c);
console.log(add3(1)(2)(3)); // 6
console.log(add3(1, 2)(3)); // 6

3. 防抖与节流

前端最常见的两个闭包应用。它们都需要在多次调用之间"记住"某个状态(定时器 ID、上次执行时间),这个状态必须私有,闭包正合适。

function debounce(fn, delay) {
  let timer = null;                  // 被闭包留住的私有状态
  return function (...args) {
    clearTimeout(timer);
    timer = setTimeout(() => fn.apply(this, args), delay);
  };
}

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

const onScroll = throttle(() => console.log('scrolling'), 200);
window.addEventListener('scroll', onScroll);

4. 记忆化(memoization)

用闭包缓存计算结果,避免重复运算。

function memoize(fn) {
  const cache = new Map();
  return function (...args) {
    const key = JSON.stringify(args);
    if (cache.has(key)) return cache.get(key);
    const result = fn.apply(this, args);
    cache.set(key, result);
    return result;
  };
}

const slowFib = (n) => (n < 2 ? n : slowFib(n - 1) + slowFib(n - 2));
const fastFib = memoize(function fib(n) {
  return n < 2 ? n : fastFib(n - 1) + fastFib(n - 2);
});
console.log(fastFib(40)); // 瞬间出结果,而 slowFib(40) 会卡很久

闭包的另一面:内存泄漏

闭包"留住变量"的能力是把双刃剑。被留住的变量不会被垃圾回收,如果留住的是大对象、DOM 节点,而闭包本身又长期存在,内存就会一直涨。

典型场景 1:闭包意外捕获大对象

function createHandler() {
  const bigData = new Array(1000000).fill('x'); // 占用大量内存
  const id = bigData.length;
  // 这个返回的函数只用到了 id,但因为和 bigData 在同一个作用域,
  // 很多引擎实现会把整个作用域留住,bigData 也跟着活下来
  return function () {
    return id;
  };
}
// 更安全的写法:只把需要的值提取出来
function createHandlerSafe() {
  const bigData = new Array(1000000).fill('x');
  const id = bigData.length;
  return (function (capturedId) {
    return () => capturedId;
  })(id);
}

典型场景 2:定时器忘记清理

function startPolling(node) {
  const timer = setInterval(() => {
    // 闭包引用了 node,只要 timer 没清,node 就无法回收
    node.textContent = new Date().toISOString();
  }, 1000);
  // 必须提供一个停止入口,否则 node 即使从 DOM 移除也泄漏
  return () => clearInterval(timer);
}
const stop = startPolling(document.getElementById('clock'));
// 组件卸载时务必调用 stop()

典型场景 3:事件监听与 DOM 引用循环

function bind() {
  const el = document.getElementById('btn');
  const data = { huge: new Array(500000) };
  el.addEventListener('click', function () {
    console.log(data.huge.length); // 闭包持有 data 和 el
  });
  // el 移除前要 removeEventListener,否则 el 和 data 都泄漏
}

规律很清晰:凡是"长寿命"的闭包(定时器回调、事件监听、全局缓存),都要警惕它捕获了什么。捕获了 DOM 或大对象,就要有对应的清理路径。

用 DevTools 排查闭包泄漏

Chrome DevTools 的 Memory 面板能直接看到闭包占了多少内存。操作步骤:

  1. 打开 Memory 面板,选择 "Heap snapshot",拍一张快照。
  2. 在快照的过滤框里输入 Closure,会列出所有闭包及其 Retained Size(它"留住"的总内存)。
  3. 怀疑泄漏时,做"三快照法":操作前拍一张 → 执行可疑操作并触发 GC → 再拍一张 → 重复操作再拍。对比 Retained Size 是否持续增长。
  4. 展开某个闭包,看它的 context 引用了哪些变量,定位到具体是哪个大对象没被释放。
// 在控制台手动触发 GC(需在启动 Chrome 时加 --js-flags="--expose-gc")
if (window.gc) window.gc();
// 或者直接用 performance.memory 粗略观察
console.log(performance.memory.usedJSHeapSize);

性能与认知误区

误区一:闭包很慢。 现代 V8 对闭包做了大量优化,创建闭包的开销极小,绝大多数场景下完全不用担心。真正的成本不在"快慢",而在"内存留存"。

误区二:每个函数都是闭包。 严格说,只有"引用了外层作用域变量"的函数才形成有意义的闭包。一个不依赖任何外部变量的纯函数,虽然理论上也带着作用域链,但引擎会把它优化掉,不构成内存负担。

误区三:闭包里的变量是"拷贝"。 不是。闭包捕获的是变量的引用,不是某一刻的值。这正是开头 3 3 3 的根源 —— 三个闭包引用同一个 i,读到的是它最终的值。

常见面试追问

问:闭包和作用域链是什么关系? 作用域链是机制,闭包是结果。函数定义时就确定了作用域链;当函数在其定义作用域之外被执行、且仍能沿着这条链访问外层变量时,我们称之为闭包。

问:闭包一定会导致内存泄漏吗? 不会。闭包"留住"变量是设计行为,不是泄漏。只有当这种留存是"非预期且无法解除"时才叫泄漏。合理使用、及时清理,闭包完全安全。

问:箭头函数和闭包有关系吗? 箭头函数同样能形成闭包,捕获外层变量的规则一致。区别只在 this:箭头函数不绑定自己的 this,而是捕获定义时外层的 this —— 这本身也是一种"闭包式"的捕获。

问:如何手动"断开"一个闭包? 把闭包引用置为 null(例如 fn = null),或者在闭包内部把不再需要的大对象引用置空。一旦没有任何引用指向这个闭包函数,它和它留住的环境就会被回收。

闭包与 this:两种不同的"捕获"

初学者常把闭包和 this 搅在一起,其实它们是两套独立机制。闭包捕获的是变量,规则是词法的(看定义位置);this 的绑定是动态的,规则看调用方式。下面这段代码同时踩中两者:

const obj = {
  name: 'mores',
  greetLater() {
    setTimeout(function () {
      console.log('hi, ' + this.name); // this 是 undefined/window,不是 obj
    }, 100);
  },
};
obj.greetLater(); // hi, undefined

问题在于:传给 setTimeout 的普通函数,被调用时不是作为 obj 的方法调用的,this 自然不指向 obj。传统修复是用闭包"把 this 存进一个变量":

greetLater() {
  const self = this;            // 用闭包捕获当前的 this
  setTimeout(function () {
    console.log('hi, ' + self.name); // self 是被闭包捕获的变量,稳定
  }, 100);
}

箭头函数让这件事变得自然 —— 箭头函数不绑定自己的 this,它会沿作用域链去找,等价于自动帮你做了 const self = this:

greetLater() {
  setTimeout(() => {
    console.log('hi, ' + this.name); // 箭头函数捕获了外层的 this
  }, 100);
}

所以可以这样理解:箭头函数对 this 的处理,本身就是一种"闭包式捕获"。把"变量捕获(词法)"和"this 绑定(动态)"这两条线分开看,很多迷惑就消失了。

实战:用闭包写一个 200 行内的状态管理

Redux 的核心思想用闭包可以极简地表达出来 —— store 内部维护一个私有的 state 和监听器列表,外部只能通过 dispatch 改它、通过 subscribe 监听它。私有性正是靠闭包保证的。

function createStore(reducer, initialState) {
  let state = initialState;        // 私有状态,外部永远拿不到引用
  let listeners = [];              // 私有监听器列表

  function getState() {
    return state;
  }

  function dispatch(action) {
    state = reducer(state, action);  // 用 reducer 算出新 state
    listeners.forEach((fn) => fn()); // 通知所有订阅者
  }

  function subscribe(listener) {
    listeners.push(listener);
    // 返回一个取消订阅的函数 —— 又一个闭包,它记住了自己是哪个 listener
    return function unsubscribe() {
      listeners = listeners.filter((fn) => fn !== listener);
    };
  }

  return { getState, dispatch, subscribe };
}

// 使用
function counterReducer(state, action) {
  switch (action.type) {
    case 'INC': return { count: state.count + 1 };
    case 'DEC': return { count: state.count - 1 };
    default:    return state;
  }
}

const store = createStore(counterReducer, { count: 0 });
const unsub = store.subscribe(() => console.log('新状态:', store.getState()));
store.dispatch({ type: 'INC' }); // 新状态: { count: 1 }
store.dispatch({ type: 'INC' }); // 新状态: { count: 2 }
unsub();                          // 取消订阅
store.dispatch({ type: 'DEC' }); // 不再打印,但 state 已变成 { count: 1 }

这段代码里出现了三层闭包:getState / dispatch / subscribe 共享并捕获了 statelisteners;unsubscribe 又额外捕获了它对应的 listener。没有闭包,你就得把 state 暴露成全局变量或对象属性,封装性立刻崩塌。这就是为什么说闭包是 JavaScript 模块化的基石 —— 它用最小的语法成本,实现了"私有"这个面向对象里需要专门关键字才能表达的概念。

写在最后

闭包不是一个孤立的"语法特性",它是 JavaScript 词法作用域 + 函数是一等公民这两个设计共同的自然产物。理解它的正确路径是:先认清"变量住在哪、怎么找"(作用域与作用域链),再认清"函数调用时发生了什么"(执行上下文),最后水到渠成地得到"为什么有些变量没被回收"(闭包)。

工程上记住两句话就够用:需要私有状态、需要让函数记住配置时,主动用闭包;写长寿命回调时,警惕它捕获了什么,并给出清理路径。把这两点做到位,闭包就只会是你的工具,而不会是你线上内存曲线里那条诡异的上升线。

—— 别看了 · 2026
声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理 邮箱1846861578@qq.com。
技术教程

Claude Code 高级使用指南:CLAUDE.md、子代理、MCP 与高效工作流

2026-5-14 17:19:08

技术教程

Python 生成器从入门到精通:yield、迭代器协议与惰性求值

2026-5-15 10:47:07

0 条回复 A文章作者 M管理员
    暂无讨论,说说你的看法吧
个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索