likes
comments
collection

从 ECMAScript 认识 JS(章三):我们到底应该怎样理解作用域查找

作者站长头像
站长
· 阅读数 14

大家好,我是早晚会起风。这是专栏《从 ECMAScript 语言规范和浏览器引擎的视角认识 JavaScript》的第四篇文章。

强烈建议按照文章顺序阅读,点击上方专栏可以查看所有文章。

前言

在开始之前,我先问一个问题 —— 你是如何理解 JavaScript 中的作用域变量查找的?

从 ECMAScript 认识 JS(章三):我们到底应该怎样理解作用域查找

图片来源:You Don't Know JS Yet: Scope & Closures - 2nd Edition

我们大部分人对于变量查找的理解就如同上面这张图一样。当我们查找一个变量时,会从当前作用域(Current Scope)开始,一直向外查找直到全局作用域(Global Scope),就像一个冒泡泡的过程。在这个过程中,如果我们找到的变量,就会立即停止并范围。如果直到最外层的作用域都没有找到,就会返回 undefined。

这样的心智模型来理解作用域查找是没有问题的。但是在这样理解时,脑袋里不免会产生一些问题,JavaScript 在执行过程中真的是这样做的吗?它在每次查找变量时都需要沿着作用域向上查找吗?如果碰到作用域嵌套很深的情况下会不会很浪费时间?

为了解决这些疑问,从根本上理解作用域,从这篇文章开始,我们从浏览器引擎和 ECMAScript 规范的角度来重新认识它。

JavaScript 是什么类型的语言?

在这之前,我们有一个问题 —— JavaScript 到底是编译型语言还是解释型语言?这对于我们理解 JavaScript 的执行有着很大的帮助。

首先我们先了解一下什么是编译型语言和解释型语言。

简单来讲,编译型语言首先是将源代码编译生成机器指令,再由机器运行机器码(二进制)。而解释型语言的源代码不是直接翻译成机器指令,而是先翻译成中间代码,再由解释器对中间代码进行解释运行。

从 ECMAScript 认识 JS(章三):我们到底应该怎样理解作用域查找

图片来源:You Don't Know JS Yet: Scope & Closures - 2nd Edition

我们在执行 JavaScript 代码时,最常见的宿主环境是 V8 引擎,它被用于 Chrome 浏览器和 Node.js 等地方。如果你还不了解 V8 引擎是什么,建议看这里

V8 率先引入了即时编译(JIT,全称 just-in-time)的双轮驱动设计。这是一种权衡策略,混合编译执行和解释执行这两种手段,给 JavaScript 的执行速度带来了极大的提升。

从 V8 编译和执行代码的角度来看,JavaScript 同时采用了解释执行和编译执行两种策略。刚开始时采用解释执行,先将代码转换为中间代码(字节码)再执行。如果碰到热点代码,V8 引擎就会将这段代码编译成机器代码(二进制)执行,提高执行速度。

说个题外话,当 JavaScript 引擎对热点代码进行编译优化时,同时还假设了变量的类型(type specialization)。比如当一个方法前 99 次调用时入参都是 number 类型,而到了第 100 次的时候入参变成了 string 类型,此时引擎就会把这段优化的代码丢弃掉。然后这段代码的执行就会回到优化前的状态,这个过程有一个专门的名词用来描述—— deoptimization。

更严重的是,当引擎一直尝试重复优化和去优化的过程时,还会严重影响代码的执行效率。所以引擎对于这种情况做了限制,比如当这个过程持续了 10 次以上,引擎就会放弃对这段代码的优化。

所以,即使 JavaScript 是一门动态语言,变量可以被动态赋值为任意类型,但是我们在实际编写代码时也不推荐这样做。因为这会破坏掉 JavaScript 引擎对代码的优化。

当然在编译过程中使用到了很多技术,除了 JIT 之外,还有延迟解析、隐藏类(HiddenClass)、内联缓存(inline caches)等等。

(PS:如果对隐藏类感兴趣,可以阅读这篇文章 Fast properties in V8

关于 JIT 这部分内容,读者如果感兴趣可以阅读 这篇文章

作用域与变量查找

在一些经典的书籍或者文章中,我们经常会听到这样的话——当你写好代码时,作用域已经被确定了。怎么理解这句话?

从浏览器引擎的角度来说, 作用域主要在代码编译时被决定。 这里说的主要是指是通常情况下,少数情况包括使用 eval()with () {},目前我们不考虑这些情况。(PS:with 声明已经从 ES5 严格模式中完全移除,eval 在严格模式下可能也不会创建变量。)

也就是说,当我们写好代码时,这些代码如何被浏览器所解释编译就已经确定了,而作用域正式在解释编译时被确定的。

一个典型的编译过程由三部分构成:

  1. Tokenizing/Lexing. The difference between tokenizing and lexing is subtle and academic, but it centers on whether or not these tokens are identified in a stateless or stateful way.
  2. Parsing: taking a stream (array) of tokens and turning it into a tree of nested elements, which collectively represent the grammatical structure of the program. This is called an Abstract Syntax Tree (AST).
  3. Code Generation: taking an AST and turning it into executable code. This part varies greatly depending on the language, the platform it's targeting, and other factors.

三个步骤简单来说就是:词法分析、生成AST树、生成可执行代码。

通过编译,代码运行时的作用域就确定了,我们通常称这个作用域为词法作用域(Lexical Scope)。除了全局的作用域,JavaScript 中还有函数作用域和块作用域,这些作用域是怎么被确定的?

If you place a variable declaration inside a function, the compiler handles this declaration as it's parsing the function, and associates that declaration with the function's scope. If a variable is block-scope declared (let / const), then it's associated with the nearest enclosing { .. } block, rather than its enclosing function (as with var).

如果一个变量在函数内声明,当编译器解析函数时,会把变量声明与该函数的作用域关联起来。同理块级作用域,变量会被关联到最近的 {..} 块中。上述描述中还有另一个事实,就是编译器会解析函数体和块内的代码。

It's important to note that compilation doesn't actually do anything in terms of reserving memory for scopes and variables. None of the program has been executed yet.

Instead, compilation creates a map of all the lexical scopes that lays out what the program will need while it executes. You can think of this plan as inserted code for use at runtime, which defines all the scopes (aka, "lexical environments") and registers all the identifiers (variables) for each scope.

In other words, while scopes are identified during compilation, they're not actually created until runtime, each time a scope needs to run. In the next chapter, we'll sketch out the conceptual foundations for lexical scope.

点击查看来源

编译阶段并没有实际在内存中存储作用域或者变量,程序这时候还没有被执行。编译创建了一张关于词法作用域的地图,当程序被执行时,可以在需要的时候找到。你可以认为这是一段插入的代码,在运行时定义作用域并为相应的作用域注册标识符。

换句话说,作用域只是在编译阶段被确定,执行阶段才会被创建。如何理解这句话?我们继续往下看。

代码编译时发生了什么?

我们以下面一段代码为例,简单解释一下从代码到可执行字节码的过程。

function add(x, y) {
	return x + y;
}

console.log(add(1, 2));

生成 AST 树和作用域记录

首先,V8引擎需要解析函数源代码,并将其转换为抽象语法树(AST)

$ out/Debug/d8 --print-ast add.js

--- AST ---
FUNC at 0
. KIND 0
. LITERAL ID 0
. SUSPEND COUNT 0
. NAME ""
. INFERRED NAME ""
. DECLS
. . FUNCTION "add" = function add
. EXPRESSION STATEMENT at 1114
. . ASSIGN at -1
. . . VAR PROXY local[0] (0x7fd09c80a630) (mode = TEMPORARY, assigned = true) ".result"
. . . CALL
. . . . PROPERTY at 1122
. . . . . VAR PROXY unallocated (0x7fd09c80a6f0) (mode = DYNAMIC_GLOBAL, assigned = false) "console"
. . . . . NAME log
. . . . CALL
. . . . . VAR PROXY unallocated (0x7fd09c80a470) (mode = VAR, assigned = true) "add"
. . . . . LITERAL 1
. . . . . LITERAL 2
. RETURN at -1
. . VAR PROXY local[0] (0x7fd09c80a630) (mode = TEMPORARY, assigned = true) ".result"

[generating bytecode for function: add]
--- AST ---
FUNC at 1087
. KIND 0
. LITERAL ID 1
. SUSPEND COUNT 0
. NAME "add"
. PARAMS
. . VAR (0x7fd09d02d6d8) (mode = VAR, assigned = false) "x"
. . VAR (0x7fd09d02d780) (mode = VAR, assigned = false) "y"
. DECLS
. . VARIABLE (0x7fd09d02d6d8) (mode = VAR, assigned = false) "x"
. . VARIABLE (0x7fd09d02d780) (mode = VAR, assigned = false) "y"
. RETURN at 1097
. . ADD at 1106
. . . VAR PROXY parameter[0] (0x7fd09d02d6d8) (mode = VAR, assigned = false) "x"
. . . VAR PROXY parameter[1] (0x7fd09d02d780) (mode = VAR, assigned = false) "y"

将转换后的AST树可视化,结果如下。

从 ECMAScript 认识 JS(章三):我们到底应该怎样理解作用域查找

最开始,add 函数被解析为树型结构,一颗子树用于参数声明,另一颗表示实际的函数体。由于 var 提升规则和 eval 以及其他一些原因,在代码解析过程中,解析器不可能知道哪些名称对应哪些变量。因此,解析器刚开始会为每个名称创建一个 VAR PROXY 节点。

随后的作用域解析步骤把这些 VAR PROXY 节点链接到 VAR 节点,或者标记为全局(global)或者动态查找(dynamic lookups),这取决于周围的作用域是否存在 eval 表达式。

以下是解析阶段打印出来的作用域。

$ d8 --print-scopes add.js
...
Inner function scope:
function add () { // (0x7fef4b019460) (1087, 1112)
  // 2 heap slots
  // local vars:
  VAR x;  // (0x7fef4b01b448) never assigned
  VAR y;  // (0x7fef4b01b490) never assigned
}
Global scope:
global { // (0x7fef4b019248) (0, 1139)
  // will be compiled
  // 1 stack slots
  // temporary vars:
  TEMPORARY .result;  // (0x7fef4b019830) local[0]
  // local vars:
  VAR add;  // (0x7fef4b019670) 
  // dynamic vars:
  DYNAMIC_GLOBAL console;  // (0x7fef4b0198f0) never assigned

  function add () { // (0x7fef4b019460) (1087, 1112)
    // lazily parsed
    // 2 heap slots
  }
}
Global scope:
function add (x, y) { // (0x7fef3a809260) (1087, 1112)
  // will be compiled
  // local vars:
  VAR x;  // (0x7fef3a8094d8) parameter[0], never assigned
  VAR y;  // (0x7fef3a809580) parameter[1], never assigned
}

首先需要说明一点,这里的作用域不是指执行阶段生成的作用域,解析阶段并没有执行代码,这里打印的作用域可以理解为一张地图,执行阶段会根据这张地图来生成实际的作用域。

我们经常使用 变量查找(Lookup)的概念来理解变量在作用域链上的查找过程,这种心智模型也暗示了变量是在执行过程中进行查找的。其实,我们可以看到,在编译阶段,变量已经被标记好了要去哪里查找。

比如如通过 0x7fef3a8094d8 这样的字符,当引擎执行代码查找变量时,就会直接到变量对应的内存中查找。

再比如 DYNAMIC_GLOBAL console 表示要去全局查找 console 这个变量,这样在执行阶段就少了很多无意义的查找操作,也印证了我们经常说的一句话——作用域在你代码写好的时候已经确定了。

注意,在第一个 Global scope 中,出现了这样的代码注释,

function add () { // (0x7fef4b019460) (1087, 1112)
  // lazily parsed
  // 2 heap slots
}

要解释它涉及到一个概念叫做 惰性解析 ,即解析器在第一次解析过程中如果碰到函数声明,会先跳过函数内部的代码,不生成其 AST 树和字节码,仅仅生成顶层代码的 AST 树和字节码,也就是全局作用域的。

函数声明会被转为函数对象(Function Object),里面存储了函数的名称和代码等等信息,函数的 AST 树和字节码会在函数第一次被调用时被解析。

怎么证明惰性解析的存在呢?我们将上面的例子简单修改一下:

function add(x, y) {
	return x + y;
}

// console.log(add(1, 2));

结果:

$ d8 --print-ast add.js

--- AST ---
FUNC at 0
. KIND 0
. LITERAL ID 0
. SUSPEND COUNT 0
. NAME ""
. INFERRED NAME ""
. DECLS
. . FUNCTION "add" = function add

$ d8 --print-scopes add.js

Inner function scope:
function add () { // (0x7fb6e2832e60) (1087, 1112)
  // 2 heap slots
  // local vars:
  VAR x;  // (0x7fb6e200ba48) never assigned
  VAR y;  // (0x7fb6e200ba90) never assigned
}
Global scope:
global { // (0x7fb6e2832c48) (0, 1142)
  // will be compiled
  // local vars:
  VAR add;  // (0x7fb6e2833070) 

  function add () { // (0x7fb6e2832e60) (1087, 1112)
    // lazily parsed
    // 2 heap slots
  }
}

很明显可以看到,将 console.log(add(1, 2)) 这行函数调用注释掉之后,生成的 AST 树和作用域内容都减少了一部分。这也证明了解析过程存在惰性解析。

特殊情况如在函数解析碰到 eval(),内部的变量才可能会被标记为动态查询,我们还是把上边的例子稍加修改一下。

var a = 1
function add(x, y) {
  eval('var a = 3')
	return x + y + a;
}

console.log(add(1, 2));

% d8 --print-ast add.js
...
--- AST ---
FUNC at 0
. KIND 0
. LITERAL ID 0
. SUSPEND COUNT 0
. NAME ""
. INFERRED NAME ""
. DECLS
. . VARIABLE (0x7f873e82be20) (mode = DYNAMIC, assigned = false) "a"
. BLOCK NOCOMPLETIONS at -1
. . EXPRESSION STATEMENT at 8
. . . INIT at 8
. . . . VAR PROXY lookup (0x7f873e82be20) (mode = DYNAMIC, assigned = false) "a"
. . . . LITERAL 3

看倒数第二行,这里的变量 a 就变成了 lookup,mode = DYNAMIC。这就是我们上面所说到的动态查找。

所以,我们不使用 eval 的一个原因是会阻碍 JavaScript 代码执行的优化。

生成字节码

这些步骤结束后,我们得到了一颗AST树,包含了从中生成可执行字节码的所有必要信息。接着这颗 AST 树被传递给 BytecodeGenerator ,它是 Ignition 解释器的一部分,该解释器基于每个函数生成字节码。

$ out/Debug/d8 --print-bytecode add.js
…
[generated bytecode for function: add]
Parameter count 3
Frame size 0
   12 E> 0x37738712a02a @    0 : 94                StackCheck
   23 S> 0x37738712a02b @    1 : 1d 02             Ldar a1
   32 E> 0x37738712a02d @    3 : 29 03 00          Add a0, [0]
   36 S> 0x37738712a030 @    6 : 98                Return
Constant pool (size = 0)
Handler Table (size = 16)

根据上述分析可见,代码编译阶段主要是生成了可执行字节码,用于之后的执行阶段。

写在最后

这篇文章,我们从浏览器引擎的角度重新认识了变量查找。从下一章节开始,我会从 ECMAScript 标准的角度来解决作用域相关的内容。

参考资料

You Don't Know JS Yet: Scope & Closures - 2nd Edition

v8.dev

A crash course in just-in-time (JIT) compilers

An Introduction to Speculative Optimization in V8

最后,欢迎大家一键三连,有大家的支持才有更新的动力嘛~