你不知道的Javascript(上卷) | 第二章难点与细节解读(词法作用域)

1 阅读9分钟

作为《你不知道的Javascript》忠实读者,多次拜读该著作,本专栏用来分享我对该书的解读,适合希望深入了解这本书的读者阅读 电子书下载网址:zh.101-c.online

第二章——词法作用域

本章在《你不知道的Javascript(上卷)》中并没用较大的篇幅去描述,书中所讲主要聚焦在定义阶段,以及欺骗词法,我们的解读将由“就是这样”向“为什么是这样”转变。下面我们去讨论一些问题。

一、为何是词法作用域?

原文表述

image.png Javascript便是使用的词法作用域而非动态作用域,那么什么是词法作用域,什么是动态作用域呢?(这里做一个总结,有书中内容,也有总结内容)

词法作用域(Lexical Scope)​

定义:词法作用域(也称为静态作用域)是由代码的书写结构决定的,在代码编译阶段(词法分析时)就确定了变量的作用域,不会在运行时改变。

特点

  1. 由代码结构决定:作用域在代码编写时就固定,函数的作用域取决于它被定义的位置,而不是调用的位置。
  2. JavaScript 采用词法作用域:JavaScript 的作用域规则是词法作用域。
  3. 闭包的基础:由于词法作用域的存在,函数可以访问定义时的外层变量,即使在外层函数执行完毕后仍然有效(闭包)。

示例

var a = 10;
function foo() {
    console.log(a); // 10(查找 foo 定义时的作用域,而非调用时的作用域)
}
function bar() {
    var a = 20;
    foo(); // 仍然输出 10,因为 foo 的词法作用域在全局
}
bar();

由于 foo 在全局定义,它的作用域链在编译时就确定了,即使 bar 内部有同名变量 afoo 仍然访问全局的 a


动态作用域(Dynamic Scope)​

定义:动态作用域是在运行时根据调用栈决定的,函数的作用域取决于它被调用的位置,而不是定义的位置。

特点

  1. 由调用链决定:变量的查找基于函数的调用顺序,而不是代码结构。
  2. JavaScript 默认不支持动态作用域,但 this 的绑定机制(如 callapplybind)有些类似动态作用域的行为。
  3. Bash、Perl 等语言支持动态作用域

示例(假设 JavaScript 是动态作用域)​

var a = 10;
function foo() {
    console.log(a); // 如果是动态作用域,这里会输出 20
}
function bar() {
    var a = 20;
    foo(); // 动态作用域下,foo 会查找 bar 的 a
}
bar();

如果 JavaScript 是动态作用域,foo 会查找调用它的 bar 的作用域,输出 20。但实际 JavaScript 是词法作用域,仍然输出 10

那么之后我们就进入本篇文章的第一个要点 “为什么Javascript要使用词法作用域,而不使用动态作用域?”,想必各位也能想出其中一二,比如动态作用域会使语言的作用域不清晰,不便于开发;动态作用域使代码性能下降等,这里我们做一个系统的分析和总结

1. 可预测性与代码可维护性

  • 词法作用域 在代码 ​编写时 就确定了变量的作用域,开发者能清晰知道一个变量来自哪里(例如函数定义时的外层作用域)。
  • 动态作用域 的变量查找依赖运行时调用链,导致代码行为难以预测,尤其是大型项目或嵌套调用时。

示例对比

// 词法作用域(可预测)
var x = 10;
function foo() { console.log(x); }
function bar() { var x = 20; foo(); }
bar(); // 输出 10(foo 始终访问定义时的 x)

// 如果是动态作用域(不可预测)
bar(); // 会输出 20(foo 访问调用时的 bar 的 x)

动态作用域下,foo 的输出会因调用位置不同而变化,增加调试难度。


2. 性能优化

  • 词法作用域 在编译阶段即可确定变量引用,引擎可以优化作用域链的查找(如静态分析、内联缓存)。
  • 动态作用域 需要在运行时动态解析变量,每次调用都可能重新计算作用域链,显著降低性能。

底层优化
V8 引擎通过 ​隐藏类(Hidden Classes)​ 和 ​内联缓存(Inline Caches)​ 加速词法作用域的变量访问,而动态作用域无法应用这些优化。


3. 闭包的支持

  • 词法作用域是闭包的基础:函数可以记住并访问定义时的作用域,即使在外层函数执行完毕后仍然有效。
  • 动态作用域无法实现闭包,因为变量的绑定在运行时才确定。

闭包示例

function outer() {
    var x = 10;
    function inner() { console.log(x); } // 记住 outer 的 x
    return inner;
}
var fn = outer();
fn(); // 输出 10(词法作用域允许闭包)

如果 JavaScript 是动态作用域,inner 无法可靠访问 x,因为它的值取决于调用时的上下文。


4. 模块化的天然支持

  • 词法作用域 允许通过函数嵌套和闭包实现模块化(如 IIFE 模式)。
  • 动态作用域 的变量容易受外部调用污染,难以隔离作用域。

模块化示例

// 模块模式(依赖词法作用域)
var module = (function() {
    var privateVar = 1;
    return { get: function() { return privateVar; } };
})();
module.get(); // 1(privateVar 被保护)

动态作用域下,privateVar 可能被外部代码意外修改。


5. 历史与语言设计哲学

  • JavaScript 受 Scheme/Lisp 影响:Brendan Eich 在设计 JavaScript 时借鉴了 Scheme 的词法作用域特性,强调函数式编程的简洁性。
  • 动态作用域更适合脚本语言:如 Bash、Perl,它们需要频繁依赖运行时上下文,但牺牲了可维护性。

至此,我们的第一部分完结

二、欺骗词法的性能问题

书中欺骗词法的讲述较为清晰,这里我只提一下 “欺骗词法的性能问题”

先来看一下原文表述

image.png

image.png 原文的表述主要聚焦在欺骗词法破坏了代码词法的静态分析,在一定程度上出现了“动态作用域的性能问题”,经管Javascript是词法作用域,但是欺骗词法是词法作用域的优势不再。那有没有更多的角度去分析呢?——

JavaScript 引擎(如 V8)在编译阶段会进行 ​静态作用域分析,优化变量访问,而 evalwith 等动态作用域操作会破坏这种优化,导致性能下降。具体原因如下:

1. 破坏作用域静态分析

(1) 词法作用域的优化机制

  • 编译阶段:引擎在代码执行前就能确定变量属于哪个作用域,生成高效的字节码或机器码。

    function foo() {
        var a = 1;
        console.log(a); // 引擎知道 a 是局部变量,直接访问栈内存
    }
    
  • 优化手段

    • 内联缓存(Inline Cache)​:缓存变量位置,避免重复查找。
    • 隐藏类(Hidden Class)​:快速定位对象属性。

(2) 动态作用域的破坏

eval 或 with 会让引擎无法在编译阶段确定变量来源,必须 ​运行时动态解析

function riskyEval(str) {
    eval(str); // 可能插入新变量,如 eval("var b = 2;")
    console.log(b); // 引擎无法提前知道 b 是否存在
}
riskyEval("var b = 3;");

后果

  • 引擎无法预分配变量存储位置(栈/堆)。
  • 所有变量访问退化为 ​慢速的动态查找​(类似哈希表查询)。

2. 禁用 JIT 优化

(1) 去优化(Deoptimization)​

现代 JavaScript 引擎(如 V8)会先快速生成未优化的字节码,运行中如果发现热点代码(频繁执行),则用 ​JIT(Just-In-Time)编译 生成优化后的机器码。
动态作用域操作会触发去优化

function unstable(x) {
    with ({ x: 1 }) {
        return x; // 引擎无法静态确定 x 的来源
    }
}
// 首次执行:生成未优化代码
unstable(10); 
// 后续执行:发现 with 无法优化,回退到解释执行

后果

  • 优化后的机器码被丢弃,回退到慢速的解释执行模式。
  • 性能可能下降 ​10~100 倍

(2) 无法内联缓存(Inline Cache)​

  • 正常情况:引擎会缓存对象属性的内存偏移量,加速访问:

    obj.a; // 第一次访问记录位置,后续直接跳转
    
  • 动态作用域下

    with (obj) {
        console.log(a); // 无法缓存,每次都要查找
    }
    

    每次访问都需重新计算属性位置,类似 HashMap.get("a"),速度极慢。


3. 内存与安全开销

(1) 作用域链膨胀

eval 可能意外注入变量,污染作用域:

function leaky() {
    eval("var secret = 123;");
    // secret 泄漏到函数作用域,可能被闭包长期持有
}

后果

  • 变量无法被及时垃圾回收,增加内存占用。
  • 作用域链变长,变量查找时间增长。

(2) 安全风险

动态代码执行(如 eval)可能引发 XSS 攻击或意外行为,引擎会启用额外的安全检查,进一步拖慢速度。

结语

JavaScript 的词法作用域设计体现了语言在灵活性与性能之间的精妙平衡。通过静态作用域规则,JavaScript 既保证了代码的可预测性和可维护性,又为引擎优化提供了坚实基础。 这种设计让开发者能够构建复杂的模块化系统,同时享受现代 JIT 编译器带来的极致性能。词法作用域不仅是 JavaScript 的核心特性,更是理解闭包、this 绑定等高级概念的关键入口。

然而,这种优雅的设计也划定了明确的边界。任何试图"欺骗"词法作用域的操作(如 eval 和 with)都会破坏引擎的静态分析能力,导致严重的性能惩罚。 这提醒我们:在追求动态灵活性的同时,必须尊重语言的核心设计哲学。理解这些底层机制,不仅能帮助我们写出更高效的代码,更能深入体会 JavaScript 作为一门精心设计的语言所蕴含的智慧。

第二章的内容还是太少了,我更期待第三章的内容......