深入理解 JavaScript 词法作用域:为什么你的代码“写在哪”决定了它的命运?

85 阅读5分钟

前言:你写的代码,决定了它的“命运”

在 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 引擎查找一个变量时,它遵循以下规则:

  1. 从当前作用域开始查找。
  2. 如果找不到,就向上一级作用域查找。
  3. 重复此过程,直到全局作用域。
  4. 如果全局作用域也找不到,抛出 ReferenceError

“遮蔽效应”(Shadowing)

如果内层作用域定义了与外层同名的变量,内层变量会“遮蔽”外层变量。

var a = 1;

function foo() {
    var a = 2; // 遮蔽了全局的 a
    console.log(a); // 输出 2
}

foo();
console.log(a); // 输出 1

查找会在第一个匹配的标识符处停止,不会继续向上查找同名变量。


四、词法欺骗:evalwith 的危险游戏

虽然词法作用域在代码编写时就已确定,但 JavaScript 提供了两个“后门”可以动态修改作用域:evalwith

⚠️ 它们被称为“词法欺骗”,不仅破坏代码可读性,还会导致性能严重下降。

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
  • 这个 bfoo 作用域的一部分,遮蔽了全局的 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 最危险的地方:它让变量声明变得不可预测


五、为什么 evalwith 会严重降低性能?

JavaScript 引擎在执行代码前会进行优化,比如:

  • 提前确定变量的存储位置(是局部变量还是全局变量)。
  • 缓存作用域查找结果。

evalwith 的存在让引擎无法确定作用域结构

  • eval 可能动态创建变量,改变作用域。
  • with 让对象属性变成变量,作用域变得动态。

因此,只要代码中存在 evalwith,引擎就必须关闭所有相关的优化,导致代码运行速度大幅下降。


六、最佳实践:远离词法欺骗

建议说明
永远不要使用 with它已被现代 JS 弃用,是代码的“毒瘤”
避免使用 eval除非极端情况(如动态加载 JSONP),否则用 JSON.parse 替代
使用严格模式 'use strict'防止意外创建全局变量,限制 eval 的危害
优先使用 let/const块级作用域更清晰,减少变量污染

七、总结:词法作用域的核心原则

原则说明
写在哪,定在哪作用域由代码书写位置决定,与调用位置无关
从内到外查找作用域查找从当前作用域开始,逐级向上
遮蔽效应内层同名变量会遮蔽外层变量
避免欺骗evalwith 破坏作用域确定性,降低性能
严格模式是朋友帮助你写出更安全、更可预测的代码

结语:掌握词法作用域,才能写出“可预测”的代码

词法作用域是 JavaScript 的灵魂特性之一。
理解它,你才能:

  • 避免闭包陷阱
  • 写出模块化的代码
  • 理解 this 和作用域的区别
  • 写出高性能、可维护的应用

建议:尝试用作用域气泡图分析你的代码结构,你会对变量的生命周期有全新的认识。