闭包与作用域

张玥 2026年9月20日 阅读时间 35分钟
JavaScript 闭包 作用域 执行上下文 内存管理 手写实现
前端开发技巧之闭包与作用域

闭包是 JavaScript 里被讲得最多、也被误解得最深的机制。有人把它当成“面试八股”背下定义,有人以为它一定会造成内存泄漏,还有人写了五年业务代码却说不清 let 和 var 在循环里的差别到底从何而来。这些困惑的根,几乎都指向同一个地方:没有真正理解作用域与词法环境。闭包不是某个需要刻意使用的技巧,它是词法作用域的必然产物——只要你在一个函数里定义了另一个函数,闭包就已经发生了,区别只在于你有没有意识到它、有没有用好它。本篇 35 分钟长文,从作用域的定义讲起,一路走过执行上下文、词法环境、变量查找、闭包的六种经典用法、循环陷阱、内存与性能、this 的混淆、五个手写实现,最后落到一份可以贴在工位上的检查清单。读完之后,你再看任何一段涉及闭包的代码,都应该能在大脑里“看见”它的作用域链。

一、作用域:变量能被看见的那片区域

先给一个足够朴素的定义:作用域(Scope)就是一套规则,它决定了在代码的某个位置,你能访问到哪些变量,以及访问不到哪些变量。换一个更工程化的说法:作用域是变量的“可见性边界”。

JavaScript 采用的是词法作用域(Lexical Scope),也叫静态作用域。这四个字的关键在于“词法”二字——作用域由代码书写的位置决定,在代码被解析的那一刻就已经确定,与函数最终被谁调用、在哪里调用无关。

看一段能立刻把人分层的代码:

var x = 1;

function f() {
  console.log(x);
}

function g() {
  var x = 2;
  f();
}

g();  // 输出 1,不是 2

如果 JavaScript 是动态作用域,f 会去“调用它的地方”找 x,也就是在 g 的作用域里找到 2。但结果是 1——因为 f 的作用域在定义它的那一刻就被钉死了:它在全局被定义,所以它永远只能看见全局的 x。

词法作用域带来的三个直接结论

  • 作用域在编译期确定:引擎解析代码时就会建立作用域结构,运行时不再改变。
  • 函数在哪里定义,比在哪里调用重要得多:这解释了为什么回调函数总能“记得”它出生时的环境。
  • 闭包是词法作用域的自然结果:既然函数记得自己的出生地,那么只要这个函数活得比它的出生地更久,它就必须把出生地一起带在身上。
常见误判
“f 是在 g 里面被调用的,那它当然能拿到 g 里的变量。”——这是把调用关系当成了作用域关系,是动态作用域的思维方式。
正确心智模型
把每个函数想象成一张纸,纸上盖了一个“出生地”的钢印。钢印在书写时盖下,之后无论这张纸飘到哪里,钢印都不会变。

为什么语言要设计成词法作用域

词法作用域不是 JavaScript 的独创,C、Java、C#、Go、Python 全都采用词法作用域。原因很实际:

  • 可预测:读代码时,你不用追踪运行时的调用栈,静态地看源码就能判断变量从哪来。
  • 可优化:引擎在编译期就能确定变量的存储位置(栈槽、上下文槽),不必每次运行时查找。
  • 可推导:静态分析工具、类型检查器、IDE 的“跳转到定义”都依赖这个性质。

动态作用域会让调试变成噩梦——同一个函数在不同调用链下行为不同,而你只能靠运行时日志去猜。JavaScript 把这个不确定性收敛到了唯一一个地方:this。这一点我们会在第八节专门展开。

二、作用域的种类与作用域链

ES6 之后,JavaScript 里实际存在四种作用域。搞清楚它们的分工,是理解闭包的前提。

全局作用域 脚本最外层,生命周期与页面一致;var 与函数声明会成为 window 的属性
函数作用域 每次调用函数都会创建一个全新的作用域,调用结束即销毁(除非被闭包留住)
块级作用域 由 { }、if、for、while、switch 创建,只对 let / const / class 生效
模块作用域 ES Module 顶层,模块内的变量默认不污染全局,且是单例的
注意 var 无视块级作用域,这是历史上大量 bug 的源头

遮蔽(Shadowing):内层会盖住外层

当内层作用域声明了一个与外层同名的变量,外层的那个就被“遮蔽”了。这不是错误,但要小心——它常常是排查 bug 时最容易被忽略的一环。

let name = 'outer';

function demo() {
  let name = 'inner';
  console.log(name);          // 'inner'
  {
    let name = 'block';
    console.log(name);        // 'block'
  }
  console.log(name);          // 'inner'
}

demo();
console.log(name);            // 'outer'

作用域链:变量查找的真实路径

当引擎执行到某个标识符时,它不会“凭空知道”这个变量在哪。它会沿着一条链,从最内层开始逐级向外查找,直到找到第一个匹配的声明为止。这条链就是作用域链。点击下面的每一环,看看查找的过程。

1
当前函数作用域
查找起点
引擎首先在当前函数的变量环境中查找。函数参数、var 声明、let/const 声明都在这里。命中即返回,不再继续向外。
2
外层块级作用域
let / const
如果当前语句位于 { } 内部,引擎会继续在外层的块级环境中查找。这也是 for 循环里的 let 能够被闭包正确捕获的原因。
3
外层函数作用域
逐级向上
继续沿着函数的嵌套关系向上,一层一层地查。嵌套了多少层,链就有多长。闭包之所以能“记住”外层变量,就是因为它持有这条链。
4
模块作用域
ESM 顶层
如果代码在 ES Module 中,模块顶层的声明构成独立的一层。它介于函数作用域与全局作用域之间,模块内的变量不会泄漏到全局。
5
全局作用域
最后一站
全局环境是作用域链的终点。在浏览器中,全局对象是 window;用 var 声明的全局变量与函数声明都会成为它的属性。
6
ReferenceError
查找失败
如果一路查到全局都没有找到,引擎抛出 ReferenceError: xxx is not defined。注意:如果是赋值操作且处于非严格模式,会在全局隐式创建变量——这是另一个历史遗留坑。

查找是单向的,且代价随链长增长

作用域链的查找方向永远是“由内向外”,反向是不可能的:外层作用域无法访问内层作用域的变量。这解释了一个常见的困惑——为什么在全局拿不到函数内部定义的变量?因为查找根本不会往里走。

另外,虽然现代引擎对变量查找做了大量优化(例如把常见查找内联化),但作用域链越长,理论上查找成本越高。这也是“尽量不要在深层嵌套里频繁访问最外层变量”这条经验法则的来源——在极热点的循环里,把外层变量先缓存到局部,确实可能有收益。

// 优化前:每次迭代都要沿作用域链向上查找 config
for (let i = 0; i < 10000; i++) {
  render(config.theme, config.size, config.locale);
}

// 优化后:把外层变量缓存在局部
const { theme, size, locale } = config;
for (let i = 0; i < 10000; i++) {
  render(theme, size, locale);
}

三、执行上下文与词法环境:引擎内部发生了什么

作用域是“规则”,而执行上下文与词法环境是这套规则在引擎里的“数据结构”。理解它们,你才能真正明白变量提升、暂时性死区这些现象为什么存在。

执行上下文栈

每当 JavaScript 开始执行一段代码,引擎就会创建一个执行上下文(Execution Context),并把它压入执行上下文栈。全局代码创建全局上下文,每次函数调用创建函数上下文,调用结束则弹出。

function a() {
  console.log('enter a');
  b();
  console.log('leave a');
}

function b() {
  console.log('enter b');
}

a();

// 输出顺序:
// enter a
// enter b
// leave a

// 栈的变化:
// [全局] → [全局, a] → [全局, a, b] → [全局, a] → [全局]

调用栈溢出(RangeError: Maximum call stack size exceeded)就是因为上下文栈被无限压入而没有被弹出——递归缺少终止条件时的经典错误。

一个执行上下文里有什么

  • 词法环境(Lexical Environment):存放 let / const 声明、块级作用域变量。
  • 变量环境(Variable Environment):存放 var 声明与函数声明。
  • this 绑定:当前上下文中的 this 指向。
  • outer 引用:指向外层词法环境,正是这个引用把各个环境串成了作用域链。

每个词法环境内部又包含一个环境记录(Environment Record),它是真正存放“变量名 → 值”映射的地方。环境记录有三种:

类型 出现位置 特点 典型表现
声明式记录 函数 / 块级作用域 直接存储绑定 局部变量、参数
对象式记录 with 语句、全局 绑定即对象属性 var 全局变量挂在 window 上
全局记录 全局环境 两者的混合体 let 全局变量不挂 window

声明提升与暂时性死区

执行上下文的创建阶段会先“扫描”一遍函数体,把声明提前登记好,然后才逐行执行。这个“先登记、后执行”的机制,就是变量提升的来源。

// var:声明提升,初始化为 undefined
console.log(a);          // undefined(不报错)
var a = 1;

// 函数声明:整体提升,连函数体一起
foo();             // 'foo'(可以正常调用)
function foo() { console.log('foo'); }

// let / const:提升但不初始化,进入暂时性死区
console.log(b);          // ReferenceError
let b = 2;

// 函数表达式不会被提升
bar();             // TypeError: bar is not a function
var bar = function () {};

// 类声明同样存在 TDZ
new Foo();         // ReferenceError
class Foo {}

把“暂时性死区”(Temporal Dead Zone,TDZ)理解清楚很关键:let 声明的变量在进入作用域时就已经被登记,但直到声明语句执行前都处于“未初始化”状态,任何访问都会抛错。这不是设计缺陷,而是一个刻意的保护——它让“先使用后声明”这类低级错误立刻暴露,而不是悄悄给你一个 undefined。

var 的静默失败
function getUser() {
  if (!user) {
    var user = fetchUser();
  }
  return user; // 永远拿到 undefined
}
let 的即时暴露
function getUser() {
  if (!user) {
    let user = fetchUser();
  }
  return user; // ReferenceError,立刻发现
}

四、闭包:函数背着自己的出生地

铺垫了三节,终于可以给闭包一个准确的定义了。先说一个流传最广、但容易让人误入歧途的说法:

“闭包是指有权访问另一个函数作用域中变量的函数。”——这个定义没错,但它描述的是结果,不是本质。

更准确的表述是:

闭包 = 函数 + 该函数定义时所处的词法环境的引用。

注意两个关键词。第一是“定义时”——不是调用时,这是词法作用域的直接推论。第二是“引用”——闭包持有的不是变量的值副本,而是变量本身。这个区别极其重要,我们马上会看到它的后果。

function createCounter() {
  let count = 0;

  return function () {
    count += 1;
    return count;
  };
}

const counter = createCounter();
counter();  // 1
counter();  // 2
counter();  // 3

const another = createCounter();
another();  // 1 —— 全新的作用域,互不影响

按常理说,createCounter 执行完毕之后,它的执行上下文应该出栈,里面的 count 应该被回收。但 counter 依然能一次次把它加上去。为什么?

因为返回的那个匿名函数,在自己的 [[Environment]] 内部槽里,保存了对 createCounter 词法环境的引用。只要这个函数还活着,这个引用就存在;只要引用存在,count 就不会被回收。垃圾回收器的工作方式是“可达性分析”,而不是“作用域是否执行完毕”。

闭包捕获的是变量,不是值

这是初学者最容易栽跟头的地方。看下面这段代码:

function make() {
  let value = 10;

  const read = () => value;
  const write = (v) => { value = v; };

  return { read, write };
}

const { read, write } = make();
read();      // 10
write(99);
read();      // 99 —— 两个闭包共享同一个 value 绑定

read 和 write 是同一个 make 调用中产生的两个闭包,它们共享同一个词法环境,因此也共享同一个 value 变量。通过 write 修改后,read 读到的就是新值。闭包之间可以互相通信,这是模块模式能够实现私有状态的根本原因。

闭包形成的三个条件

条件一 存在函数嵌套——内层函数定义在外层函数内部
条件二 内层函数引用了外层函数的变量
条件三 内层函数在外层函数返回后仍然可达(被返回、被赋值给外部变量、被注册为回调等)

三个条件缺一不可。尤其是第三条——如果内层函数只是在外部函数里被立即调用一次,那外层作用域随调用结束就销毁了,不会产生“活得比出生地更久”的现象。

不是闭包
function outer() {
  let x = 1;
  function inner() { return x; }
  inner(); // 立即调用,没有外流
}
outer();
是闭包
function outer() {
  let x = 1;
  return function () { return x; };
}
const fn = outer(); // 活了下来

五、闭包的六种经典用法

闭包不是炫技,它在真实项目中的出场频率远比你想象的高。下面六种用法,覆盖了日常开发中的绝大多数场景。

用法一:私有变量与模块模式

在 ES Module 普及之前,IIFE + 闭包是模拟“私有成员”的唯一手段。即使在今天,当你需要隐藏内部状态、只暴露受控接口时,这个模式依然有效。

const counter = (function () {
  let count = 0;             // 外部无法直接访问

  function increase() {
    count += 1;
  }

  function getCount() {
    return count;
  }

  return { increase, getCount };
})();

counter.increase();
counter.getCount();  // 1
counter.count;        // undefined —— 拿不到

用法二:函数工厂

用闭包批量生成“行为相似但参数不同”的函数,可以避免重复代码。

function createValidator(min, max) {
  return function (value) {
    return value >= min && value <= max;
  };
}

const isAge = createValidator(0, 150);
const isScore = createValidator(0, 100);

isAge(30);    // true
isScore(120);  // false

用法三:柯里化

把多参数函数拆成一系列单参数函数,每一层都用闭包保存上一层的实参。

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 add = (a, b, c) => a + b + c;
const curriedAdd = curry(add);

curriedAdd(1)(2)(3);     // 6
curriedAdd(1, 2)(3);     // 6
curriedAdd(1)(2, 3);     // 6

用法四:事件回调中的状态记忆

这是闭包在业务代码里出现频率最高的地方。每次注册监听器时,闭包都会记住注册那一刻的上下文。

function bindTabs(tabList) {
  tabList.forEach(function (tab) {
    tab.addEventListener('click', function () {
      // 每个回调都记住了自己那一次的 tab
      console.log('激活:', tab.dataset.name);
    });
  });
}

用法五:记忆化(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 <= 1 ? n : slowFib(n - 1) + slowFib(n - 2));
const fastFib = memoize((n) => (n <= 1 ? n : fastFib(n - 1) + fastFib(n - 2)));

用法六:防抖与节流

这两个工具的“状态”(定时器 ID、上次执行时间)必须跨越多次调用保持,而闭包正是保存这种状态的最自然的方式。

// 防抖:停止触发 delay 后才执行
function debounce(fn, delay) {
  let timer = null;

  return function (...args) {
    if (timer) clearTimeout(timer);
    timer = setTimeout(() => {
      fn.apply(this, args);
      timer = null;
    }, delay);
  };
}
// 节流:每 interval 最多执行一次
function throttle(fn, interval) {
  let last = 0;

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

注意这两个实现里都用到了 fn.apply(this, args)。这不是多余的:如果不转发 this 和参数,被包装的函数在作为对象方法使用时就会丢失上下文。这是闭包与 this 交汇处的经典细节,我们在第八节会深入。

六、循环中的闭包:一道被问了二十年的题

如果闭包只有一个知识点值得反复咀嚼,那就是它。下面这段代码,是 JavaScript 历史上被讨论最多的片段之一。

for (var i = 0; i < 3; i++) {
  setTimeout(function () {
    console.log(i);
  }, 0);
}

// 实际输出:3 3 3
// 原因:var 是函数作用域,三个回调共享同一个 i
// 循环结束时 i 已经是 3,回调才依次执行

let 到底做了什么

规范里对 for 语句中的 let 有一套专门的处理:每次循环迭代开始时,引擎都会创建一个新的词法环境,把上一轮的变量值复制进来,然后执行循环体。这意味着:

  • 第 0 次迭代的回调,捕获的是第 0 次迭代的环境,里面的 i 是 0。
  • 第 1 次迭代的回调,捕获的是第 1 次迭代的环境,里面的 i 是 1。
  • 以此类推,三个回调各持一份,互不干扰。

这套机制有个名字,叫“每次迭代绑定”(Per-Iteration Binding)。它不是 let 在块级作用域之外的额外魔法,而是 for 语句配合 let 时的标准行为。

ES5 时代的三种解法

在 let 出现之前,开发者用各种方式“手动制造”每次迭代独立的作用域。理解这些写法,能帮你更深刻地理解闭包。

// 解法一:IIFE 立即执行函数
for (var i = 0; i < 3; i++) {
  (function (j) {
    setTimeout(function () {
      console.log(j);
    }, 0);
  })(i);
}
// 输出 0 1 2
// 解法二:把逻辑抽成独立函数
function logLater(value) {
  setTimeout(function () {
    console.log(value);
  }, 0);
}

for (var i = 0; i < 3; i++) {
  logLater(i);
}
// 输出 0 1 2
// 解法三:利用 setTimeout 的第三个参数(较少人知)
for (var i = 0; i < 3; i++) {
  setTimeout(function (j) {
    console.log(j);
  }, 0, i);
}
// 输出 0 1 2 —— i 在注册时就被作为实参传入

一个更容易踩坑的变体

下面这段代码里,闭包捕获的是同一个 i,但循环体内的代码却是同步执行的,所以输出是正常的 0、1、2。真正出问题的只有“异步回调”和“事件回调”。

// 同步:没问题
for (var i = 0; i < 3; i++) {
  console.log(i);  // 0 1 2
}

// 异步:出问题
for (var i = 0; i < 3; i++) {
  Promise.resolve().then(() => console.log(i));  // 3 3 3
}

// 事件绑定:同样出问题
for (var i = 0; i < buttons.length; i++) {
  buttons[i].onclick = () => console.log(i);  // 全是最后一个下标
}

判断标准很简单:如果回调的执行时机晚于循环结束,而它又捕获了 var 变量,就一定有问题。养成习惯——只要写 for 循环,就用 let。

七、闭包与内存:到底会不会泄漏

“闭包会导致内存泄漏”是一个被过度传播的说法。准确的说法应该是:闭包会延长被捕获变量的生命周期,如果这些变量本身持有大量数据或 DOM 引用,就可能造成事实上的内存占用居高不下。

生命周期是怎么被延长的

普通函数的局部变量,在函数返回后就可以被回收;而一旦某个变量被闭包捕获,且闭包本身可达,这个变量就会一直存活。判断一个变量是否会被回收,只需要问一个问题:从根对象(全局对象)出发,能不能顺着引用链找到它?

function attach() {
  const hugeData = new Array(1e6).fill('x');  // 约 8MB
  const el = document.getElementById('panel');

  el.addEventListener('click', function () {
    // 只用到了 el,却让整个作用域都活了下来
    el.classList.toggle('active');
  });
}

// hugeData 永远不会被回收,尽管回调里根本没用到它

不过,现代 JavaScript 引擎(V8、SpiderMonkey、JavaScriptCore)都做了闭包变量裁剪优化:如果某个变量在闭包中完全没有被引用,引擎可能不会把它保留在上下文中。但这个优化依赖于具体的编译路径与优化层级,不能作为编写代码时的依赖。稳妥的做法还是主动规避。

三种真实存在的泄漏场景

场景一 闭包捕获了 DOM 元素,元素已从文档移除,但事件监听器未解绑,导致元素与闭包互相引用
场景二 定时器 / 轮询 / WebSocket 回调持有闭包,页面切换后没有清除,回调持续执行
场景三 全局缓存(Map / 数组)不断累积,从未设置过期或上限,本质上是一个无界闭包状态

对应的三种解法

// 1. 手动解绑:组件卸载时清理监听器
function setup(el) {
  const handler = () => { /* ... */ };
  el.addEventListener('click', handler);

  return () => el.removeEventListener('click', handler);
}

// 2. 清除定时器:用 AbortController 或显式 clear
const controller = new AbortController();
fetch(url, { signal: controller.signal });
window.addEventListener('resize', onResize, {
  signal: controller.signal,
});
// 需要时:controller.abort();

// 3. 有界缓存:给 Map 设上限,配合 LRU 淘汰
class LRUCache {
  constructor(limit = 100) {
    this.limit = limit;
    this.map = new Map();
  }

  get(key) {
    if (!this.map.has(key)) return undefined;
    const value = this.map.get(key);
    this.map.delete(key);
    this.map.set(key, value);
    return value;
  }

  set(key, value) {
    if (this.map.has(key)) this.map.delete(key);
    this.map.set(key, value);
    if (this.map.size > this.limit) {
      this.map.delete(this.map.keys().next().value);
    }
  }
}

用 WeakMap 切断强引用

当你需要给 DOM 元素附加数据,又不希望阻止它被回收时,WeakMap 是标准答案。它的键是弱引用——只要键对象被回收,对应的值也会自动释放。

// 反例:Map 强引用 DOM,元素移除后仍占内存
const cache = new Map();
cache.set(el, metadata);

// 正解:WeakMap 让元素与数据同生共死
const weakCache = new WeakMap();
weakCache.set(el, metadata);
// el 被移除并被回收后,metadata 也随之释放
写法 内存风险 说明
闭包只捕获小变量 极低 计数器、开关、配置项等
闭包捕获大数组 / 大对象 中等 引擎可能裁剪,但不能依赖
闭包捕获 DOM 且不解绑 高 典型泄漏,SPA 中尤其常见
闭包持有未清除的定时器 高 回调会持续执行,性能与内存双输
用 WeakMap 关联 DOM 极低 推荐方案

最后强调一句:闭包本身不是泄漏,忘记清理才是。用闭包实现的状态,就应该配一个对称的清理逻辑——这在组件化的前端框架里体现为 useEffect 的返回函数、onUnmounted 钩子、disconnectedCallback 生命周期。

八、闭包与 this:两个常被混淆的概念

初学者常把闭包和 this 混为一谈,因为它们都涉及“函数能拿到外部的什么东西”。但它们遵循完全不同的规则:

作用域 词法决定,看函数写在哪里;闭包捕获的是变量
this 调用方式决定,看函数怎么被调用;与作用域链无关
箭头函数 没有自己的 this,会捕获定义时外层的 this——这一点与闭包捕获变量类似
结论 闭包不会“保存 this”,除非你写的是箭头函数

经典陷阱:回调中的 this 丢失

const timer = {
  seconds: 0,

  start() {
    setInterval(function () {
      this.seconds++;  // TypeError:this 是 undefined 或 window
    }, 1000);
  }
};

timer.start();

为什么会这样?因为 setInterval 内部是以普通函数调用方式执行回调的(相当于 cb()),此时 this 在严格模式下是 undefined,非严格模式下是全局对象。回调在定义时确实位于 start 方法内部,但作用域能捕获变量,不能捕获 this。

四种修复方式

// 方案一:箭头函数(推荐)
start() {
  setInterval(() => {
    this.seconds++;
  }, 1000);
}

// 方案二:提前保存 this
start() {
  const self = this;
  setInterval(function () {
    self.seconds++;
  }, 1000);
}
// 方案三:bind 绑定
start() {
  setInterval(function () {
    this.seconds++;
  }.bind(this), 1000);
}

// 方案四:类字段 + 箭头函数
class Timer {
  seconds = 0;
  tick = () => { this.seconds++; };
  start() { setInterval(this.tick, 1000); }
}

箭头函数的 this 是“词法 this”

箭头函数没有自己的 this、arguments、super 和 new.target。它内部出现的 this,会沿着作用域链去找最近的一个非箭头函数的 this。所以箭头函数的 this 行为,其实与闭包捕获变量非常相似——都是“定义时决定”。

不该用箭头函数的场合
对象方法、需要动态 this 的原型方法、需要 arguments 的函数。
const obj = { name: 'a', hi: () => this.name } ——这里的 this 指向外层,不是 obj。
适合箭头函数的场合
回调、数组方法、Promise 链、定时器、需要固定 this 的场景。
它们本来就不需要自己的 this,用箭头函数反而更安全。

一个综合案例:防抖函数中的 this 转发

回到第五节的防抖实现。如果写成下面这样,会丢掉调用者的 this:

// 错误版本
function debounce(fn, delay) {
  let timer = null;
  return function (...args) {
    clearTimeout(timer);
    timer = setTimeout(() => fn(...args), delay);
    // fn 是以普通函数形式调用的,this 丢失
  };
}

// 正确版本:转发 this
function debounce(fn, delay) {
  let timer = null;
  return function (...args) {
    const context = this;  // 捕获调用时的 this
    clearTimeout(timer);
    timer = setTimeout(() => fn.apply(context, args), delay);
  };
}

注意 context 这个变量——它被闭包捕获,因此箭头函数在延迟执行时依然能拿到正确的 this。这是一个把“闭包捕获变量”和“this 动态绑定”两个机制结合起来解决实际问题的典型例子。

九、五个手写实现:把理解变成肌肉记忆

读完理论,最好的检验方式是手写。下面五个实现,覆盖了闭包在工程中最常见的形态。建议先遮住答案自己写一遍。

实现一:once —— 只执行一次

function once(fn) {
  let called = false;
  let result;

  return function (...args) {
    if (called) return result;
    called = true;
    result = fn.apply(this, args);
    return result;
  };
}

const init = once(() => {
  console.log('初始化');
  return 42;
});

init();  // '初始化',返回 42
init();  // 什么都不做,返回 42

关键点:不仅要记住“是否调用过”,还要缓存返回值,否则第二次调用会返回 undefined,破坏调用方的一致性预期。

实现二:带缓存的 memoize(支持自定义 key)

function memoize(fn, resolver) {
  const cache = new Map();

  const memoized = function (...args) {
    const key = resolver ? resolver.apply(this, args) : args[0];

    if (cache.has(key)) {
      return cache.get(key);
    }

    const result = fn.apply(this, args);
    cache.set(key, result);
    return result;
  };

  memoized.cache = cache;  // 暴露缓存,便于手动清理
  return memoized;
}

// 用法:自定义 key,避免 JSON.stringify 的性能开销
const getUser = memoize(fetchUser, (id, type) => `${id}:${type}`);

实现三:完整的 curry

function curry(fn) {
  return function curried(...args) {
    if (args.length >= fn.length) {
      return fn.apply(this, args);
    }
    const self = this;
    return function (...rest) {
      return curried.apply(self, args.concat(rest));
    };
  };
}

// 占位符版本(lodash 风格)
const _ = Symbol('placeholder');

function curryWithPlaceholder(fn) {
  return function curried(...args) {
    const ready = args.length >= fn.length && !args.slice(0, fn.length).includes(_);
    if (ready) return fn.apply(this, args);

    return (...rest) => {
      const merged = args.map((v) => (v === _ && rest.length ? rest.shift() : v));
      return curried.apply(this, merged.concat(rest));
    };
  };
}

实现四:带立即执行选项的 debounce

function debounce(fn, delay, immediate = false) {
  let timer = null;
  let result;

  function debounced(...args) {
    const context = this;

    if (timer) clearTimeout(timer);

    if (immediate) {
      const callNow = !timer;
      timer = setTimeout(() => { timer = null; }, delay);
      if (callNow) result = fn.apply(context, args);
    } else {
      timer = setTimeout(() => {
        fn.apply(context, args);
        timer = null;
      }, delay);
    }

    return result;
  }

  debounced.cancel = function () {
    clearTimeout(timer);
    timer = null;
  };

  return debounced;
}

cancel 方法的加入非常重要——它给闭包状态提供了一个显式的释放入口,这正是第七节所说的“对称清理”。

实现五:简易沙箱 / 命名空间

function createStore(initialState) {
  let state = { ...initialState };
  const listeners = new Set();

  function getState() {
    return { ...state };  // 返回副本,防止外部篡改
  }

  function setState(patch) {
    const prev = state;
    state = { ...state, ...patch };
    listeners.forEach((fn) => fn(state, prev));
  }

  function subscribe(fn) {
    listeners.add(fn);
    return () => listeners.delete(fn);  // 返回取消订阅函数
  }

  return { getState, setState, subscribe };
}

const store = createStore({ count: 0 });
const unsubscribe = store.subscribe((s) => console.log(s.count));

store.setState({ count: 1 });  // 1
unsubscribe();
store.setState({ count: 2 });  // 无输出

这个模式几乎就是 Vuex、Pinia、Redux 的雏形。它把 state 和 listeners 完全封闭在闭包里,外部只能通过返回的三个方法间接操作——这就是用闭包实现封装的最小完整示例。

十、十个最常见的闭包误区

误区 1 认为“只有返回函数才算闭包”,忽略了事件回调、定时器、Promise 回调同样是闭包
误区 2 以为闭包捕获的是变量的值快照,而不是变量绑定本身
误区 3 认为闭包一定会导致内存泄漏,因而刻意回避使用
误区 4 用 var 写 for 循环并绑定事件,然后困惑为什么全都指向最后一个元素
误区 5 在循环里创建闭包,却忘了每个闭包都会独立持有一份作用域,造成不必要的开销
误区 6 把 this 当成作用域的一部分,以为闭包能“记住 this”
误区 7 在闭包里修改外层变量却没有意识到这是共享状态,导致竞态或难以追踪的 bug
误区 8 认为 let 在循环里“每次都是新变量”是语法糖,没意识到是规范定义的每次迭代绑定
误区 9 依赖引擎的闭包变量裁剪优化,捕获了巨大的对象却以为自己没用就没事
误区 10 为了“避免闭包”而在循环里反复创建 DOM 查询,反而制造了更大的性能问题

两个需要展开说的点

关于误区 3:闭包本身只是一个引用关系,它不分配额外的“泄漏空间”。真正的问题是引用链上的对象太大了、或者生命周期太长了。把“闭包 = 泄漏”改成“闭包 = 延长的生命周期,需要配套的释放策略”,心态会健康很多。

关于误区 9:V8 确实会在某些情况下做变量裁剪,但它的行为依赖于代码是否被优化、优化是否被回退(deopt)。在生产代码里依赖这种未公开的优化是非常危险的。正确做法是——只捕获你真正需要的东西。如果只需要一个值,就在创建闭包前先把它取出来。

// 不推荐:闭包持有整个 config
function setup(config) {
  return () => render(config.theme);
}

// 推荐:只捕获需要的字段
function setup(config) {
  const theme = config.theme;  // 提前取出
  return () => render(theme);
}

十一、调试技巧:在 DevTools 里看见闭包

理解闭包最好的方式,是亲眼在调试器里看到它。Chrome DevTools 提供了相当完整的闭包可见性。

在 Sources 面板打断点

  1. 打开 DevTools → Sources 面板,找到你的脚本文件。
  2. 在闭包内部的某一行打上断点,触发执行。
  3. 右侧的 Scope 面板会列出四类作用域:Local、Closure、Script、Global。
  4. 展开 Closure,你会看到引擎为这个函数保留的每一个外层变量,以及它们当前的实时值。

有一个非常直观的实验:在 createCounter 返回的函数里打断点,连续点击几次,观察 Closure 里的 count 是如何在多次调用之间持续累加的。这比读十遍定义都管用。

用 console.dir 查看函数内部槽

function outer() {
  let secret = 'hidden';
  return () => secret;
}

const fn = outer();

console.dir(fn);
// 展开 [[Scopes]],可以看到:
// Closure (outer)
// secret: "hidden"

[[Scopes]] 是引擎暴露给调试器的一个内部属性。它清楚地展示了闭包到底“带着”什么——有时候你会惊讶地发现某个变量被保留了,而你完全没在闭包里用到它,那就说明存在可以优化的空间。

用 Memory 面板定位泄漏

  1. 打开 Memory 面板,选择 Heap snapshot,拍摄快照。
  2. 在页面上执行一遍会创建闭包的操作,再拍一次快照。
  3. 用 Comparison 视图对比两次快照,筛选 Detached HTMLElement——如果这个数字持续增长,基本可以确认存在 DOM 相关的闭包泄漏。
  4. 点开某个对象,查看 Retainers 面板,它会告诉你“谁在引用它”,顺着引用链就能找到没被清理的闭包。

一个快速自检的小技巧

// 想知道某个函数是不是闭包?检查它是否持有 [[Scopes]]
function isClosure(fn) {
  // 这是调试技巧,不是生产代码
  const str = fn.toString();
  return str.includes('[native code]') ? false : true;
}

// 更实用的做法:直接 console.dir 看 [[Scopes]]

最后提醒一句:调试器看到的是“当前引擎在非优化状态下保留的作用域”,可能比生产环境优化后保留的更多。所以调试时看到的闭包内容,是上界而不是精确值。

十二、检查清单与代码基线

把全文的结论压缩成一份可以随时对照的清单。写代码时扫一眼,能挡掉绝大多数与闭包、作用域相关的坑。

  • 默认使用 let / const,只有在确实需要函数作用域时才用 var——实际上几乎没有这种场景。
  • 循环绑定事件一律用 let,不要依赖 IIFE 补救。
  • 记住闭包捕获的是变量绑定,多个闭包共享同一变量时会互相影响。
  • 只捕获需要的变量,不要为了省事把整个大对象留在闭包里。
  • 为每一个闭包状态配一个释放入口:unsubscribe、cancel、destroy、abort。
  • DOM 事件监听器要成对解绑,尤其在单页应用的组件卸载时。
  • 定时器、轮询、WebSocket 必须显式清理。
  • 缓存必须有上限,用 LRU 或 TTL 约束,不要写无界的 Map。
  • 关联 DOM 的附加数据用 WeakMap。
  • 回调里需要 this 就用箭头函数,或者显式保存 const self = this。
  • 包装函数要转发 this 和参数:fn.apply(this, args)。
  • 不要用闭包实现全局可变状态,那会让测试变得极其困难。
  • 调试时用 DevTools 的 Scope 面板确认闭包内容,不要靠猜。
  • 用 console.dir(fn) 查看 [[Scopes]],验证闭包到底持有什么。
  • 怀疑泄漏时用 Memory 面板的 Comparison 视图,重点看 Detached HTMLElement。
/* 一份可直接复用的闭包使用基线 */

// 1. 状态封装 + 显式销毁
function createController() {
  let disposed = false;
  const handlers = new Set();

  function add(fn) {
    if (disposed) throw new Error('controller disposed');
    handlers.add(fn);
    return () => handlers.delete(fn);
  }

  function dispose() {
    disposed = true;
    handlers.clear();
  }

  return { add, dispose };
}

// 2. 只捕获需要的值
function bindOnce(el, config) {
  const { type, delay } = config;
  const handler = debounce(() => { /* 用 type */ }, delay);

  el.addEventListener(type, handler);
  return () => el.removeEventListener(type, handler);
}

// 3. 循环里正确使用闭包
for (const [index, item] of list.entries()) {
  buttons[index].addEventListener('click', () => select(item));
}

// 4. 包装函数转发 this 与参数
function wrap(fn) {
  return function (...args) {
    return fn.apply(this, args);
  };
}

// 5. 有界缓存
const cache = new WeakMap();  // 键是对象时优先
const lru = new Map();    // 键是原始值时限长限容

最后的话

闭包之所以让人又爱又恨,是因为它把“函数的定义位置”这个平时被忽略的信息,变成了运行时的实际行为。它让 JavaScript 拥有了私有状态、工厂函数、装饰器模式,也让一个没写好的 for 循环能在生产环境藏三个月才被发现。

理解闭包,本质上是理解一件事:函数不只包含代码,还包含它出生时的整个世界。当你写下一个内层函数并让它逃逸到外部时,你就把那个世界一起带了出来。带出来的东西有多大、要放多久、什么时候该放下——这些决定,应该由你来做,而不是由垃圾回收器猜。

所以,下一次你写下 return function () { ... } 的时候,不妨多问自己一句:“这个函数带走了什么?它什么时候才该放手?”——答案清晰了,闭包就从玄学变成了工具。