你不知道的Javascript(上卷) | 第一章难点与细节解读(作用域是什么?)

2,233 阅读19分钟

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

第一章——作用域是什么?

一、分词/词法分析的区别 [p5]

先来看一下原文的表述

image.png

原文提到主要差异在于词法单元的识别是通过有状态还是无状态的方式进行的,那我们不得不思考并解决一个问题“什么叫做有状态的方式,什么又叫做无状态的方式?”

1. 无状态分词(Tokenizing)​

无状态分词就像用剪刀按固定规则切割字符串,​不关心上下文,遇到特定符号(如空格、标点)就直接断开。

例子

  • 句子 "I'm happy" 被无状态分词后可能变成:
    ["I", "'", "m", " ", "happy"]
    'm 被错误地拆开,因为无状态分词不知道它是 am 的缩写)

特点

  • 速度快,但可能产生无意义的碎片。
  • 不依赖之前的内容,每次切割只看当前字符。

类似场景

  • JavaScript 的 split(" ") 方法是无状态的,它不会智能合并缩写或特殊语法。

2. 有状态词法分析(Lexing)​

有状态词法分析更像人类阅读——会记住上下文,动态判断字符是否属于同一个词法单元(token)。

例子

  • 句子 "I'm happy" 被有状态词法分析后可能变成:
    ["I'm", "happy"]
    (识别 'm 是 am 的缩写,合并为 I'm

特点

  • 更智能,能处理缩略语、变量名、多字符运算符(如 ===)。
  • 依赖“状态”​​(如“当前是否在字符串中?”“是否在数字中间?”)。

类似场景

  • JavaScript 引擎的词法分析器(Lexer)在解析代码时:

    • 看到 v 时,可能只是一个变量名的开始。
    • 看到 v + a 时,知道 + 是运算符,而不是变量名的一部分。

当我们清晰的认识到何为有状态何为无状态之后就可以思考下一个问题,“编译代码时,何时分词,何时词法分析?

这里我给出两个结论

  • 词法分析为主
  • 分词是词法分析的一部分

1. 编译器的实际行为:词法分析为主

  • 词法分析(Lexing)​是编译器的正式阶段,而分词(Tokenizing)可以视为其子步骤或简化版。
  • 编译器不会单独做无状态分词:因为无状态分词无法处理编程语言的复杂语法(如区分 > 和 >>、合并模板字符串 ${x} 等)。

示例:解析 let x = 1 + "2";

  • 词法分析输出
    ["let", "x", "=", "1", "+", ""2"", ";"](每个 token 被正确分类为关键字、变量名、运算符等)。
  • 无状态分词可能输出
    ["l", "e", "t", " ", "x", " ", "=", " ", "1", " ", "+", " ", """, "2", """, ";"](完全无意义)。

2. 为什么说“分词”是词法分析的一部分?

词法分析器的实现通常包含以下步骤:

  1. 初步切割(类似分词)​:按字符流读取代码,遇到空格/换行时跳过。

  2. 有状态合并:根据上下文决定如何合并字符为 token。

    • 例如:

      • 读到 = 时,检查下一个字符是否是 =,决定生成 = 或 ==
      • 读到 / 时,需判断是除法运算符、正则表达式开头,还是注释起始。

总结:

分词(Tokenizing)是无状态的机械切割,不依赖上下文;词法分析(Lexing)是有状态的智能解析,需记忆上下文(如合并I'm)。编译器以词法分析为主,分词仅是其底层步骤。

二、解析和语法分析的中的抽象语法树 [P5]

看一下原文的表述

image.png

这里主要讨论两个问题,我觉得这两个问题挺有意义(个人认为)

  • 抽象语法树的具体形态
  • 为什么要用树,而不是其他数据结构

1. 抽象语法树(AST)的具体形态

以 var a = 2; 为例,其 AST 的典型结构如下(完整版):

{
  "type": "Program",
  "body": [
    {
      "type": "VariableDeclaration",
      "declarations": [
        {
          "type": "VariableDeclarator",
          "id": {
            "type": "Identifier",
            "name": "a",
            "loc": {
              "start": { "line": 1, "column": 4 },
              "end": { "line": 1, "column": 5 }
            },
            "range": [4, 5],
            "optional": false
          },
          "init": {
            "type": "Literal",
            "value": 2,
            "raw": "2",
            "loc": {
              "start": { "line": 1, "column": 8 },
              "end": { "line": 1, "column": 9 }
            },
            "range": [8, 9]
          },
          "loc": {
            "start": { "line": 1, "column": 4 },
            "end": { "line": 1, "column": 9 }
          },
          "range": [4, 9]
        }
      ],
      "kind": "var",
      "loc": {
        "start": { "line": 1, "column": 0 },
        "end": { "line": 1, "column": 10 }
      },
      "range": [0, 10]
    }
  ],
  "sourceType": "script",
  "loc": {
    "start": { "line": 1, "column": 0 },
    "end": { "line": 1, "column": 10 }
  },
  "range": [0, 10],
  "comments": [],
  "tokens": [
    { "type": "Keyword", "value": "var", "loc": { /* ... */ } },
    { "type": "Identifier", "value": "a", "loc": { /* ... */ } },
    { "type": "Punctuator", "value": "=", "loc": { /* ... */ } },
    { "type": "Numeric", "value": "2", "loc": { /* ... */ } },
    { "type": "Punctuator", "value": ";", "loc": { /* ... */ } }
  ]
}

这里我觉得具体去理解这棵AST的每个词法什么意思并不重要,但是当ATS树以可视的方式呈现在我们眼前时AST树的神秘和陌生就已不再了

2. 为什么用树形结构(AST)?

树形结构是描述代码语法的最优选择,原因如下:

(1) 天然匹配代码的层级关系

代码本质是嵌套的语法结构,例如:

  • 函数声明包含参数列表和函数体。
  • 循环语句包含条件判断和循环体。
    树形结构能直接表达这种嵌套关系,而线性结构(如数组)或图结构无法清晰体现层级。

(2) 便于静态分析和操作

  • 查询:通过遍历树节点,快速定位特定语法(如找到所有函数调用)。
  • 修改:增删子树即可实现代码转换(如 Babel 插件操作 AST)。
  • 优化:编译器可通过树形结构分析代码作用域、依赖关系等。

(3) 与其他数据结构的对比

数据结构缺点
线性结构​(如 tokens 数组)无法表达嵌套关系(如 if (x) { y(); } 中 y() 属于 if 的代码块)。
图结构过度复杂,代码的语法关系无需环路或交叉引用。

(4) 实际工具中的应用

  • Babel:将代码转为 AST 后修改,再生成新代码。
  • ESLint:通过 AST 检查代码风格和错误。
  • V8 引擎:生成 AST 后转换为字节码执行。

总结

  • AST 形态:树形结构,节点类型(如 VariableDeclaration)和属性(如 name: "a")精确描述代码语义。

  • 为何用树

    • 代码是嵌套的,树能天然表达层级。
    • 支持高效查询、修改和优化。
    • 线性结构太扁平,图结构太冗余。

AST 是编译器和工具链的核心数据结构,就像“代码的 DNA”——既能拆解到原子单位,又能还原整体逻辑。 🌳

三、代码生成的机器指令是什么样子的

看看原文的表述

image.png

不知道大家看到这段文字会有何感想,对于我来说,不论是学习C语言的时候,或者是学习JAVA时,或者是学习JS时,我都想去深入了解我写的代码最终变成了什么,是晶体管的电信号吗?我无从而知,但我现在想知道所谓的“机器指令长什么样子”,也不忘学了那么长时间的编程

V8 会将 AST 转换为字节码(类似汇编的中间表示),部分指令如下:


LdaConstant [0]   ; 加载常量2到累加器
Star a            ; 将累加器的值存储到变量a

关键指令解析

  • LdaConstant:从常量池加载值(此处 2)。
  • Star a:将值存储到变量 aStar = "Store Accumulator to Register")。

看上去好像也不是特别神秘,不是吗?

三、编译var a = 2全流程

经过我们上述的讨论结合《你不知道的Javascript》中内容,我们可以深入了解编译的全流程,这里我们使用var a=2为例

从源代码到可执行代码的完整编译流程可分为 ​4 个核心阶段,每个阶段的具体操作如下:


1. 词法分析(Lexing)​

输入:源代码字符串 "var a = 2;"
输出:Token 流(标记化的词法单元)

过程

  • 扫描字符流,识别关键字、变量名、运算符等。

  • 生成 Tokens

    
    
    [  { "type": "Keyword", "value": "var" },  { "type": "Identifier", "value": "a" },  { "type": "Punctuator", "value": "=" },  { "type": "Numeric", "value": "2" },  { "type": "Punctuator", "value": ";" }]
    

关键点

  • 有状态合并字符(如区分 = 和 ==)。
  • 忽略空格/注释,保留位置信息(用于错误提示)。

2. 语法分析(Parsing)​

输入:Token 流
输出:抽象语法树(AST)

过程

  • 按语法规则(如 ECMAScript 标准)将 Tokens 组织成树形结构。

  • 生成 AST​(ESTree 标准):

    
    {
      "type": "Program",
      "body": [{
        "type": "VariableDeclaration",
        "declarations": [{
          "type": "VariableDeclarator",
          "id": { "type": "Identifier", "name": "a" },
          "init": { "type": "Literal", "value": 2, "raw": "2" }
        }],
        "kind": "var"
      }]
    }
    

关键点

  • 校验语法合法性(如变量名是否重复)。
  • 明确代码结构(如 a = 2 是 VariableDeclaration 的子节点)。

3. 语义分析与中间代码生成

输入:AST
输出:优化后的中间表示(如字节码、三地址码)

过程

  • 语义分析:检查作用域、类型等(如 var a 是否已声明)。

  • 中间代码生成​(以 V8 字节码为例):

    LdaConstant [2]   ; 加载常量2到累加器
    Star a            ; 存储到变量a
    

关键点

  • 平台无关的中间表示,便于后续优化。
  • 可能进行常量折叠(如 a = 1 + 1 → a = 2)。

4. 代码生成(目标代码)​

输入:中间代码
输出:机器指令或虚拟机字节码

过程​(根据目标平台不同):

  • JavaScript 引擎(V8)​
    直接解释执行字节码,或进一步编译为机器码。

全流程图示

源代码 → 词法分析 → Tokens → 语法分析 → AST → 语义分析 → 中间代码 → 代码生成 → 机器指令
  • JavaScript(V8)​:生成字节码后由虚拟机解释执行(或 JIT 编译为机器码)。

总结

  1. 词法分析"var a = 2;" → ["var", "a", "=", "2", ";"]
  2. 语法分析:Tokens → AST(树形结构,描述代码逻辑)。
  3. 语义分析:检查作用域/类型,生成中间代码。
  4. 代码生成:中间代码 → 目标平台指令(汇编/字节码)。

这一流程将高级语言逐层降级,最终变为计算机可执行的底层指令。 🚀

四、JS代码编译到底发生在什么时候?

先来看一下原文表述

image.png

从第一次看完这段文字后我就有一个疑问,为什么JS要这样设计,JS特殊在哪里呢,这样设计有什么好处?

1. JS 编译发生在什么时候?

  • 编译时机:​代码执行前的几微秒内​(甚至更短),通常就在解释执行前。

    • 例如:执行 var a = 2; 时,引擎会先快速编译这段代码,生成字节码或机器码,然后立即执行。
  • 特殊情况

    • JIT(即时编译)​:对热点代码(如循环)进行二次编译优化。
    • 延迟编译/重编译:根据运行时反馈调整优化策略(如 V8 的 TurboFan)。

2. 为什么要这样设计?

JavaScript 的编译设计是动态语言特性性能权衡的结果:

  1. 适应动态特性

    • JS 是动态类型语言,变量类型、作用域可能在运行时改变(如 evalwith)。
    • 而 ​静态编译语言(如 C++)​ 的类型和作用域在编译时即可完全确定,无需运行时编译

举个栗子

(1)动态类型示例
let a = 2;     // 编译时无法确定a未来是否会变成字符串
a = "hello";   // 运行时类型变化 → 需重新编译
​(2)动态作用域示例
function foo() {
  eval("var x = 1;");  // 编译时无法预测eval内容
  console.log(x);      // 作用域在运行时被修改
}
  1. 快速启动需求

    • 浏览器环境要求 JS 代码立即执行​(如页面加载事件),不能像 Java/C# 那样预先编译。
  2. 平衡性能与灵活性

    • 解释执行:直接解释源码(如早期 JS)速度慢。
    • AOT(预先编译)​:不适合动态代码(如 new Function())。
    • JIT 折中:混合解释+编译,按需优化热点代码。

3. 这样的设计有什么效果?

优点
  1. 快速启动

    • 代码无需等待构建,直接编译后执行(适合 Web 场景)。
  2. 动态优化

    • JIT 根据运行时信息优化(如 V8 的隐藏类机制加速属性访问)。
  3. 灵活性

    • 支持 eval、动态导入等动态特性。
缺点
  1. 初始性能开销

    • 编译时间计入执行时间,可能导致短暂延迟(尤其是大型脚本)。
  2. 优化受限

    • 相比静态语言(如 Rust),JS 的运行时编译无法做深度优化(如内存布局预测)。
  3. 复杂性

    • 引擎需实现多层优化(如 V8 的 Ignition+TurboFan),增加维护成本。

总结

  • 何时编译:执行前的瞬间 + JIT 按需优化。

  • 设计原因:适应动态语言特性,平衡启动速度与执行性能。

  • 效果

    • ✅ 灵活、快速启动,适合 Web。
    • ❌ 无法媲美静态语言的极致优化。

这种设计使 JavaScript 在动态性和性能之间取得了最佳平衡,成为 Web 的基石。 🌐

五、函数声明是否需要进行LHS查询

先来看一下原文的表述

image.png 这里读者可能会产生三个疑问

1.到底函数声明要不要LHS查询

2.为何进行/不进行LHS查询

3.为什么在代码生成的同时处理声明和值的定义就代表不进行LHS查询

下面一一为大家解答

1. 函数声明是否需要 LHS 查询?

不需要。函数声明(function foo() {})在编译阶段被直接处理,​不会像变量赋值(如 var foo = function() {})那样触发运行时 LHS 查询。 即函数声明并不是赋值


2. 为何函数声明不进行 LHS 查询?

​(1)核心原因:编译阶段静态绑定
  • 函数声明

    • 词法分析阶段,编译器会直接将函数标识符(foo)与函数体关联,并存入当前作用域的符号表。
    • 无需运行时分配:函数的内存空间和引用关系在编译时已确定。
  • 对比变量赋值

    • var foo = function() {} 需要运行时 LHS 查询,因为右侧的函数表达式是运行时求值的值。
​(2)引擎行为差异
类型处理阶段是否触发 LHS 查询示例
function foo() {}编译阶段❌ 否直接绑定到作用域
var foo = function() {}运行时✅ 是先声明 foo,再赋值

3. 为何“代码生成时处理声明和值”代表不进行 LHS 查询?

​(1)编译阶段的静态处理
  • 函数声明的完整信息(名称、参数、函数体)在编译时已知,编译器可以:

    1. 在符号表中预定义 foo
    2. 直接生成字节码或机器码,​跳过运行时的变量赋值步骤
  • 关键区别

    • LHS 查询的本质是运行时动态分配​(如 a = 2 需要找到变量 a 的容器并赋值)。
    • 函数声明是静态绑定,没有“运行时分配”这一动作。
​(2)具体实现(以 V8 引擎为例)​
  1. 编译阶段

    function foo(a) { return a * 2; }
    
    • 编译器将 foo 直接写入当前作用域的变量环境(Variable Environment),​无需运行时查找
  2. 代码生成

    • 生成字节码时,函数调用 foo(2) 直接引用已绑定的函数对象,​无需查询 foo 的引用
​(3)反例:函数表达式需要 LHS
var foo = function(a) { return a * 2; };
  • 运行时步骤:

    1. 对 foo 进行 LHS 查询(在作用域中声明变量)。
    2. 对函数表达式进行 RHS 查询(获取函数值)。
    3. 将函数值赋值给 foo

总结

  1. 函数声明不触发 LHS 查询:因编译时静态绑定,运行时直接引用。
  2. 不进行 LHS 的原因:函数标识符和函数体的关联在编译阶段完成,无需运行时动态分配。
  3. 代码生成时的处理:编译器直接生成函数引用指令,跳过了运行时的变量赋值流程。

这种设计优化了性能,避免了不必要的运行时开销。

六、为什么引擎问作用域要console,不问作用域要log?

原文表述

image.png

可以发现,引擎问作用域要console,不问作用域要log,这是为什么呢,难道是作用域在引擎第一次询问时就把console下的log给引擎了,这在很多方面是说不通的,那么具体是为什么呢,有没有规律可循?

核心问题:为什么 console 需要作用域查找,而 log 不需要?

(1) console 的查找:作用域链查询
  • 技术原理
    console 是全局对象(浏览器中为 window.console),引擎需通过作用域链逐层查找:

    1. 当前函数作用域 → 2. 外层作用域 → 3. 全局作用域。
  • 为什么必须查作用域?
    JavaScript 允许动态修改作用域(如 with 或全局变量覆盖),引擎必须确认当前 console 的引用是否被篡改。

    // 示例:动态覆盖 console
    function foo() {
      const console = { log: () => alert("Hacked!") };
      console.log("Hi"); // 输出 "Hacked!" 而非日志
    }
    
(2) log 的访问:对象属性解析
  • 技术原理
    log 是 console 对象的属性,引擎通过 ​对象属性访问规则 直接获取,流程如下:

    1. 检查 console 对象的隐藏类(Hidden Class)确定属性偏移量。
    2. 直接读取 log 方法的内存地址(无需作用域参与)。
  • 为什么不用查作用域?
    对象属性的访问完全依赖对象自身的结构,与作用域链无关。即使 console 被局部覆盖,log 的访问方式仍不变:

    const myConsole = { log: () => {} };
    myConsole.log(); // 直接访问 myConsole 的属性 log
    
(3) 关键对比
操作查找机制是否依赖作用域示例
console作用域链查询✅ 是从局部到全局逐层查找 console
console.log对象属性访问❌ 否直接访问 console 对象的属性
foo(函数声明)编译时静态绑定❌ 否函数声明已预绑定

3. 底层实现细节(以 V8 引擎为例)​

(1) console 的查找过程
flowchart LR
  Engine -->|RHS查询| Scope[当前作用域]
  Scope -->|未找到| Global[全局作用域]
  Global -->|返回| consoleObject[console对象]
(2) log 的访问过程
flowchart LR
  Engine -->|读取属性| console[console对象]
  console -->|隐藏类检查| HiddenClass[log属性偏移量]
  HiddenClass -->|直接访问| logMethod[log函数]
(3) 性能优化
  • console 缓存:频繁访问的全局变量(如 console)会被引擎优化,跳过作用域链遍历。
  • 内联缓存(Inline Cache)​:对 console.log 的多次调用会记录属性偏移量,加速后续访问。

4. 总结

  • console 需要作用域查找:因为它是变量,引擎需确认其当前绑定的对象(可能被动态修改)。

  • log 不需要作用域查找:它是对象属性,访问规则与作用域无关,直接通过对象内部结构解析。

  • 设计意义

    • 作用域链保证变量引用的正确性(应对动态语言特性)。
    • 对象属性访问规则提供高效性能(避免重复作用域查询)。

这种分工体现了 JavaScript 在动态性与性能之间的精妙平衡。

那么我们思考下一个问题

是不是作用域给引擎console时把它下面的log一起给他了?

1. 作用域仅提供 console 对象
  • 作用域的职责
    当引擎对 console 进行 RHS 查询时,作用域只返回全局对象 console 的引用(类似给你一个“空盒子”)。

    // 作用域返回的只是 console 对象本身
    const consoleObj = 作用域.get('console'); // 返回全局 console 对象
    
  • 为什么不能带属性?
    作用域系统不关心对象内部结构,它的任务仅是确认变量 console 是否存在并返回其绑定值。


2. log 的访问是独立的属性查找

引擎拿到 console 对象后,​通过对象属性访问规则​(而非作用域)获取 log 方法:

// 引擎内部行为
const logFunc = consoleObj.log; // 属性查找,与作用域无关

关键区别

操作数据来源机制示例
console作用域变量引用查询作用域返回 window.console
console.logconsole 对象对象属性访问consoleObj.log(属性读取)

至此,我对《你不知道的Javascript (上卷)》第一章的难点和细节解读就到这里结束了,看到这里可能有的读者会很失望——为什么这篇博客不讲解一些《你不知道的Javascript(上卷)》中的主要内容呢,而是去关注一些细小的,生涩难懂的细节,所以我补充两点作用域的主要内容

补充点一、作用域的查找规则

作用域的查找规则决定了如何在嵌套的作用域中查找变量。具体规则如下:

  1. 从当前作用域开始查找:当需要访问一个变量时,引擎首先在当前执行的作用域中查找该变量。

  2. 逐级向外查找:如果在当前作用域中找不到该变量,引擎会向上一级作用域继续查找,直到找到该变量或到达全局作用域。

  3. 查找终止条件

    • 如果找到变量,则使用该变量。

    • 如果到达全局作用域仍未找到变量:

      • 对于 RHS 查询(获取变量值),会抛出 ReferenceError 异常。
      • 对于 LHS 查询(变量赋值),在非严格模式下会隐式创建一个全局变量,在严格模式下会抛出 ReferenceError 异常。
  4. 遮蔽效应:如果多层作用域中存在同名变量,内部作用域的变量会“遮蔽”外部作用域的变量(即优先使用内部变量)。


补充点二、LHS 与 RHS 的作用与异常处理

LHS(Left-Hand Side)和 RHS(Right-Hand Side)的作用:

  • LHS 查询:目的是找到变量的容器本身,以便对其赋值。
    例如:a = 2; 这里是对变量 a 进行 LHS 查询,目的是将 2 赋值给 a
  • RHS 查询:目的是获取变量的值。
    例如:console.log(a); 这里是对变量 a 进行 RHS 查询,目的是获取 a 的值。

异常处理:

  1. RHS 查询失败​(变量未声明):

    • 会抛出 ReferenceError,表示“变量未定义”。
      例如:访问未声明的变量 b 时,console.log(b); 会抛出 ReferenceError: b is not defined
  2. LHS 查询失败​(变量未声明且尝试赋值):

    • 非严格模式:引擎会隐式创建一个全局变量(不推荐,容易导致 bug)。
      例如:a = 2;a 未声明,会隐式创建全局变量 a
    • 严格模式("use strict")​:会抛出 ReferenceError,禁止隐式创建全局变量。
      例如:严格模式下 a = 2; 会抛出 ReferenceError: a is not defined
  3. RHS 查询成功但操作非法

    • 如果变量存在但操作不合法(例如对非函数类型的值调用函数),会抛出 TypeError
      例如:var a = 1; a(); 会抛出 TypeError: a is not a function

结语

通过这篇对《你不知道的JavaScript(上卷)》第一章的深度解读,我们从看似微小的细节中挖掘出了许多关键的设计思想和底层机制。无论是词法分析与分词的区别、AST的树形结构本质,还是JavaScript独特的动态编译策略,甚至是LHS/RHS查询的微妙差异,这些内容都揭示了JavaScript语言设计的精妙之处。

正是这些“生涩难懂”的细节构成了JavaScript的核心逻辑。理解它们不仅能帮你避开常见的陷阱(如变量遮蔽、隐式全局变量),更能让你真正掌握这门语言的灵魂——动态性、灵活性与性能之间的平衡。