带你搞懂 执行上下文 和 作用域

Posted by Leonsux on April 25, 2022

执行上下文(Execution Context)

每当 Javascript 代码在运行的时候,它都是在执行上下文中运行,用来跟踪记录代码运行时环境的抽象概念。

执行上下文的三种类型:

  • 全局执行上下文

    任何不在函数内部的代码都在全局环境中

    一个程序只有一个全局执行上下文

    全局环境会生成一个全局对象,在浏览器中为 windowthis 会指向该全局对象

  • 函数执行上下文

    每当一个函数被调用时, 都会为该函数创建一个新的函数执行上下文,并被推入 环境栈(执行栈) 中,当函数实行完毕出栈,控制权返回给之前的执行上下文(栈顶的执行上下文)。

  • Eval 函数执行上下文

执行上下文的创建

执行上下文在创建时,做了下面两件事:

  • 创建词法环境(LexicalEnvironment)

  • 创建变量环境(VariableEnvironment)

看网上说法不一,有说在创建时进行 this 的绑定,也有说 this 的绑定是在 创建词法环境时执行的。而通过查看ecma原文可以看到结论:

The abstract operation ResolveThisBinding determines the binding of the keyword this using the LexicalEnvironment of the running execution context.

大意是 执行上下文 通过 词法环境 来确定 this 的绑定。

词法环境

词法环境是ECMA中的一个规范类型,基于代码词法嵌套结构用来记录标识符具体变量或函数关联

词法环境内部有两个组件:

  • 环境记录EnvironmentRecord:用来储存变量和函数声明

  • 外部引用outer:提供访问父词法环境的能力

与执行上下文对应,词环境也有 全局环境(GlobalEnvironment)函数环境(FunctionEnvironment) 的区分。

let a = 1;
function fn(){
  let b = 2;
}
fn();

// 伪代码 全局词法环境
GlobalEnvironment: {
  LexicalEnvironment: { // 词法环境
      EnvironmentRecord: { // 环境记录(存储变量和函数声明)
          type: 'object',
          a: <uninitialized>,
          fn: <function>
      },
      outer: <null>, // 由于是全局词法环境,无外部引用,故为 null
      this: <globalObject>    
  }

}

// 伪代码 函数词法环境
FunctionEnvironment: {
  LexicalEnvironment: { // 词法环境
      EnvironmentRecord: { // 环境记录(存储变量和函数声明)
          type: 'declarative',
          arguments: {length: 0},
          b: <uninitialized>,
      },
      outer: <GlobalEnvironment>, // 外部引用为父词法环境
      this: <globalObject>    
  }
}

变量环境

变量环境本质上仍是词法环境,但它只存储var声明的变量,这样在初始化变量时可以赋值为undefined(变量提升)。

var a = 1;
let b = 2;

// 伪代码 全局词法环境
GlobalEnvironment: {
  VariableEnvironment: { // 变量环境
      EnvironmentRecord: { // 环境记录(存储变量和函数声明)
          type: 'object',
          a: <undefined>, // 注意这里为 undefined
      },
      outer: <null>, // 由于是全局词法环境,无外部引用,故为 null
      this: <globalObject>
  },
  LexicalEnvironment: { // 词法环境
      EnvironmentRecord: { // 环境记录(存储变量和函数声明)
          type: 'object',
          b: <uninitialized>,
      },
      outer: <null>, // 由于是全局词法环境,无外部引用,故为 null
      this: <globalObject>    
  }
}

执行栈

程序运行时,首先把全局环境入栈,后面每次遇到函数被执行时,将该函数的执行上下文入栈,函数执行结束则出栈,所有代码都执行完毕,最后全局环境出栈

console.log(m); // undefined
var m = 999;

var num = 1;

var fnA = function(param) {
  var a = 2;
  console.log('fnA', param)
  fnB(a);
}

var fnB = function(param) {
  var b = 3;
  console.log('fnB', param);
}

fnA(4);

如上代码,首先全局执行上下文入栈,同时全局执行上下文初始化,这时候环境中的变量都是未定义的状态(所以用var定义的变量在遇到变量提升时会为 undefined)

执行栈 Stack:[全局执行上下文]

num: undefined
fnA: undefined
fnB: undefined
this: 浏览器中为 window其余情况比如node为该全局对象一个空对象

然后在逐行执行代码的过程中,变量被赋值

num: 1
fnA: function
fnB: function

执行到 fnA(4) 时,fnA 函数被调用,创建一个函数执行上下文,并入栈

执行栈 Stack:[全局执行上下文, fnA的函数执行上下文]

a: undefined
param: 4
arguments: [4]
this: window

fnA 函数执行上下文中,代码逐行执行,对变量赋值,代码执行到 fnB(a) 时,创建 fnB 的函数执行上下文并入栈

执行栈 Stack:[全局执行上下文, fnA的函数执行上下文, fnB的函数执行上下文]

fnB 执行结束后 fnB的函数执行上下文 出栈

执行栈 Stack:[全局执行上下文, fnA的函数执行上下文]

fnA 执行结束后 fnA的函数执行上下文 出栈

执行栈 Stack:[全局执行上下文]

最后代码执行完毕,全局执行上下文 出栈,栈空

作用域

作用域 和 执行上下文并不是同一个概念。

JavaScript 属于解释型语言,JavaScript 的执行分为:解释执行 两个阶段

解释阶段:

  • 词法分析
  • 语法分析
  • 作用域规则确定

执行阶段:

  • 创建执行上下文
  • 执行函数代码
  • 垃圾回收

作用域 就是在 解释阶段 确定的,而 执行上下文 是在 执行阶段 创建的。

所以对于一个函数来说,函数的作用域在函数定义的时候就已经确定了;而函数的执行上下文在其被调用的时候才会创建,且每次执行的执行上下文可能不一样(this不一样、里面的变量值不一样)。

this 的值是在函数执行前,创建函数执行上下文时被赋值的,this 的值跟调用该方法的对象有关,当前执行上下文指向的对象,即谁调用就指向谁。

Tips:箭头函数除外,箭头函数中的 this 始终指向该函数定义时所在作用域指向的对象,由于作用域不变,所以箭头函数的this指向在定义时就固定了,不会改变。

如下代码,fn 的函数作用域在定义的时候确定了,而不同的调用则创建了不同的执行上下文:

const obj = { a: 1 };

const other = { b: 2 };

function fn(param) {
  console.log('this: ', this, 'param: ', param);
}

obj.fn = fn;

obj.fn(1);        // this: obj     param: 1
fn(2);            // this: 全局对象  param: 2
fn.call(other, 3) // this: other   param: 3

作用域链

先看下面代码,全局作用域中有 变量a 和 函数fn;fn的函数作用域中有 变量b。

fn 中打印变量 a,但 fn 的函数作用域中并没有 a,但我们仍然可以使用 a,这个变量 a 对函数作用域来说就是 自由变量。而向父级作用域取值这一特性就是借助了作用域链。

var a = 1;

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

fn();

再看一个例子,下面的代码最终打印的结果是 1,因为对于 fn1 来说,他的父级作用域是全局作用域,而不是 fn2

var a = 1;

function fn1() {
  console.log(a);
}

function fn2() {
  var a = 2;
  fn1();
}

fn2();

参考

https://juejin.cn/post/6844904145372053511#heading-3

https://juejin.cn/post/7043408377661095967#heading-2

https://blog.fundebug.com/2019/03/15/understand-javascript-scope/

https://262.ecma-international.org/6.0/#sec-resolvethisbinding