执行上下文与调用栈

543 阅读9分钟

前面的文章中讲了闭包,作用域,词法作用域及this,总感觉这些知识点与点之间,好像有着说不清、道不明的联系。事实上,它们之间的关联确实不止于此。闭包也好、this也罢,我们完全可以把它们放在一个完整的知识链路里来理解,那就是JS的执行上下文

为什么要有执行上下文

我们平时写项目的时候,肯定是一个文件一个文件去写;具体到每一个文件里,又会细分出不同的方法、模块。应该不会有人会把成千上万行的庞大的代码逻辑塞进一个文件里。当大家这样做的时候,其实已经在践行一种软件世界里非常重要的思想 —— 分治

分治是编写软件的一种策略,它意味着你会把一个庞大的问题拆分成若干个具体的小问题,然后逐个去解决它们,以此来化解问题的复杂度。表现在代码上,就是把庞大的逻辑拆分成独立的代码块。这些"代码块" 根据粒度的不同,有着不同的名字,它可以是函数、模块、包等等等等。

如果说把代码逻辑划分成"块",是我们程序员在编写阶段的智慧。那么把庞大的执行任务划分成不同的执行上下文,就是JS引擎在执行阶段的智慧了

我们可以把执行上下文理解为引擎在执行过程中对代码进行了又一次的"划分",这样做的目的,仍然是为了分解复杂度

执行上下文是什么

执行上下文,从定义上理解,是"执行代码的环境"。这个不太好理解,下面从执行上下文的分类、组成和生命周期等具体的维度去理解它。

执行上下文的分类

  • 全局上下文 —— 全局代码所处的环境,不在函数中的代码都在全局执行上下文中
  • 函数上下文 —— 在函数调用时创建的上下文

全局上下文的创建和组成

当JS脚本跑起来之后,第一个被创建的执行上下文就是全局上下文。当脚本里一行代码也没有的时候里,全局上下文里会比较干净,只有两个东西:

  • 全局对象(浏览器里是 Window, Node 环境下是 Global)
  • this 变量。这里的 this,指向的还是全局变量

但只要你往里面写了点东西,这个世界就会热闹起来了,比如你写这么几句:

var name = 'xiuyan'
var tel = '123456'

function getMe() {
    return {
        name: name,
        tel: tel
    }
}

全局上下文的组成就会立刻丰富成下面这个样子:

js2.jpg

我明明给 name 和 tel 都赋值了,它咋还是 undefined 呢?这里就要引出上下文的一个生命周期了,每一个执行上下文都会经历这样一个生命周期:

  • 创建阶段 —— 执行上下文的初始化状态,此时一行代码都还没有执行,只是做了一些准备工作
  • 执行阶段 —— 逐行执行脚本里的代码

上图就是创建阶段的全局上下文,创建阶段里,JS引擎不多不少只做这么几件事:

  • 创建全局对象(Window 有了)
  • 创建 this ,并让它指向全局对象
  • 给变量和函数安排内存空间
  • 默认给变量赋值为 undefined;将函数声明放入内存
  • 创建作用域链

到这里为止, 真正的赋值动作都还没有执行。执行阶段的全局上下文是什么样子呢

js3.jpg

需要注意的是,执行上下文在执行阶段里其实始终是处在一个动态,比如说你执行完第一行没执行第二行的时候,这时候就只有 name 有值了, 而 tel 还是 undefined;再往下执行一行, tel 也有值了,就会看到执行上下文的内容又变了。

js4.jpg

站在上下文角度,理解"变量提升"的本质

// 没有报错,而是输出 undefined
console.log(name)
var name = 'xiuyan'

JS 引擎不会抛出变量未声明的错误,而是会输出一个 undefined 值,表现得好像这个 name 变量早已被声明过一样。像这样的现象,我们叫它"变量提升"。

现在结合上下文创建过程,其实根本不存在任何的"提升",变量一直在原地。所谓的"提升",只是变量的创建过程(在上下文创建阶段完成)和真实赋值过程(在上下文执行阶段完成)的不同步带来的一种错觉。执行上下文在不同阶段完成的不同工作,才是"变量提升"的本质。

函数上下文的创建和组成

函数上下文它在机制层面和全局上下文高度一致,只需要关注它与全局上下文之间的不同即可。两者之间的不同主要体现在以下方面上

  • 创建的时机 —— 全局上下文在进入脚本之初就被创建,而函数上下文则是在函数调用时被创建
  • 创建的频率 —— 全局上下文仅在代码刚开始被解释的时候创建一次;而函数上下文由脚本里函数调用的多少决定,理论上可以创建无数次
  • 创建阶段的工作内容不完全相同 —— 函数上下文不会创建全局对象(Window),而是创建参数对象(arguments);创建出的 this 不再死死指向全局对象,而是取决于该函数是如何被调用的 —— 如果它被一个引用对象调用,那么 this 就指向这个对象;否则,this 的值会被设置为全局对象或者 undefined(在严格模式下)

调用栈

在Js代码的执行过程中,引擎会为我们创建"执行上下文栈"(也叫调用栈)。

因为函数上下文可以有许多个,不可能保留所有的上下文。当一个函数执行完毕,其对应的上下文必须让出之前所占用的资源。因此上下文的建立和销毁,就对应了一个"入栈"和"出栈"的操作。当调用一个函数的时候,就会把它的上下文推入调用栈里,执行完毕后出栈,随后再为新的函数进行入栈操作。

站在调用栈的角度,理解作用域的本质

作用域是什么?前面的文章说作用域是"访问变量的一套规则"。但现在可以定义为,作用域其实就是当前所处的执行上下文。下面基于执行上下文,来理解一下作用域的特征:

作用域对外隔离

function testA() {
  console.log('执行第一个测试函数的逻辑');
  testB();
  console.log('再次执行第一个测试函数的逻辑');
}
function testB() {
  console.log('执行第二个测试函数的逻辑');
}
testA();

这里,全局作用域相对于 testA 的函数作用域,它是外部作用域;全局作用域、testA 相对于 testB 的函数作用域,它们都是外部作用域。作用域在嵌套的情况下,外部作用域是不能访问内部作用域的变量的。现在,结合调用栈的情况,来清楚这其中的原因:

以 testB为例,最初处于外部作用域(testA、全局上下文)时,testB 对应的上下文还没有被推入调用栈;而当 testB 执行结束、代码执行退回到外部作用域时,testB 早已从栈顶弹出。这意味着,每次位于外部作用域时,testB 的执行上下文都压根不存在于调用栈内。此时无论如何也找不到任何关于 testB 的线索,自然访问不到它内部的变量啦!

闭包 —— 特殊的"弹出"

一般来说,函数出栈后,我们都没有办法再访问到函数内部的变量了。但闭包可不是这样:

function outer(a) {
  return function inner (b) {
    return a + b;
  };
}

var addA = outer(10);

addA(20)

在这个例子里,inner 函数引用了 outer 函数的自由变量 a 变量,形成了一个闭包。在 outer 函数执行完毕出栈后,实际上 inner 函数仍然可以访问到这个 a 变量 —— a 变量好像没用随着 outer 函数执行上下文的消失而消失,这是为什么呢?

别忘了,在执行上下文的创建阶段,跟着被创建的还有作用域链!这个作用域链在函数中以内部属性的形式存在,在函数定义时,其对应的父变量对象就会被记录到这个内部属性里。闭包正是通过这一层作用域链的关系,实现了对父作用域执行上下文信息的保留。

一个执行上下文一般有如下内容:其中outer就是作用域链

js5.jpg

自由变量的查找 —— 作用域链

前面讲了外部作用域难以"触及"内部作用域的原因。但反过来看,站在函数作用域内部,却可以访问到外部作用域的变量,这又是为啥呢?

var name = 'xiuyan'
function testA() {
  console.log('执行第一个测试函数的逻辑');
  testB();
  console.log('再次执行第一个测试函数的逻辑');
}
function testB() {
  console.log(name);
}
testA();

当代码执行到 testB 这个位置时,它位于调用栈的栈顶,此时 testA 和全局上下文都稳稳地坐在调用栈底部 —— 这首先为 testB 查找到自由变量创造了可能性。

在执行阶段,如果像例子中的 testB 一样,在函数作用域内部找不到 name 这个变量,那么引擎会沿着作用域链向上找、定位到它对应的父级作用域的上下文、看有没有目标变量,如果还没有,那么就沿着作用域链继续往上定位、直到找到为止。

注意这里是沿着作用域链找,而不是沿着调用栈一层一层往上找!调用栈是在执行的过程中形成的,而作用域链可是在书写阶段就决定了。因此,testB 里找不到的变量,绝不会去 testA 里找,而是去全局上下文变量里找!