你不知道的JavaScript 第二章:词法作用域

40 阅读5分钟

你不知道的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 后)生成词法作用域的 “快照”

  • 记录当前作用域内声明的所有变量 / 函数;
  • 记录该作用域嵌套的外层作用域(即 “父作用域”)。

foobar的嵌套为例,编译期会生成这样的 “作用域关系树”:

全局作用域
  ↳ foo作用域(包含变量a、函数bar)
    ↳ bar作用域

2. 运行时的 “作用域链查找”

当代码运行时,引擎查找变量会严格遵循编译期确定的 “作用域链”:

  • 从当前执行的函数 / 块的作用域开始查找;
  • 若找不到,就沿着 “父作用域→父父作用域→…→全局作用域” 的链向上查找;
  • 一旦找到目标变量,立即停止查找;若全局作用域也找不到,抛出ReferenceError

这个过程是 “静态且确定” 的 —— 无论函数在何处被调用、如何被调用,它的作用域链都由书写时的嵌套关系决定。

三、“欺骗词法” 的尝试:evalwith的风险

在 JavaScript 中,有两种特殊语法试图 “打破” 词法作用域的静态规则,即evalwith。但它们的使用会带来严重的性能和可读性问题,属于 “不推荐的黑魔法”。

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;
  • 代码逻辑变得模糊(无法直观判断变量属于哪个作用域)。

因此,evalwith在生产代码中应完全避免使用

四、词法作用域 vs 动态作用域:“静态” 与 “动态” 的本质差异

为了更清晰地理解词法作用域的 “静态性”,我们可以对比它与动态作用域(如 Bash 脚本、早期的 Lisp)的区别:

对比维度词法作用域(JavaScript)动态作用域
作用域确定时机编译期(代码书写时)运行期(函数调用时)
变量查找规则沿着 “书写时的嵌套作用域链” 查找沿着 “函数调用的调用栈” 查找
典型代表JavaScript、C、JavaBash、部分 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 作用域的基石,它通过 “代码书写时的静态绑定”,保证了作用域规则的可预测性和引擎优化的可能性。虽然evalwith试图打破这一规则,但它们带来的性能损耗和可读性问题使其不具备实用价值。

理解词法作用域,不仅能帮你解决 “变量找不到”“作用域链混乱” 等问题,更能让你在设计模块、封装代码时,利用作用域的静态特性写出更健壮、更高效的 JavaScript 程序。下一章我们将深入 “函数作用域和块作用域”,看看词法作用域在具体语法结构中的体现。