深入JavaScript闭包(一):执行上下文,执行上下文栈
前言
闭包是学习JavaScript绕不过的话题,也是JavaScript的难点之一。在我之前学习闭包的过程中,只是简单的了解下它的概念,看了一些使用闭包的代码过一段时间,发现自己对于闭包已经忘的差不多了,于是又重复之前的操作,看概念,看代码,然后又会了... 终于,痛定思痛,下定决心深入学习下闭包,从其源头出发,要彻底弄清楚闭包是个什么玩意,毕其功于一役!
要弄清楚闭包,首先得从执行上下文开始。
执行上下文
什么是执行上下文?
先看一段代码,根据代码来分析更清晰些。
第一段代码抛出异常:
a
未定义,这很正常。但第二段和第三段代码先在控制台输出a
,之后才对a
进行声明,赋值,结果控制台输出a
的值是 undefined
,并没有抛出异常,说明控制台输出a
之前,a
已经被定义了。
有一定JS基础的同学可以看出这是属于var
关键字声明的变量提升。变量提升,通俗来说,就是在JavaScript代码执行前,JavaScript引擎会把变量声明部分和函数声明部分提升到代码开头的行为,变量被提升后会给变量设置默认值undefined
。类似于下面这种效果。
这个过程是在执行代码之前完成的。也就是说,在JavaScript引擎执行代码之前会做一些准备工作,包括变量提升之类的,这个准备工作,就可以把它看成执行上下文。
总结一下,当JavaScript
引擎解析到可执行代码(executable code) 阶段的时候,会做一些执行前的准备工作,这个准备工作就是执行上下文。
执行上下文的内容
前面说到执行上下文就是执行代码前的准备工作,那除了变量提升还有哪些准备工作呢?这时有人可能想到了函数提升,确实,函数提升也是准备工作的内容。看下面这段代码:
不论是函数声明还是函数表达式都会进行函数提升,但两者的区别在于,使用函数声明的方式,不仅会进行函数提升,还会给函数赋值,而使函数表达式的方式,只进行函数提升,并不会赋值,默认值为undefined
。这个过程也是在执行上下文中完成的。
除此之外,还有 this
的赋值问题。不知道大家在使用 this
的时候有没有困惑过,这个 this
可以在代码中直接使用,无论我们在代码哪个地方获取 this
,它都是有值的。但在代码中并没有找到定义 this
的地方,而且在不同的地方 this
的值还不一样!
比如在全局使用 this
,它的值就指向 window
,在构造函数中使用,this
指向它的实例对象。
其实
this
的赋值也是在执行上下文中完成的,即在执行代码前的准备工作中完成给 this
赋值的操作。
好了,说到这里大家对执行上下文可能有了大致的了解,就是执行代码前的准备工作,包括一些变量和函数的提升,this
的赋值等等。但这个准备工作具体是什么,呈现什么样的状态都是很模糊的。下面通过一段代码来演示下这个准备工作具体是什么。
在执行左边这段代码前,会先将代码扫描一遍,生成一个关联 变量对象(variable object),这段代码中定义的所有变量、函数、this
都存在于这个对象上。
我们无法通过代码访问变量对象,但后台处理数据会用到它,这段代码中变量的取值赋值都是在这个对象上进行的。在所有代码都执行完毕后这个变量对象会被销毁,包括定义在它上面的所有变量和函数。
总结一下,执行上下文就是执行代码前的准备工作,包括变量,函数的提升,this
的赋值等工作。准备工作的结果是会生成一个关联的 变量对象(variable object),在准备工作中所有的变量和函数,this
都会存在这个对象上。
执行上下文的类型
之前说过,执行上下文是JavaScript引擎解析 可执行代码(executable code) 前的准备工作。在JavaScript
中,可执行代码分为3类:全局代码、函数代码、Eavl代码。对应的执行上下文也分为3类:全局上下文,函数上下文,Eval上下文。
全局上下文
全局上下文是最外层的上下文,或者说是最基础的上下文。一个程序中只会存在一个全局上下文,它在整个 JavaScript
脚本的生命周期内都会存在于执行堆栈的最底部不会被栈弹出销毁(全局上下文在应用程序退出前才会被销毁,比如关闭网页或退出浏览器)。
全局上下文会生成一个对应的全局对象(以浏览器环境为例,这个全局对象是window
)。并将this
绑定到这个全局对象上。
还是以这段代码为例,准备工作生成的执行上下文对象(variable object) 就是全局对象,也就是window
,代码中定义的变量、方法都是定义在window
上的。
函数上下文
还是先看一段代码,根据代码分析更清晰些。
在执行函数体代码时,a
、arguments
、this
等变量没有定义就直接使用了,但并没有抛出异常。
这说明在执行这段代码前也有一个准备工作,将这些变量都提前定义好了。这个准备工作就是函数上下文了。
与全局上下文有所区别的是,函数里面有一些默认的参数arguments
,它主要存储传入函数的参数,每次函数调用可能传入不同的参数,arguments
的值也可能是不同的。
也就是说,每当一个函数被调用时都会创建一个不同的函数上下文。函数执行完之后,这个函数上下文 会被销毁。
在执行完第八行代码后,
changeColor('blue')
的上下文会被销毁。
在执行第九行代码前会创建changeColor('red')
的上下文,执行完毕后也会被销毁掉。
Eavl上下文
之前说过,可执行代码分为3类:全局代码、函数代码、Eavl代码。全局代码和函数代码比较常见,Eavl代码就很少见到被使用。Eavl代码是指传递给eval函数的参数部分的源代码文本。
这里简单介绍下 eval 函数 :eval函数是一种接受字符串作为参数,并且可以将接受的字符串转换成 js表达式 并且立即执行该表达式的特殊函数。eval函数的参数只有一个就是字符串,这个字符串就是 Eavl代码
例如:eval('console.log(112233)')
其中console.log(112233)
就是Eval代码。Eval代码用的比较少,这里就不做过多叙述了。
执行上下文栈
上下文栈是什么?
当JavaScript
引擎执行全局代码前会产生一个全局上下文,会将全局上下文放入一个栈中,此时全局上下文位于栈顶,是活动状态。
当执行代码进入函数时,会生成函数上下文,函数上下文也会被推入栈中,此时函数上下文位于栈顶,变为活动状态。函数调用完成后,函数上下文会被弹出栈进行销毁,于是又回到全局上下文。这是一个入栈出栈的过程。这个栈就是执行上下文栈,用来保存执行上下文。
上下文入栈,出栈的执行过程
通过一段代码来演示下执行·上下文入栈出栈的过程。
var color = 'blue';
function changeColor(){
var anotherColor = 'red';
function swapColors(){
var tempColor = anotherColor;
anotherColor = color;
color = tempColor;
}
swapColors();
}
changeColor();
1,执行代码前的初始状态,此时只有一个全局上下文,全局上下文处于活跃状态。
3,在执行changeColor
函数体代码时会给对应的变量赋值,执行到12行,调用swapColors
函数,解析swapColors
函数体代码,生成对应的swapColors
函数上下文,并将其压入上下文栈中。
4,在执行swapColors
函数体代码过程中,用到了全局上下文和changeColor
函数上下文里定义的变量color
、anotherColor
等,它会先在活跃状态的上下文(swapColors函数上下文)中搜索变量和函数,没有找到会到上一级上下文(changeColor函数上下文)中继续搜索,找到了直接返回,找不到继续向上一级搜索直到全局上下文。
上下文之间的连接是线性的,有序的。每个上下文都可以到上一级上下文中去搜索变量和函数,但任何上下文都不能到下一级上下文中去搜索。
执行完swapColors
函数体代码后,上下文栈会弹出swapColors
上下文并将其销毁。此时changeColor
函数上下文处于活动状态。
5,之后changeColor
函数执行完毕,上下文栈会弹出changeColor
函数上下文并销毁。全局上下文处于活跃状态。
6,最后,代码执行完毕,全局上下文出栈并销毁。
扩展延伸
上面通过分析一段代码的执行流程演示了执行上下文环境的演变过程。但这只是最理想的状况,还有一些比较复杂的情况,比如说闭包。关于闭包中上下文的执行过程我会在下下章节中讲到。
栈溢出
栈溢出的产生
先看一段代码及运行结果
左边是一道比较经典的递归阶乘函数,但当num
的数量较大时会抛出异常:Maximum call stack size exceeded
(超过最大调用堆栈大小)。
我们之前通过例子分析了代码执行时,执行上下文栈的运行过程。每次调用函数都会生成一个对应的函数上下文,并将其压入上下文栈中。在递归函数中,如果我们传入的参数过大,它会不断的调用自身,每调用一次,就会生成对应的函数上下文,然后压入上下文栈中。最后超出栈的最大容量,爆栈,也就是栈溢出。栈溢出经常发生在递归中。
解决策略
针对这个问题又不同的解决策略,1,把递归改写成迭代循环形式。
function factorial(num){
if(num <= 1){
return num;
}
let sum = 1;
for(let i = 2; i<= num; i++){
sum *= i;
}
return sum;
}
2,保持递归实现,将其转化为满足优化条件的尾调用形式
《JavaScript高级程序设计》中对于尾调用优化的介绍
ECMAScript6 规范新增了一项内存管理优化机制,让JavaScript引擎在满足条件时可以重用栈帧。具体来说,这项优化非常适合尾调用,即外部函数的返回值是内部函数的返回值。
看一段代码
function outerFunction(){
return innerFunction(); //尾调用
}
在ES6优化之前,执行这个例子会在内存中发生如下操作。
- 执行到outerFunction函数体,生成outerFunction函数上下文,推到栈中。
- 执行 return 语句 调用innerFunction函数,生成innerFunction函数上下文,推到栈中。
- innerFunction函数执行完毕,栈推出并销毁innerFunction函数上下文。
- outerFunction函数执行完毕,栈推出并销毁outerFunction函数上下文。程序执行完毕。
- 执行到outerFunction函数体,生成outerFunction函数上下文,推到栈中。
- 执行 return 语句 调用innerFunction函数,发现innerFunction的返回值也是outerFunction的返回值,并且在innerFunction函数体中并没有用到outerFunction函数上下文中的变量,销毁outerFunction函数上下文对之后的代码执行也没有影响。所以这里会先推出并销毁outerFunction函数上下文。
- 之后将生成的innerFunction函数上下文推入上下文栈中。
- innerFunction函数执行完毕,栈推出并销毁innerFunction函数上下文。程序执行完毕。
运用这种思路,我们把之前的递归阶乘函数改造下
function factorial (num, sum = 1) {
if (num <= 1) return sum;
return factorial(num - 1, sum * num);
}
console.log(factorial(10));
console.log(factorial(100));
console.log(factorial(1000));
console.log(factorial(10000));
console.log(factorial(100000));
结果.... 第四步就爆栈了
查阅了一些资料发现,虽然它是ES6规范提出的,但目前的绝大多数浏览器并不支持这个优化,只能说未来可期。
小结
执行上下文的概念在JavaScript中是颇为重要的,理解了执行上下文和执行上下文栈的运作过程,对于我们阅读代码是很有帮助的。
转载自:https://juejin.cn/post/7253251884525256759