彻底理解JavaScript的运行原理


JavaScript代码是在浏览器中被执行的,肯定和浏览器的内核有关系,浏览器内核由两部分组成,主要看下常见的两个浏览器 SafariChrome

  1. Safari浏览器:使用webkit内核,由以下两部分组成
    • WebCore:负责HTML解析、布局、渲染等等相关的工作;
    • JavaScriptCore:解析、执行JavaScript代码;
  2. Chrome浏览器
    • Blink:在2013年被开发是从WebKit项目分支出来的一个独立的渲染引擎,更容易与其他组件(如V8引擎)集成
    • V8引擎:主要理解学习它的原理,非常高性能的JavaScript引擎,由谷歌开发,最初用于 Chrome浏览器,现在广泛用于 Node.js 以及其他项目

v8引擎的执行原理

在这里插入图片描述

在这里插入图片描述

1. 解析

Parse模块将JavaScript代码转换成AST(抽象语法树)

Parse模块

ParseV8官方文档:https://v8.dev/blog/scanner

在这里插入图片描述

这张图展示了从 Blink 引擎接收 JavaScript 代码到生成AST的详细流程,即Parse模块做了什么:

  1. 首先Blink拿到JavaScript代码,将从 Blink 渲染引擎接收到的各种编码格式(如 ASCII、Latin1、UTF-8转换为统一的 UTF-16 编码,因为V8 引擎内部处理字符串和字符数据时,通常使用 UTF-16 编码
  2. 然后Scanner(扫描器)UTF-16 编码的代码单元转换为标记(tokens。这些标记是解析器(Parser)用来生成抽象语法树(AST)的基本单位,Scanner具体作用如下
    • 词法分析(Lexical AnalysisScanner 执行词法分析,将连续的字符流分割成有意义的标记(tokens
    • 标记化(Tokenization:每个标记代表代码中的一个基本语法成分,包含了其类型和相关的值,如关键字、标识符、字面量、操作符等
    • 过滤无关字符Scanner 还负责过滤掉代码中的无关字符,如空白符(空格、制表符、换行符)和注释
    • 错误检测:在标记化过程中,Scanner 还可以进行一些基本的语法错误检测,识别出不符合语法规则的字符序列,并生成相应的错误信息
  3. PreParser:预解析器用于快速扫描代码,检测基本语法错误,进行初步优化,并可能跳过不必要的代码解析,比如会跳过函数体的解析只记录函数的定义
  4. Parser:从 ScannerPreParser 接收标记,解析标记的顺序和语法规则,生成抽象语法树(AST),进行详细的语法分析和检查

AST(抽象语法树)

表示源代码结构的树状数据结构,用途如下:

  • 代码解析:将源代码转换为 AST,使其结构化和易于分析。
  • 代码优化:编译器可以通过分析 AST 来进行代码优化,例如常量折叠、死代码消除等。
  • 代码生成:编译器可以根据 AST 生成目标代码,如机器码或字节码。
  • 代码分析和转换:工具可以通过操作 AST 来进行代码分析、格式化、重构等任务。

2. 解释执行

V8使用一个解释器,称为 Ignition/ɪɡˈnɪʃn/
),将 JavaScript 源代码编译成字节码(ByteCode)并执行

Ignition解释器

IgnitionV8官方文档:https://v8.dev/blog/ignition-interpreter

  • 即使代码只执行一次,JIT机器代码也会消耗大量内存。为了减轻这种开销,V8团队构建了一个新的 JavaScript 解释器,称为 Ignition
  • 收集TurboFan优化所需要的信息(比如函数参数的类型信息,有了类型才能进行真实的运算)
  • 如果函数只调用一次Ignition会执行ByteCode

3. 即时编译(JIT

优化性能,将热点代码编译成机器码

在此过程V8使用了两个JIT编译器:

  1. Baseline 编译器:快速生成初始机器码,提升初始执行速度。
  2. TurboFan/ˈtɝboʊˌfæn/)优化编译器:在代码执行过程中收集运行时信息,针对热点代码进行深入优化,生成高效的机器码

TurboFan编译器

TurboFanV8官方文档:https://v8.dev/blog/turbofan-jit

  • 如果函数被多次调用,这个函数被标记为热点函数,那么就会经过TurboFan编译成优化的机器码,提高代码的执行性能
  • 但是优化的机器码也会被还原成ByteCode,这是因为后续执行函数的过程中,类型发生了改变(比如sum函数原来是执行number类型的值相加,后面调用函数传的参数是string类型),这种情况之前优化的机器码并不能正确的处理运算了,就会再逆向的转换成字节码

5. 垃圾回收

垃圾回收(GC)是自动管理内存的过程,负责回收不再使用的内存,以防止内存泄漏和优化内存使用。JavaScript语言本身提供了垃圾回收机制,而V8引擎则具体实现了这一机制,也就是JavaScript的垃圾回收机制主要由 JavaScript 引擎(如V8)实现V8的垃圾回收机制使得JavaScript的自动内存管理更加高效

内存管理

JavaScript 的内存管理依赖于 V8 引擎的实现

不管使用什么方式管理内存,内存的管理都会有如下生命周期:

  • 第一步:分配申请你需要的内存(申请)
  • 第二步:使用分配的内存(存放一些东西,比如对象等)
  • 第三步:不需要使用时,对其进行释放

不同的编程语言对于第一步和第三步会有不同的实现:

  • 手动管理内存:比如CC++,包括早期的OC,都是需要手动来管理内存的申请和释放的(mallocfree函数)
  • 自动管理内存:比如JavaJavaScriptPythonSwift等,它们有自动帮助管理内存

JavaScript会在定义数据时为我们分配内存:

  • JS对于原始数据类型内存的分配会在执行时,直接在栈空间进行分配
  • JS对于复杂数据类型(如对象、数组和函数)内存的分配会在堆内存中开辟一块空间,并且将这块空间的指针返回值作为变量引用

V8引擎为了提高内存的管理效率,对内存进行非常详细的划分:

在这里插入图片描述

垃圾回收器(GC)怎么知道哪些对象是不再使用的呢?这主要是使用以下算法:

常见GC算法——引用计数

一种较早的算法垃圾回收算法,原理如下:

  • 每一个对象都有一个引用计数器,用来记录这个对象被引用的次数
  • 当有新引用指向这个对象,它的引用计数就会加一
  • 当一个对象的引用为0时,这个对象就可以被销毁

有一个缺点就是会产生循环引用:(两个对象互相引用,但它们都不再被其他对象引用)

在这里插入图片描述

常见GC算法——标记清除

标记清除是 JavaScript 最常用的垃圾回收算法:

  • 标记清除的核心思路是可达性(Reachability
  • 垃圾回收器会定期从根对象(如全局对象)开始,遍历对象图(图结构),找所有从根开始有引用到的可达对象并标记,没有引用到的对象,就认为是不可达的对象,解决了循环引用的问题

在这里插入图片描述

常见GC算法——其他算法优化补充

JS引擎比较广泛采用是可达性中的标记清除算法,当然类似于V8引擎为了进行更好的优化,它在算法的实现细节上也会结合一些其他的算法:

  • 标记整理:和标记清除类似,但它回收期间回收器会同时整理内存碎片,将存活对象汇集到连续的内存空间进行整合,以确保有大块连续的空间可用避免内存碎片化
  • 分代收集V8 将堆内存分为新生代(Young Generation)和老生代(Old Generation
    • 新生代:存放生命周期较短的对象,使用复制算法,新生代内存分为两个等大小的空间(FromTo),对象首先分配在 From 空间。当 From 空间满了时,垃圾回收器开始工作,它遍历并标记所有可达的对象并复制到 To 空间,调整对象的引用指向新的内存地址,清空 From 空间,然后交换 FromTo 空间的角色,原来的 To 空间成为新的 From 空间
    • 老生代:存放些长期存活生命周期较长的对象,使用标记清除和标记整理算法
  • 增量收集
    • 如果有许多对象,如果我们试图一次遍历并标记整个对象集,可能需要一些时间,会在执行过程中带来明显的延迟
    • 所以V8会将垃圾回收过程分为多个小步,每一小步只做一小部分工作,减少单次垃圾回收的停顿时间
  • 闲时收集:垃圾收集器只会在 CPU 空闲时尝试运行,以减少可能对代码执行的影响

JavaScript执行中的概念

1. 作用域(Scope

作用域是指代码中变量和函数声明的可见范围JavaScript三种作用域:

  • 全局作用域:在全局范围内声明的变量和函数在整个代码中都可见
  • 函数作用域:在函数内部声明的变量和函数只在该函数内部可见
  • 块级作用域:在ES6引入,在块级结构(如 ifforwhile 等)内使用 letconst声明的变量,只在该块级结构内部可见

2. 作用域链(Scope Chain

查找变量时沿着执行上下文链向上查找的规则,适用于所有执行上下文。在当前作用域中找不到变量时,JavaScript 引擎会沿着作用域链向上查找,直到找到变量或到达全局作用域,都找不到则抛出引用错误

  • 每个执行上下文都有一个作用域链,当进入到一个执行上下文时,作用域链被创建,是一个对象列表

  • 函数执行上下文中,作用域链在函数调用时动态创建,包含函数的AO和这个函数内部的[[Scopes]]属性记录的作用域信息

    • [[Scopes]] 属性:在函数定义时静态创建,保存了函数定义时的作用域链(包括父函数的 AO 和全局作用域),不会随函数调用的上下文而变化

3. 全局对象(GOGlobal Object

JavaScript引擎会在代码执行之前,在堆内存中创建一个全局对象GO

  • 会将所有全局定义的变量、函数等加入到GO,并且所有的作用域都可以访问

    • 函数会在变量之前声明,在堆内存分配一个函数对象,保存函数相关信息
    • 变量被声明但是并不会赋值(即为undefined),这个过程也称变量的作用域提升(hoisting
  • GO包含MathDateArrayStringNumbersetTimeout等等,有个window属性指向GO自己

  • 在浏览器环境中,全局对象就是 window

4. 执行上下文(ECExecution Contexts

JavaScript引擎内部有一个执行上下文栈ECSExecution Context Stack),它是用于执行代码的调用栈

执行上下文分为三种,我们主要学前两种:

  • 全局执行上下文GECGlobal Execution Context

    • 代码在全局作用域中执行时,会创建一个GEC并放入ECS
    • 只有一个GEC ,在整个JavaScript脚本的生命周期内都会存在于执行栈的最底部也不会被栈弹出销毁
  • 函数执行上下文FECFunction Execution Context

    • 每当函数被调用时,都会创建一个新的FEC,并压入到ECS
    • 当函数体执行完时会弹出栈销毁
  • eval 执行上下文(Eval Execution Context):自行学习并谨慎使用

每个执行上下文有三个重要部分

  1. 变量对象VOGEC中包含全局定义的变量、函数等,FEC中包含函数的参数、内部变量和函数声明等
  2. 作用域链:包含当前执行上下文的 VO(在作用域链的顶端) 和所有父级执行上下文的 VO
  3. this 绑定:浏览器环境GEC中是windowFEC中根据函数调用方式设置 this 的指向 https://juejin.cn/post/7390413118889377832

5. 变量对象(VOVariable Object

  • 在每个执行上下文创建阶段,VO被初始化并关联VO是一个静态的结构
  • VO在整个执行上下文生命周期内不会改变,内部定义的属性也不能被直接访问的
  • 全局执行上下文中VOGO(浏览器环境中是 window 对象)
  • 函数执行上下文中VO就是活动对象AO
  • 可以说VO只是一个抽象概念,它会包含执行上下文中所有变量、函数和参数的对象,但GOAOVO的具体实现

6. 活动对象AOActivation Object

FEC中的一个动态结构,它在函数调用时被创建AO用于处理函数的实际执行,比如执行函数体代码时定义和修改变量及参数的值

  • FECVO就是AO,其实VOAO是一个东西,只不过处于不同的状态和阶段而已
  • AO使用arguments做初始化,并且初始值是传入的参数
  • AO不使用的属性优化:当函数被解析时,V8 不会立即创建AO,而是根据实际需要进行延迟解析。只有在访问变量或参数时,才会为其分配内存,对于从未使用过的变量和参数,V8 不会为其分配内存

全局代码执行过程

var code = "javaScript";
var names = ['你','我','他']
var obj = {
  name: 'obj',
  age: 18,
}
var num1 = 10;
var num2 = 20;
var result = num1 + num2;
console.log(result);

代码执行前
在这里插入图片描述

代码执行完: 执行代码会将相关变量进行赋值,objnames会在堆内存中创建新的对象空间objnames变量在栈内存中存储的是对象和数组对象在堆内存中的地址或指针,图示如下:
在这里插入图片描述

函数的执行过程

function foo(age) {
  var bar = function () {
    console.log(age);
  };
  return bar
}
var baz = foo(18);
baz();

代码执行前
遇到函数声明是使用 function 关键字并且函数声明是有名称时,在解析中会被提升,函数声明会先被处理,然后才是变量声明,声明函数会先在堆内存中分配一个对象来表示这个函数,里面包括一些内置属性、函数的代码、作用域链Scope Chain)和闭包(如果有的话)
在这里插入图片描述

代码执行中
foo函数执行上下文中的Scope Chain[foo AO, Global Scope]var baz = foo(18)执行图:
在这里插入图片描述

foo函数执行上下文出栈,然后baz函数执行上下文进栈,baz()执行图:
在这里插入图片描述

代码执行完
在这里插入图片描述

闭包

在这里插入图片描述

在上面函数执行的代码中我们可以了解到执行baz()时我们访问了外层作用域的age值为18,这是因为闭包通过保存函数创建时的作用域链,实现了对外层作用域变量的持久访问,即使在该函数外部作用域执行结束后,仍然可以访问这些变量

  1. 那什么是闭包? 这是 JavaScript 中的一个核心概念,下面我们好好学习:

    • 一个函数和对其周围状态(lexical environment)的引用捆绑在一起(或者说函数被引用包围),这样的组合就是闭包closure
    • 在 JavaScript 中,每当创建一个函数,闭包就会在函数创建的同时被创建出来
    • 从广义的角度来说JavaScript中的函数都是闭包
    • 从狭义的角度来说JavaScript中一个函数,如果访问了外层作用域的变量,那么这个函数和周围环境就是一个闭包
  2. 那什么是内存泄漏? 在函数执行过程例子中如果我们后面再也不会调用baz了,该函数对象和函数引用的AO怎么被销毁掉?

    • 所以我们经常说的闭包会造成内存泄露,其实是baz引用链中的所有对象都是无法释放的(执行完的图中可以看出)
    • 我们不用的时候baz设置为null,这时baz没有任何引用,在GC的下一次检测中,它们就会被销毁掉,如图:
      在这里插入图片描述
  3. 闭包的用途

    • 函数柯里化:将一个多参数函数转换为多个单参数函数的技术,可以保持对初始参数的引用
    • 模拟块级作用域:在 ES6 之前,JavaScript 没有块级作用域,通过闭包可以模拟块级作用域,避免变量污染全局作用域
    • 事件处理:闭包在事件处理器中很常见,它可以让处理器函数访问到事件绑定时的环境变量
  4. 代码

    var btnEl = document.querySelector(".btn");
    /* 
      1. 使用var声明时 i 会被提升值为undefined
      2. 再执行for循环,i = 0,
      3. 判断 0 < btnEl.children.length,得出0小于4
      4. 执行循环体的代码btnEl.children[0].onclick = 函数
      5. i++,i = 1,再重复执行for循环
      当循环执行完时 i = 4,这时你点击按钮会找 i,
      在函数作用域没找到会向外层即全局查找,找到 i=4
      所以不管你点击第几个按钮都是4,那么怎么解决呐?
    */
    for (var i = 0; i < btnEl.children.length; i++) {
      // 事件处理函数是闭包,创建时会捕获其外部作用域(全局作用域)
      btnEl.children[i].onclick = function () {
        console.log(`${i + 1}个按钮被点击了`);
      };
    }
    
    /* 
      使用立即执行函数解决:
        当执行到循环体代码时,立即执行函数会立即调用创建FEC,
        形成自己的作用域,并定义传入的参数ii=0和事件,
        当点击时执行事件函数,取到立即执行函数中的ii,不会取到全局的4
    */
    for (var i = 0; i < btnEl.children.length; i++) {
      (function (ii) {
        // 事件处理函数是闭包,创建时会捕获其外部作用域(立即执行函数的函数作用域)
        btnEl.children[ii].onclick = function () {
          console.log(`${ii + 1}个按钮被点击了`);
        };
      })(i);
    }
    
  • 25
    点赞
  • 11
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值