一文打通JS引擎和执行上下文、作用域、闭包及相关内容
参考
执行上下文(Execution context stack
简称 ECS
)
“执行上下文“ 可以理解为一个运行环境,抽象成一个盒子,它包含变量、作用域等。
每个上下文都有一个关联的变量对象(variable object),而这个执行上下文中定义的所有变量的函数都存在于这个对象上。
(虽然无法通过代码访问变量对象,但后台处理数据会用到它。)
目前先得到这样的结构:
全局执行上下文是最外层的执行上下文。
根据ECMAScript实现的宿主环境,表示全局执行上下文呢的对象可能不一样。浏览器中是全局执行上下文就是我们常说的 window 对象,因此所有通过 var 定义的全局变量和函数都会成为 window 对象的属性和方法。
执行上下文在其所有代码都执行完毕后被销毁,包括定义在它上面的所有变量和函数(全局执行上下文在应用程序退出前才会被销毁,比如关闭网页或退出浏览器)。
每个函数调用都有自己的上下文。
当代码执行流进入函数时,函数的执行上下文被推到一个执行上下文栈上。在函数执行完后,栈弹出该执行上下文,将控制权返还给之前的执行上下文。ECMAScript程序的执行流就是通过这个执行上下文栈进行控制。
作用域
作用域就是一个区域,它决定了当前执行代码对变量的访问权限。
ES5只有全局作用域和函数作用域。
ES6新增了块级作用域{}
。
function fun() {
//局部(函数)作用域
var innerVariable = "inner"
}
console.log(innerVariable)
// Uncaught ReferenceError: innerVariable is not defined
上面的例子中,变量innerVariable
是在函数中,也就是在局部作用域下声明的,而在全局作用域没有声明,所以在全局作用域下输出会报错。
也就是说,作用域就是一个让变量不会向外暴露出去的独立区域。作用域最大的用处就是隔离变量,不同作用域下同名变量不会有冲突。
function fun1(){
var variable = 'abc'
}
function fun2(){
var variable = 'cba'
}
作用域链
执行上下文中的代码在执行的时候,会创建变量对象的一个作用域链(scope chain)。
(这里对作用域链不清晰不要紧,它跟原型链一样,是一个抽象的概念,下面代码第二个例子中的图就是一个作用域链。)
var a = 100
function fn() {
var b = 200
console.log(a) // 这里的a在这里就是一个自由变量
console.log(b)
}
fn()
console.log(a)
要得到变量a,但是在当前的作用域中没有定义a(可对比一下b)。当前作用域没有定义的变量,会成为 自由变量
。自由变量的值如何得到呢?它会向父级作用域一层一层地向外查找,直到找到全局window
对象,也就是全局作用域,如果全局作用域里还没有,就返回undefined
。类似于顺着一条链条从里往外一层一层查找变量,这条链条,我们就称之为作用域链。
这个作用域链决定了各级执行上下文中的代码在访问变量和函数时的顺序。代码正在执行的上下文的的变量始终位于作用域链的最前端。如果上下文是函数,则其活动对象(activation object) 用作变量对象。
活动对象最初只有一个定义变量:arguments(全局上下文中没有这个变量)。作用域链中的下一个变狼对象来自包含上下文,再下一个对象来自下一个包含上下文。以此类推至全局上下文。
全局上下文的变量对象是中是作用域链的最后一个变量对象。
看一个例子:
var color = "blue";
function changeColor() {
if (color === "blue") {
color = "red";
} else {
color = "blue";
}
}
changeColor();
对于这个例子而言,函数 changeColor() 的作用域链包含两个对象:
- 它自己的变量对象(就是定义 arguments 对象的那个)
- 全局上下文的变量对象
再看下一个例子:
var color = "blue";
function changeColor() {
let anotherColor = "red";
function swapColors () {
let tempColor = anotherColor;
anotherColor = color;
color = tempColor;
// 这里可以访问 color、anotherColor、tempColor
}
// 这里可以访问 color、anotherColor,但访问不到 tempColor
swapColors();
}
// 这里只能访问 color
changeColor();
以上代码得到3个上下文:
三个执行上下文:
- 全局上下文
- changeColor() 的局部上下文
- swapColors() 的局部上下文
上图展示了这个例子的作用域链。图中矩形表示不同的执行上下文。
作用域增强
执行上下文主要有全局上下文和函数上下文两种(eval()调用内部存在第三种上下文),但有其他方式来增强作用域链。
某些语句会导致在作用域链前端临时添加一个上下文,这个上下文在代码执行后被删除。通常在两种情况下会出现这个现象,即代码执行到下面任意一种情况时:
- try/catch 语句的 catch 块
- with 语句
JS引擎
Parser
负责将 JS 源码转换成 AST,确切说,Parser 将源码转成 AST 之前,还有一个 Scanner的过程,具体流程如下:
Ignition(点火)
interpreter(解释器)将 AST 转换为 ByteCode,解释执行 ByteCode。同时收集 TurboFan (涡轮风扇)优化编译所需的信息,比如函数参数的类型。
解释器执行时主要有四个模块,内存中的字节码、寄存器、栈、堆。
Ingition 的原始动机是减少移动设备上的内存消耗。
TurboFan
compiler(优化编译器)利用 Ignition 所收集的类型信息,将 ByteCode 转换为优化的汇编代码。
Orinoco
garbage collector,垃圾回收模块,负责将程序不再需要的内存空间回收。
在运行 C、C++以及 Java 等程序之前,需要进行编译,不能直接执行源码;但对于 JavaScript 来说,我们可以直接执行源码(比如:node test.js
),它是在运行的时候先编译再执行,这种方式被称为即时编译(Just-in-time compilation),简称为 JIT。因此,V8 也属于 JIT 编译器。
V8
V8 执行一段 JS 的流程如下图所示:
结合上文介绍的Chrome V8 架构,聚焦到JavaScript上,浏览器拿到JavaScript源码,Parser,Ignition 以及 TurboFan 可以将 JS 源码编译为汇编代码,其流程图如下:
简单地说,Parser 将 JS 源码转换为 AST,然后 Ignition 将 AST 转换为 Bytecode,最后 TurboFan 将 Bytecode 转换为经过优化的 Machine Code(实际上是汇编代码)。
- 如果函数没有被调用,则 V8 不会去编译它。
- 如果函数只被调用 1 次,则 Ignition 将其编译 Bytecode 就直接解释执行了。TurboFan 不会进行优化编译,因为它需要 Ignition 收集函数执行时的类型信息。这就要求函数至少需要执行 1 次,TurboFan 才有可能进行优化编译。
- 如果函数被调用多次,则它有可能会被识别为热点函数,且 Ignition 收集的类型信息证明可以进行优化编译的话,这时 TurboFan 则会将 Bytecode 编译为 Optimized Machine Code(已优化的机器码),以提高代码的执行性能。
图片中的红色虚线是逆向的,也就是说 Optimized Machine Code 会被还原为 Bytecode,这个过程叫做 Deoptimization。这是因为 Ignition 收集的信息可能是错误的,比如 add 函数的参数之前是整数,后来又变成了字符串。生成的 Optimized Machine Code 已经假定 add 函数的参数是整数,那当然是错误的,于是需要进行 Deoptimization。
V8 本质上是一个虚拟机,因为计算机只能识别二进制指令,所以要让计算机执行一段高级语言通常有两种手段:
- 第一种是将高级代码转换为二进制代码,再让计算机去执行;
- 另外一种方式是在计算机安装一个解释器,并由解释器来解释执行。
解释执行和编译执行都有各自的优缺点,解释执行启动速度快,但是执行时速度慢,而编译执行启动速度慢,但是执行速度快。为了充分地利用解释执行和编译执行的优点,规避其缺点,V8 采用了一种权衡策略,在启动过程中采用了解释执行的策略,但是如果某段代码的执行频率超过一个值,那么 V8 就会采用优化编译器将其编译成执行效率更加高效的机器代码。
简单总结如下,V8 执行一段 JavaScript 代码所经历的主要流程包括:
- 初始化基础环境;
- 解析源码生成 AST 和作用域;
- 依据 AST 和作用域生成字节码;
- 解释执行字节码;
- 监听热点代码;
- 优化热点代码为二进制的机器代码;
- 反优化生成的二进制机器代码。
V8 的事件机制:
总结
- V8 引擎接收到 JS 源码
- scanner(扫描器)堆代码进行
词法分析
,将代码分析为tokens
- parser(解析器)将词法分析结果
tokens
转换为抽象语法树(AST)
,同时验证语法,有错误就抛出 - 然后
interpreter(解释器)
将AST
转换成ByteCode
- 然后执行代码。
- 除了解释器,V8 引擎还有编译器,比如一个函数执行多次,可能会被识别为
热点函数
,就会经过编译器优化
,将ByteCode
编译为Optimized Machine Code
,以提高代码的执行性能,然后再执行代码。
再来回顾下这个流程:
- V8 启动执行 JS 前,它还需要准备执行 JS 所需的一些基础环境,包括
堆空间
、栈空间
、全局执行上下文
、全局作用域
、事件循环系统
、JS内置函数
等。 - JS 非常灵活,对象的结构和属性可以在运行时任意修改,而经过优化编译器优化过的代码只能针对某种固定的结构,一旦在执行过程中对象的结构被动态修改了,那么优化之后的代码势必会变成无效代码,这时候优化编译器就需要执行
反优化
操作,经过反优化
的代码,下次执行时就会会退到解释器解释执行。
闭包
闭包指的是那谢谢引用了另一个函数作用域中变量的函数,通常是在嵌套函数中实现的。
看如下代码:
function createComparisionFunction(propertyName) {
return function(object1, object2) {
let value1 = object1[propertyName]; // 关键代码
let value2 = object2[propertyName]; // 关键代码
if (value1 < value2) {
return -1;
} else if (value1 > value2) {
return 1;
} else {
return 0;
}
}
}
let compare = createComparisionFunction('name');
let result = compare({name: "Nicholas"}, {name: "Matt"});
这里内部匿名函数中,引入了外部函数的变量 propertyName 。
在这个内部函数被返回并在其他地方使用后,它仍然引用着那个变量。这是因为内部函数的作用域链包含 createComparisionFunction() 函数的作用域。
在上文中介绍了作用域链。在调用一个函数时,会为这个函数调用创建一个执行上下文,并创建一个作用域链。然后用 arguments 和其他命名参数来初始化这个函数的活动对象。外部函数的活动对象是内部函数作用域链上的第二个对象。这个作用域链一直向外串起了所有包含函数的活动对象,直到全局执行上下文才终止。
如果照作用域中图来解释就是:
在 createComparisonFunction()
返回匿名函数后, 它的作用域链被初始化为包含 createComparisonFunction()
的活动对象和全局变量对象。这样,匿名函数就可以访问到 createComparisonFunction()
可以访问的所有变量。另一个有意思的副作用就是,createComparisonFunction()
的 活动对象并不能在它执行完毕后销毁, 因为匿名函数的作用域链中仍然有对它的引用。 在 createComparisonFunction()
执行完毕后,其执行上下文的作用域链会销毁,但它的活动对象仍然会保留 在内存中,直到匿名函数被销毁后才会被销毁:
注意:
因为闭包会保留它们包含函数的作用域,所以比其他函数更占用内存。过度使用闭包可能导致内存过度占用,因此建议仅在十分必要时使用。V8 等优化的 JavaScript 引擎会努力回收被闭包困住的内存,不过我们还是建议在使用闭包时要谨慎。