1. JavaScript的执行过程
1 基础
1.1 浏览器内核
浏览器内核,也称浏览器的排版引擎、浏览器引擎、页面渲染引擎。
- Gecko:早期被Netscape和Mozilla Firefox浏览器浏览器使用;
- Trident:微软开发,被IE4~IE11浏览器使用,但是Edge浏览器已经转向Blink;
- Webkit:苹果基于KHTML开发、开源的,用于Safari,Google Chrome之前也在使用;
- Blink:是Webkit的一个分支,Google开发,目前应用于Google Chrome、Edge、Opera等;
浏览器的工作过程:
浏览器的渲染流程:
1.2 JavaScript 引擎
高级的编程语言都是需要转成最终的机器指令来执行的,JavaScript引擎将JavaScript代码翻译成CPU指令来执行。
1.2.1 常见的 JavaScript 引擎
- SpiderMonkey (蜘蛛猴?):第一款 JavaScript 引擎,由 Brendan Eich 开发(也就是JavaScript作者);
- JavaScriptCore:WebKit 中的 JavaScript 引擎,Apple 公司开发;
- V8:Google 开发的强大JavaScript引擎,也帮助 Chrome 从众多浏览器中脱颖而出;
- Chakra:微软开发,用于IT浏览器;
1.3 浏览器内核 + JS 引擎
以 WebKit 为例,WebKit 事实上由两部分组成的:
- WebCore:负责HTML解析、布局、渲染等等相关的工作;
- JavaScriptCore:解析、执行 JavaScript 代码;
小程序中编写的 JavaScript 代码,就是被 JsCore 执行的。
1.4 V8 引擎
定义:V8 是用 C ++ 编写的 Google 开源高性能 JavaScript 和 WebAssembly 引擎,它用于 Chrome 和 Node.js 等。实现了 ECMAScript 和 WebAssembly。V8可以独立运行,也可以嵌入到任何C ++应用程序中。
V8 引擎的大致执行过程:
- Blink 内核会把网络进程下载好的 Js 代码,以 stream 流的形式传输给 V8 引擎。
- V8 引擎会进行词法分析、语法分析,然后转化为 AST。
- PrePaser:这里有一个 Js 的预解析优化:并不是所有的JavaScript代码,在一开始时就会被执行。
- Lazy Parsing(延迟解析):将暂不执行的函数进行预解析,只解析函数内暂时需要的内容,而对函数的全量解析是在函数被调用时才会进行。
- 比如在一个函数 foo 内定义了一个函数 inner,那么 inner 函数就会进行预解析,只有在 inner 作用域环境被执行时(入调用栈前),才会解析 inner。
- 然后就是下图的流程。
Parse 模块:会将JavaScript代码转换成AST(抽象语法树),这是因为解释器并不直接认识JavaScript代码;
- 如果函数没有被调用,那么是不会被转换成AST的;
- Parse:就是下文的词法分析 + 语法分析。
- 代码 --> token --> AST --> 执行上下文
Ignition 解释器:会将AST转换成ByteCode(字节码)
- 同时会收集TurboFan优化所需要的信息(比如函数参数的类型信息,有了类型才能进行真实的运算);
- 如果函数只调用一次,Ignition会执行解释执行ByteCode;
TurboFan 编译器:可以将字节码编译为CPU可以直接执行的 机器码;
- 一个短时间内被多次调用的函数会被标记为 热函数,该函数就会经过TurboFan转换成优化的机器码,提高代码的执行性能;
- 如在一个 for 循环中反复调用的无副作用的函数。
- 但是,机器码实际上也会被还原为 ByteCode,这是因为如果后续执行函数的过程中,类型发生了变化(比如sum函数原来执行的是 number 类型,后来执行变成了 string 类型),之前优化的机器码并不能正确的处理运算,就会逆向的转换成字节码。所以,使用 ts 规范类型,一定程度上也会优化 js 的执行速度。
- TurboFan的V8官方文档:https://v8.dev/blog/turbofan-jit
2 Js 的执行过程
面对一个 Js 脚本,JavaScript 引擎在运行这段脚本会经历 2 个阶段,词法/语法分析 和 运行阶段。在运行阶段又划分为 编译时 和 运行时。这几个阶段的间隔时间会非常的短,可以理解为当引擎刚刚完成了词法分析,便会立刻开始编译阶段和运行阶段。而不像传统的 C,Java 等语言,编译阶段和运行阶段可以分开执行。
- C:源代码
.c
----[编译为]---->.s
----[汇编为]---->.obj
---------->.exe
可执行文件。 - Java:源代码
.java
----[编译为]---->.class
2.1 三个阶段
JavaScript 的执行,一共有 3 个阶段:
主要是为了分析代码的语法是否正确,如果不正确会抛出语法错误(syntaxError
)并停止执行代码。
- 词法分析(Tokenizing)。将代码拆分成具有意义的最小代码块,也称之为词法单元(token),比如代码
var a = 2;
,会拆分成:var
,a
,=
,2
,;
。 - 语法分析(Parsing)。将词法单元流,按照语法结构转为 抽象语法树(Abstract Syntax Tree, AST)。转换后的抽象语法树,具有了逐级嵌套的树状结构。
- 编译时,会通过 AST 构建执行上下文。
- Ignition 生成字节码
TurboFan
生成机器码:优化代码。
构建执行上下文:
引擎根据抽象语法树分析代码的 词法作用域,进而形成 运行环境。一个运行环境,就是一个词法作用域。
引擎从 全局运行环境(全局词法作用域) 开始,进入每个运行环境(词法作用域)中。先编译,后运行该运行环境中的代码。运行完毕后,进入下一个运行环境。这样一直到所有运行环境执行完毕。
创建一个 调用栈 call stack。
当进入某一个运行环境中,会进入:
2.1.1 编译时(在词法阶段中)
根据运行环境的词法作用域,创建 执行上下文(execution context),并放入 调用栈(call stack)中。 栈底是 全局执行上下文(global execution context),栈顶则是当前的执行上下文。
创建当前执行上下文,其基本结构如下:
variable environment 变量环境
- outer 外部环境
lexical environment 词法环境
this value
函数提升、变量提升、变量初始化赋值,工作在编译阶段完成。
2.1.2 运行时
根据创建好的 当前执行上下文,开始逐次运行 当前运行环境 中的全部代码。
- 当代码全部执行完毕后,弹出当前执行上下文。
如果在代码执行中,遇到一个函数调用,会暂停当前执行上下文的执行,创建这个函数调用的执行上下文,把这个执行上下文压入 调用栈 中。此时会进入这个调用函数的编译和运行阶段,直到该函数内的全部代码执行完毕,弹出该执行上下文。
注意:不同的运行环境执行都会进入到 编译和执行两个阶段。而词法/语法分析阶段不区分运行环境,对全部代码进行解析。
2 作用域 scope
作用域是一个区域,规定了代码中变量和函数的可访问范围。所以,作用域决定了变量和函数的可见性和生命周期。
2.1 作用域的两种工作模型
动态作用域
动态作用域中,对作用域的定义发生在 运行阶段。
动态作用域不关心函数和作用域是如何声明以及在何处声明的,只关心它们从何处调用。作用域链是基于调用栈的,而不是根据代码结构的作用域嵌套。
词法作用域
词法作用域中,对作用域的定义发生在 词法分析阶段。 换句话说,每个变量都有一个对应它的词法作用域:无论函数在哪里被调用,无论如何调用,它的词法作用域都 只由函数被声明时所处的位置决定。
- 词法作用域只查找一级标识符,如果存在这样一个代码:
foo.bar.baz.name
。Js 引擎会通过词法作用域查找到foo
,剩余的查找工作会通过对象的属性访问去查找。 - 根据代码的层层结构,形成了作用域链。
JavaScript 同绝大多数编程语言一样,使用静态的词法作用域。
3 词法作用域
词法作用域就是指作用域是由代码中函数声明的位置来决定的,所以词法作用域是静态的作用域,通过它就能够预测代码在执行过程中如何查找标识符。
词法作用域是代码编译阶段就决定好的,和函数是怎么调用的没有关系。
词法作用域的划分:
全局作用域
全局作用域中的对象在代码中的任何地方都能访问。随着代码的执行完毕,生命周期结束。全局作用域在一个完整的代码脚本中,有且只有一个。
函数作用域
一个函数内部,便是一个函数作用域。函数内部定义的变量会随着函数执行结束而销毁。
闭包
是一个变量的集合。也是一个函数作用域。当一个执行完毕的函数,本应被销毁,但其内部定义的变量引用,依然其他作用域持有。为了节省开销,JS 引擎会销毁这个函数自身绝大多数的内容(这些内容只要不影响这些被引用的变量,都会被销毁)。而这些没有被销毁的、被引用的变量所组成的集合,就是闭包。
块级作用域
使用一对大括号包裹的一段代码,比如函数、判断语句、循环语句、try /catch / finally,单独的一个 {}
都被看作是一个块级作用域。
块级作用域限制 let
和 const
变量的可访问范围;不限制 var
变量和 function
函数的可访问范围。
分析打印结果
function bar() {
console.log(myName)
}
function foo() {
var myName = "Ninjee"
bar()
}
var myName = "Moxy"
foo() // Moxy
原因:
根据作用域链,bar 和 foo 两个函数作用域都在全局作用域中。作用域的判定是函数声明的位置。可以看到,这两个函数的声明没有嵌套的关系,所以两个函数的作用域也没有嵌套关系。
代码中,bar 在 foo 的内部调用,在函数调用上出现了嵌套关系,误导了作用域的判断。
需要记住:作用域链和执行上下文栈完全是两套逻辑;前者在词法分析阶段被确定,后者在编译时和运行时被确定。
引用
《你不知道的JavaScript》
winter - 重学前端
李兵 - 浏览器工作原理与实战
coderwhy - JavaScript 高级语法课