作用域链与闭包
基于现代 JavaScript(ES2025 · 核于 2026-06)
速查
- 词法作用域:变量在哪里可见,由它在源码中的书写位置决定(不是调用位置)——内层函数能读外层变量,外层读不到内层
- 作用域链:查变量时沿「当前 → 外层 → … → 全局」逐级向上找,找到即止,到顶仍无则
ReferenceError - 词法环境(Lexical Environment):内部规范对象,含「环境记录」(存本层变量)+「外层引用」(指向父环境)
[[Environment]]:每个函数都有的隐藏属性,记住它被创建时所处的词法环境——这是闭包的实现根基- 闭包:函数 + 它创建时的词法环境;函数即便被返回到外部,仍能访问外层变量。JS 里所有函数天生都是闭包
- 每次调用外层函数,都生成一份独立的词法环境与闭包(多个计数器互不干扰)
- 循环陷阱:
var在循环里只有一个绑定,所有闭包共享它 → 全部拿到最后的值;用let/const(每轮一个新绑定)或 IIFE / 函数工厂解决 - 模块模式:用 IIFE / 工厂函数把变量藏进闭包,只暴露方法 → 实现私有状态
- 垃圾回收:闭包引用的外层环境不会被回收;不再需要时解除引用,避免内存占用
词法作用域:位置决定可见性
「词法」(lexical)指的是「按源码书写位置」。一个变量对哪些代码可见,在写代码时就定了,与运行时怎么调用无关:
function init() {
const name = "Mozilla"; // init 的局部变量
function displayName() {
console.log(name); // 内层函数读到了外层的 name
}
displayName();
}
init(); // "Mozilla"内层 displayName 能读外层 name,但反过来不行——外层访问不到内层声明的变量。这种「内能看外、外不能看内」就是词法作用域的核心规则。
作用域链:逐级向上查找
函数可以多层嵌套,变量查找沿「作用域链」一级级向外:
function A(x) {
function B(y) {
function C(z) {
console.log(x + y + z); // C 同时读到 x(来自 A)、y(来自 B)、z(自己)
}
C(3);
}
B(2);
}
A(1); // 6 —— 链路:C → B → A → 全局若内外层有同名变量,内层优先(就近原则),查到就停止:
function outside() {
const x = 5;
function inside(x) {
return x * 2; // 用 inside 自己的参数 x,不是外层的 5
}
return inside;
}
outside()(10); // 20词法环境与 [[Environment]]
要理解闭包「怎么记住变量」,需要两个底层概念:
- 词法环境(Lexical Environment) 是一个内部规范对象,由两部分组成:环境记录(Environment Record,把本层的局部变量当属性存着)+ 对外层环境的引用。所谓「读 / 写一个变量」,本质就是「读 / 写环境记录这个对象的某个属性」。
- 每个函数都带一个隐藏属性
[[Environment]],记录它被创建时所处的那个词法环境。函数运行时要查外层变量,就顺着自己的[[Environment]]往上找。
正因为函数「随身携带」创建时的环境引用,即便外层函数早已执行完毕,只要内层函数还活着,外层的变量就不会消失。
闭包:函数 + 它的词法环境
闭包就是「一个函数」与「它被创建时的词法环境」捆绑在一起。它让函数即使被搬到别处执行,也仍然能访问当初定义处的外层变量:
function makeFunc() {
const name = "Mozilla";
return function displayName() {
console.log(name);
};
}
const myFunc = makeFunc(); // makeFunc 已执行完
myFunc(); // "Mozilla" —— name 仍可访问makeFunc 返回后理论上其局部变量该被回收,但返回的 displayName 通过 [[Environment]] 仍引用着那个环境,于是 name 被「闭住」、继续存活。
一句话定义
闭包 = 一个能记住并访问其外层变量的函数。在 JavaScript 里,函数在创建时自动成为闭包——所以「所有函数都是闭包」并不夸张。
计数器:闭包保存私有状态
function makeCounter() {
let count = 0; // 私有变量
return function () {
return count++; // 每次调用读改同一个 count
};
}
const counter = makeCounter();
counter(); // 0
counter(); // 1
counter(); // 2count 对外部完全不可见,只能通过返回的函数间接操作——这就是闭包做「数据封装」的基础。
每次调用生成独立闭包
每调用一次外层函数,就新建一份词法环境,于是多个闭包互不干扰:
const counter1 = makeCounter();
const counter2 = makeCounter();
counter1(); // 0
counter1(); // 1
counter2(); // 0 —— 与 counter1 完全独立函数工厂
闭包能「记住」工厂函数传入的参数,批量生产定制函数:
function makeAdder(x) {
return function (y) {
return x + y; // 记住了各自的 x
};
}
const add5 = makeAdder(5);
const add10 = makeAdder(10);
add5(2); // 7
add10(2); // 12循环里的闭包陷阱
这是闭包最著名的坑。用 var 时,整个循环只有一个 i/item 绑定,所有回调闭住的是同一个变量;等回调真正执行时,循环早已结束,它们读到的都是最后一次的值:
// ❌ var:三个回调共享同一个 item,最终都指向最后一条
for (var i = 0; i < helpText.length; i++) {
var item = helpText[i];
document.getElementById(item.id).onfocus = function () {
showHelp(item.help); // 全部显示最后一条的 help
};
}解法一:用 let / const(首选)
let/const 是块级作用域,循环每一轮都会创建一个新的绑定,每个闭包各自捕获当轮的值:
for (let i = 0; i < helpText.length; i++) {
const item = helpText[i];
document.getElementById(item.id).onfocus = () => {
showHelp(item.help); // 正确:各自捕获本轮 item
};
}解法二:函数工厂
function makeHelpCallback(help) {
return function () {
showHelp(help);
};
}
for (var i = 0; i < helpText.length; i++) {
const item = helpText[i];
document.getElementById(item.id).onfocus = makeHelpCallback(item.help);
}解法三:IIFE 当场冻结值
for (var i = 0; i < helpText.length; i++) {
(function () {
var item = helpText[i];
document.getElementById(item.id).onfocus = function () {
showHelp(item.help);
};
})(); // 立即执行,为每轮单独建一层作用域
}现代首选 let
解法一最简洁、最直观,是现代代码的默认做法;var 时代才需要工厂或 IIFE。理解后两者主要为读懂老代码。
模块模式:闭包实现私有
把变量藏进闭包、只暴露方法,就得到「带私有状态」的模块:
const counter = (function () {
let privateCounter = 0; // 外部无法直接访问
function changeBy(val) {
// 私有方法
privateCounter += val;
}
return {
increment() {
changeBy(1);
},
decrement() {
changeBy(-1);
},
value() {
return privateCounter;
},
};
})();
counter.value(); // 0
counter.increment();
counter.increment();
counter.value(); // 2
counter.decrement();
counter.value(); // 1三个返回的方法共享同一份词法环境(privateCounter 与 changeBy),外界只能通过它们间接读写,无法直接触碰私有变量。
性能与垃圾回收
闭包引用的外层环境只要可达就不会被回收——这是特性,但用不好会浪费内存。两点实践:
- 别在构造函数里给实例方法:每
new一个实例就重新创建一遍方法(和对应闭包)。共享方法应挂到prototype上,只定义一次:
// ❌ 每个实例都新建 getName 闭包
function Obj(name) {
this.name = name;
this.getName = function () {
return this.name;
};
}
// ✅ 方法挂原型,所有实例共享
function Obj(name) {
this.name = name;
}
Obj.prototype.getName = function () {
return this.name;
};- 不再需要时解除引用:长期持有的闭包(如挂在全局的回调)会让其外层变量一直存活;及时置空引用,让 GC 回收。
V8 调试器的「优化」坑
Chrome 的 V8 引擎在调试时会优化掉「代码中未被引用」的外层变量——于是断点处可能看不到某个理论上应可访问的外层变量。这是已知的调试怪象,不代表闭包没捕获它。
下一步
闭包解释了「变量怎么被记住」,而函数运行时的另一条主线是「this 指向谁」。下一页系统讲解 this 的四条规则——默认、隐式、显式、new,以及最常见的 this 丢失场景。