作用域与闭包:函数如何找到变量
一个函数可以在别处被调用,却仍然读取定义位置的变量。要解释这种行为,先把“代码在哪里定义”和“代码从哪里调用”分开。
查名字沿着定义位置向外找
const topic = '全书';
function readTopic() {
return topic;
}
function section() {
const topic = '本章';
return readTopic();
}
console.log(section()); // 全书readTopic 内没有局部 topic,于是沿着它定义时的外层作用域查找。section 的局部变量不是它的词法外层,只是调用现场的变量,因此不会被取到。这叫词法作用域。
模块作用域 topic='全书'
├── readTopic → 查找 topic 回到模块
└── section 内 topic='本章' → 只对其词法内部可见作用域链与调用栈是两张不同的图:前者解释名字解析,后者记录当前调用怎样进入和返回。把它们混为一谈,就容易以为“谁调用我,我就用谁的变量”。
let、const 与 var 的生命周期
let 和 const 以块为边界;var 以最近的函数或全局脚本为边界,不以普通 if 块为边界。模块又有独立作用域,顶层声明不等于 globalThis 上的属性。
function compare() {
if (true) {
var visible = 1;
let local = 2;
}
console.log(visible); // 1
// console.log(local); // ReferenceError
}
compare();在作用域执行前,绑定会被建立。var 被初始化为 undefined,函数声明会关联函数值;let/const 在执行到声明初始化前不能读取,这段区间叫暂时性死区(TDZ)。
console.log(oldValue); // undefined
var oldValue = 3;
// 下两行请作为独立失败实验运行:
// console.log(newValue); // ReferenceError
// let newValue = 3;“提升”是描述初始化行为的比喻,不是引擎真的把源代码挪到文件顶端。typeof notDeclared 可以返回 'undefined',但 typeof 读取 TDZ 内的变量仍然抛错。内层同名 let 从块开始就遮蔽外层,不能在初始化前穿过去读取外面的值。
都发生在“声明前”,错误却未必相同
把函数声明、var保存的函数表达式、let保存的函数表达式并排思考:
| 写法 | 执行到初始化前的绑定状态 | 提前调用 |
|---|---|---|
| function声明 | 已关联函数对象 | 能调用 |
| var加函数表达式 | 已初始化为undefined | TypeError:找到的值不能调用 |
| let/const加函数表达式 | 已建立但尚未初始化 | ReferenceError:此时不能读取 |
以下代码中的失败行默认注释;每组分别取消注释观察,不要放在一起要求所有结果都能继续输出。
console.log(declared()); // 1
function declared() { return 1; }
// console.log(byVar()); // TypeError
var byVar = function () { return 2; };
// console.log(byLet()); // ReferenceError
let byLet = function () { return 3; };TypeError并非名字找不到:如果没找到,为什么能知道它保存了undefined?ReferenceError也不总是没声明:TDZ恰好是已经找到了内层绑定,但它尚不可读。错误类型提供了“解析名字成功了吗、取到的值能做什么”的线索。
重复声明也由绑定规则决定:同一词法作用域不能重复声明let/const,也不能再用var占用同一名字;不同嵌套块可以有同名变量。旧非严格脚本中块内函数声明存在额外历史规则,本书模块基线不把这些例外当主线写法。
闭包保存的是访问绑定的能力
function makeCounter(start) {
let count = start;
return function () {
count += 1;
return count;
};
}
const first = makeCounter(10);
const second = makeCounter(10);
console.log(first(), first(), second()); // 11 12 11外层调用结束后,返回的函数仍然能访问那次调用的 count。这就是闭包发挥作用的地方。first 两次调用使用同一个绑定,所以递增;第二次执行 makeCounter 创建另一套局部环境,所以 second 独立。
闭包不是把 count 的数值截屏保存在函数里,而是保留对绑定的访问。只要仍能通过函数访问所需环境,其中的数据就可能继续存活。闭包本身不是内存泄漏;长期保存闭包且无意保留巨大数据才可能形成问题。
同一次调用可以让多个函数共享一份状态
计数器只返回一个函数,容易让人误以为“每个闭包各自有一份拷贝”。下面返回一个包含函数属性的对象:一个读取页数,一个推进页数,另一个读取初始描述。read() { return page; } 是对象方法的简写,read是属性名,花括号中是函数体;progress.read() 取出并调用它。
function makeProgress() {
let page = 1;
const initialText = `初始页数:${page}`;
return {
read() { return page; },
advance() { page += 1; },
describeInitial() { return initialText; },
};
}
const progress = makeProgress();
console.log(progress.read()); // 1
progress.advance();
console.log(progress.read()); // 2
console.log(progress.describeInitial()); // 初始页数:1
console.log(makeProgress().read()); // 1read与advance都访问同一个page绑定,所以读函数能看到写函数的结果。initialText则在创建时计算成一个字符串,字符串不会随page改变。describeInitial同样是闭包,却读的是另一个没有再赋值的绑定;区别不是“有无闭包”,而是捕获了哪个绑定,以及这个绑定后来如何变化。
第一次 makeProgress 的环境
page ── 2 ← read和advance共同访问
initialText ── '初始页数:1'
第二次 makeProgress 的环境
page ── 1 ← 另一套状态如果需要保存当时的值,显式建立snapshot绑定;如果需要最新值,保留对会更新的绑定的读取。避免一句“闭包保存变量”同时承担两种互相矛盾的需求。
当这些函数不再可达,其使用的环境才可能不再需要保留。引擎对捕获变量有优化空间,不能从源码里看见一个大数组,就断言同作用域的所有闭包必定永久保留它;判断泄漏需要实际可达关系与运行观察。
循环闭包为什么常错在 var
const oldReaders = [];
for (var i = 0; i < 3; i += 1) {
oldReaders.push(() => i);
}
console.log(oldReaders[0](), oldReaders[1]()); // 3 3
const readers = [];
for (let j = 0; j < 3; j += 1) {
readers.push(() => j);
}
console.log(readers[0](), readers[1]()); // 0 1push 把函数放到数组末尾,readers[0]() 取出第一个再调用;完整数组方法下一章学习。第一组函数共享一个 var i,调用时循环已经结束。for (let...) 为每轮提供对应绑定,函数因此保留不同的 j。不需要引入计时器或异步,这个同步例子已经证明闭包问题。
旧代码可以用立即调用函数表达式(IIFE)把每轮值作为参数传入,得到独立函数作用域:
const readers = [];
for (var i = 0; i < 3; i += 1) {
const read = (function (current) {
return function () { return current; };
})(i);
readers.push(read);
}
console.log(readers[0](), readers[1](), readers[2]()); // 0 1 2外层括号把function当作表达式,后面的 (i) 立即调用它。每轮把i当时的数值传给新的current绑定,返回函数保存对current的访问;循环随后修改i不会修改那些局部参数。这是在补足每轮绑定,并非把函数推迟到循环外执行。
现代 for (let...) 已提供每轮绑定,通常不用再包IIFE;需要立即算出一个值并隐藏临时变量时,IIFE仍有用途。它引入真正的函数边界,return结束的是这次调用,内部break/continue也不能越过函数去控制外层循环,不应把任何代码块机械套进去。
模块边界和动态求值
模块顶层变量由模块自己管理,不必靠 IIFE 隐藏。直接 eval 会引入运行时代码与作用域复杂性,new Function 创建的函数不闭包捕获当前局部作用域;不能用它们代替普通函数组织。严格模式禁止 with,因为动态属性查找会破坏名字来源的可预测性。这里说明边界,模块与执行机制在后续章节深入。
练习
写
makePrefix(prefix)返回函数,使makePrefix('JS:')('闭包')得到'JS:闭包'。解释 prefix 的存活原因。为什么
let n = 1; { console.log(n); let n = 2; }抛错?为什么不能读到外层的 1?为什么read和advance可以共享page,而describeInitial仍显示1?提前调用var保存的函数为何不是TDZ的ReferenceError?