JavaScript 之深入理解执行上下文

572 阅读11分钟

在 JavaScript 中,执行上下文是一个基本的概念,但其中又包含了变量对象、作用域链、this 指向等更深入的内容,深入理解执行上下文以及其中的内容,对我们以后理解 JavaScript 中其它更深入的知识点(函数/变量提升、闭包等)会有很大的帮助。

执行上下文(Execution Context)

执行上下文可以理解为当前代码的运行环境。在 JavaScript 中,运行环境主要包含了全局环境函数环境

在 JavaScript 代码运行过程中,最先进入的是全局环境,而在函数被调用时则进入相应的函数环境。全局环境和函数环境所对应的执行上下文我们分别称为全局上下文函数上下文

在一个 JavaScript 文件中,经常会有多个函数被调用,也就是说在 JavaScript 代码运行过程中很可能会产生多个执行上下文,那么如何去管理这多个执行上下文呢?

执行上下文是以栈(一种 LIFO 的数据结构)的方式被存放起来的,我们称之为执行上下文栈(Execution Context Stack)

在 JavaScript 代码开始执行时,首先进入全局环境,此时全局上下文被创建并入栈,之后当调用函数时则进入相应的函数环境,此时相应函数上下文被创建并入栈,当处于栈顶的执行上下文代码执行完毕后,则会将其出栈。

所以在执行上下文栈中,栈底永远是全局上下文,而栈顶则是当前正在执行的函数上下文。

文字表达既枯燥又难以理解,让我们来看一个简单的栗子吧~

function fn2() {
  console.log('fn2')
}
function fn1() {
  console.log('fn1')
  fn2();
}
fn1();

运行上述代码,可以得到相应的输出,那么上述代码在执行过程中执行上下文栈的行为是怎样的呢?

/* 伪代码 以数组 ECStack=[] 来表示执行上下文栈  */
// 代码执行时最先进入全局环境,全局上下文被创建并入栈
ECStack.push(global_EC);
// fn1 被调用,fn1 函数上下文被创建并入栈
ECStack.push(fn1_EC);
// fn1 中调用 fn2,fn2 函数上下文被创建并入栈
ECStack.push(fn2_EC);
// fn2 执行完毕,fn2 函数上下文出栈
ECStack.pop();
// fn1 执行完毕,fn1 函数上下文出栈
ECStack.pop();
// 代码执行完毕,全局上下文出栈
ECStack.pop();

以一个更形象的图来说明上述的流程

执行上下文栈 ECStack

在一个执行上下文中,最重要的三个属性分别是变量对象(Variable Object)、**作用域链(Scope Chain)**和 this 指向

我们可以采用如下方式表示

EC = {
  VO,
  SC,
  this
}

一个执行上下文的生命周期分为创建执行阶段。创建阶段主要工作是生成变量对象建立作用域链确定 this 指向。而执行阶段主要工作是变量赋值以及执行其它代码等。

变量对象(Variable Object)

我们已经知道,在执行上下文的创建阶段会生成变量对象,生成变量对象主要有以下三个过程:

  1. 检索当前上下文中的参数。该过程生成 Arguments 对象,并建立以形参变量名为属性名,形参变量值为属性值的属性;
  2. 检索当前上下文中的函数声明。该过程建立以函数名为属性名,函数所在内存地址引用为属性值的属性;
  3. 检索当前上下文中的变量声明。该过程建立以变量名为属性名,undefined 为属性值的属性(如果变量名跟已声明的形参变量名或函数名相同,则该变量声明不会干扰已经存在的这类属性)。

我们可以通过以下伪代码来表示变量对象

VO = {
  Arguments: {}, 
  ParamVariable: 具体值,  //形参变量
  Function: <function reference>,
  Variable:undefined
}

当执行上下文进入执行阶段后,变量对象会变为活动对象(Active Object)。此时原先声明的变量会被赋值。

变量对象和活动对象都是指同一个对象,只是处于执行上下文的不同阶段

我们可以通过以下伪代码来表示活动对象

AO = {
  Arguments: {},
  ParamVariable: 具体值,  //形参变量
  Function: <function reference>,
  Variable:具体值
}

同样的,让我们以实际栗子来理解在代码执行过程中某执行上下文中变量对象的变化情况~

function fn1(a) {
  var b = 1;
  function fn2() {}
  var c = function () {};
}
fn1(0);

当 fn1 函数被调用时,fn1 执行上下文被创建(创建阶段)并入栈,其变量对象如下所示

fn1_EC = {
  VO = {
    Arguments: {
      '0': 0,
      length: 1
    },
    a: 0,
    b: undefined,
    fn2: <function fn2 reference>,
    c:undefined
  }
}

而在 fn1 函数代码的执行过程中(执行阶段),变量对象变为活动对象,原先声明的变量会被赋值,其活动对象如下所示

fn1_EC = {
  AO = {
    Arguments: {
      '0': 0,
      length: 1
    },
    a: 0,
    b: 1,
    fn2: <function fn2 reference>,
    c:<function express c reference>,
  }
}

对于全局上下文来说,由于其不会有参数传递,所以在生成变量对象的过程中只有检索当前上下文中的函数声明和检索当前上下文中的变量声明两个步骤。

在浏览器环境中,全局上下文中的变量对象(全局对象)即我们熟悉的 window 对象,通过该对象可以使用其预定义的变量和函数,在全局环境中所声明的变量和函数,也会成为全局对象的属性。

弄明白了变量对象的生成过程后,我们就能够更深入地理解函数提升以及变量提升的内在机制了。

console.log(a) // undefined
fn(0); // fn
var a = 0;
function fn() {
  console.log('fn')
}

上述代码中,在全局上下文的创建阶段,会检索上下文中的函数声明以及变量声明,函数会被赋值具体的引用地址而变量会被赋值为 undefined。

所以上述代码实际上的运行过程如下

function fn() {
  console.log('fn')
}
var a = undefined;
console.log(a) // undefined
fn(0); // fn
a = 0;

所以,这就是我们经常提到的函数提升以及变量提升的内在机制。

作用域链(Scope Chain)

作用域链是指由当前上下文和上层上下文的一系列变量对象组成的层级链。它保证了当前执行环境对符合访问权限的变量和函数的有序访问。

我们已经知道,执行上下文分为创建和执行两个阶段,在执行上下文的执行阶段,当需要查找某个变量或函数时,会在当前上下文的变量对象(活动对象)中进行查找,若是没有找到,则会沿着上层上下文的变量对象进行查找,直到全局上下文中的变量对象(全局对象)。

那么当前上下文是如何有序地去查找它所需要的变量或函数的呢?

答案就是依靠当前上下文中的作用域链,其包含了当前上下文和上层上下文中的变量对象,以便其一层一层地去查找其所需要的变量和函数。

执行上下文中的作用域链又是怎么建立的呢?

我们都知道,JavaScript 中主要包含了全局作用域和函数作用域,而函数作用域是在函数被声明的时候确定的

每一个函数都会包含一个 [[scope]] 内部属性,在函数被声明的时候,该函数的 [[scope]] 属性会保存其上层上下文的变量对象,形成包含上层上下文变量对象的层级链。[[scope]] 属性的值是在函数被声明的时候确定的

当函数被调用的时候,其执行上下文会被创建并入栈。在创建阶段生成其变量对象后,会将该变量对象添加到作用域链的顶端并将 [[scope]] 添加进该作用域链中。而在执行阶段,变量对象会变为活动对象,其相应属性会被赋值。

所以,作用域链是由当前上下文变量对象及上层上下文变量对象组成的

SC = AO + [[scope]]

让我们来看个栗子~

var a = 1;
function fn1() {
  var b = 1;
  function fn2() {
    var c = 1;
  }
  fn2();
}
fn1();

在 fn1 函数上下文中,fn2 函数被声明,所以

fn2.[[scope]]=[fn1_EC.VO, globalObj]

当 fn2 被调用的时候,其执行上下文被创建并入栈,此时会将生成的变量对象添加进作用域链的顶端,并且将 [[scope]] 添加进作用域链

fn2_EC.SC=[fn2_EC.VO].concat(fn2.[[scope]])
=>
fn2_EC.SC=[fn2_EC.VO, fn1_EC.VO, globalObj]

this 指向

**this 的指向,是在函数被调用的时候确定的。**也就是执行上下文被创建时确定的。

关于 this 的指向,其实最主要的是三种场景,分别是全局上下文中 this函数中 this构造函数中 this

全局上下文中 this

在全局上下文中,this 指代全局对象。

// 在浏览器环境中,全局对象是 window 对象:
console.log(this === window); // true
a = 1;
this.b = 2;
console.log(window.a); // 1
console.log(window.b); // 2
console.log(b); // 2

函数中 this

函数中的 this 指向是怎样一种情况呢?

如果被调用的函数,被某一个对象所拥有,那么其内部的 this 指向该对象;如果该函数被独立调用,那么其内部的 this 指向 undefined(非严格模式下指向 window)。

举个栗子~

var a = 1;
function fn() {
  console.log(this.a)
}
var obj = {
  a: 2,
  fn: fn
}
obj.fn(); // 2
fn(); // 1

上述代码中 fn 函数都是输出 this.a,根据上述的结论,obj.fn() 由于其是被 obj 对象所拥有,所以 this 指向 obj 对象;而 fn 是被独立调用,在非严格模式下 this 指向 window。

构造函数中 this

要清楚构造函数中 this 的指向,则必须先了解通过 new 操作符调用构造函数时所经历的阶段。

通过 new 操作符调用构造函数时所经历的阶段如下:

  1. 创建一个新对象;
  2. 将对象的 __proto__ 关联到构造函数的 prototype 属性;
  3. 将构造函数的 this 指向这个新对象;
  4. 执行构造函数内部代码;
  5. 返回这个新对象;

所以从上述流程可知,对于构造函数来说,其内部 this 指向新创建的对象实例

function Person(name, age) {
  this.name = name;
  this.age = age;
}
var ttsy = new Person('ttsy', 24);
console.log(ttsy.name);  // ttsy
console.log(ttsy.age);  // 24

需要注意的是,在 ES6 中箭头函数中,this 是在函数声明的时候确定的,具体可看 es6.ruanyifeng.com/#docs/funct…

一个完整的栗子

接下来,让我们来完整地 look 一下程序运行过程中执行上下文及其内部属性的变化情况。

function fn1() {
  var a = 1;
  function fn2(b) {
    var c = 3
  }
  fn2(2)
}
fn1();

上述代码在执行过程中,执行上下文栈的变化过程如下

/* 伪代码 以数组来表示执行上下文栈 ECStack=[] */
// 代码执行时最先进入全局环境,全局上下文被创建并入栈
ECStack.push(global_EC);
// fn1 被调用,fn1 函数上下文被创建并入栈
ECStack.push(fn1_EC);
// fn1 中调用 fn2,fn2 函数上下文被创建并入栈
ECStack.push(fn2_EC);
// fn2 执行完毕,fn2 函数上下文出栈
ECStack.pop();
// fn1 执行完毕,fn1 函数上下文出栈
ECStack.pop();
// 代码执行完毕,全局上下文出栈
ECStack.pop();

首先进入全局环境,全局上下文被创建并入栈

全局上下文如下

global_EC = {
  VO: globalObj,
  SC: [globalObj],
  this: globalObj,
}

接着 fn1 被调用,fn1 函数上下文被创建并入栈

在 fn1 函数上下文被创建之前,会有一个函数声明过程,这个过程发生在全局上下文创建阶段,在这个过程中,fn1.[[scope]] 会保存其上层作用域的变量对象。

在 fn1 函数上下文创建阶段,其执行上下文如下

fn1_EC = {
  VO: {
    Arguments: {
      length: 0
    },
    fn2: <function fn2 reference>,
    a:undefined
  },
  SC:[fn1_EC.VO, globalObj],
  this:null
}

在 fn1 函数上下文执行阶段,其执行上下文如下

fn1_EC = {
  VO: {
    Arguments: {
      length: 0
    },
    fn2: <function fn2 reference>,
    a:1
  },
  SC:[fn1_EC.VO, globalObj],
  this:globalObj
}

然后在 fn1 中调用 fn2,fn2 函数上下文被创建并入栈

在 fn2 函数上下文创建阶段,其执行上下文如下

fn2_EC = {
  VO: {
    Arguments: {
      '0': 2,
      length: 0
    },
    b: 2,
    c: undefined
  },
  SC: [fn2_EC.VO, fn1_EC.VO, globalObj],
  this: null
}

在 fn2 函数上下文执行阶段,其执行上下文如下

fn2_EC = {
  VO: {
    Arguments: {
      '0': 2,
      length: 0
    },
    b: 2,
    c: 3
  },
  SC: [fn2_EC.VO, fn1_EC.VO, globalObj],
  this: globalObj
}

最后是各个上下文出栈

在各个上下文出栈后,其对应的变量对象会被 JavaScript 中的自动垃圾收集机制回收。

而我们经常说闭包能够访问其所在环境的变量,其实是因为闭包能够阻止上述变量对象被回收的过程。

深入地理解了执行上下文的内容后,对于我们理解闭包也会有很大的帮助,关于闭包我写过一篇 《 JavaScript 闭包详解 》,感兴趣的童鞋也可以继续阅读。