原稿:语雀 · JS运行机制 · 原目录:JavaScript › JS运行机制

总结以下内容是因为红宝书多次提出了上下文和执行栈概念,为方便后续学习,先整理一遍,中间参考的资料链接都会放在最后

1. 编译执行

JavaScript作为一门解释性语言,每次执行程序时,解释器都需要边编译边执行,常见的v8引擎就是一个解释器。参考我的编译原理笔记阅读体验更佳

Ch1 概述 1.1 语言处理器处理器CompilersInterpreters执行模式将源代码翻译成目标语言编写的程序后再执行输入,产生输出不翻译,直接利用输入执行源程序 执行速度远快于解释器较慢错误诊断弱于解释器更好,因为逐个语句地执行源程序语言代表 Python优点经过“翻译”后,目标程序的执行速… 劝退宝典🤦‍♀️

1.1 编译——执行

v8对JS程序的编译执行大致分为四个阶段: 2. 词法分析:对代码逐行进行分词(tokenizing/lexing)操作,在此期间引擎可以进行变量登记,其中包括变量声明、函数声明、形参声明等,输出有意义的词法单元(tokens)

  • var声明会为变量分配内存并初始化为undefined,赋值在执行阶段进行
  • let/const声明不会初始化变量,保持uninitialized
  • 函数声明则会直接在内存中创建函数对象并直接初始化为该函数对象
  1. 语法分析:分词结束后,引擎将对tokens进行语法分析(parsing),得到一棵AST,如果语法分析出错则会停止后续操作直接报错
  2. 代码生成阶段:这一阶段解释器会根据AST生成字节码(bytecode),v8引擎还会将字节码优化为CPU可执行的机器码
  3. 执行阶段

1.2 执行栈与执行上下文

执行阶段是开发者可直接面对的场景,通常调试js程序可以看到一个调用栈,调用栈是什么?栈里的每一帧是什么,是什么时候放进去的?

image.png

调用栈和栈帧是什么?

调用栈里的每个栈帧是一个执行上下文(execution context),JS程序执行时会生成不止一个上下文,不同的执行上下文会遵循LIFO顺序被存入一个栈结构——调用栈

什么时候产生并入栈新的上下文?

JS是边编译边执行的解释性语言,执行栈控制着JS程序的执行流:

  • 开始执行任何代码前,全局上下文是最先创建的上下文,被压入栈底,之后进行“词法分析-语法分析-代码生成-执行”
  • 当引擎执行到函数时会创建新的函数上下文入栈,对函数体中代码进行“词法分析-语法分析-代码生成-执行”,执行到子函数时重复上述入栈到执行的步骤。当前正在执行函数的上下文总在栈顶,对应Chrome调试窗口蓝色箭头所执行的上下文
  • 当函数执行完,执行栈会弹出当前函数上下文,将控制权返还给之前的执行上下文

1.3 上下文构建

下面深入了解上下文的构建过程:

上下文的构建工作主要发生在新建上下文后到词法分析阶段,分为三步 2. this绑定(This Binding) 4. 词法环境创建(Lexical Environment) 6. 变量环境创建(Variable Environment)

this绑定

在创建新的上下文时会根据代码的执行条件确定this的指向

  • 在全局上下文中,this指向全局对象,即浏览器中的window对象
  • 在函数上下文中,this默认指向全局对象,但可能存在隐式绑定,比如通过对象调用函数时this调用函数的上下文对象(ES6中的箭头函数有所不同,箭头函数的this默认指向定义箭头函数的上下文对象),此外使用call()/apply()/bind()可以指定绑定对象

词法环境创建

在分词阶段提到需要进行变量登记,这个地方即当前上下文的词法环境(Lexical Environment)

词法环境有两个组成部分:

  • 环境记录(Environment Record):即记录标识符 → 值/函数映射关系的条目
  • 声明式环境记录(Declarative Environment Record):用来记录直接有标识符定义的元素,比如变量声明、函数声明
  • 对象式环境记录(Object Environment Record):主要用于with和global的词法环境
  • 对外部环境的引用(outer):指向当前环境可访问的外部词法环境

因为每个上下文有自己的词法环境,全局上下文和函数上下文也就有对应的全局词法环境和函数词法环境,二者有略微区别

  • 全局词法环境:环境记录存储用户在全局范围内声明的变量,但没有外部环境引用
  • 函数词法环境:环境记录存储用户在函数范围内声明的变量,其中还有arguments对象,外部环境引用可以指向全局或函数词法环境

变量环境创建

此外还要创建一个特殊的词法环境——变量环境(Variable Environment),ES6中词法环境可用于存储函数声明和let/const声明的变量绑定,变量环境只存储var声明的变量绑定

1.4 实例分析

以一段代码分析执行栈和每个上下文的创建过程

var a = 2;
let x = 1;
const y = 5;

function foo() {
  console.log(a);
  function mul() {
    var b = 3;
    console.log(a * b);
  }
  mul();
}
function enter() {
  var a = 10;
  foo();
}

enter();
context (0).svg

当全局执行上下文构建完成但未执行时👈:

  • let/const变量x,y都处于未初始化
  • var变量a被初始化为undefined
  • 函数对象在堆中被创建,和标识符之间完成绑定

📍 开始执行机器码,完整全局上下文中变量赋值

📍📍 执行enter(),创建enter函数上下文,压入执行栈,继续执行

📍📍📍 执行foo(),创建foo函数上下文,压入执行栈,继续执行

📍📍📍📍 执行mul(),创建mul函数上下文,压入执行栈,继续执行

📍📍📍📍 执行完毕,出栈

📍📍📍 执行完毕,出栈

📍📍 执行完毕,出栈

📍 程序执行完毕

全局上下文

enter函数上下文

context (1).svg
context (4).svg

foo函数上下文

mul函数上下文

context (3) .svg
context (4) .svg

全程执行栈变化如下

执行栈.svg

为什么会存在变量、函数提升和暂时性死区?

  • 对于var变量声明,构建上下文时会给标识符分配内存空间,初始化为undefined。执行阶段,在声明语句之前访问var变量会读取到undefined,不会报错
  • 对于let/const变量声明,保持uninitialized。如果代码中在声明语句前访问,会报引用错误(未初始化)
  • 对于函数,会在内存里创建函数对象,并且直接初始化为该函数对象

作用域链如何形成?

可执行上下文中的词法环境中含有outer,通过这个引用获取外部词法环境的变量、声明等,这些引用串联起来一直指向全局的词法环境,形成了作用域链

  • mul的函数词法环境里没有变量a,所以就会到它的上一层词法环境(foo函数词法环境)里去找
  • foo函数词法环境里也没有变量a,就接着去foo函数词法环境的上一层(全局词法环境)去找
  • 在全局词法环境里var a=2

参考链接


2. 作用域链与闭包

2.1 作用域链

  • 作用域链是一个包含指针的列表,每个指针指向了一个执行栈中上下文的词法环境组件。在函数/全局作用域内部的函数在定义阶段(即外部执行上下文构建时)就会在创建自己的作用域链,并保存到函数内部的\[Scope\[Scope](在堆内存上),其中已有的指针指向了全局词法环境、外部函数词法环境
  • 当真正执行这个函数时,创建执行上下文,通过复制\[Scope\[Scope]创建该上下文的作用域链,将它的词法环境引用推入作用链最前方(上下文中存放的是作用域链引用的副本,可以操作堆上函数对象的实际作用域)
  • 当函数执行期间访问变量时,会沿着作用域链查找,如果当前上下文词法环境没有找到,则进入作用链下个引用指向的词法环境寻找,如此直到全局词法环境【每个上下文持有独立的作用域链,外部函数的作用域链没有保存内部函数词法环境的引用,自然无法访问内部函数作用域】

2.2 闭包

三种定义:

  • MDN:函数和函数内部所能访问到变量的总和
  • 红宝书:引用了另一个函数作用域中变量的函数
  • 你不知道的JS:当函数可以记住并访问所在的词法作用域,即使函数是在当前词法作用域之外执行,这时产生闭包(感觉这个比较好理解)

闭包通常是在嵌套函数中实现,以下面代码为例说明闭包,但不一定返回函数,也可以通过赋值等方式(这部分《你不知道的JS》讲的很好)

function createComparisonFunction(propertyName) {
  return function (object1, object2) {
    let value1 = object1[propertyName];
    let value2 = object2[propertyName];
    if (value1 < value2) {
      return -1;
    } else if (value1 > value2) {
      return 1;
    } else {
      return 0;
    }
  };
}

3-4行代码位于内部匿名函数中,但引用了外部函数的propertyName。当内部函数被返回并在其他地方使用后,它仍然引用着该变量。因为内部函数作用域链第二个指针指向了createComparisonFunction()的词法环境。

这导致当createComparisonFunction()被执行并返回一个比较器后,它的上下文弹出调用栈,但它的词法环境无法销毁,会一直保存直到最初返回的匿名函数对象被销毁。

2.3 实例分析

还是使用上面的cCF()函数,接下方代码,分析每一步调用栈和内存堆空间变化

// 创建比较函数
let compareNames = createComparisonFunction("name");
// 调用函数
let result = compareNames({ name: "Nicholas" }, { name: "Matt" });
// 解除对函数的引用,这样就可以释放内存了
compareNames = null;
scope1.svg

正准备执行cCF()时的全局上下文与作用域链

全局.png

Chrome调试说明:全局上下文,全局词法环境(作用域)

// 创建比较函数

  • 执行到createComparisonFunction()(后面记作cCF()),创建函数上下文,this指向window,构建词法环境、变量环境,此时词法环境中arguments包含“name” 【栈空间:全局上下文、cCF()上下文】
  • 执行cCF(),创建作用域链,完成变量赋值,propertyName保存“name”,匿名函数此时作为值在堆内存中被初始化后返回
  • cCF()执行完毕,但匿名函数\[Scope\[Scope]持有对cCF()词法环境引用,这小部分栈内存被持久化为堆内存。弹出cCF()函数上下文 【栈空间:全局上下文;堆空间:匿名函数实例、cCF()词法环境】
scope2.svg