大家可能会听说过以下专业术语:
- 变量提升(Hoisting)
- 作用域(Scope)和作用域链(Scope chain)
- 闭包(Closure)
- this
这些都涉及到一个知识点 —— 执行上下文(execution context)
执行栈与执行上下文
执行上下文是 JavaScript 执行一段代码时的基础运行环境;包括了变量环境,词法环境,this值,外部环境等内容。
(完整的运行时还包括 WEB API, 堆栈内存,消息队列,事件循环系统等等)
JS代码在引擎中是以“一段一段”的方式来解析的,而并非一行一行来解析的。而这“一段一段”的可执行代码可分为四种:Global code、Function Code、Eval code、Module code。每进入一个不同的可执行代码都会创建一个相应的执行上下文(Execution Context) ,然后将其压入执行栈(Call Stack)并执行,执行完以后会被弹出执行栈
比如有代码:
var a;
function foo() {
a = "hi, i am foo";
console.log(a);
}
function baz() {
foo();
}
baz();
整个代码执行过程如下:
图中的蓝色方块就是执行上下文(Execution Context),包在蓝色方块的灰色区域就是执行栈(Call Stack),整个执行栈遵循后进先出的原则,伪代码大概如下所示:
// 代码执行前创建全局执行上下文
ECStack = [Global Execution Context]
// bar 调用
ECStack.push('baz Function Execution Context')
// foo 调用
ECStack.push('foo Function Execution Context')
// console.log 调用
ECStack.push('log Function Execution Context')
// console.log 调用完毕然后出栈
ECStack.pop('log Function Execution Context')
// foo 调用完毕然后出栈
ECStack.pop('foo Function Execution Context')
// bar 调用调用完毕然后出栈
ECStack.pop('baz Function Execution Context')
// 此时执行栈中只剩下一个全局执行上下文
执行栈(Call Stack)是JS引擎追踪函数执行流程的一种机制,当执行环境中调用了多个函数时,通过这种机制,我们能够追踪到哪个函数正在执行,执行的函数体又调用了哪个函数;
执行栈也称为执行上下文栈,又称为调用栈;
当一段 JavaScript 代码被调用运行时,会遇到两个阶段:编译和执行。
这看起来很简单。然而,所有的秘密都隐藏在这两个阶段中。
当 JavaScript 被编译时时候,一个执行上下文(execution context)被创建。当执行上下文准备就绪时,执行阶段就开始了。所有可执行的 JavaScript 代码会被一行一行地运行。
执行上下文是由以下四个组件组成:
- 变量环境(Variable Environment)
- 词法环境(Lexical Environment)
- Outer
- this
变量环境(Variable Environment)
此处我们先说说变量环境(Variable Environment)。先暂时忽略其他组件。
变量( Variables )和变量环境( Variable Environment )
让我们从一段代码代码开始:
var apple = 10;
console.log(apple);
当这段 JavaScript 代码运行时,第一步是编译。
在这个步骤中,执行上下文被创建。
同时,变量 apple 被声明,且值为undefined,并存储到变量环境中。
编译阶段结束,执行阶段开始。
当第一行被执行时,变量 apple 被赋值为数值 10。变量环境也同时被更新。
然后执行第二行代码,console 开始在变量环境中寻找变量apple,并成功找到。最终,console.log 输出 10 到控制台。
整个过程结束,同时执行上下文也被移除。
从这段示例代码中,可以收获以下关键点:
- 变量赋值实际上被分成了两个阶段。
- 编译阶段负责处理变量声明。
- 执行阶段负责变量赋值和其余代码执行。
提升(Hoisting)
console.log(apple);
var apple = 10;
这段示例代码与上面的略有不同。在变量 apple 被声明之前,先执行 console.log 操作。
我们知道最终控制台会输出 undefined。但是,让我们从执行上下文的角度来一步一步看看到底发生了什么。
在编译阶段,第一行代码被跳过,因为它与变量声明没有关系。然后第二行中的变量 apple 在变量环境中被声明,并赋值 undefined。此时,编译阶段结束。
执行阶段,第一行代码中console开始在变量环境中寻找变量apple。此时,变量apple为undefined。所以,控制台输出了undefined。
然后执行第二行代码,变量apple被赋值为数字10。整个过程结束。
我们称这个过程为提升( Hoisting ),因为变量apple的声明好像是被提升到代码顶端。下面的代码片段模拟了提升的效果:
var apple = undefined;
console.log(apple);
apple = 10;
然而,从执行上下文角度来看,其实并没有什么提升。它的发生是因为变量在编译阶段中先声明了。提升这个名称是基于结果而产生的。
提升和函数
函数的提升略有不同,因为我们有两种方式来声明函数。
showNumber();
showName();
//赋值( assignment )
var showName = function() {
console.log("Hi, this is my name.")
}
//声明( declaration )
function showNumber() {
console.log("Hey, show a number.");
}
将函数赋值给变量 showName,而 showNumber 只是一个函数声明。
有趣的是,我们看到的输出是 Hey, show a number. 后面跟着一个错误信息 showName is not a function.。
让我们回顾一下编译和执行这两个阶段,看看到底发生了什么。
在编译阶段,showNumber 是一个声明,所以在这个时候它将被保存到执行上下文中。
当来到showName时,它被赋值为undefined,因为这是一个赋值语句。
showName直到执行阶段才会被赋值具体函数。
如果你在第二行用 console.log(showName) 来替换 showName(),控制台会输出 undefined。因为 undefined 不是函数,所以才会有错误提示 showName is not a function。
在编译阶段,JS引擎会扫描代码片段中的函数和变量的声明。这些函数和变量的声明都会被添加到内存中一个叫做词法环境的 JavaScript 数据结构中。所以这些函数和变量可以在实际声明之前就被使用,这就叫提升
示例
第一个示例:命名冲突
我们知道,当使用相同的变量名称时,后面一个变量会覆盖前面的一个变量。
你知道下面这段示例代码最终会输出什么吗?
showNumber()
function showNumber(){
console.log("I'm showNumber1.")
}
function showNumber(){
console.log("I'm showNumber2")
}
此处会输出"I'm showNumber2",
如果函数名的属性已经存在,那么该属性将会被新的引用所覆盖。
showNumber()
function showNumber(){
console.log("I'm a declaration function.")
}
var showNumber = 20
此处输出 I'm a declaration function.。第二个 showNumber 变量并没有覆盖第一个。
如果变量名的属性已经存在,为了防止同名的函数被修改为undefined,则会直接跳过,原属性值不会被修改。
var c = 1
function c(c) {
console.log(c)
var c = 3
}
c(2)
此处输出 Uncaught TypeError: c is not a function. 报错!!
这是一个很容易被忽视的陷阱,所以我们应该避免使用相同的变量名称。
console.log(apple);
if(false){
var apple = 10;
}
这里,if 的条件是 false。这意味着 if 中的语句块永远都不会运行。
如果你运行这段代码,控制台会输出 undefined,而不是 apple is not defined 的错误信息。
这种情况下,编译阶段,变量 apple 仍然会被声明到变量环境中。
if 中的条件只在执行阶段起作用,而在编译阶段没有作用。
词法环境(Lexical Environment)
块作用域
我们可以用 let 重写上面的代码,这次引入了新的作用域,块作用域。
var apple = 'apple';
// block scope starts
if (true) {
let apple = 'banana';
console.log('if apple:', apple); // if apple: banana
}
// block scope ends
console.log('Global apple:', apple); // Global apple: apple
控制台中输出了两个不同变量 apple 的值。第一个变量 apple 的值是 apple,if 块中变量 apple 的值是 banana。
怎么会有两个同名的变量呢?
在编译阶段,第一个 apple 变量被添加到全局执行上下文并初始化为 undefined。
同时,JavaScript 引擎决定跳过第二个 apple 变量,这么做有两个原因:
- 它是用 let 创建的变量
- 它在块作用域中
接下来,执行阶段开始了。第一个 apple 变量被赋值为 apple。
当读到 if 代码块时,发生了嵌套的编译阶段操作。第二个 apple 变量被创建(未进行初始化)。
这个变量并没有在变量环境中创建,而是被添加到了词法环境中。
很快,banana被赋给词法环境中的变量apple。
现在,我们在两个环境中有了两个名称相同的变量。这就是 JavaScript 引擎处理let的方式,并且还向后兼容了var。
词法环境中的作用域栈
为了更好的理解 let 和 var 的区别,我们用一个有趣的示例把它们结合起来。
var apple = 'global apple';
let banana = 'global banana';
{
let banana = 'block banana';
var grape = 'global grape';
let orange = 'block orange';
console.log(apple);
console.log(banana);
}
console.log(banana);
console.log(grape);
console.log(orange);
在编译阶段,变量 apple和 grape 在变量环境中被初始化为 undefined。这里变量 grape 的初始化被提升。同时词法环境中创建了变量 banana(未进行初始化)。
执行阶段开始,变量apple被赋值为global apple,而变量banana被赋值为global banana。
这时候开始处理块中的变量了。
在这里,变量banana和变量orange处在一个独立的作用域中(未进行初始化)。
从图中可以看出,当进入块级作用域时,作用域块中通过 let/const 声明的变量,会被存放在词法环境的一个单独的区域中,这个区域中的变量并不影响作用域块外面的变量,
其实,在词法环境内部,维护了一个小型栈结构,栈底是函数最外层的变量,进入一个作用域块后,就会把该作用域块内部的变量压到栈顶;当作用域执行完成之后,该作用域的信息就会从栈顶弹出,这就是词法环境的结构。需要注意下,我这里所讲的变量是指通过 let 或者 const 声明的变量。
很快,这两个变量都被赋予相应的值。
最后一句赋值语句执行完成后,所以的变量都准备就绪。
当输出第一个变量时,JavaScript 引擎首先在词法环境中从上到下寻找变量 apple。然后再检查全局变量环境,并找到了变量 apple,然后输出 global apple。
当查找变量banana时,JavaScript 引擎遵循同样的步骤,最终在词法环境的顶端找到变量banana,然后输出block banana。
与此同时,块中不再有可执行代码了。块作用域将被移除。
脚本继续执行。JavaScript 引擎在词法环境中找到了变量banana,在全局变量环境中找到了变量grape,并相应地输出global banana和global grape。
当脚本执行到搜索变量orange时,这个变量此时并不存在,因为它存在的词法作用域已经被删除了。这时候将抛出一个错误orange is not defined。
示例
let apple = 'apple';
{
console.log(apple);
let apple = 'banana';
}
上面这段代码最终会输出什么?apple 还是 banana?
令人奇怪的是,会输出一个错误 Cannot access apple before initialization.。
从编译到执行,一个变量要经历三个阶段:
- 创建(Creation)
- 初始化(Initialization)
- 赋值(Assignment)
- 对于
var 变量来说,它的创建和初始化是被提升的,但赋值不是。 - 对于
let 变量来说,它的创建是被提升的,但是初始化和赋值不是。 - 对于函数来说,它的创建、初始化和赋值同时被提升。
JavaScript 中所有的声明(function, var , let , const 和 class )都会被提升,而 **** var ****声明被初始化为 ****undefined ,但是 **** let **** 和 **** const **** 声明没有被初始化。
将变量初始化之前的代码阶段称为暂时死区(temporal dead zone TDZ)。
Outer
变量查询
在下面的代码示例中,变量的查询会让人觉得很困惑。
var apple = 'apple';
function isApple() {
console.log(apple);
}
function isBanana() {
var apple = 'banana';
isApple();
}
isBanana(); // console.log 将会输出什么?
当执行 isApple 函数的时候,有三个执行上下文在调用栈中。
- 全局执行上下文
-
isBanana 函数执行上下文 -
isApple 函数执行上下文
接下来,console 开始查找变量 apple。
直观的看,我们可以通过调用栈中从上到下的顺序来查找变量。这样的话 console 会输出 banana,因为会在 isBanana 函数执行上下文中找到变量 apple。
不同的是,console 实际输出了 apple,这个在全局执行上下文中变量 apple 的值。
这是为什么?
我们的链条查询遗漏了执行上下文中一个重要的组件—— outer。。
outer 定义了 JavaScript 引擎如何执行链条查询,也被称为作用域链(scope chain)。
如果我们看 isApple 函数执行上下文,它的 outer 指向的是全局执行上下文。
为什么 isApple 函数执行上下文中的 outer 指向了全局执行上下文而不是 isBanana 函数执行上下文?
词法作用域(Lexical Scope)
JavaScript 中的作用域链是由词法作用域决定(即代码结构中的函数位置决定的),不受调用栈的影响
var apple = 'apple';
function isApple() {
console.log(apple);
}
function isBanana() {
var apple = 'banana';
isApple();
}
isBanana(); // console.log 将会输出什么?
在这个示例中,isApple 和 isBanana 这两个函数都是在全局作用域中声明。因此,它们的词法作用域都是全局作用域。
从两个阶段运行的角度看,作用域链是在编译阶段定义的,而不是在执行阶段
当 JavaScript 引擎编译代码时,这两个函数执行上下文的 outer 都指向了全局执行上下文。
不同于在全局作用域中声明函数,我们将每个函数声明到前一个函数的内部。
let price = 10;
function priceA() {
let price = 20;
(function priceB() {
let price = 30;
(function priceC() {
console.log(price);
})();
})();
}
priceA();
在这个示例中,
- 函数
priceA 定义在全局作用域中; - 函数
priceB 定义在 priceA 作用域中; - 函数
priceC 定义在 priceB 作用域中。
根据词法作用域定义,我们可以推理出每个执行上下文中的 outer :
- 在
priceC 函数执行上下文中,outer 指向 priceB 函数执行上下文; - 在
priceB 函数执行上下文中,outer 指向 priceA 函数执行上下文; - 在
priceA 函数执行上下文中,outer 指向全局执行上下文。
最后执行的时候,console 输出 30。
这就是作用域链在 JavaScript 的执行上下文中的工作方式。
This
谁调用这个方法,谁就是 this。
this 与作用域链没有关联,两者是不同的机制;
为什么箭头函数没有this
箭头函数不创建自己的执行上下文,所以就没有this
箭头函数会无视以上所有的规则,是因为箭头函数不会创建自己的执行上下文, 而 this 本身是属于执行上下文中的一个组件,执行上下文都不创建了,自然不会有属于自己的 this; 如果非要在箭头函数中使用 this , 实际上使用的是外部作用域的 this ;
普通函数执行的时候, 需要创建一堆的环境, 比如变量环境, 词法环境, this, outer 等等, 但是很多函数类似于 function (x, y) { return x + y } 这种纯函数,根本不涉及这些环境信息的,为了加快函数的执行效率, ECMA委员会专门设计了一个阉割版的函数, 也就是箭头函数, 箭头函数直接不创建执行上下文, 加快了执行的效率
延伸
1:其他 ES 版本的执行上下文
ES3 中,包含三个部分。
scope:作用域,也常常被叫做作用域链。
variable object:变量对象,用于存储变量的对象。
this value:this 值。
在 ES5 中,改进了命名方式,把执行上下文最初的三个部分改为下面这个样子。
lexical environment:词法环境,当获取变量时使用。
variable environment:变量环境,当声明变量时使用。
this value:this 值。
在 ES2018 中,执行上下文又变成了这个样子,this 值被归入 lexical environment,但是增加了不少内容。
lexical environment:词法环境,当获取变量或者 this 值时使用。
variable environment:变量环境,当声明变量时使用。
code evaluation state:用于恢复代码执行位置。
Function:执行的任务是函数时使用,表示正在被执行的函数。
ScriptOrModule:执行的任务是脚本或者模块时使用,表示正在被执行的代码。
Realm:使用的基础库和内置对象实例。
Generator:仅生成器上下文有这个属性,表示当前生成器。
2:为什么 JS 运行时有编译和执行两个阶段
首先先了解一些概念:
编译器和解释器
之所以存在编译器和解释器,是因为机器不能直接理解我们所写的代码,所以在执行程序之前,需要将我们所写的代码“翻译”成机器能读懂的机器语言。
按语言的执行流程,可以把语言划分为编译型语言和解释型语言。编译型语言在程序执行之前,需要经过编译器的编译过程,并且编译之后会直接保留机器能读懂的二进制文件,这样每次运行程序时,都可以直接运行该二进制文件,而不需要再次重新编译了。比如 C/C++、GO 等都是编译型语言。而由解释型语言编写的程序,在每次运行时都需要通过解释器对程序进行动态解释和执行。比如 Python、JavaScript 等都属于解释型语言。
了解上面的概念之后,v8引擎就是走的基于解释器的路线,但是又与它不同,是一个改进版。具体如下图
v8引擎处理代码流程.png
从图中可以看出v8是解释器、编译器一起用了的。
通常,如果有一段第一次执行的字节码,解释器 Ignition 会逐条解释执行。解释器 Ignition 除了负责生成字节码之外,它还有另外一个作用,就是解释执行字节码。在 Ignition 执行字节码的过程中,如果发现有热点代码(HotSpot),比如一段代码被重复执行多次,这种就称为热点代码,那么后台的编译器 TurboFan 就会把该段热点的字节码编译为高效的机器码,然后当再次执行这段被优化的代码时,只需要执行编译后的机器码就可以了,这样就大大提升了代码的执行效率。
回到上面的js运行时两个阶段,编译阶段和执行阶段,编译阶段就是源码 ->AST -> 字节码。执行阶段就是基于字节码、机器码执行。
参考文献