你不知道的JavaScript 第二章:词法作用域
在 JavaScript 的世界里,作用域的规则并非 “运行时动态变化” 的魔术,而是在代码书写阶段就被静态确定的 “契约”—— 这就是第二章核心内容 “词法作用域” 的本质。本文将从词法作用域的定义、工作机制、“欺骗词法” 的风险与实践,以及它与动态作用域的对比四个维度,带你建立对 JavaScript 作用域最底层的认知。
一、词法作用域的定义:代码书写时的 “静态绑定”
词法作用域(Lexical Scoping) 是 JavaScript 默认的作用域规则,其核心逻辑是:作用域由代码中变量和函数的 书写位置 决定,在词法分析阶段(编译的一部分)就已确定,与运行时的调用方式无关。
举个直观的例子:
function foo() {
var a = 2;
function bar() {
console.log(a); // 2
}
bar();
}
foo();
在这段代码中,bar函数能访问foo作用域中的a,是因为在代码书写时,bar就嵌套在foo内部—— 词法作用域在编译时就记录了这个嵌套关系,所以运行时bar执行时,会沿着 “bar作用域→foo作用域→全局作用域” 的链查找a。
二、词法作用域的工作机制:“编译期的作用域快照”
要理解词法作用域的工作细节,需结合编译流程和作用域链的查找规则:
1. 编译期的 “作用域记录”
当 JS 引擎对代码进行词法分析时,会为每个函数、块(ES6 后)生成词法作用域的 “快照” :
- 记录当前作用域内声明的所有变量 / 函数;
- 记录该作用域嵌套的外层作用域(即 “父作用域”)。
以foo和bar的嵌套为例,编译期会生成这样的 “作用域关系树”:
全局作用域
↳ foo作用域(包含变量a、函数bar)
↳ bar作用域
2. 运行时的 “作用域链查找”
当代码运行时,引擎查找变量会严格遵循编译期确定的 “作用域链”:
- 从当前执行的函数 / 块的作用域开始查找;
- 若找不到,就沿着 “父作用域→父父作用域→…→全局作用域” 的链向上查找;
- 一旦找到目标变量,立即停止查找;若全局作用域也找不到,抛出
ReferenceError。
这个过程是 “静态且确定” 的 —— 无论函数在何处被调用、如何被调用,它的作用域链都由书写时的嵌套关系决定。
三、“欺骗词法” 的尝试:eval与with的风险
在 JavaScript 中,有两种特殊语法试图 “打破” 词法作用域的静态规则,即eval和with。但它们的使用会带来严重的性能和可读性问题,属于 “不推荐的黑魔法”。
1. eval(...):运行时动态修改作用域
eval的作用是将传入的字符串当作代码执行,并试图在当前作用域中 “注入” 变量声明。例如:
function foo(str) {
eval(str); // 执行"var a = 2"
console.log(a); // 2
}
foo("var a = 2;");
在编译期,引擎无法预知eval会注入什么代码,因此会放弃对foo作用域的优化(因为作用域可能被动态修改)。这会导致:
- 性能严重下降(引擎无法提前编译优化);
- 代码可读性极差(变量来源不明确)。
在严格模式下,eval会被限制在 “私有作用域” 中执行,无法污染外层作用域,进一步削弱了它 “欺骗词法” 的能力。
2. with:运行时动态创建作用域
with的作用是将对象当作作用域,试图让对象的属性 “看似” 是当前作用域的变量。例如:
var obj = { a: 1, b: 2 };
with (obj) {
a = 3;
b = 4;
c = 5; // 非严格模式下,c会成为全局变量
}
console.log(obj.a); // 3
console.log(obj.b); // 4
console.log(c); // 5(非严格模式下的全局变量)
with的问题更严重:
- 编译期无法确定
with块内的变量是对象属性还是新变量,引擎同样会放弃优化; - 非严格模式下可能意外创建全局变量,引发难以排查的 bug;
- 代码逻辑变得模糊(无法直观判断变量属于哪个作用域)。
因此,eval和with在生产代码中应完全避免使用。
四、词法作用域 vs 动态作用域:“静态” 与 “动态” 的本质差异
为了更清晰地理解词法作用域的 “静态性”,我们可以对比它与动态作用域(如 Bash 脚本、早期的 Lisp)的区别:
| 对比维度 | 词法作用域(JavaScript) | 动态作用域 |
|---|---|---|
| 作用域确定时机 | 编译期(代码书写时) | 运行期(函数调用时) |
| 变量查找规则 | 沿着 “书写时的嵌套作用域链” 查找 | 沿着 “函数调用的调用栈” 查找 |
| 典型代表 | JavaScript、C、Java | Bash、部分 Lisp 方言 |
举个例子,看两者的差异:
// 词法作用域的逻辑(JavaScript)
function foo() {
console.log(a);
}
function bar() {
var a = 3;
foo(); // 查找a时,沿foo的词法作用域链(foo→全局),所以找全局的a=2
}
var a = 2;
bar(); // 输出2
# 动态作用域的逻辑(Bash脚本)
foo() {
echo $a
}
bar() {
local a=3
foo
}
a=2
bar # 输出3(因为沿调用栈查找,bar是foo的调用者,所以找bar的a=3)
可见,词法作用域的 “静态绑定” 让代码的作用域规则可预测、易维护,这也是它成为 JavaScript 默认作用域模型的根本原因。
五、总结:词法作用域是 JavaScript 的 “静态契约”
词法作用域是 JavaScript 作用域的基石,它通过 “代码书写时的静态绑定”,保证了作用域规则的可预测性和引擎优化的可能性。虽然eval和with试图打破这一规则,但它们带来的性能损耗和可读性问题使其不具备实用价值。
理解词法作用域,不仅能帮你解决 “变量找不到”“作用域链混乱” 等问题,更能让你在设计模块、封装代码时,利用作用域的静态特性写出更健壮、更高效的 JavaScript 程序。下一章我们将深入 “函数作用域和块作用域”,看看词法作用域在具体语法结构中的体现。