参考资料
极客时间课程《浏览器工作原理与实践》 – 李兵
一、js代码执行过程
(一)javascript代码的执行流程
浏览器执行javascript代码的流程如下图所示:
javascript的执行机制是:先编译,再执行。在编译阶段生成了执行上下文、可执行代码。执行上下文由变量环境、词法环境构成,它为可执行代码在执行时提供运行环境。
在编译阶段,javascript引擎将变量的声明部分和函数的声明部分提升到代码开头,并且给被提升的变量设置默认值:undefined,同时这些被提升的变量将被放入到变量环境对象中。
(二)变量提升(Hoisting)
所谓变量提升就是js代码在编译阶段,js引擎将变量的声明部分和函数的声明部分提升到代码开头的一种行为。在js代码执行前会先进行编译,在编译阶段出现了变量提升,关于编译阶段为什么会出现变量提升、变量提升带来的问题、ES6如何修正变量提升,将在 变量提升 中进行深入了解。下面先对变量提升做初步了解:
下面举例分析一下:
- 例1:常见的变量提升
//示例代码
console.log("fun1", fun1);
showName()
console.log("myname",myname);
var myname = "aaa";
function showName () {
console.log("执行showName函数");
}
var fun1 = function () {
console.log("另一种函数声明方式");
}
//变量提升:
function showName () {
console.log("执行showName函数")
}
var myname = undefined;
var fun1 = undefined;
//可执行代码:
console.log("fun1", fun1);
showName()
console.log("myname",myname);
myname = "aaa";
fun1 = function () {
console.log("另一种函数声明方式");
}
//执行结果:
fun1 undefined
执行showName函数
myname undefined
该例子中的变量环境对象大致如下表所示,对于函数声明(function声明),函数体一起被提升,并存在变量环境对象中。当函数被调用时,函数体会被编译,并创建函数执行上下文,为函数运行提供环境。当函数执行完毕,函数执行上下文将被销毁。
变量名 | 值 |
---|---|
showName | function () { console.log(“执行showName函数”); } |
myname | undefined |
fun1 | undefined |
- 例2:变量与函数同名
//示例代码
showName()
/** 函数表达式等同变量声明处理,函数声明在前,变量声明在后 **/
function showName () {
console.log(1);
}
/** 重新赋值 **/
var showName = function () {
console.log(2);
}
showName()
//变量提升:
/** 函数提升优先级高于变量提升,先被提升 **/
function showName () {
console.log(1);
}
var showName = undefined;
//可执行代码:
showName()
showName = function () {
console.log(2);
}
showName()
//执行结果:
1
2
分析:变量提示时,存在变量showName与函数showName同名,此时:函数提升比变量提升的优先级要高,且不会被变量声明覆盖,但是会被变量赋值之后覆盖。简单来说就是:在进行变量提升时,先提升函数声明,再提升变量声明;当有变量名与函数名相同时,函数声明不会被变量声明覆盖,但在运行阶段,可以通过赋值语句,对该变量进行重新赋值。所以上述代码运行结果与下面代码运行结果一致:
//示例代码
showName()
/** 函数表达式等同变量声明处理,变量声明在前,函数声明在后 **/
/** 重新赋值 **/
var showName = function () {
console.log(2);
}
function showName () {
console.log(1);
}
showName()
//变量提升:
/** 函数提升优先级高于变量提升,先被提升 **/
function showName () {
console.log(1);
}
var showName = undefined;
//可执行代码:
showName()
showName = function () {
console.log(2);
}
showName()
//执行结果:
1
2
- 例3:函数与函数同名,js编译阶段会选择最后声明的那个
总结:变量提升只提升声明,不提升赋值,针对变量声明与函数声明进行提升。函数声明主要有:函数声明、函数表达式。其中函数声明会将方法体也提升,而函数表达式同变量提升一样,只会提升声明。当变量提升遇到ES6的let/const,会出现暂时性死区,效果就像没有提升。另外要注意在ES6的块级作用域中,var变量能够穿透块提升到全局。
二、调用栈
栈是一种后进先出的数据结构。在执行js代码时,可能会存在多个执行上下文,这些执行上下文可能是编译全局代码创建的全局执行上下文(全局唯一)、编译函数体创建的函数执行上下文、编译eval函数创建的执行上下文。这些执行上下文之间也可能存在相互调用关系,js引擎采用栈来对这些执行上下文进行管理。通常将这种用于管理执行上下文的栈称为调用栈,也叫做执行上下文栈。
通过代码示例分析调用栈如何管理执行上下文:
-
创建全局执行上下文,生成可执行代码,并将上下文压入栈底
a. 更新全局上下文,a = 2;
b. 调用addAll -
开始编译addAll函数体,生成可执行代码,并将上下文入栈
a. 更新函数上下文,d = 10;
b. 调用add -
开始编译add函数体,生成可执行代码,并将上下文入栈
a. 执行函数返回:return b + c ; // 9 -
add函数上下文被弹出栈,更新addAll函数上下文:result = 9;
-
addAll函数继续执行
a. 执行函数返回:return a + result + d; // 21 -
add函数上下文被弹出栈,无需更新全局上下文
-
全局上下文继续执行
-
执行完毕
总结:js引擎通过执行上下文的变量环境来支持变量提升。
三、变量提升
在深入了解变量提升前,需要先了解一下什么是作用域,以及ES6出现的块级作用域。
(一)作用域
作用域是指在程序中定义变量的区域,该位置决定了变量的生命周期。简单来说,作用域就是变量与函数的可访问范围,它控制着变量和函数的可见性和生命周期,决定了变量在何处能够被访问,在何时会被销毁。在ES6之前,js只有全局作用域和函数作用域,在ES6时为了解决变量提升带来的一系列问题,引入了块级作用域。以下是在各个作用域中定义的变量的访问范围和生命周期:
- 全局作用域:
- 访问范围:代码中的任何地方
- 生命周期:伴随页面的生命周期
- 函数作用域:
- 访问范围:在函数内部
- 生命周期:在函数执行时存在,执行结束后被销毁
- 块级作用域:
- 访问范围:在{}代码块内部
- 生命周期:在执行{}代码块时存在,执行结束后被销毁
(二)变量提升带来的问题
- 变量容易在不被察觉的情况下被覆盖掉
/** 示例代码 **/
var myname = "1";
function showName () {
console.log(myname);
if (0) {
var myname = "2";
}
console.log(myname);
}
showName()
/** 全局执行上下文 **/
//变量提升
function showName () {
console.log(myname);
if (0) {
var myname = "2";
}
console.log(myname);
}
var myname = undefined;
//可执行代码
myname = "1";
showName()
/** 函数执行上下文 **/
//变量提升
var myname = undefined;
//可执行代码
console.log(myname);
if (0) {
myname = "2";
}
console.log(myname);
/** 输出结果 **/
undefined
undefined
分析:该代码在ES6出现前,调用函数showName时开始编译showName函数体,if条件中声明的myname由于没有块级作用域而被提升至函数顶部,并放入函数执行上下文中的变量环境对象。在执行可执行代码时,js优先从当前的执行上下文中查找变量。由于当前执行上下文中包含了变量myname,值为undefined,所以输出结果均为undefined。该段代码在有块级作用域的其他语言中,输出结果应当均为"1"。
- 本应被销毁的变量没有被销毁
/** 示例代码 **/
function foo () {
for (var i = 0; i < 7; i++) {
}
console.log(i);
}
foo()
/** 全局执行上下文 **/
//变量提升
function foo () {
for (var i = 0; i < 7; i++) {}
console.log(i);
}
//可执行代码
foo()
/** 函数执行上下文 **/
//变量提升
var i = undefined
//可执行代码
for (i = 0; i < 7; i++) {}
console.log(i);
/** 输出结果 **/
7
分析:在创建函数执行上下文时,变量i被提升,当for循环结束后,变量i并没有被销毁,它存在函数执行上下文的变量环境对象中。这导致输出结果为7。而在有块级作用域的其他语言中,for循环完毕,变量应当被销毁。
(三)ES6如何修正变量提升
在最初设计js语言时,设计者没有打算把这门语言设计得太复杂,只是引入函数作用域和全局作用域,忽略了一些块级作用域。这样如果变量或者函数在if块、while块里面,因为它们没有作用域,所以在编译阶段,可以直接把这些变量或者函数提升到开头,这样大大降低了设计语言的复杂性,但也埋下了混乱的种子。通过上面变量提升带来的问题,可以了解到在需要有块级作用域的时候,js的运行结果与c、java等拥有块级作用域语言的运行结果截然不同。随着JavaScript的流行,这门最初只为了让网页动起来而设计的语言逐渐暴露出更多的问题,最终为了能够解决这些问题,推出了ES6。
ES6在语言层面做了很大的调整,但为了保持向下兼容,就必须在支持旧规则的同时实现新的规则。针对变量提升来说,我们知道在编译阶段会将变量、函数提升并存放于执行上下文的变量环境对象中,在执行阶段通过在调用栈管理的一个个上下文环境的变量环境对象中查找目标变量/函数。可以说变量环境对象+调用栈支持了变量提升。在ES6中,为了保持兼容,选择在词法环境中自行维护一个类似调用栈的小型栈来管理块级作用域。在编译阶段,针对let、const关键字声明的变量,都将被放置到词法环境中,而var声明的变量或函数将被放置到变量环境对象中。
(四)从执行上下文角度分析块级作用域
通过代码示例分析,ES6如何结合let关键字支持块级作用域:
-
调用函数时开始编译函数体,只编译一次
-
进行变量提升,将a、c放入变量环境对象,将处于函数内部且非处于块级作用域内的变量压入词法环境维护的小型栈作为栈底。
-
对于{}块级作用域,对b、d进行提升并初始化为undefined,形成的对象暂不压入栈中
-
foo函数体生成可执行代码如下:
a = 1; b = 2; { b = 3; c = 4; d = 5; console.log(a); console.log(b); } console.log(b); console.log(c); console.log(d);
-
执行可执行代码,更新变量环境对象:a = 1;更新词法环境小型栈栈底:b = 2;
-
执行到{}代码块,将块级作用域编译阶段形成的对象压入栈中,作为栈顶。
-
更新词法环境小型栈栈顶:b = 3;
-
从词法环境小型栈栈顶向下查找变量c,找不到则进入变量环境对象查找c。更新变量环境对象:c = 4;
-
更新词法环境小型栈栈顶:d = 5;
-
先查找词法环境,再查找变量环境对象,查找a,打印值
-
打印b,查找方式同10
-
{}代码块执行完毕,将块级作用域编译阶段形成的对象从栈顶弹出。
-
继续执行函数体,打印b,先进入词法环境查找,再进入对象环境查找。
-
打印c
-
打印d时因查找不到变量而抛出异常
-
最终输出结果:1 3 2 4 抛出异常:Uncaught ReferenceError: d is not defined
总结:ES6通过在词法环境中维护一个类似调用栈的小型栈来支持块级作用域。当函数执行时,会将函数最外层通过let、const声明的变量,并且不处于块级作用域内的变量,进行变量提升,初始化为undefined,然后压入栈底。当运行带{}代码块时,再将该代码块通过let、const声明的变量追加入栈;代码块执行结束时又将它弹出栈顶销毁。当需要查找变量时,先从词法环境开始查找,自栈顶向下查找,若未命中目标,将进入变量环境查找。
(五)暂时性死区
在编译阶段已经有词法环境,变量也已经默认初始化为undefined,但在let声明该变量前使用,将会出现暂时性死区。如下:
function foo () {
//debugger
console.log("a", a);
let a = 1;
}
foo()
通过断点可以观察到在编译阶段,变量a被提升到函数顶并初始化为undefined;但上述代码最终执行结果是抛出异常:Uncaught ReferenceError: Cannot access ‘a’ before initialization,而不是打印出undefined。这是js引擎做的一个控制,通过let声明的变量,即使在编译阶段被提升并且初始化为undefined了,但就是不允许在代码执行到let声明前被访问。
总结:暂时性死区是语法规定,虽然通过let声明的变量在代码执行阶段已经存在词法环境中,但在执行到对应的let声明前访问该变量,js引擎就会抛出一个错误。