前言:你写的代码,决定了它的“命运”
在 JavaScript 中,变量在哪里声明,决定了它在哪里可被访问。
这听起来理所当然,但背后隐藏着一个核心概念:词法作用域(Lexical Scope)。
与之相对的是动态作用域(Dynamic Scope),它根据“函数如何被调用”来决定变量的查找路径。
而 JavaScript 采用的是词法作用域——作用域在你写代码的那一刻就已经确定了,与函数如何调用无关。
本文将带你深入理解词法作用域的工作模型、作用域查找机制,以及那些“欺骗”词法作用域的危险操作。
一、词法作用域 vs 动态作用域:两种世界观
1. 词法作用域(JavaScript 的选择)
作用域由代码的书写位置决定。
function foo() {
var a = 1;
bar(); // bar 在全局作用域中定义
}
function bar() {
console.log(a); // ❌ 报错!a is not defined
}
foo();
bar函数在全局作用域中定义,因此它的作用域链只包含全局作用域。- 尽管
bar是在foo中调用的,但它无法访问foo的内部变量a。
这就是词法作用域:定义时决定,调用时无关。
2. 动态作用域(假设)
如果 JavaScript 使用动态作用域,上面的代码会输出 1,因为 bar 是在 foo 中调用的,会继承 foo 的作用域。
但 JavaScript 不是这样工作的。
类比:词法作用域像“户籍制度”——你出生在哪,户籍就在哪;动态作用域像“临时居住”——你现在在哪,就归哪管。
二、词法阶段:作用域气泡的形成
当你写下代码时,JavaScript 引擎会为每个函数创建一个“作用域气泡”。
示例:
var a = 1;
function outer() {
var b = 2;
function inner() {
var c = 3;
console.log(a, b, c);
}
inner();
}
outer();
作用域结构如下:
全局作用域
├── a = 1
├── outer 函数作用域
│ ├── b = 2
│ └── inner 函数作用域
│ └── c = 3
- 每个函数都创建一个独立的作用域气泡。
- 气泡的嵌套关系由代码的书写位置决定。
- 引擎根据这个结构进行变量查找。
三、作用域查找:从内到外的“寻亲之旅”
当 JavaScript 引擎查找一个变量时,它遵循以下规则:
- 从当前作用域开始查找。
- 如果找不到,就向上一级作用域查找。
- 重复此过程,直到全局作用域。
- 如果全局作用域也找不到,抛出
ReferenceError。
“遮蔽效应”(Shadowing)
如果内层作用域定义了与外层同名的变量,内层变量会“遮蔽”外层变量。
var a = 1;
function foo() {
var a = 2; // 遮蔽了全局的 a
console.log(a); // 输出 2
}
foo();
console.log(a); // 输出 1
查找会在第一个匹配的标识符处停止,不会继续向上查找同名变量。
四、词法欺骗:eval 和 with 的危险游戏
虽然词法作用域在代码编写时就已确定,但 JavaScript 提供了两个“后门”可以动态修改作用域:eval 和 with。
⚠️ 它们被称为“词法欺骗”,不仅破坏代码可读性,还会导致性能严重下降。
1. eval(..):动态执行字符串代码
eval 会将传入的字符串当作 JavaScript 代码执行。
function foo(str, a) {
eval(str); // 执行 "var b = 3;"
console.log(a, b); // 1, 3
}
var b = 2;
foo("var b = 3;", 1);
console.log(b); // 2
发生了什么?
eval("var b = 3;")在foo函数内部创建了一个新的b。- 这个
b是foo作用域的一部分,遮蔽了全局的b。 - 所以
foo内部输出1, 3,而全局b仍是2。
⚠️ 严格模式下的 eval
在严格模式下,eval 有自己的作用域,不会影响外层:
function foo(str, a) {
'use strict';
eval(str);
console.log(a, b); // ReferenceError: b is not defined
}
2. with 关键字:将对象变为作用域
with 可以将一个对象的属性当作变量来访问。
function foo(obj) {
with (obj) {
a = 2; // 直接写 a,不写 obj.a
}
}
var o1 = { a: 3 };
var o2 = { b: 3 };
foo(o1);
console.log(o1.a); // 2 —— 修改成功
foo(o2);
console.log(o2.a); // undefined —— o2 没有 a 属性
console.log(a); // 2 —— a 被泄漏到全局作用域!
为什么 a 被泄漏到全局?
with (o2)时,o2没有a属性。a = 2是一个 LHS 查询(赋值目标)。- 在
with块中找不到a,引擎向上查找,在全局作用域也找不到。 - 非严格模式下,引擎会在全局创建一个
a变量,导致污染全局命名空间。
这是
with最危险的地方:它让变量声明变得不可预测。
五、为什么 eval 和 with 会严重降低性能?
JavaScript 引擎在执行代码前会进行优化,比如:
- 提前确定变量的存储位置(是局部变量还是全局变量)。
- 缓存作用域查找结果。
但 eval 和 with 的存在让引擎无法确定作用域结构:
eval可能动态创建变量,改变作用域。with让对象属性变成变量,作用域变得动态。
因此,只要代码中存在 eval 或 with,引擎就必须关闭所有相关的优化,导致代码运行速度大幅下降。
六、最佳实践:远离词法欺骗
| 建议 | 说明 |
|---|---|
永远不要使用 with | 它已被现代 JS 弃用,是代码的“毒瘤” |
避免使用 eval | 除非极端情况(如动态加载 JSONP),否则用 JSON.parse 替代 |
使用严格模式 'use strict' | 防止意外创建全局变量,限制 eval 的危害 |
优先使用 let/const | 块级作用域更清晰,减少变量污染 |
七、总结:词法作用域的核心原则
| 原则 | 说明 |
|---|---|
| 写在哪,定在哪 | 作用域由代码书写位置决定,与调用位置无关 |
| 从内到外查找 | 作用域查找从当前作用域开始,逐级向上 |
| 遮蔽效应 | 内层同名变量会遮蔽外层变量 |
| 避免欺骗 | eval 和 with 破坏作用域确定性,降低性能 |
| 严格模式是朋友 | 帮助你写出更安全、更可预测的代码 |
结语:掌握词法作用域,才能写出“可预测”的代码
词法作用域是 JavaScript 的灵魂特性之一。
理解它,你才能:
- 避免闭包陷阱
- 写出模块化的代码
- 理解
this和作用域的区别 - 写出高性能、可维护的应用
建议:尝试用作用域气泡图分析你的代码结构,你会对变量的生命周期有全新的认识。