JavaScript执行过程学习笔记

440 阅读13分钟

作为一个成熟的代码搬运工,以前我从来没有思考过我写了这么多js代码,那么它到底是怎样执行的呢,参考了很多文章同时加入自己的一些理解,总结了这篇学习笔记。涉及的概念比较多,后续会一一攻破,总结。

思维导图

JavaScript执行过程.png

1.什么是JavaScript?

一种直译式脚本语言,是一种动态类型、弱类型、解释型的、基于原型的语言,内置支持类型。它的解释器被称为JavaScript引擎,为浏览器的一部分,广泛用于客户端的脚本语言,最早是在HTML网页上使用,用来给HTML网页增加动态功能。JavaScript兼容于ECMA标准,因此也称为ECMAScript。

几个名词:解释型、头等函数、动态、基于原型、多范式

解释型语言 VS 编译型语言

JS是一种高级语言,而CPU只能识别二进制指令,那要怎么办?有2中解决方案

解释执行

需要先将输入的源代码通过解析器编译成中间代码,之后直接使用解释器解释执行中间代码,然后直接输出结果

解释执行.webp

编译执行

采用这种方式时,也需要先将源代码转换为中间代码,然后我们的编译器再将中间代码编译成机器代码。通常编译成的机器代码是以二进制文件形式存储的,需要执行这段程序的时候直接执行二进制文件就可以了。还可以使用虚拟机将编译后的机器代码保存在内存中,然后直接执行内存中的二进制代码。

编译执行.webp

动态类型语言 VS 静态类型语言

编程语言按照数据类型大体可以分为两类,一类是静态类型语言,另一类是动态类型语言。

动态类型语言

不需要直接指定变量类型,在解释的时候,转换为目标代码和运行程序一步到位。 优点:编码灵活,只需关注行为,无需关注对象本身 缺点:代码运行期间有可能会发生与类型相关的错误 可以通过TS解决

静态类型语言

变量的类型在编译之前就需要确定,在编译的时候需要先编译,将源码转换成目标代码,然后需要运行目标代码程序才能运行,比如C++、Java、Delphi、C#。

优点:

  • 避免程序运行是的时候出现类型相关的错误
  • 提前声明了变量的类型,编译器可以针对性的进行优化从而提高执行的速度

缺点:

  • 编写代码的时候需要格外注意变量的类型
  • 过多的类型声明会增加代码量

弱类型语言 VS 强类型语言

弱类型定义语言:数据类型可以被忽略的语言。它与强类型定义语言相反, 一个变量可以赋不同数据类型的值。弱类型语言包括vb 、PHP、javascript等语言。

如js

var a = "1";// 字符串类型
a = 3 // 数字类型

强类型定义语言:强制数据类型定义的语言。也就是说,一旦一个变量被指定了某个数据类型,如果不经过强制转换,那么它就永远是这个数据类型了。举个例子:如果你定义了一个整型变量a,那么程序根本不可能将a当作字符串类型处理。强类型定义语言是类型安全的语言。强类型语言包括Java、.net 、Python、C++等语言。

如Java

int a=2
a = "2" // 报错

2 JavaScript是如何运行的?

为什么需要js引擎

从js的语言特性中我们知道js是一种解释型的高级语言。js代码直接运行在浏览器或者node端的时候,底层CPU是不认识的,也没法执行。JavaScirpt 引擎可以将 JS 代码编译为不同 CPU(Intel, ARM 以及 MIPS 等)对应的汇编代码,这样我们就不需要去翻阅每个 CPU 的指令集手册来编写汇编代码了。当然,JavaScript 引擎的工作也不只是编译代码,它还要负责执行代码、分配内存以及垃圾回收

js引擎有哪些

  • V8 (Google),用 C++编写,开放源代码,由 Google 丹麦开发,是 Google Chrome 的一部分,也用于 Node.js。
  • JavaScriptCore (Apple),开放源代码,用于 webkit 型浏览器,如 Safari ,2008 年实现了编译器和字节码解释器,升级为了 SquirrelFish。苹果内部代号为“Nitro”的 JavaScript 引擎也是基于 JavaScriptCore 引擎的。
  • Rhino,由 Mozilla 基金会管理,开放源代码,完全以 Java 编写,用于 HTMLUnit
  • SpiderMonkey (Mozilla),第一款 JavaScript 引擎,早期用于 Netscape Navigator,现时用于 Mozilla Firefox。
  • Chakra (JScript 引擎),用于 Internet Explorer。
  • Chakra (JavaScript 引擎),用于 Microsoft Edge。
  • KJS,KDE 的 ECMAScript/JavaScript 引擎,最初由哈里·波顿开发,用于 KDE 项目的 Konqueror 网页浏览器中。
  • JerryScript — 三星推出的适用于嵌入式设备的小型 JavaScript 引擎。
  • 其他:Nashorn、QuickJSHermes

V8引擎的内部结构

重要的四个模块

  • Parser:负责将 JavaScript 源码转换为 Abstract Syntax Tree (AST)
  • Ignition:interpreter,即解释器,负责将 AST 转换为 Bytecode,解释执行 Bytecode;同时收集 TurboFan 优化编译所需的信息,比如函数参数的类型;解释器执行时主要有四个模块,内存中的字节码、寄存器、栈、堆。
  • TurboFan:compiler,即编译器,利用 Ignitio 所收集的类型信息,将 Bytecode 转换为优化的汇编代码;
  • Orinoco:garbage collector,垃圾回收模块,负责将程序不再需要的内存空间回收。

简单地说,Parser 将 JS 源码转换为 AST,然后 Ignition 将 AST 转换为 Bytecode,最后 TurboFan 将 Bytecode 转换为经过优化的 Machine Code(实际上是汇编代码)

V8是如何执行js代码的

V8 出现之前,所有的 JavaScript 虚拟机所采用的都是解释执行的方式,这是 JavaScript 执行速度过慢的一个主要原因。而 V8 率先引入了即时编译(JIT)双轮驱动的设计(混合使用编译器和解释器的技术),这是一种权衡策略,混合编译执行和解释执行这两种手段,给 JavaScript 的执行速度带来了极大的提升。V8 也是早于其他虚拟机引入了惰性编译、内联缓存、隐藏类等机制,进一步优化了 JavaScript 代码的编译执行效率

v8执行.webp

  • 解释执行和编译执行都有各自的优缺点,解释执行启动速度快,但是执行时速度慢,而编译执行启动速度慢,但是执行速度快。为了充分地利用解释执行和编译执行的优点,规避其缺点,V8 采用了一种权衡策略,在启动过程中采用了解释执行的策略,但是如果某段代码的执行频率超过一个值,那么 V8 就会采用优化编译器将其编译成执行效率更加高效的机器代码

  • V8 执行一段 JavaScript 代码所经历的主要流程包括:

    • 初始化基础环境;
    • 解析源码生成 AST 和作用域;
    • 依据 AST 和作用域生成字节码;
    • 解释执行字节码;
    • 监听热点代码;
    • 优化热点代码为二进制的机器代码;
    • 反优化生成的二进制机器代码。

3. JavaScript代码执行过程详解

编译阶段

词法分析

将由字符组成的字符串分解成(对编程语言来说)有意义的代码块,这些代码块被称为词法单元(token)esprima.org/demo/parse.…

词法分析.jpeg

[    {        "type": "Keyword",        "value": "var"    },    {        "type": "Identifier",        "value": "name"    },    {        "type": "Punctuator",        "value": "="    },    {        "type": "String",        "value": "'finget'"    },    {        "type": "Punctuator",        "value": ";"    }]

语法分析

这个过程是将词法单元流(数组)转换成一个由元素逐级嵌套所组成的代表了程序语法结构的树。这个树被称为“抽象语法树”(Abstract Syntax Tree,AST),是给js引擎读的。astexplorer.net/

  • 确定作用域,根据静态作用域的特点,这个时候每个变量的作用域已经很明确了,不会在改变

  • 记录每个作用域的所有变量和内嵌函数

{
  "type": "Program",
  "body": [
    {
      "type": "VariableDeclaration",
      "declarations": [
        {
          "type": "VariableDeclarator",
          "id": {
            "type": "Identifier",
            "name": "name"
          },
          "init": {
            "type": "Literal",
            "value": "finget",
            "raw": "'finget'"
          }
        }
      ],
      "kind": "var"
    }
  ],
  "sourceType": "script"
}

字节码生成

这里涉及到一个很重要的概念:JIT(Just-in-time)一边解释,一边执行。参考上面v8是如何执行js代码

作用域

作用域是一套规则,用来管理引擎如何查找变量。在es5之前,js只有全局作用域函数作用域。es6引入了块级作用域。但是这个块级别作用域需要注意的是不是{}的作用域,而是letconst关键字的块级作用域

执行阶段

执行程序需要有执行环境, Java 需要 Java 虚拟机,同样解析 JavaScript 也需要执行环境,我们称它为“执行上下文”。

执行上下文有三种类型

  • 全局执行上下文(只有一个)

    JS 引擎执行全局代码的时候,会编译全局代码并创建执行上下文,它会做两件事:

    1、创建一个全局的 window 对象(浏览器环境下)

    2、将 this 的值设置为该全局对象;全局上下文在整个页面生命周期有效,并且只有一份

  • 函数执行上下文

    当调用一个函数的时候,函数体内的代码会被编译,并创建函数执行上下文,一般情况下,函数执行结束之后,创建的函数执行上下文会被销毁。

  • eval

    调用 eval 函数也会创建自己的执行上下文(eval函数容易导致恶意攻击,并且运行代码的速度比相应的替代方法慢,因此不推荐使用)

执行上下文创建阶段

  • 确定 this 的值,也被称为 This Binding
  • LexicalEnvironment(词法环境) 组件被创建。
  • VariableEnvironment(变量环境) 组件被创建。
ExecutionContext = { // 执行上下文
  Binding This, // this值绑定
  LexicalEnvironment = { ... }, // 词法环境
  VariableEnvironment = { ... }, // 变量环境
}
绑定this

在全局执行上下文中,this 的值指向全局对象。(在浏览器中,this 引用 Window 对象)。

在函数执行上下文中,this 的值取决于该函数是如何被调用的

  • 通过对象方法调用函数,this 指向调用的对象
  • 声明函数后使用函数名称普通调用,this 指向全局对象,严格模式下 this 值是 undefined
  • 使用 new 方式调用函数,this 指向新创建的对象
  • 使用 callapplybind 方式调用函数,会改变 this 的值,指向传入的第一个参数
词法环境

ES6官方文档定义词法环境

词法环境是一种规范类型,用于根据ECMAScript代码的词法嵌套结构定义标识符与特定变量和函数的关联。词法环境包括一个环境记录和一个对外部词法环境的可能为空的引用。

  • 环境记录:即词法环境中记录变量和函数声明的地方,并绑定this

    • 全局环境:在全局执行上下文中)是一个没有外部环境的词法环境。全局环境的外部环境引用为 null。它拥有一个全局对象(window 对象)及其关联的方法和属性(例如数组方法)以及任何用户自定义的全局变量,this 的值指向这个全局对象。
    • 函数环境:用户在函数中定义的变量被存储在环境记录中,对函数而言,环境记录还包含一个arguments对象,this指向该函数式如何调用的
  • 外部环境引用:意味着它可以访问其外部词法环境。(实现作用域链的重要部分)

    对于外部环境的引用意味着在当前执行上下文中可以访问外部词法环境。也就是说,如果在当前的词法环境中找不到某个变量,那么Javascript引擎会试图在上层的词法环境中寻找。Javascript引擎会根据这个属性来构成我们常说的作用域链

变量环境

变量环境也是词法环境的一种,它的环境记录包含了变量声明语句在执行上下文中创建的变量和具体值的绑定关系。在ES6中,词法环境变量环境的不同就是前者用来存储函数声明和变量声明(letconst)绑定关系,后者只用来存储var声明的变量绑定关系。

为什么要有个词法环境

变量环境组件(VariableEnvironment) 是用来登记var function变量声明,词法环境组件(LexicalEnvironment) 是用来登记let const class等变量声明。

在ES6之前都没有块级作用域,ES6之后我们可以用let const来声明块级作用域,有这两个词法环境是为了实现块级作用域的同时不影响var变量声明和函数声明

执行上下文执行阶段

在这个阶段,将完成所有变量的赋值操作,然后执行代码。 举个例子来消化一下这一堆文字吧

let a = 20;
const b = 30;
var c;
​
function multiply(e, f) {
 var g = 20;
 return e * f * g;
}
​
c = multiply(20, 30);

执行上下文:

GlobalExectionContext = {

  ThisBinding: <Global Object>,

  LexicalEnvironment: {  
    EnvironmentRecord: {  
      Type: "Object",  
      // 标识符绑定在这里  
      a: < uninitialized >,  
      b: < uninitialized >,  
      multiply: < func >  
    }  
    outer: <null>  
  },

  VariableEnvironment: {  
    EnvironmentRecord: {  
      Type: "Object",  
      // 标识符绑定在这里  
      c: undefined,  
    }  
    outer: <null>  
  }  
}

FunctionExectionContext = {  
  ThisBinding: <Global Object>,
  LexicalEnvironment: {  
    EnvironmentRecord: {  
      Type: "Declarative",  
      // 标识符绑定在这里  
      Arguments: {0: 20, 1: 30, length: 2},  
    },  
    outer: <GlobalLexicalEnvironment>  // 指定全局环境
  },

  VariableEnvironment: {  
    EnvironmentRecord: {  
      Type: "Declarative",  
      // 标识符绑定在这里  
      g: undefined  
    },  
    outer: <GlobalLexicalEnvironment>  
  }  
}

4.执行栈 Execution Context Stack

每个函数都会有自己的执行上下文,多个执行上下文就会以栈(调用栈)的方式来管理。

每进入一个不同的运行环境都会创建一个相应的执行上下文(Execution Context) ,那么在一段JS程序中一般都会创建多个执行上下文,js引擎会以栈的方式对这些执行上下文进行处理,形成函数调用栈(call stack) ,栈底永远是全局执行上下文(Global Execution Context),栈顶则永远是当前执行上下文。

function a () {
  console.log('In fn a')
  function b () {
    console.log('In fn b')
    function c () {
      console.log('In fn c')
    }
    c()
  }
  b()
}
a()

可以用这个工具试一下,更直观的观察进栈和出栈javascript visualizer 工具

执行栈.jpeg

执行栈1.jpeg

看这个图就可以看出作用域链了吧,很直观。作用域链就是在执行上下文创建阶段确定的。有了执行的环境,才能确定它应该和谁构成作用域链。

5.总结

JavaScript执行分为两个阶段,编译阶段和执行阶段。编译阶段会经过词法分析、语法分析、代码生成步骤生成可执行代码; JS 引擎执行可执行性代码会创建执行上下文,包括绑定this、创建词法环境和变量环境;词法环境创建外部引用(作用域链)和 记录环境(变量对象,let, const, function, arguments), JS 引擎创建执行上下完成后开始单线程从上到下一行一行执行 JS 代码了。

参考