一. 浏览器原理_v8_js执行原理

78 阅读10分钟

一.浏览器原理_v8_js执行原理

1.1. 浏览器工作原理

image-20220208203018940.png

在这个执行过程中,HTML解析的时候遇到了JavaScript标签,会停止解析HTML,而去加载和执行JavaScript代码;而JavaScript代码由js引擎执行

1.2. 认识浏览器的内核

  • 我们经常会说:不同的浏览器有不同的内核组成

    • Gecko:早期被Netscape和Mozilla Firefox浏览器浏览器使用;
    • Trident:微软开发,被IE4~IE11浏览器使用,但是Edge浏览器已经转向Blink;
    • Webkit:苹果基于KHTML开发、开源的,用于Safari,Google Chrome之前也在使用;
    • Blink:是Webkit的一个分支,Google开发,目前应用于Google Chrome、Edge、Opera等;
    • 等等...
  • 事实上,我们经常说的浏览器内核指的是浏览器的排版引擎:

    • 排版引擎(layout engine),也称为浏览器引擎(browser engine)、页面渲染引擎(rendering engine)

    样版引擎

1.3. 认识JavaScript引擎

  • 为什么需要JavaScript引擎呢?

    • 我们前面说过,高级的编程语言都是需要转成最终的机器指令来执行的;

    • 事实上我们编写的JavaScript无论你交给浏览器或者Node执行,最后都是需要被CPU执行的;

    • 但是CPU只认识自己的指令集,实际上是机器语言,才能被CPU所执行;

    • 所以我们需要JavaScript引擎帮助我们将JavaScript代码翻译成CPU指令来执行;

      比如有一个函数foo(){},浏览器并不认识他,需要js引擎转成机器语言如0011001011才能被识别

1.4. 浏览器内核和JS引擎的关系

  • 这里我们先以WebKit(浏览器内核)为例,WebKit事实上由两部分组成的:

    • WebCore: 负责HTML解析、布局、渲染等等相关的工作;

    • JavaScriptCore: 解析、执行JavaScript代码; image-20220208144050820.png

  • 看到这里,学过小程序的同学有没有感觉非常的熟悉呢?

    • 在小程序中编写的JavaScript代码就是被JSCore执行的;

      image-20220208144122261.png

  • 另外一个强大的JavaScript引擎就是V8引擎。

1.5. V8引擎的原理

官方对V8引擎的定义:

  • V8是用C ++编写的Google开源高性能JavaScript和WebAssembly引擎,它用于Chrome和Node.js等。
  • 它实现ECMAScript和WebAssembly,并在Windows 7或更高版本,macOS 10.12+和使用x64,IA-32, ARM或MIPS处理器的Linux系统上运行。
  • V8可以独立运行,也可以嵌入到任何C ++应用程序中 image-20220208151005605.png

1.6. V8引擎结构

  • Parse:模块经过 词法分析、语法分析将JavaScript代码解析成AST(抽象语法树),这是因为解释器(Ignition模块)并不直接认识JavaScript代码;所以需要经过编译器将js代码生成AST,当需要进行代码转换时,例如es6,ts代码需要转换成es5的时候,可以访问AST树,将AST树转换为es5规定的代码,然后再生成新的AST树

    • 如果函数没有被调用,那么是不会被转换成AST的;
    • Parse的V8官方文档:v8.dev/blog/scanne…
  • Lgnition:解释器、转换器,会将AST转换成ByteCode(字节码)

    • 同时会收集TurboFan优化所需要的信息(比如函数参数的类型信息,有了类型才能进行真实的运算);
    • 如果函数只调用一次,Ignition会执行解释执行ByteCode;
    • Ignition的V8官方文档:v8.dev/blog/igniti…
  • TurboFan: 是一个编译器,可以将字节码编译为CPU可以直接执行的机器码;

    • 如果一个函数被多次调用,那么就会被标记为热点函数,那么就会经过TurboFan转换成优化的机器码,提高代码的执行性能;
    • 但是,机器码实际上也会被还原为ByteCode,这是因为如果后续执行函数的过程中,类型发生了变化(比如sum函数原来执行的是number类型,后来执行变成了string类型),之前优化的机器码并不能正确的处理运算,就会逆向的转换成字节码;
    • TurboFan的V8官方文档:v8.dev/blog/turbof…
  • 问:为什么不将AST转换成机器机器语言01001? 答:js代码不确定是在哪种环境,可能是在mac、windows、linux、node上面,而不同的环境有不同的cpu和cpu架构,他们能执行的机器指令是不一样的,所以Lgnition不确定到底要转成哪种机器的指令,所以转成字节码,这些字节码由v8创造的,是跨平台的,等到真正运行的时候,v8会将字节码转成不同平台的汇编指令,再由汇编指令去执行对应的机器指令,
  • 问:每次执行代码都要执行字节码转成汇编指令再转成机器指令,能否直接将字节码转成对应的机器指令,来提高效率? 答:假如有函数foo(){clg"foo"}执行了多次,若每次将函数foo转成汇编指令再转成机器指令,效率是比较低的,如果把函数foo字节码直接转成机器指令01001,再次执行foo时,直接执行01001,效率确实会高很多; 假如函数foo只执行了一次,就没有必要将其转成机器指令并保存下来了 这时TurboFan就登场了,TurboFan由Lgnition收集一些执行信息,如果发现foo是执行频率较高的函数,就会将它标记成hot(热函数),然后由TurboFan将foo变成优化后的机器指令,后面在准备执行foo时,直接执行这个机器指令,运算结果就可以了
  • 问:这时候会有另外的问题,假如是另一个函数sum(num1,num2){clg"num1+num2"},假如调用sum时,大多时候传入是两个数字,sum函数转成机器指令时,要做的事情就是对两个数字做相加,如果某次是传入两个字母调用sum(a,b),由于js是动态语言,并不会对类型做检测,之前转成两数相加(+)的机器指令在这里是不能用的,因为这次传的是字符串,结果是做拼接,这时候v8引擎会这样做,Deoptimization一旦发现下次执行对应的机器指令时,执行的操作不一样了,这时候会做一个Deoptimization(反向优化),将机器指令转回字节码,然后按照字节码的方式,转成汇编,再转成机器指令去执行 知道了这样的一个原理,我们在调用函数时,尽量传相同类型的值;如果是用ts写的代码,执行效率会比js高,因为ts不允许随意修改类型

1.7. 变量执行过程

假如我们有下面一段代码(都是变量,没有函数),它在JavaScript中是如何被执行的呢?

 console.log(name);
 var name = "why"
 var num1 = 1
 var num2 = 2
 var result = num1 + num2
 console.log(result);

1.初始化全局对象GO

代码在执行之前,v8引擎会在堆内存中创建一个Global对象(GO),

  • 该对象 所有的作用域(scope) 都可以访问;

  • 里面会包含Date、Array、String、Number、setTimeout、setInterval等等;

  • 其中还有一个window属性指向自己;

    image-20220209153350086.png

2.执行上下文栈(调用栈)

v8为了执行代码,v8引擎内部会有一个执行上下文栈(Execution Context Stack,简称ECStack)(也叫函数调用栈),它是用于执行代码的调用栈。代码只要想运行,都必须放到ESCtack里,一般ECStack放的都是函数,所以被执行的函数都要放到ECStack里,执行完后就出栈并销毁

  • 现在v8要执行谁?执行的是全局代码快

    • 如果执行的全局代码不是函数(如上),全局代码块为了执行,会创建 全局执行上下文(Global Execution Context)(全局代码需要被执行时才会创建)

    • GEC会 被放入到ECS中 执行;

      GEC被放入到ECS中里面包含两部分内容

      • 第一部分: 在代码执行前,在parser转成AST的过程中,会将全局定义的变量、函数等加入到GlobalObject中,但是并不会赋值(代码执行时才会赋值);这个过程也称之为变量的作用域提升(hoisting)

         let globalObject = {
             String: "类",
             Date: "类",
             setTimeout: "函数",
             window: this.globalObject, // window本身指向Global
             ......
             name: undefined,
             num1: undefined,
             num2: undefined,
             result: undefined
         }
        
      • 第二部分: 在代码执行中,对变量赋值,或者执行其他的函数;

        GEC内部维护了一个东西叫VO,对于GEC来说,VO指向的就是GO,然后执行代码,如执行let name = "why",将name塞进函数调用栈赋值并保存在栈,然后通过VO找到GO对象里面去,然后将GO的name的undefined直接改为”why“,后面的代码依次这样执行

其他:如果在最后一行打印result,结果是50,代码会这样执行:去VO里面找GO对象,在GO里面查找result,然后查出result为50 如果在第一行打印name,,结果是undefined,不是报错说找不到,因为在GO里面能找到name,只是在预解析阶段被复制为 undefined,这也就是常说的变量提升

image-20220208223849126.png

1.8. 函数执行的过程


假如我们有下面一段代码(有变量,有函数),它在JavaScript中是如何被执行的呢?

 var name = "why"
 foo(123)
 function foo(num) {
     var m = 10
     var n = 20
     console.log("foo");
 }

1.先初始化全局对象Global,

2.然后GEC会 被放入到ECS中 执行

2.1 执行代码前,会进行预解析,先解析的是全局代码快,所以会创建全局执行上下文,将name提升到GO,

解析到foo时,发现是函数, 会自动在堆内存开闭一个空间(假设内存地址是0xa01),用于储存foo函数,空间里包含两个东西:父级作用域(parent scope)函数的执行体(代码块) ,go保存的是foo的0xa01地址,这个地址引用着foo函数对象,

2.2 执行时,先将“why”赋值给name。执行到foo()时,会去vo的go里面查找,发现foo是个内存地址,根据地址找到这个空间后,就会根据函数体在ECStack函数调用栈里面创建一个函数执行上下文(Functional Execution Context,

简称FEC)

  • FEC中包含三部分内容:

    • 第一部分:在解析函数成为AST树结构时,会创建一个Activation Object(AO):AO中包含形参、arguments、函数定义和指向函数对象、定义的变量;

    • 第二部分:作用域链:由VO(在函数中就是AO对象)和父级VO组成,查找时会一层层查找;

    • 第三部分:this绑定的值:这个我们后续会详细解析;

      image-20220210115404255.png

此时才生成AO对象,和作用域链,函数执行上下文内部会创建VO,VO对应的是AO对象,然后解析函数,将foo()的参数和内部的变量提升到AO里面(不赋值),然后执行函数,执行完函数后,函数调用栈和ao都会被销毁,下次执行foo()时,被根据内存地址再次创建函数调用栈和AO

image-20220210140501594.png

image-20220210103612607.png