先看一段大多数人第一眼都会答错的代码:
javascript复制var x = 10;
function foo() {
console.log(x);
}
function bar() {
var x = 20;
foo();
}
bar(); // 输出多少?
如果你凭直觉回答是 20,那你其实还没真正掌握 JavaScript 的作用域规则;如果回答是 10,但说不出为什么,那这篇内容正好适合你。
JavaScript 使用的是词法作用域(Lexical Scoping),这决定了变量的“归属权”在代码书写阶段就已经确定,跟函数在哪里调用没有关系。很多前端人在闭包、变量提升、循环里用 setTimeout 打印索引这类问题上反复踩坑,根子都在于对词法作用域和作用域链的理解停留在“好像懂了”的层面。
这篇文章我会把词法作用域、作用域链、声明提升、暂时性死区、闭包、块级作用域,以及现代引擎的优化逻辑串起来讲清楚。不只是背概念,而是把“为什么这么设计”“执行时底层做了什么”都说透,适合刚入门不久的开发者,也适合写了几年 JS 但总觉得某些行为“诡异”的工程师。
1. 一段经典面试题暴露出的认知分水岭
先拿一个前端社区几乎人人都见过的题目开刀:
javascript复制for (var i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 100);
}
这个代码段的输出是 3 3 3,而不是 0 1 2。如果换成 let 声明:
javascript复制for (let i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 100);
}
输出就变成了 0 1 2。
很多人第一次遇到这个现象时,只记住了一句口诀:“用 let 就行,var 不行。”但这个口诀没有解释任何东西——为什么声明方式不同,输出结果会有这么大差异?
问题的本质恰恰落在词法作用域与作用域链上。
用 var 声明时,循环体内并没有构成一个独立的块级作用域,三次迭代共享同一个 i。setTimeout 里的箭头函数虽然写法上在循环体内,但它真正执行发生在 100ms 之后,那时循环早就跑完,i 已经变成 3。三个回调函数顺着各自的作用域链往外找 i,找到的是同一个变量,于是全是 3。
用 let 声明时,情况完全不同。let 支持块级作用域,而且规范要求每一轮循环都会创建一个新的词法环境,把当前这一轮的 i 值绑定进去。三个回调函数创建时分别捕获了各自那一轮的环境,虽然执行时机仍在之后,但作用域链上记录的已经是被冻结在各自迭代里的值。
也就是说,是否输出 0 1 2,关键不在于“let 比 var 聪明”,而在于每一轮循环是否为回调函数创建了独立的作用域链捕获点。
理解到这一层,你就已经踩在正确理解 JavaScript 作用域机制的门口了。接下来要解决的核心问题变成:这段变量到底归谁管?这个归属是何时确定的?函数在执行时又是沿着一条什么样的链去找到变量的?
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 词法作用域:这段变量归谁管,在写代码的那一刻就已注定
2.1 词法作用域的定义与“词法”二字的含义
词法作用域,也叫静态作用域,意思是:变量的作用域是由代码在源代码中出现的物理位置决定的。更直白一点,变量能被哪些代码访问,在解析器读到你写的代码时就已经固定了,跟程序运行时的调用关系完全无关。
为什么叫“词法”?因为 JavaScript 引擎在运行代码之前会先做词法分析,把字符流拆成一个个有意义的词法单元。在拆解过程中,引擎就能确定每个函数嵌套在哪个作用域里,从而确定标识符的解析路径。这个阶段发生在任何一行代码执行之前。
看一下这个更直接的例子:
javascript复制const a = 'global';
function outer() {
const a = 'outer';
function inner() {
console.log(a);
}
inner();
}
outer(); // 输出 'outer'
这里 inner 在 outer 内部定义,所以它的作用域链是 inner -> outer -> 全局。即使我们在全局也有一个 a,inner 往外查找时先经过 outer,找到 outer 里的 a 就停下来,于是输出 outer。
这个结果没有任何争议。真正让初学者迷惑的是下面这个变体:
javascript复制const a = 'global';
function outer() {
const a = 'outer';
function inner() {
console.log(a);
}
return inner;
}
const fn = outer();
fn(); // 输出什么?
注意,这里 inner 被返回到了全局,并在全局环境下通过 fn() 调用。很多人会想:inner 都已经离开 outer 了,现在在全局运行,是不是应该访问全局的 a,也就是输出 global?
答案依然是 outer。
因为 inner 的作用域链在定义那一刻就已经确定:它的外层是 outer 的作用域,不是调用时的全局作用域。函数走到哪儿,作用域链跟到哪儿。这也是闭包概念里最核心、也最反直觉的地方。
2.2 对比动态作用域:为什么 JS 不采用另一种模型
为了把词法作用域的重要性讲透,有必要提一下另一种模型:动态作用域。
在动态作用域的语言里(比如早期的 Lisp 方言、Perl 的某些默认配置、Bash 脚本),函数查找变量不是看自身定义在哪里,而是看调用链上每个函数的局部变量。用一个 Bash 版例子来说明:
bash复制var=global
function foo() {
echo $var
}
function bar() {
local var=bar
foo
}
bar
这段脚本输出的是 bar。为什么?因为 foo 在 bar 中被调用,动态作用域会把调用栈里最近一层的 var 找出来,也就是 bar 的局部变量 var=bar。
如果把这段逻辑换成 JavaScript 语法,大家直觉上最容易得出的答案就是这种动态作用域规则——函数在哪里被调用,就往哪里的局部作用域去找。但 JS 不是这么设计的,现代主流语言也几乎都采用词法作用域,原因非常实际:
- 可预测性:词法作用域下,看到一段代码就能判断变量指向哪里,不需要追踪运行时调用栈。
- 静态分析友好:编辑器、LSP、代码检查工具可以在不执行代码的情况下进行变量引用分析、类型推断、自动补全。
- 编译优化空间:引擎可以在执行前确定作用域链结构,提前完成变量访问优化。
动态作用域虽然在某些场景下写起来“方便”,但调试噩梦远远大于收益。JavaScript 从一开始就选择了词法作用域,并且坚持至今,这个选择深刻影响了闭包、模块化、执行上下文等几乎所有核心机制。
2.3 词法作用域的“嵌套”本质
词法作用域本质上是一棵静态的树。
全局作用域是根节点;每个函数、每个块(ES6 之后)都是树上的节点;子节点可以访问父节点上的变量,但反过来不行。这种嵌套关系与函数的调用时间完全无关,纯粹由代码的书写结构决定。
javascript复制const globalVar = 'global';
function f1() {
const f1Var = 'f1';
function f2() {
const f2Var = 'f2';
function f3() {
const f3Var = 'f3';
console.log(globalVar); // 可以访问
console.log(f1Var); // 可以访问
console.log(f2Var); // 可以访问
console.log(f3Var); // 可以访问
}
// console.log(f3Var); // Error: f3Var is not defined
f3();
}
f2();
}
f1();
f3 在 f2 里面,f2 在 f1 里面,所以 f3 可以一路向上访问所有外层变量;反过来,f1 访问不了 f2 和 f3 的局部变量。这就是作用域嵌套最基本的规则。
到这里,读者应该建立了一个意识:作用域是编译期就能确定的结构。接下来的问题是,函数执行时,这个结构如何变成一个可用的“链”?这就是作用域链的搭建过程。
3. 从函数创建到调用:作用域链到底是怎么被搭起来的
很多教材直接告诉你“作用域链是函数创建时确定的”,这句话对,但不够精确。它省去了最核心的机制,导致很多人不理解为什么函数嵌套能形成链,也不理解闭包为什么能“记住”外部变量。
要讲透作用域链的构建,必须引入 JavaScript 引擎内部的两个关键概念:词法环境(Lexical Environment) 和 外部环境引用(Outer Environment Reference)。
3.1 执行上下文与词法环境
每当 JavaScript 引擎准备执行一段代码(全局代码、函数体、eval 代码),都会创建一个执行上下文(Execution Context)。你可以把这个上下文理解为“这段代码运行所需要的全部环境信息”。
每个执行上下文中都包含一个词法环境(Lexical Environment),它负责维护当前作用域里声明的变量与函数的绑定关系。词法环境内部由两部分构成:
- 环境记录(Environment Record):存储实际的变量名和值的映射。比如
const a = 1之后,环境记录里就有一个a -> 1的条目。 - 外部环境引用(Outer Environment Reference):指向外层词法环境。这是作用域链物理上的实现基础。
当代码访问一个变量时,引擎先在当前词法环境的环境记录中查找;如果找不到,就顺着外层引用往外层词法环境找;再找不到,继续往外;直到全局词法环境。如果全局也没有,就会抛出一个 ReferenceError: xxx is not defined。
这条由环境记录和外部引用串起来的一条查找路径,就是我们常说的作用域链。
3.2 关键机制:函数对象身上的 [[Environment]]
真正让作用域链“活”起来的关键,藏在函数对象本身的内部属性里。
JavaScript 中函数也是对象。每个函数对象在被创建时,都会捕获一个内部属性 [[Environment]],这个属性指向函数被定义时所处的词法环境。
这个过程非常关键。它意味着:
- 函数定义在全局中,
[[Environment]]就指向全局词法环境; - 函数定义在另一个函数体内部,
[[Environment]]就指向那个外层函数的词法环境; - 这个内置属性在函数创建时写入,之后不会再被修改。
当函数被调用时,引擎新创建一个执行上下文,并为其词法环境设置外部环境引用。这个外层引用指向哪里?不指向调用者的词法环境,而指向函数对象身上的 [[Environment]]。
换句话说:调用位置不会影响函数的外部引用,只有创建位置才影响。
用图示思维描述一下这个流程:
- 解析阶段:定义函数
f3,捕获当前f2的词法环境,写入f3.[[Environment]]。 - 调用阶段:创建
f3的执行上下文,新建f3的词法环境,设置该词法环境的外层引用等于f3.[[Environment]](也就是f2的词法环境)。 - 作用域链成形:
f3 的 LE -> f2 的 LE -> f1 的 LE -> 全局 LE。 - 变量查找:在
f3中访问变量,沿着这条链依次查。
javascript复制function f1() {
const a = 'a from f1';
function f2() {
const b = 'b from f2';
function f3() {
const c = 'c from f3';
console.log(a, b, c);
}
f3();
}
f2();
}
f1();
当 f3() 执行时,它的作用域链结构可以展开表示为:
f3词法环境:{ c: 'c from f3' },外层引用 →f2词法环境f2词法环境:{ b: 'b from f2' },外层引用 →f1词法环境f1词法环境:{ a: 'a from f1' },外层引用 → 全局词法环境- 全局词法环境:内置对象、全局变量等
f3 虽然只声明了 c,但因为外层引用的存在,它可以访问 b、a 以及全局变量。这种访问能力不是从天上掉下来的,而是创建 f3 时捕获、调用时串联的结果。
3.3 每次调用都创建独立的环境,但链的结构始终一致
还要注意一个容易混淆的细节:函数每次被调用时,都会创建全新的词法环境。
javascript复制function count() {
let n = 0;
n++;
return n;
}
console.log(count()); // 1
console.log(count()); // 1
console.log(count()); // 1
n 不会在一次又一次调用之间累积,因为每次调用都新建了一个词法环境,n 也重新初始化为 0。函数定义时确定的是“作用域链的形状”,而不是“变量的实例”。每个变量实例都是在函数调用时被创建和初始化的。
但链条的“形状”始终不变:还是 count 的 LE -> 全局 LE。这个不变性非常重要,它是现代 JavaScript 引擎能够对变量查找做编译期优化的前提——因为作用域链结构在函数创建时就固定了,引擎可以在执行前分析出某个标识符在链上的第几层、在环境记录里的哪个位置。
理解了这一节,[[Environment]] 这个内部属性就成了打通“定义时”和“运行时”的桥梁。接下来可以进入一个更高频的话题:既然作用域链结构是提前确定的,那 var、let、const 以及“变量提升”这些声明规则,又是怎么和这条链打交道的?
4. var、let 与暂时性死区:声明规则写给词法环境的“冷面条款”
作用域链回答了“变量去哪儿找”的问题,但还有一个同样基本的问题:变量在什么时候可以被安全访问。这就不得不提 JavaScript 里最著名的几个“奇怪行为”:变量提升(Hoisting)和暂时性死区(TDZ,Temporal Dead Zone)。
4.1 变量提升的本质:声明与初始化被拆开了
先看这段代码:
javascript复制console.log(a); // undefined
var a = 1;
为什么不会报错,而是输出 undefined?
因为用 var 声明变量时,JavaScript 引擎在处理当前作用域时,会先把所有 var 声明的变量“登记”到词法环境的环境记录里,并初始化为 undefined。然后代码才逐行执行。所以 console.log(a) 执行时,a 已经存在于环境记录中,只是还没有被赋值为 1,于是输出 undefined。
这个过程看起来就像“声明被提升到了作用域顶部”,于是民间把它叫“变量提升”。但这个说法容易误导。真正发生的事是:
- 作用域确定发生在编译阶段(词法分析阶段)。
- 引擎在创建词法环境时,提前把
var声明的变量加入环境记录并初始化为undefined。 - 赋值操作仍停留在原代码位置,执行到那一行时才发生。
再看函数声明的提升:
javascript复制hello(); // 'hello'
function hello() {
console.log('hello');
}
函数声明也会被提前登记,而且整个函数对象都会被初始化好,所以调用可以在声明之前。这是引擎的“福利”:函数是JS里最常用的结构,把它提前准备好可以避免很多麻烦。
但 let 和 const 的行为完全不同。
4.2 let 和 const 的暂时性死区:访问即报错
同名代码,换成 let:
javascript复制console.log(a); // ReferenceError: Cannot access 'a' before initialization
let a = 1;
let 声明的变量确实也会被提前登记在词法环境中,但引擎不会给它初始化为 undefined。在变量声明语句执行之前,变量一直处于一种“存在但不可访问”的状态,这个区域就是暂时性死区。
为什么这样设计?回想一下 var 的行为:声明提升到顶部并初始化为 undefined,导致开发者可以顺手在声明前读取变量,得到 undefined,这几乎总是 bug。
let 的设计者决定干脆让它报错,把这种隐患从“静默失败”变成“立即暴露”。所以在 let a 声明语句执行前的任意代码中访问 a,都会得到一个 ReferenceError。
javascript复制let x = 1;
{
console.log(x); // ReferenceError: Cannot access 'x' before initialization
let x = 2;
}
这段代码尤其容易让人掉坑。外面明明已经有 x = 1,为什么块内访问还是报错?因为块内的 let x 声明让整个块成为 x 的词法作用域,块内代码从块开始就处于该 x 的暂时性死区。在解析器眼中,块内已经存在一个局部 x,它遮蔽了外部的 x;既然它还没初始化,访问就报错。
这里有一个值得专门提一嘴的细节:typeof 的安全机制在 TDZ 面前也会失效。
javascript复制typeof x; // ReferenceError: Cannot access 'x' before initialization
let x;
在 ES6 之前,typeof 是开发者判断变量是否存在最常用的方式,即使变量没有声明,typeof undeclaredVar 也只会返回 "undefined"。但遇到 TDZ 中的 let 变量时,typeof 同样会抛错。这意味着我们不能再用 typeof 去检测一个已经声明但尚未初始化的 let 变量,只能通过其他手段。
日常开发里的一个侧面印证:浏览器地址栏里常见到的 javascript:void(0),本质上也是 JS 语言规则的一部分——void 运算符让任何表达式返回 undefined,常用于让 href 不触发页面跳转。它和这里的变量声明其实都在遵守同一条语言规则:表达式在语句执行求值时才能访问已初始化的变量。你无法用 void 去跳过 TDZ 检查,该报错还是报错。
4.3 var、let、const 的语义对比
把三种声明的特征放到一张表里,方便直接对照:
| 特性 | var | let | const |
|---|---|---|---|
| 作用域 | 函数作用域 | 块级作用域 | 块级作用域 |
| 声明提升 | 是,初始化为 undefined | 是,但不初始化(TDZ) | 是,但不初始化(TDZ) |
| 重复声明 | 允许 | 不允许 | 不允许 |
| 赋值 | 可重新赋值 | 可重新赋值 | 声明时必须赋值,不可重新赋值 |
| 全局声明属性 | 挂到全局对象(如 window) | 不挂到全局对象 | 不挂到全局对象 |
const 还有一个常见误解:const 不是“值不可变”,而是“绑定不可重新赋值”。如果你 const obj = { a: 1 },修改 obj.a = 2 完全合法,因为绑定的对象引用没有变。真正不能被改变的是 obj = somethingElse 这个赋值操作。
到这里,词法作用域、作用域链、声明规则的底层机制都讲完了。接下来看作用域链在真实业务中最常见的呈现方式:闭包。
5. 闭包不是玄学:作用域链被保留下来之后的事
5.1 闭包就是“函数 + 创建时的词法环境”
闭包是一个被讲烂了的名词,但真正能把它解释清楚的人不多。最朴素的定义是:
闭包 = 函数对象 + 函数创建时的词法环境。
因为函数对象保存了 [[Environment]] 引用,所以即使函数被移动到它的词法环境之外调用,它依然可以访问当初创建时作用域链上的变量。这些变量不会被垃圾回收,因为函数对象还保持着对词法环境的引用。
看一个最经典的计数器:
javascript复制function createCounter() {
let count = 0;
return function () {
count++;
return count;
};
}
const counter = createCounter();
console.log(counter()); // 1
console.log(counter()); // 2
console.log(counter()); // 3
createCounter 执行后,它的执行上下文按理说该被销毁了。但内部返回的函数保存了对 createCounter 词法环境的引用,导致 count 所在的环境没有被回收。每次调用 counter(),都会在作用域链上找到同一个 count,所以计数能够持续递增。
如果闭包是“值的快照”,第二次调用应该还是 1。但结果是 2,说明闭包持有的是活的词法环境,不是复制出来的值。
用面向对象做个类比:对象通过属性表存储状态,对象的方法通过 this 访问这些状态;闭包则是把状态直接封闭在词法环境里,通过作用域链访问。在函数式编程风格中,闭包承担了“私有状态”的角色。
5.2 闭包在业务代码里的典型场景
闭包不只是面试考点,它在真实项目中无处不在。简单列几个最常见的用途:
数据私有化
javascript复制function createUser(name) {
let password = '';
return {
getName() {
return name;
},
setPassword(pwd) {
password = pwd;
},
checkPassword(pwd) {
return password === pwd;
},
};
}
const user = createUser('alice');
console.log(user.password); // undefined
user.setPassword('123456');
console.log(user.checkPassword('123456')); // true
外部无法直接访问 password,只能通过暴露的接口操作。这就是模块模式的基础。
回调与事件处理器
防抖、节流、请求回调、定时器,几乎都依赖闭包来维持状态。
javascript复制function debounce(fn, delay) {
let timer = null;
return function (...args) {
if (timer) clearTimeout(timer);
timer = setTimeout(() => {
fn.apply(this, args);
}, delay);
};
}
const handleResize = debounce(() => {
console.log('resize done');
}, 200);
window.addEventListener('resize', handleResize);
timer 被保存在 debounce 的词法环境中,每次触发都沿着闭包链找到并更新它。
5.3 闭包与内存:作用域链变长的代价
闭包是把双刃剑。因为闭包保持对整个词法环境的引用,而词法环境里可能不止一个变量。
javascript复制function heavy() {
const bigArray = new Array(1000000).fill('data');
const secret = 'secret';
return function () {
console.log(secret);
};
}
const fn = heavy();
这个例子中,内部函数只访问了 secret,但词法环境中同时也存在 bigArray。只要 fn 存活,整个词法环境就不会被回收,bigArray 就会一直占着内存。
这是闭包内存泄漏最常见的来源之一。很多团队在代码评审时只盯着闭包“能不能访问外层变量”,忽略了它可能“无条件拖住整个外部环境”。
经验上的处理方式:
- 尽量让闭包只引用它真正需要的变量,必要的时候把大对象解构后传入,避免整个环境被一起捕获。
- 在不需要闭包时,手动释放引用:
fn = null。 - 大型临时数据最好放在闭包外层函数的局部变量中,并在闭包创建后及时把外层引用置空。
javascript复制function heavy() {
const bigArray = new Array(1000000).fill('data');
const secret = 'secret';
const closure = function () {
console.log(secret);
};
// 把不想被捕获的变量从环境里“摘出去”
bigArray = null;
return closure;
}
当然,现代引擎已经做了大量优化,比如会分析闭包实际引用了哪些变量,尝试只保留被引用部分。但这不是规范强制行为,不能完全依赖。最稳妥的做法仍然是控制闭包的生命周期。
6. 块级作用域改变了代码组织方式:ES6 之后的世界
6.1 从“函数级作用域”到“无处不在的块级作用域”
ES6 引入 let 和 const 之后,JavaScript 的作用域体系发生了实质性的变化。从那时起,不仅函数会创建作用域,任何一对花括号 {} 都会创建块级词法环境。
javascript复制if (true) {
let a = 1;
console.log(a); // 1
}
console.log(a); // ReferenceError: a is not defined
if、for、while、switch、try/catch,以及裸花括号,都会形成块级作用域。这意味着我们在组织代码时,可以把变量的暴露范围压缩到最小可见性。
这也是“变量提升”这个概念在工程实践中逐渐淡化的原因。在 let/const 时代,声明的位置变得极其重要:把变量声明在哪个花括号里,它在哪个花括号内可见,这是完全可预测的。代码比 ES5 时代更容易推理。
6.2 循环中的 let:每轮迭代独立绑定
回到开头的 for 循环问题。用 let 声明循环变量时,每一轮迭代都会生成一个新的词法环境,并复制上一轮的值作为初值。这一机制在规范里称为“每次迭代创建新的词法环境”。
javascript复制for (let i = 0; i < 3; i++) {
setTimeout(() => console.log(i), 0);
}
// 输出 0 1 2
引擎在内部为三轮循环创建了三个独立的 i 绑定:
- 第 1 轮:环境 A,
i = 0 - 第 2 轮:环境 B,
i = 1,外层指向环境 A - 第 3 轮:环境 C,
i = 2,外层指向环境 B
每个回调函数的 [[Environment]] 分别指向对应的迭代环境。于是 100ms 后三个回调执行时,各自在自己的环境上找到了属于自己的 i。
如果没有这个机制,经典的 ES5 解决方案是 IIFE:
javascript复制for (var i = 0; i < 3; i++) {
(function (j) {
setTimeout(() => console.log(j), 0);
})(i);
}
IIFE 立刻执行并接收当前 i 作为参数,在函数参数中创建了一个新的绑定。这实际上是开发者手动模拟了“块级作用域 + 独立绑定”。ES6 只是把这个需求内置到了语言层面。
6.3 块级作用域在重构中的威力
块级作用域对代码组织的最大意义在于:它可以精准限制变量的“生命周期”。
写 ES5 代码时,很多开发者习惯把函数内部所有变量都提到顶部,因为 var 只有函数作用域,声明位置不影响可见性。但这带来一个实际烦恼:变量到底在哪里被赋值、在哪里被使用,需要读完整段函数才知道。
用 let/const 后,推荐的做法是“就近声明”:变量在靠近它第一次使用的地方声明,用完就离开块级作用域,别人看不到它。
举个例子:
javascript复制function processItems(items) {
// 第一步:排序
const sorted = [...items].sort((a, b) => a - b);
// 中间有一些与 sorted 无关的逻辑
const prefix = 'result:';
// 第二步:只在需要时定义临时变量
{
const temp = sorted.map((n) => n * 2);
console.log(temp);
}
return prefix + sorted.join(',');
}
temp 被限定在一个花括号块内,它不会污染整个函数的作用域。在更复杂的业务逻辑里,这种“局部性”能显著降低心智负担。
6.4 裸块作用域的一个实用场景:交换变量
偶尔我们会在代码里看到用裸花括号来限制临时变量可见性的写法,比如不借助第三个变量交换两个值:
javascript复制let a = 1;
let b = 2;
{
// 在新块内声明临时变量
const temp = a;
a = b;
b = temp;
}
temp 这个中间变量在这个块外不可见,块结束后会被回收。虽然现在交换变量更推荐解构赋值 [a, b] = [b, a],但裸块作用域的类型很清晰地展示了“块级边界”的工程价值。
7. 作用域链上的性能损耗与现代引擎的应对
很多开发者认为,作用域链只是概念层面的东西,和性能没有关系。这么想就低估了变量查找对运行时的影响。
7.1 作用域链查找的成本:越深越慢,但不是线性灾难
传统的变量解析流程是:当代码访问一个标识符时,引擎需要有作用域链逐层查找。如果每一层都要遍历环境记录,那嵌套越深、层数越多,查找就越慢。
不过现代 JavaScript 引擎早已不做这种“天真”的逐层查找。V8 在编译 JavaScript 代码时,会先做作用域分析,把每个标识符在作用域链上的位置预先算出来。因为在词法作用域下,作用域链结构是编译期就能确定的,所以引擎可以把变量访问编译成对固定环境记录中固定槽位的读取。大部分情况下,变量查找根本不会跑完整条链。
这就像你搬进一栋新公寓:虽然理论上你要找某个物品需要逐间屋子翻,但如果你提前在户型图上标好了“袜子在第 2 层抽屉”,那你就直接拉开抽屉就好,不用翻整栋楼。
这种优化能够生效的前提是:作用域链结构稳定。如果运行过程中有人动态改变作用域链,引擎的预分析就白做了,只能退化回逐层查找。
7.2 破坏优化的事:with 和 eval
ECMAScript 5 严格模式直接禁用了 with,就是因为 with 会向作用域链头部临时插入一个对象,这个对象是运行时才确定的东西。引擎无法在编译期知道作用域链的形状,于是所有变量查询都可能受影响。
javascript复制const obj = { a: 1 };
with (obj) {
console.log(a); // 1
}
with 内部读取 a 时,会先在 obj 上查找,如果没有再到外层环境找。这个行为看似方便,实际上让编译器彻底失去优化能力。好在严格模式禁用了它,正常工程代码不应该再写了。
eval 也有类似的破坏性。因为 eval 的字符串参数可以在执行过程中引入新的变量声明或修改现有绑定,编译器同样无法提前确定作用域链。
javascript复制function foo() {
eval('var x = 100');
console.log(x); // 100
}
foo();
看起来好像很灵活,实则是在告诉引擎:“不要优化我”。在现代工程中,eval 几乎应该被禁止使用,除非是在构建工具、代码生成器等极端场景下。
7.3 工程实践:如何让作用域链为项目服务
了解了底层逻辑后,在工程层面就有了一些明确的指导原则:
第一,避免深层的函数嵌套回调。
不是迷信“回调地狱”这个说法,而是从作用域链的角度看,每多一层嵌套,内部的函数就多一个外层环境引用。虽然现代引擎优化得很好,但过深的作用域链让代码可读性变差,也让闭包捕获的范围变大。更合理的做法是把逻辑拆分成独立函数或模块,让每个函数的作用域尽可能扁平。
javascript复制// 不推荐:层层嵌套,作用域链很长
function outer() {
let a = 1;
function middle() {
let b = 2;
function inner() {
let c = 3;
console.log(a + b + c);
}
inner();
}
middle();
}
// 推荐:拆成独立函数,各自作用域清晰
function outer() {
const a = 1;
const middle = () => a + calculateB() + calculateC();
}
第二,尽量减少全局变量的使用。
全局作用域是所有作用域链的末端终点。如果全局变量过多,每个函数的外部引用最后都要落到全局环境中,而且全局环境还容易被其他模块、第三方脚本污染。ES Module 已经是事实标准,应该把业务逻辑放到模块内部,通过 import/export 显式连接。
第三,对频繁访问的全局变量做局部缓存。
如果在一个热点函数里反复使用 document、window、Math 这类全局对象,可以在函数顶部把引用存到局部变量中:
javascript复制function updateUI() {
const doc = document;
const list = doc.getElementById('list');
// 后续频繁使用 doc,避免每次都走作用域链到全局
}
现代引擎的优化已经让这种做法的收益变小,但它是一种低成本的习惯,尤其适合使用全局对象的密集循环场景。
第四,用纯函数和模块构建清晰的作用域边界。
纯函数意味着函数的输出只依赖参数,不隐式读取外部变量。这等于在代码层面主动隔离作用域链的隐式依赖,让每个函数的作用域链变得可预测。模块则是更大的作用域容器,把一组相关的函数和数据封闭在模块内部,只暴露最少必要接口。作用域理解得越深,写出的代码边界就越干净。
7.4 从实际问题回到概念本身
当你真正理解作用域链之后,很多以前觉得“诡异”的 JavaScript 行为都会变得顺理成章。
比如用 var 在 for 循环中声明的变量会在函数作用域里“活着”,因为函数级作用域只有一层;比如在块级作用域中定义函数,不同浏览器在 ES6 之前的表现不一致,因为函数声明在块级环境中的语义直到 ES6 才被标准明确;比如模块中顶层 const 不会挂到 window 上,因为模块有自己独立的顶层词法环境。
这些问题的背后,都是同一套机制:词法作用域在编译期决定变量归属,作用域链通过环境记录和外部引用在运行时连接,闭包让这些连接可以在函数生命周期内持续存在。
我更愿意把作用域链理解成一种“信任链”。每一层词法环境都无条件信任自己的外层环境,变量查找就是沿着信任链向上请求帮助的过程。一个函数能访问哪些变量,不取决于它被谁调用,而取决于它在代码中被写在哪一层“信任圈”里。想清楚这一点,JavaScript 中 90% 的变量访问问题都能迎刃而解。
我自己的习惯是,在写任何一段稍复杂的 JS 代码之前,先在脑子里画出当前函数的作用域链结构:我处在第几层?我有哪些外层变量可以用?我定义的内部函数将来会在哪里执行,它需要访问哪些变量?不需要访问的变量,尽量不要让它出现在闭包能捕获的范围内。这套思维方式帮我在很多大型前端项目里避免过难以追踪的隐性依赖问题。如果你也经常被变量访问路径搞晕,不妨从“画作用域链”这个动作开始练习。
