【JS基础系列】彻底搞懂执行上下文和调用栈(下)

javascript/jquery

浏览数:324

2020-5-28

今天是系列第七篇,有点来迟了。上一篇文章文章讲了执行上下文和调用栈的一些知识点。但是在后面我又发现还有一个块作用域的知识点没有提到,所以打算在这篇文章补充一下这块的内容。另外第六篇文章后面其实还有一些到现在也没有怎么搞懂的点,通过和朋友进行了讨论,最后得出结论。如果有错误的地方请朋友更正一下。那先从块作用域说起吧。

块作用域

首先我们肯定有一个疑问。咱们全局作用域和函数作用域用的好好的,为啥突然要引入块作用域呢?这一切都得从变量提升这个知识点说起,如果对变量提升有疑惑的,可以回过头去重新看一下第六篇文章
在变量提升中经常遇到这样一种情况:

var name ="familyli"
function run(){
    console.log(name)
    if(true){
        var name = 'ming'
    }
}

第一个问题是值覆盖的问题。很显然,最初我们的意图应该是想让这里打印出来的值是familyli,但是这里因为变量提升的原因导致打印出来的结果是undefined,这就显得很奇怪,并且让人费解。我们再看第二个问题,即本应该销毁的值而没有销毁。

for(var i=0;i<5;i++>){}
console.log(i)

这段代码最后打印出来的结果是5。我们本来想要达到的效果是在执行完循环后i的值就应该销毁了,但是实际的情况是因为变量提升的原因导致i被提升到了全局,执行完循环后保留了下来。

因为变量提升的原因导致了上面的这些问题,我们在编写代码的时候常常因为这个带来困扰。所以es6引入了关键字let和const来声明变量,引入了块作用域的概念。

那块作用域解决了哪些问题?
解决了变量提升的问题(不能进行变量提升),声明变量前不能对指进行获取和操作(暂时性死区),值覆盖的问题(同一个块作用域内不能重复声明)。那接下来我举个例子来说一下块作用域整个执行过程吧。

let name = 'familyli'
function run(){
    let name = 'ming'
    console.log(name)
}
run()

用执行栈的知识点解读这一块的执行过程
1、在执行代码之前创建全局执行上下文和调用栈,并把执行上下文压入调用栈的栈底。进入编译阶段,编译结束后全局执行上下文中变量环境中有run = function(){…}(变量提升的变量),词法环境中有name=undefined(预编译的时候创建的变量赋值位undefined)。
2、进入全局上下文中的可执行代码。词法环境中的变量name赋值为familyli,代码接着往下执行到run()
3、创建函数run执行上下文,并把该执行上下文压入调用栈的栈顶。进入编译阶段,编译结束后函数执行上下文中变量环境中为空,词法环境中有name=undefined。
4、进入函数run执行上下文的可执行代码。词法环境中的变量name赋值为familyli,代码接着往下执行到console.log(name)。
5、先去当前执行上下文中找变量name,发现在词法环境中找到了,于是输出值ming。
6、将函数run执行上下文从调用栈中弹出,并且销毁其执行上下文
7、代码往下执行,调用栈中保存着全局执行上下文。除非主动杀死进程,否则全局执行上下文将一直存在于调用栈中。

好,回到开头的那个值覆盖的问题。如果在let name = ‘ming’前面插入console.log(name),会因为变量提升输出undefined吗?不会,会出现暂时性死区,代码报错。
再看下面的代码

let name = 'familyli'
{
    let name = 'ming'
    console.log(name)
}
console.log(name)

上面的代码输出的值是ming和familyli,而不会再跟以前那样因为变量提升的原因覆盖全局中的变量。事实上,这里的name是两个不同的变量。因为name=’ming’在大括号内,自己形成了块作用域,在离开大括号后name的值就销毁了,所以不会污染全局的变量。

好,讲完了块作用域后提出几个上一篇文章没有想明白的几个问题。

遗留的几个问题

1、执行上下文和作用域的区别,作用域的赋值过程是怎样的?

js的作用域是词法作用域,是在编写代码结构的时候就已经确定了。而在函数执行阶段找变量的过程是通过作用域链去找变量然后输出值的。那作用域链上的这个值是怎么确定的呢?
个人的一个猜想:作用域链中只保存各个变量之间的位置关系,而不保存变量的值。变量的值是保存在编译后的变量环境中的,当代码执行阶段找某个变量的值时,先确定作用域链中该变量在链上的位置,然后通过这个位置关系去对应执行上下文中变量环境中变量的值。
所以说:作用域是在编写代码的时候就确定的,但是是在编译阶段才创建的。因为如果脱离了代码执行去理解作用域,变量可访问的范围其实是没有意义的。所以一切都说的通了,编译之后每一个执行上下文中的有一个变量outer,指向其上层作用域,从而形成了作用域链。在执行代码打印值的时候通过作用域链去找变量的位置,然后在执行上下文中找对应变量的值。

2、块作用域暂时性死区的问题
{
    let name = 'familyli'  //[1]
    let age = 20
}

我在1的位置打一个断点,此时初始化的值是name=undefiend,age=uundefined;但是如果我在1的位置后面添加console.log(age),那么此时在1位置的断点初始化显示是name=undefiend,age没有了。这是暂时性死区导致词法环境中不创建该变量吗?

这个问题需要知道块作用域会有一个预编译的过程,在初始化变量之前会把块作用域中的所有变量存储到declareData中。然后发现console.log(age)中的age已经声明了,但是没有初始化,会抛出错误。

另外我对let(块作用域声明),var(普通声明)和函数声明的变量提升的理解是:
1、var的创建和初始化被提升,赋值不会被提升。
2、let的创建被提升,初始化和赋值不会被提升。
3、function的创建、初始化和赋值均会被提升。

3、下面这段代码的执行顺序
var a=0;
if(true){
    a = 1;
    function a(){};
    a=21;
    console.log(a) // 21
}
console.log(a)  // 1

分析:函数提升存在块作用域的(块作用域是有预解析的),而变量的提升不存在。而这个里的函数声明其实可以看做声明一个变量并且它指向指向函数体。所以这里的函数声明会提升到代码的开头,而函数定义会提升到块作用域的前面。而这个变量属于函数级的变量(个人认为在这里指的是全局作用域),所以在块级函数定义的时候,会将该变量同步到函数级的作用域,能被全局的变量访问到。
所以实际上他的预解析是这样的:

var a = 0;
if(true){
    console.log(a,window.a);// 函数提升,是块级作用域,输出 function a 和 0
    a = 1;  // 取作用域最近的块级作用域的 function a ,且被重置为 1了,本质又是一个 变量的赋值。
    console.log(a,window.a);// a 是指向块级作用域的 a, 输出 1 和 0
    function a(){} // 函数的声明,将执行函数的变量的定义同步到函数级的作用域。
    console.log(a,window.a);// 输出 1 和 1
    a = 21; // 仍然是函数定义块级作用域的 a ,重置为 21
    console.log(a,window.a); // 输出为函数提升的块级作用域的 a, 输出 21,1
    console.log("里面",a);
}
console.log("外部",a);
堆栈溢出解决方法(还没想通)

我之前看过递归调用通常容易造成堆栈溢出的情况。我看到一些常用的解决办法是
(1)使用事件循环操纵函数,而不是调用堆栈操纵函数
(2)用循环的调用函数的方式
(3)尾递归解决递归函数(原理是:函数调用会产生“调用记录(存储着函数的相关信息)”存放在栈中,当有函数返回,对应的调用记录才会消失;上面的递归普通函数没有返回,所以调用记录会越来越多,导致栈溢出。
尾递归是在函数中增加了return函数自身的操作,让执行完当前函数之后调用记录及被删除,所以这样一直递归下去也不会导致内存溢出。)

例子见下面的代码。但是我打断点的时候在审查元素中看到了Call Stack中还是会创建很多ruunStack函数,不知道是不是这样验证的方式是错误的(小弟能力有限这个问题还没想通,如果有知道的朋友希望能在这里纠正一下并且在下方评论区留言)

  // 初始函数
  // function runStack(n) {
  //   if (n === 0) {
  //     return 100
  //   }
  //   console.log(n)
  //   runStack(n - 10)
  // }
  // runStack(1000)


// 尾递归
// function runStack (n){
//     if(n === 0) {
//         return 100
//     }
//     console.log(n)
//     return runStack(n-10)
// }
// runStack(1000)

// 循环的方式
// function runStack (n){
//     while(n > 0) {
//         runStack(n-10)
//     }
//     console.log(n)
//     if(n === 0){
//         return 100
//     }
// }
// runStack(1000)


// 事件循环
function runStack (n){
    if(n === 0) {
        return 100
    }
    console.log(n)
    setTimeout(runStack, 0, n-10)
}
runStack(1000)

总结

  • 因为变量提升存在值覆盖和本该销毁的值而没有销毁的问题,所以引入了块作用域。
  • 全局作用域中的变量会提升到全局最顶层,函数内声明的变量只会提升至该函数作用域最顶层。
  • 一个块作用域内不能重复声明,存在暂时性死区,没有变量提升。

参考文章

作者:摩根