和执行上下文有关的那些事

215 阅读13分钟

要脱离单纯劳动力的角色,就要深入去了解那些底层的原理,今天我们就来聊聊执行上下文。只有理解了 JavaScrip 的执行上下文,你才能更好地理解 JavaScript 语言本身,因为JavaScript中的很多概念变量提升、作用域和闭包等等都是和执行上下文有关的。
执行上下文主要包括:

  • 全局执行上下文——代码首次执行的默认环境。

  • 函数执行上下文——每当进入一个函数内部。

  • Eval执行上下文——eval内部的文本被执行时。

我们就从那个这些概念出发,看看执行上下文的那些事情。

变量提升

变量提升对于前端人员来说是已经习以为常的事情,但从别的语言来看,这种特性显得非常奇怪。
所谓的变量提升,是指在 JavaScript 代码执行过程中,JavaScript 引擎把变量的声明部分和函数的声明部分提升到代码开头的“行为”。变量被提升后,会给变量设置默认值,这个默认值就是我们熟悉的 “undefined”。

对于变量提升的过程我们就不详细讲了,很多面试题都在变着花样的考察变量、函数的提升。

我们来看下对于JavaScript引擎来说在执行过程中做了什么,怎么实现的变量提升。 一段代码的执行分为两个阶段:编译阶段执行阶段

是的,JavaScript的执行是需要编译的,只不过它属于解释型语言,编译过程是在执行之前的很短时间之内完成的(具体的编译原理,后面的文章中会详细介绍)。

在编译阶段做了什么呢?

我们以一个例子来进行分析:

showMyName()
console.log(myname)
var myname = '前端记事本'
function showMyName() {
    console.log('函数 showName 被执行');
}

这段代码在执行的时候,变量myname和showMyName的函数声明会被提升。这就是在编译过程中处理的。

代码编译之后会分为两个部分:执行上下文可执行代码。(变量环境中的函数体实际上是存在堆内存中的,变量环境中会创建一个指向这个函数体的变量,我们这里图中不做详细标注了)。

而执行上下文中又分为:

  • 变量环境

  • 词法环境

  • this绑定

我们可以看到,被提升的变量都被放在了变量环境中,剩余的部分则是可执行代码,在执行阶段处理。 过程是怎样的呢? 第 1 行和第 2 行不是变量或者函数声明,所以 JavaScript 引擎不会做任何处理;

第 3 行是经过 var 声明的变量,因此 JavaScript 引擎将在环境对象中创建一个名为 myname 的属性,并将其初始化为undefined;

第 4 行是函数声明, JavaScript 引擎会将函数定义存储到堆 (HEAP)中,并在环境对象中创建一个 showName 的属性,然后将该属性值指向堆中函数的位置(这里涉及到JavaScript内存管理,大家可以去查资料了解一下啊)。

然后就是将代码编译成字节码的过程了,这里就不详细说了。

下面就是执行阶段了

这段代码的变量被提升之后还剩几部分,就是我们要执行的代码了。

showMyName()
console.log(myname)
myname = '前端记事本'

在执行到showName()时会到变量环境中查找这个函数,因为引擎将对这个函数的引用存在了变量环境中,所以很容易找到这段函数代码并执行;

在执行 console.log 的时候,会在变量环境中找到 myname 变量,但此时它的值为 undifined,所以会输出undifined。
最后是对变量myname的赋值操作,会将变量环境中myname的值由undifined -> '前端记事本'
这就是整个变量提升的过程了,其实通过这个过程我们也就知道了,如果声明了两个相同名称的函数,在执行的时候会怎么样了:因为名称相同,所以变量环境中只会存在一个函数名称变量,这个变量名称会指向最后声明的那个函数。
好了,变量提升讲完了,我们可以看到对于执行上下文的三个部分,我们只涉及到了变量环境,那其它两个部分呢?我们下面再来看另外一个概念,作用域。

作用域

上面在提到变量提升的时候我们说,对于其它语言,变量提升是一个很奇怪的特性。因为JavaScript 变量提升这种特性,会导致了很多与直觉不符的代码,这也是 JavaScript 的一个重要设计缺陷。

其中一个很大的影响就是作用域。
作用域是指在程序中定义变量的区域,该位置决定了变量的生命周期。通俗地理解,作用域就是变量与函数的可访问范围,即作用域控制着变量和函数的可见性和生命周期。

javascript的作用域主要包括:全局作用域函数作用域。没有块级作用域。这就造成了我们面试中那个经典的for循环输出i的问题。相信大家对于这种面试题的结果已经很了解了。

但现在,我们从执行上下文的角度分析一下。

对于作用域:

全局作用域中的对象在代码中的任何地方都能访问,其生命周期伴随着页面的生命周期。
函数作用域就是在函数内部定义的变量或者函数,并且定义的变量或者函数只能在函数内部被访问。函数执行结束之后,函数内部定义的变量会被销毁。
我们说过,变量提升会对作用域的理解产生误解。

再来分析一个例子。

var myname = " 三木 "
function showName(){
  console.log(myname);
  var myname = " 前端记事本 "
  console.log(myname);
}
showName()

这段代码输出是“undifined”和“前端记事本”,为什么呢?你可以看到这里有两个 myname 变量:一个在全局执行上下文中,其值是“三木”;另外一个在 showName 函数的执行上下文中,变量被提升了,其值是 undefined。

所以在执行第一个 console.log(myname)时直觉以为会是“三木”,但实际用的是 showName 函数的执行上下文中的myname,此时的值为 undefined。执行第二个console.log(myname)时,showName 函数的执行上下文中的 myname 已经被赋值为“前端记事本”了。

所以通过第一个 myname 的输出可以看到变量容易在不被察觉的情况下被覆盖掉

后来ES6出现了,推出了let、const关键字,使得 javascript 多了块级作用域。 let 和 const 声明的变量不再被提升了。

所以下面这段代码:

function foo(){
    var a = 1
    let b = 2
    {
      let b = 3
      var c = 4
      let d = 5
      console.log(a)
      console.log(b)
    }
    console.log(b) 
    console.log(c)
    console.log(d)
}   
foo()

我们前面说过 javaScript 引擎会先对其进行编译并创建执行上下文,然后再按照顺序执行代码。现在我们引入了 let 关键字,let 关键字会创建块级作用域,那么 let 关键字是如何影响执行上下文的呢?

看下上面的例子,它在编译之后的 foo 函数执行上下文是这样的:

另外,函数代码块中用 let 定义的 b 和 d 并没有出现在foo函数的执行上下文中。 所以可以看到:

函数内部通过 var 声明的变量,在编译阶段全都被存放到变量环境里面了。

通过 let 声明的变量,在编译阶段会被存放到词法环境(Lexical Environment)中。

在函数的作用域内部,通过 let 声明的变量并没有被存放到词法环境中。

然后是执行阶段,在执行到代码块的时候,会在foo函数的词法环境中单独一块区域存放 b 和 d。

可以看到在代码块外部定义的 b 和内部定义的 b 是独立分开的。

其实,在词法环境内部,维护了一个小型栈结构,栈底是函数最外层的变量,进入一个作用域块后,就会把该作用域块内部的变量压到栈顶;当作用域执行完成之后,该作用域的信息就会从栈顶弹出,这就是词法环境的结构。

需要注意下我这里所讲的变量是指通过 let 或者 const 声明的变量。

所以这样在执行下面代码在查询变量值的时候,会先从词法环境的栈顶开始查找,如果找不到就去变量环境中去查找。

因为词法环境维护了一个小的栈,所以在代码块执行完之后, b 和 d 所在的区块就被弹出销毁了。

这就是块级作用域在执行栈的处理过程。

Tips:对于 let 和 const 定义的变量有个“暂时性死区”的概念,我们执行一个例子。

这就是暂时性死区的报错,初始化之前无法访问tmp。其实变量的整个创建过程包括:创建、初始化、赋值。所以 let 变量不会变量提升的真实情况是:在块作用域内,let声明的变量被提升,但变量只是创建被提升,初始化并没有被提升,在初始化之前使用变量,就会形成一个暂时性死区。

  • var 的创建和初始化被提升,赋值不会被提升。

  • let 的创建被提升,初始化和赋值不会被提升。

  • function的创建、初始化和赋值均会被提升。

作用域链

看个例子:

function bar() {
    console.log(myName)
}
function foo() {
    var myName = " 三木 "
    bar()
}
var myName = " 前端记事本 "
foo()

我本来以为结果是:“三木”,结果它输出的是 " 前端记事本 "。为什么会这样? 这段代码的执行栈是这样的:

按照直觉来说,查找 myName 变量的顺序应该是:当前函数执行上下文(bar) -> 外部函数执行上下文(foo) -> 全局执行上下文

但这里有个作用域链的概念。

在每个执行上下文的变量环境中,都包含了一个外部引用,用来指向外部的执行上下文,我们把这个外部引用称为outer。

所以,真实情况是:

当一段代码使用了一个变量时,JavaScript 引擎首先会在“当前的执行上下文”中查找该变量,比如上面那段代码在查找 myName 变量时,如果在当前的变量环境中没有查找到,那么 JavaScript 引擎会继续在 outer 所指向的执行上下文中查找。

那又是什么决定outer的指向呢?

那就是:词法作用域: 词法作用域就是指作用域是由代码中函数声明的位置来决定的,所以词法作用域是静态的作用域,通过它就能够预测代码在执行过程中如何查找标识符。

JavaScript 作用域链是由词法作用域决定的,词法作用域是由函数声明位置决定的,所以上面 bar() 函数在全局环境声明的,所以 outer 指向是全局执行上下文,而不是 foo 函数的执行上下文。

this

对于刚学习 JavaScript 的同学来说,this 真的很让人头大。而这个 this 的指向就是绑定在第一部分我们说过执行上下文中的 this绑定 上的。

在函数执行上下文中,this 的值取决于该函数是如何被调用的,而不是函数声明的位置。

一般对于 this 的调用就分为几种:

  • 对象调用内部的函数,该方法的执行上下文中的 this 指向对象本身;

  • call、bind和apply改变方法的 this 指向;

  • new 一个构造函数的时候也是 this 指向改变的过程。

闭包

**函数和对其周围状态(lexical environment,词法环境)的引用捆绑在一起构成闭包(closure)。也就是说,闭包可以让你从内部函数访问外部函数作用域。 ** 对于下面这段代码,内部函数showName函数,访问了外部 foo 函数的变量 myName。

            function foo() {
                var myName = " 三木 "
                function showName() {
                    console.log(myName)
                }
                return showName
            }
            var bar = foo();
            bar()

整个调用栈的情况如下图:

根据词法作用域的规则,内部函数 showName 总是可以访问它们的外部函数 foo 中的变量,所以返回给全局变量 bar 时,虽然 foo 函数已经执行结束,但是 showName 函数依然可以使用 foo 函数中的变量 myName 。所以当 foo 函数执行完成之后,其整个调用栈的状态如下图所示:

在浏览器中看下是这样的:

从上图可以看出,foo 函数执行完成之后,其执行上下文从栈顶弹出了,但是由于返回的 showName 方法中使用了 foo 函数内部的变量 myName,所以这个变量依然保存在内存中。就像单独给showName方法配置了一个数据包,无论在哪里调用showName函数,都会带着这个数据包。

因为是专属于showName函数的数据包,所以除了 showtName 函数之外,其他任何地方都是无法访问该数据包的,我们就可以把这个数据包称为 foo 函数的闭包。

好了,现在我们终于可以给闭包一个正式的定义了。在 JavaScript 中,根据词法作用域的规则,内部函数总是可以访问其外部函数中声明的变量,当通过调用一个外部函数返回一个内部函数后,即使该外部函数已经执行结束了,但是内部函数引用外部函数的变量依然保存在内存中,我们就把这些变量的集合称为闭包。比如外部函数是 foo,那么这些变量的集合就称为 foo 函数的闭包。

总结

我们从JavaScript的执行上下文的角度分析了一下我们常见的几个概念,整体较少比较粗略,感兴趣的同学可以自己去深入研究一下执行上下文,对于我们分析一些执行原理和处理性能优化是非常有帮助的。

参考文章:
《了解JavaScript的执行上下文》
《浏览器工作原理与实践》 - 李兵
《Understanding Execution Context and Execution Stack in Javascript》 - Sukhjinder Arora

如果你希望了解更多前端知识,请关注我的公众号“前端记事本”