好好搞懂 JS 代码执行原理 —— 执行上下文 !

JavaScript 执行原理

执行机制:先编译,再执行。

在编译阶段,JS 引擎会将代码编译成两部分,一部分是执行上下文,另一部分就是可执行的代码语句。

执行上下文

官方说法:执行上下文(Execution context 简称 EC)就是一个评估和执行 JavaScript 代码环境的抽象概念。

简而言之,执行上下文是 JavaScript 执行代码时所处的运行环境。

通俗地说,就是每当 Javascript 代码在运行的时候,它都是在执行上下文中运行。

执行栈(调用栈)

代码运行在执行上下文,而执行上下文存放在一种名为栈的数据结构中,执行上下文栈用来管理执行上下文,即执行栈。

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

栈是有大小的,当入栈的执行上下文超过一定数目,分配的栈空间被占满时,JavaScript 引擎就会报错(Maximum call stack size exceeded),我们把这种错误叫做栈溢出。特别是在递归函数中,就很容易出现栈溢出的情况。

个人理解: 为什么执行栈又叫做调用栈,因为栈中管理的执行上下文大多都是函数执行上下文(全局执行上下文只有一个),因此,执行栈更像是用来管理函数之间的调用关系的,是追踪函数执行的一个机制。当一次有多个函数被调用时,通过调用栈 JavaScript 引擎就能够追踪到哪个函数正在被执行以及各函数之间的调用关系。

执行上下文的类型

  • 全局执行上下文

    这是默认或者说基础的上下文,任何不在函数内部的代码都在全局上下文中。一个程序中只会有一个全局执行上下文。

  • 函数执行上下文

    每当一个函数被调用时, 函数体内的代码会被编译,并创建一个新的函数执行上下文。函数执行结束之后,一般情况下,创建的函数执行上下文也会随之被销毁。每个函数都有它自己的执行上下文,不过是在函数被调用时创建的。函数上下文可以有任意多个。

  • Eval 函数执行上下文

    执行在 eval 函数内部的代码也会有它属于自己的执行上下文

  • module 模块执行上下文

    importrequire 一个模块的时候也会创建一个执行上下文

全局执行上下文

当进入全局代码时,JS 引擎会创建一个新的全局的执行上下文,并加入执行栈的栈顶。

具体流程如下:

  1. 创建环境:创建全局执行上下文(此时代码的当前执行上下文即全局执行上下文,因为当前执行上下文始终指向栈顶)

  2. 分析代码:找到代码中所有的声明,包括:

    • 所有非函数中的 var 声明(所有的 var 声明,函数体内的除外)
    • 所有的顶级函数声明(不在 {} 中的函数声明,即不在块级作用域内的函数声明)
    • let 、const 、class 声明
    • 收集所有变量名
  3. 名字重复处理,规则如下:

    • let 、const 、class 声明的变量,名字不能有重复
    • let 、const 、class 声明的变量,和 var 、function 声明的变量名字不能有重复
    • var 、function 名字可以重复,但是 function 声明的函数名优先
    JavaScript 复制代码
    // 第一种场景,会报错
    // 编辑器中也会提示 无法重新声明变量 a
    let a = 1;
    const a = 2;
    // Uncaught SyntaxError: Identifier 'a' has already been declared
    ​
    // 第二种情况,同上
    ​
    // 第三种情况,无论是先用 var 声明再用 function 声明
    // 还是先用 function声明再用 var 声明,结果都是函数
    ​
    var a;
    function a() {}
    ​
    function b() {}
    var b;
    ​
    console.log(a);
    console.log(b);
    ​
    // [Function: a]
    // [Function: b]
  4. 创建绑定:

    • var 声明:登记变量名并初始化,值为 undefined
    • 顶级函数声明:登记函数名,初始化为一个函数对象(所以上面第三种场景打印的 a 、b 都是一个函数) ,且该函数对象 体内保留了,函数创建时的执行上下文的文本环境。
    • let 、const 、class 声明:登记名字,但未初始化(所以声明之前不可使用)
    • 块级中的函数声明:登记名字,初始化为 undefined

    变量提升即是先登记再使用的结果

  5. 执行语句。

需要注意 的是:全局执行上下文的文本环境有两部分组成,一个是全局对象 window (在浏览器环境中为 window 对象),另一个是全局的作用域 scope

  • var 声明的变量,顶级的函数声明,都绑定在全局对象中
  • let 、const 、class 声明的变量,绑定在全局作用域 scope
  • 当 JS 代码在执行时,需要寻找一个变量时,会先在全局 scope 中查找,如果找不到再去全局对象中查找。
JavaScript 复制代码
// 验证
​
// window 中并没有保存 let 声明的变量
let a = 1;
console.log("变量 a 的值为:", a, ",window.a 的值为:", window.a);
// 变量 a 的值为: 1 ,window.a 的值为: undefined
​
// window 中保存了 var 声明的变量
var b = 2;
console.log("变量 b 的值为:", b, ",window.b 的值为:", window.b);
// 变量 b 的值为: 2 ,window.b 的值为: 2
​
// 众所周知,window 对象中保存了 JS 的一些内置对象,如 Array、Function、Date等
// 当使用 let 声明一个 已在 window 对象中存在属性,并不会覆盖掉原属性的值
// 而使用 var 声明的变量则会覆盖,进一步证明了 var 和 let 声明的变量不在同一个作用域
let Array = [1, 2, 3];
console.log(
  "变量 Array 的值为:",
  Array,
  ",window.Array 的值为:",
  window.Array
);
// 变量 Array 的值为: (3) [1, 2, 3] ,window.Array 的值为: ƒ Array() { [native code] }
​
var Date = '日期';
​
console.log(window.Date);
// '日期'
new Date();
// 报错 Uncaught TypeError: Date is not a constructor

函数执行上下文

当函数被调用时,也会创建一个执行上下文即函数执行上下文,并加入执行栈的栈顶(此时当前执行上下文就变成了函数的执行上下文)。

先看一段代码:

JavaScript 复制代码
var a = 1
function fun() {
  console.log(a)
  let a
}
fun()
​
// 会报错
// 因为在 函数 fun 的作用域中,变量 a 已经被声明,只是没有初始化
// 所以 let 其实有变量提升,只是未经过初始化将其赋值为 undefined
// ES6 明确规定,如果区块中存在 let 和 const 命令,这个区块对这些命令声明的变量,从一开始就形成了封闭作用域。凡是在声明之前就使用这些变量,就会报错。
// 总之,在代码块内,使用 let 命令声明变量之前,该变量都是不可用的。这在语法上,称为"暂时性死区"(temporal dead zone,简称 TDZ)。

具体流程为:

  1. 创建环境:创建函数执行上下文并推入栈顶(此时代码的当前执行上下文即函数执行上下文,因为当前执行上下文始终指向栈顶)

    2 ~ 5 步与上述全局执行上下文的流程基本一致

由于函数执行上下文的文本环境只有一个 scope(不像全局执行上下文那样还有一个全局对象 window ) ,所以无论是 var 、function 还是 let 、const 、class 声明的变量都保存在这个 scope 上,也就是当前函数的作用域。

函数执行上下文的文本环境(函数作用域 scope)有一个特点是:它会将体内的文本环境作为自己的父作用域,这形成了作用域链。(PS:这个文本环境就是函数在初始阶段声明时所保存的那时的执行上下文的文本环境)

注意:函数的作用域是在函数创建的时候决定的而非是在调用的时候,也并非根据调用嵌套形成作用域链,而是根据函数声明嵌套形成作用域链,也就是函数的书写位置形成作用域链,因此称为词法作用域。

如果不理解,请看下面的🌰

JavaScript 复制代码
function foo() {
  console.log(a);
}
​
function bar() {
  var a = 3;
  foo();
}
​
var a = 2;
bar();
​
// 输出 2   

执行上下文的组成

上面在介绍全局执行上下文和函数执行上下文时,只笼统的将 执行上下文 ≈ 作用域 scope 了,这样会方便理解,但实际上是不够准确的。

事实上,执行上下文包括了三部分:

  1. 词法环境:是一个小型的栈,栈底就是当前执行上下文的作用域。当运行到内部的块级代码时,会新建一个 scope 并加入栈顶
  2. 变量环境:存放 var 声明和 function 声明的变量
  3. ThisBinding:即 this 指向。

词法环境有两个主要组成部分:环境记录和外部词法环境引用。环境记录是一个对象,它存储了当前词法环境中所有的标识符和它们的值之间的映射关系。外部词法环境引用是一个指针,它指向了当前词法环境的外部词法环境。这个指针的作用是为了支持词法作用域链的查找。

之所以不全部存放在词法环境,而是搞出了两个环境,是为了实现块级作用域的同时也不影响到变量提升这一特性(说白了就是要兼容)。简单理解,变量环境就是词法环境的一个实例化对象,内部存放着引起变量提升的标识符(var、function)。

而全局执行上下文被创建时,JS 引擎在实例化其内部的变量环境时,会与全局对象 window 相关联,通过这种关联,在全局中使用 var 、function 声明的标识符,可以通过 window.标识符 的形式访问。同样的,有重名也会覆盖掉 window 对象的属性。

上面提到的文本环境,可以理解为变量环境和词法环境的集合,包含了声明的所有变量,相当于作用域。区别只在于,块级作用域没有变量环境,只有词法环境。而执行上下文比文本环境只多了 this 绑定,所以如果不严格的说,这三者可以划上等号。即 执行上下文 ≈ 文本环境 ≈ 作用域

归纳:

  • var 声明、function 声明,绑定在变量环境
  • let 、const 、class 声明的变量,绑定在词法环境
  • 当 JS 代码在执行时,需要寻找一个变量时,会先在词法环境中查找,如果找不到再去变量环境中查找。
  • 再找不到就会沿着作用域链向父级查找
  • 最后还是找不到即 undefined

作用域

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

JavaScript 的作用域分以下三种:

  • 全局作用域:脚本模式运行所有代码的默认作用域
  • 模块作用域:模块模式中运行代码的作用域
  • 函数作用域:由函数创建的作用域

此外,用 letconst 声明的变量属于额外的作用域:

  • 块级作用域:用一对花括号 {} 创建出来的作用域

全局作用域与函数作用域

这两部分请看上面执行上下文部分的内容,与块级作用域不同的是,这两种的作用域实际由两部分组成(词法环境和变量环境)。而块级作用域仅有词法环境。

块级作用域

当 JS 引擎开始执行到一个被 {} 包围的代码块时,不会创建一个新的执行上下文,但是会在这个执行上下文中的词法环境栈中创建一个新的词法环境,来保存代码块内部所声明的变量或函数。

来试看一段代码分析一下

JavaScript 复制代码
var a = 10;
let b = 20;
function bar() {
  var c = 30;
  let d = 40;
  if (d) {
    var c = 50;
    let e = 60;
    console.log(c, "c in block");
  }
  console.log(c, "c in function");
  console.log(e, "e");
}
bar();

当执行到块中的 console 语句即第 9 行时,此时执行栈中的情况如下示意图:

  1. 执行栈的指针指向 bar 函数的执行上下文
  2. 而 bar 函数内部的词法环境栈指向 if 块中的词法环境
  3. 当执行完 if 块中的代码后,该词法环境销毁,指针重新指向原 bar 函数的词法环境

具体流程如下:

  1. 创建新的词法环境,推入栈顶。当块内的代码全部执行完成后,即执行到 } 之后,会立即销毁这个新的词法环境,然后指针重新指回原词法环境)

  2. 分析代码:找到块中所有的声明,包括:

    • 所有的顶级函数声明
    • let 、const 声明

    注意:由于 var 声明的变量已经在执行上下文中处理过了,此时忽略不计入。

  3. 名字重复处理:function 、let 、const 之间不能有重复

  4. 创建绑定:

    • 登记 function 名字,并初始化为一个函数对象
    • 登记 let、const 但未初始化
  5. 执行语句

将这段代码拿到浏览器中执行发现:

scope 对应这几部分

  • Block 就是 当前 if 块的作用域,存放着其内部声明的变量 e
  • Local 表示本地即当前执行上下文,存放着 bar 函数内部声明的变量 c 、d
  • Script 即脚本,存放着变量 b ,由此可以推断 script 应该是 全局执行上下文中的词法环境
  • Global 全局,即 window 对象,var 、function 声明的变量挂载其上
如果在 if 块中使用了未在词法环境中声明的变量,JS 引擎就会沿着上述的作用域查找,即形成了作用域链。

作用域链

作用域(scope) 是解析(查找)变量名的一个集合,值和表达式在其中"可见"或可被访问。如果一个变量或表达式不在当前的作用域中,那么它是不可用的。作用域也可以堆叠成层次结构,即作用域链。沿着这条链,子作用域可以访问父作用域,反过来则不行(如果非要这么干,就必须使用闭包)。

为什么可以沿着上述的作用域查找?

其原因是:在每个执行上下文的变量对象内部实际还有一个名为 outer 的标识符,保存了对外部执行上下文的引用。当然了,全局执行上下文已经是最外层了,其变量环境中的 outer 为 NULL.

执行上下文对块级作用域中声明的函数是如何处理的

找到块中声明的函数,如果名字与之前已登记的名字重复,不作处理;反之,在全局对象中创建一个变量,变量名就是该函数名字,并将其初始化为 undefined ,只有当执行到该函数创建的位置,才会将函数对象赋值到全局对象中的同名变量上。

JavaScript 复制代码
console.log(foo); // 输出: undefined
if (true) {
  // 变量提升在 if 块作用域声明函数 foo: obj
  console.log(foo); // 输出: [Function: foo]
  foo = 1; // 将 if 块作用域的 foo 的值覆盖为 1
  function foo() {
    console.log("xxx");
  } // 这里执行特殊逻辑,判断全局 window 对象是否存在 foo ,存在则覆盖
  foo = 2; // 将 if 块作用域的 foo 重新赋值为 2
}
console.log(foo); // 输出 1,而不是 2

//(注意:块中声明的函数也会提升,但是与全局声明的函数的提升相比,全局执行上下文的处理不相同)

// 输出 undefined,说明有提升,否则应该报错
console.log(foo);
if (false) {
  function foo() {
    console.log("block");
  }
}

循环中的块级作用域

for 循环中使用 var 声明和 let 声明为什么会导致结果不同?

JavaScript 复制代码
// 当使用 var 时,输出 5 5 5 5 5
// 使用 let 声明,输出 0 1 2 3 4
​
var list = [];
​
// for (var i = 0; i < 5; i++) {
//   list[i] = function () {
//     console.log(i);
//   };
// }
​
for (let i = 0; i < 5; i++) {
  list[i] = function () {
    console.log(i);
  };
}
​
list[0]();
list[1]();
list[2]();
list[3]();
list[4]();

// let 声明会先创建一个块级作用域,声明的变量 i 存储在其中,然后拷贝出一个副本,执行判断语句,通过的话进入到 `{}` 中会再创建一个块级作用域,所以变量 i 所处的作用域并不是执行 `{}` 语句时创建的那个块级作用域。

// 验证
for (let i = 0; i < 5; i++) {
  let i = 10;
  console.log(i);
}
// 输出 10 10 10 10 10
// 由此可见,for 循环语句中 `()`内部声明的变量保存在一个单独的作用域中

闭包

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

来看下面这样一段产生闭包的代码

JavaScript 复制代码
let a = 1;
let b = {
  a: 1,
};

function foo() {
  let a = 10;
  const b = 20;
  var c = 30;
  return function () {
    let d = 40;
    console.log(a + b);
  };
}
let bar = foo();
bar();

从执行上下文的角度来看,当 foo 函数执行完毕后,该函数执行上下文将会被销毁,内部保存的变量也应该随之销毁。但在闭包中却并非完全如此,由于 return 了一个函数,而在这个函数又使用了外层函数的变量(在上面的例子中即为 foo 函数中的变量 a、b)。根据词法作用域的规则,内部函数总是可以访问它们的外部函数 foo 中的变量。所以当 foo 的执行上下文被销毁时,内存中依然开辟了一块空间来保存 foo 函数中被内层函数用到的变量(a、b),这就形成了闭包。这块空间就称为是 foo 函数的闭包。

划线这句说得不对,根据 MDN 的说法:在 JavaScript 中,闭包会随着函数的创建而被同时创建。也就是说在函数创建后就已经形成了闭包,而不是在函数执行上下文销毁前,所以,执行上下文的销毁不影响闭包。

具体的作用域链可以参考下面的截图:当在全局作用域中调用 foo 函数返回的内层函数时,其访问路径为:bar 函数的执行上下文 -> foo 函数的闭包空间 -> 全局执行上下文 -> 全局对象

闭包是怎么回收的

  • 通常,如果引用闭包的函数是一个全局变量,那么闭包会一直存在直到页面关闭;但如果这个闭包以后不再使用的话,就会造成内存泄漏。
  • 如果引用闭包的函数是个局部变量,等函数销毁后,在下次 JavaScript 引擎执行垃圾回收时,判断闭包这块内容如果已经不再被使用了,那么 JavaScript 引擎的垃圾回收器就会回收这块内存。

所以在使用闭包时要遵循这样一个原则:如果该闭包会一直使用,那么它可以作为全局变量而存在;但如果使用频率不高,而且占用内存又比较大的话,那就尽量让它成为一个局部变量。

this

在上面讲执行上下文的组成时,有介绍过 this 是执行上下文的一部分。也就是说 this 是跟执行上下文绑定在一起的。

全局执行上下文中的 this

首先我们来看看全局执行上下文中的 this 是什么。

当在控制台中输入 console.log(this) 来打印出来全局执行上下文中的 this,最终输出的是 window 对象。所以可以得出这样一个结论:全局执行上下文中的 this 是指向 window 对象的。这也是 this 和作用域链的唯一交点,作用域链的最底端包含了 window 对象,全局执行上下文中的 this 也是指向 window 对象。

函数执行上下文中的 this

执行下面这段代码,打印出来的也是 window 对象,这说明在默认情况下调用一个函数,其执行上下文中的 this 也是指向 window 对象的。

JavaScript 复制代码
function foo(){
  console.log(this)
}
foo()

如果执行下面这段代码,结果又会不一样

JavaScript 复制代码
const obj = {
  name: "xiaolong",
  getName: function () {
    console.log(this);
  },
};
obj.getName();
// { name: 'xiaolong', getName: [Function: getName] }

会看到输出结果是调用这个方法的对象本身,说明通过一个对象来调用其内部的一个方法,该方法的执行上下文中的 this 指向对象本身。

还可以通过使用 apply、call、bind 来改变 this 的指向,以及使用 new 关键字通过构造函数内部设置来改变 this。

this 的设计缺陷以及应对方案

  1. 嵌套函数中的 this 不会从外层函数中继承
JavaScript 复制代码
const obj = {
  name: "xiaolong",
  getName: function () {
    console.log(this);
    function bar() {
      this.name = "bar";
      console.log(this);
    }
    bar();
  },
};
obj.getName();
// {name: 'xiaolong', getName: ƒ}
// Window {window: Window, self: Window, document: document, name: 'bar', location: Location, ...}

通过这两处打印可以看出,函数 bar 中的 this 指向的是全局 window 对象,而函数 getName 中的 this 指向的是 obj 对象。

有两种做法可以改善这种机制:

  • 第一种是把 this 保存为一个 self 变量,再利用变量的作用域机制传递给嵌套函数。
  • 第二种是继续使用 this,但是要把嵌套函数改为箭头函数,因为箭头函数没有自己的执行上下文,所以它会继承调用函数中的 this。
  1. 普通函数中的 this 默认指向全局对象 window

使用 JS 严格模式,这样全局对象就是 undefined

JavaScript 复制代码
"use strict";
const obj = {
  name: "xiaolong",
  getName: function () {
    console.log(this);
    function bar() {
      console.log(this);
    }
    bar();
  },
};
obj.getName();
// {name: 'xiaolong', getName: ƒ}
// undefined

结语

受本人水平所限,如果有对一些概念或原理的理解有偏差或错误之处,还请各位大佬不吝赐教~

本文参考内容(不限于以下):

MDN

李兵老师的 《浏览器工作原理与实践》

G哥讲码堂 js执行上下文与作用域

还有掘金上一些大佬的文章,如:深入JavaScript系列

以及一些文章下面的神级评论

甚至我还问了 Bing AI (🐶保命)

相关推荐
NoloveisGod25 分钟前
Vue的基础使用
前端·javascript·vue.js
GISer_Jing26 分钟前
前端系统设计面试题(二)Javascript\Vue
前端·javascript·vue.js
海上彼尚1 小时前
实现3D热力图
前端·javascript·3d
理想不理想v1 小时前
使用JS实现文件流转换excel?
java·前端·javascript·css·vue.js·spring·面试
EasyNTS1 小时前
无插件H5播放器EasyPlayer.js网页web无插件播放器vue和react详细介绍
前端·javascript·vue.js
老码沉思录2 小时前
React Native 全栈开发实战班 - 数据管理与状态之Zustand应用
javascript·react native·react.js
poloma2 小时前
五千字长文搞清楚 Blob File ArrayBuffer TypedArray 到底是什么
前端·javascript·ecmascript 6
老码沉思录2 小时前
React Native 全栈开发实战班 :数据管理与状态之React Hooks 基础
javascript·react native·react.js
guokanglun2 小时前
Vue.js动态组件使用
前端·javascript·vue.js
我认不到你2 小时前
antd proFromSelect 懒加载+模糊查询
前端·javascript·react.js·typescript