手写面试题四:执行上下文、执行栈和词法环境

635 阅读10分钟

转载请注明原文链接。原文链接

手写面试题系列是我为了准备当下和以后的面试而编写的文章系列,当然对于前端小伙伴也有帮助。我建议读完之后,自己动手敲代码或者手写一遍才能更好地掌握。

参考文献:

  1. 【译】 理解 JavaScript 中的执行上下文和执行栈
  2. Lexical Environment — The hidden part to understand Closures
  3. 【译】词法环境——闭包的隐秘角落

执行上下文、执行栈是JavaScript中相对简单的概念,但是它们会牵扯到词法环境、闭包等概念。这篇文章主要讲述执行上下文执行栈的概念,并结合实际的例子进行说明,最后会提到一些关于词法环境的知识。由于词法环境闭包联系很紧,这篇文章只会简单介绍一下词法环境,下一篇文章会着重讲词法环境闭包,敬请期待。

一、执行上下文

1. 什么是执行上下文?

执行上下文是评估和执行 JavaScript 代码的环境的抽象概念。每当 Javascript 代码在运行的时候,它都是在执行上下文中运行。

简而言之,执行上下文就是JavaScript中代码运行的环境。例如我们知道的全局环境,又或者叫做全局作用域,函数内部或者函数作用域等。按照代码的执行环境,可以把JavaScript中的执行上下文分为三种。

  • 全局执行上下文 - 任何不在函数内部的代码都在全局上下文中。它会执行两件事:创建一个全局的 window 对象(浏览器的情况下),并且设置 this 的值等于这个全局对象。一个程序中只会有一个全局执行上下文。
  • 函数执行上下文 — 每当一个函数被调用时, 都会为该函数创建一个新的上下文。每个函数都有它自己的执行上下文,不过是在函数被调用时创建的。函数上下文可以有任意多个。每当一个新的执行上下文被创建,它会按定义的顺序(将在后文讨论)执行一系列步骤。
  • Eval 函数执行上下文 — 执行在 eval 函数内部的代码也会有它属于自己的执行上下文,但由于 JavaScript 开发者并不经常使用 eval,所以在这里我不会讨论它。

二、执行栈

执行栈,也就是在其它编程语言中所说的“调用栈”,是一种拥有 LIFO(后进先出)数据结构的栈,被用来存储代码运行时创建的所有执行上下文。 当 JavaScript 引擎第一次遇到你的脚本时,它会创建一个全局的执行上下文并且压入当前执行栈。每当引擎遇到一个函数调用,它会为该函数创建一个新的执行上下文并压入栈的顶部。 引擎会执行那些执行上下文位于栈顶的函数。当该函数执行结束时,执行上下文从栈中弹出,控制流程到达当前栈中的下一个上下文。

举例说明:

function foo() {
	console.log('第一次在函数foo内部');
	foo1();
	console.log('第二次在函数foo内部');
}

function foo1() {
	console.log('在函数foo1内部');
}

foo();
console.log('全局执行上下文');

相信很多读者都知道上面代码输出结果的顺序,而执行栈的概念的引入可以帮助我们更有条理地理解代码的运行顺序

上面代码输出结果的顺序为:

第一次在函数foo内部
在函数foo1内部
第二次在函数foo内部
全局执行上下文

我们使用执行栈的概念描述一下代码的运行顺序:

  1. 全局执行上下文入栈
  2. 定义函数foo()和函数foo1();
  3. 运行函数foo(),把函数foo()的函数执行上下文入栈;
  4. 进入函数foo()内部,运行代码console.log('第一次在函数foo内部');并输出“第一次在函数foo内部”;
  5. 执行函数foo1(),并把函数foo1()的函数执行上下文入栈,进入到函数foo1()内部,运行代码console.log('在函数foo1内部');并输出在函数foo1内部,foo1()内部代码全部运行完毕,把函数foo1()的函数执行上下文出栈;
  6. 函数foo1()运行完毕,继续运行代码console.log('第二次在函数foo内部');并输出第二次在函数foo内部,函数foo()运行完毕,把函数foo()的函数执行上下文出栈,回到全局执行上下文
  7. 回到全局执行上下文,运行代码console.log('全局执行上下文');并输出全局执行上下文全局执行上下文所有代码执行完毕,把全局执行上下文出栈,程序运行接结束;

运用执行栈的概念,就可以很清晰地把上面代码的运行顺序理解清楚了。下面我来讲讲如何创建执行上下文。

三、如何创建执行上下文?

创建执行上下文有两个阶段:创建阶段 和 执行阶段。

执行阶段的含义很简单,就是按照顺序从上执行地运行代码,运行执行栈的概念课哟很好地分析代码的执行顺序。这里,我主要讲讲创建阶段JavaScript引擎都做了哪些事情。

在创建阶段JavaScript引擎会做三件事:

  1. this 值的决定,即我们所熟知的 This 绑定;
  2. 创建词法环境组件;
  3. 创建变量环境组件;

执行上下文的概念可以表示为:

// 执行上下文
ExecutionContext = {
  ThisBinding = <this value>, // this 绑定
  LexicalEnvironment = { ... }, // 词法环境
  VariableEnvironment = { ... }, // 变量环境
}
1. this绑定

看过我上一篇文章的读者应该可以理解this绑定的含义,就是把this指向某个对象。在全局执行上下文中,this指向的是全局对象,浏览器中为window对象;在函数执行上下文中,this的指向取决于函数是如何调用的(如何还不能很好地理解函数中this指向,建议阅读我的上一篇文章:手写面试题三:深入理解 js this 绑定)。

2. 词法环境

很多读者或许对词法环境的概念很陌生,我来解释一下。

词法环境由2个部分组成,一是环境记录器,二是外部环境的引用

环境记录器是存储变量和函数声明的实际位置; 外部环境的引用意味着它可以访问其父级词法环境(作用域);

通俗点解释就是,在词法环境中,环境记录器记录保存了执行上下文中的变量和函数的实际值,外部环境的引用使得该执行上下文可以沿着作用域链访问父级的作用域。

3. 变量环境

变量环境和词法环境含义相似,在ES6中,词法环境的环境记录器用来存储函数和使用let、const声明的变量,而变量环境的环境记录器用来存储var 声明的变量。

四、梳理创建执行上下文的步骤

理解清楚了上面的概念,我们来梳理一下如何创建全局执行上下文和如何创建函数执行上下文

1. 创建全局执行上下文的步骤
  1. this绑定,把全局执行上下文中的this指向window对象;
  2. 确定词法环境,把全局执行上下文中的所有函数声明和使用let、const声明的变量存储到词法环境的环境记录器,把全局执行上下文的对外部环境的引用指向null;
  3. 确定变量环境:把全局执行上下文中的var声明的变量存储变量环境的环境记录器,并把这些变量的值初始化为undefined;

可以看到,创建全局执行上下文时,确定了全局执行上下文中this的指向问题,记录了全局执行上下文中所有的函数声明,记录所有的变量声明(包括使用let、const、var)声明的变量,其中var声明的变量初始值设置为undefined。 也就是说在一行代码没运行前,在全局执行上下文被推入到执行栈之前,JavaScript引擎已经做了这些事情;

2. 创建函数执行上下文的步骤
  1. this绑定,函数执行上下文中的this的指向取决于函数是如何调用,在代码运行前已经确定好了每一次调用函数的代码中的this的指向;
  2. 确定词法环境,把函数执行上下文中的所有函数声明和使用let、const声明的变量存储到词法环境的环境记录器,另外还会把包含函数参数的arguments对象存储到词法环境的环境记录器,把函数执行上下文的对外部环境的引用指向;
  3. 确定变量环境:把函数执行上下文中的var声明的变量存储变量环境的环境记录器,并把这些变量的值初始化为undefined;

仔细阅读函数执行上下文的创建步骤,读者可以发现它和全局执行上下文创建步骤的一些不同。

  1. 函数可以被多次调用,所以同一个函数被调用多次会有多个函数执行上下文,每个函数执行上下文的this不一定相同,但是都会在代码运行前确定;
  2. 函数的词法环境的变量中,还包含给函数传入的参数组成的arguments对象,这也表明函数的参数可以理解为函数内部声明的内部变量,和函数内部定义的函数变量一样;
  3. 函数的执行上下文的词法环境还包含对外部环境的引用(包含对父级作用域的引用),这意味在代码还没执行的时候,函数可以访问的父级作用域就已经确定了(思考一下闭包?)。

这里,我举个例子,读者可以思考一下执行结果;

var b = 10;

function foo(a) {
    a = 1;
    console.log(a, b);
}

function foo1() {
  b = 100;
  foo(b);
}
foo(1000); // 输出 ?
foo1(); //输出 ?

正确答案是: 1 10 1 100

第一个输出是运行foo(1000)时的输出。传入的参数1000不生效的原因时,在函数内部对参数a进行了二次赋值,所以无论传入任何参数都无法改变a输出的值为1;而b的值为10是因为函数foo()的词法环境中对外部环境的引用指向的就是全局上下文,所以当函数foo内部没有变量b的时候就会在全局上下文中寻找变量b。

第二个输出是运行foo1()时的输出。函数foo1()内部调用了函数foo,传入参数为100,根据第一步的分析可以得知传入100不会改变a输出的值为1;而foo1()内部运行的代码b = 100;实际上等价于window.b = 100;,改变了全局执行上下文中b的值,导致输出的b的值为100;这里,即便后面运行foo(),输出的值也会一直是1 100,因为全局执行上下文中的b的值被永久地改变了。

上面的例子中穿插了函数中this指向的知识,如果读者不能完全理解可以参考我的上一篇文章:手写面试题三:深入理解 js this 绑定)。 下一篇文章,我将详细地分析词法环境、闭包的概念和用法,敬请期待。