大家可能会听说过以下专业术语:
- 变量提升(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 -> 字节码。执行阶段就是基于字节码、机器码执行。