深入浏览器工作原理和JS引擎

307 阅读14分钟

浏览器工作原理和JS引擎

浏览器的多进程架构

一个好的程序常常被划分为几个相互独立又彼此配合的模块,浏览器也是如此,以 Chrome 为例,它由多个进程组成,每个进程都有自己核心的职责,它们相互配合完成浏览器的整体功能,每个进程中又包含多个线程,一个进程内的多个线程也会协同工作,配合完成所在进程的职责。

在理解浏览器多进程架构前,首先要了解进程和线程的概念以及它们的一些特点。

进程(process)和线程(thread)

  • 进程是一个应用的执行程序:也就是说,在我们打开一个应用的时候操作系统就会创建相应的进程。当我们打开浏览器,操作系统会就创建浏览器进程,打开微信会创建微信进程,其它应用也一样。

  • 进程有以下三个特点

    • 进程能让操作系统启动另外的进程来执行不同的任务。
    • 进程之间相互隔离:操作系统会为不同的进程分配不同的内存块,这样可以避免进程中的数据写入到另一个进程中。如果进程之间需要通信,则需要通过IPC(Inter Process Communication进程间通信)。
    • 结束进程后,浏览器会回收相应的内存资源。把应用关掉后,该应用的进程也会结束运行,随后操作系统会回收进程运行时占用的内存资源,等待分配给其它进程。
  • 线程: 线程是进程内部用来执行任务的结构,一个进程可以使用启动多个线程来执行任务。

  • 线程也有一些特点

    • 线程共享进程内的数据
    • 同一进程内的线程上下文切换比进程快的多
    • 同一进程内的一个线程运行出错可能会引起整个进程崩溃

浏览器的多进程架构

浏览器也是一种应用程序,它可以是单进程多线程的,也可以是多进程的。本文以Chrome的多进程架构(面向服务的架构)为例,来理解浏览器的工作原理。

我们可以先通过 Shift + Esc 打开chrome的任务管理器,看看有哪些进程。

image.png

如图所示我们看到了以下进程:

  • 浏览器进程(browser process)
  • GPU 进程
  • Network Service
  • Storage Service
  • Audio Service (音频服务)
  • 还有许多的标签页进程(Renderer Process)

我们看到了很多Service进程,这是因为Chrome采用了面向服务(SOA)的多进程架构。

基本上一个标签页一个渲染进程,如图也有2个标签页一个渲染进程的,当我再打开一个 “写文章” 的标签页时,它们还是共用一个渲染进程。后面将会更详细的介绍渲染进程。

Chrome的多进程架构是可以提高稳定性,但在硬件环境有限的情况下,这些Service进程会合并到浏览器进程中,成为它的线程,以减少内存使用。所以Chrome核心的进程有4个: kdzjuslhmp.gif

  • 浏览器进程:主要负责界面显示、用户交互、子进程管理,同时还包含网络请求和文件访问等。
  • 渲染进程:核心任务是将 HTML、CSS 和 JavaScript 转换为用户可以与之交互的网页,排版引擎 Blink 和 JavaScript 引擎 V8 都是运行在该进程中,默认情况下,Chrome 会为每个 Tab 标签创建一个渲染进程。出于安全考虑,渲染进程都是运行在沙箱模式下。
  • GPU进程:与其他进程隔离处理 GPU 任务。
  • 插件进程:主要是负责插件的运行,因插件易崩溃,所以需要通过插件进程来隔离,以保证插件进程崩溃不会对浏览器和页面造成影响。

下图可展示它们的职责范围:

image.png

优缺点

优点: Chrome的多进程框架提供了很多速度、稳定性、安全性的好处,它允许位于不同Tab的Web Pages并行运行,它允许用户在其他Tab崩溃后继续浏览。因为渲染进程不需要直接访问网络、磁盘、设备,Chrome可以在沙盒中运行它们。从而可限制攻击者利用渲染引擎的漏洞的破坏范围,使其更难接触到文件系统、设备、特权页面(e.g. 设置页或者扩展管理页)或者其他配置中的Pages(e.g. 隐私模式)。

ChromeSiteIsolationProject-arch.jpg

缺点: 由于每打开一个标签页,就会创建一个渲染进程。而这些进程中包含重复的功能模块,比如JS运行环境,当打开的页面很多时,将占用大量的内存资源。

由于进程之间不共享内存,打开相同的网页时,可能需要共享内存。此时Chrome不会创建新的渲染进程,而让它们共享同一个渲染进程。比如上述的Chrome任务管理器中,多个标签页共享一个渲染进程。另外,当内存不足时,打开相同的标签页,即使不需要共享内存,也会共享一个渲染进程来减少内存开销。

浏览器工作原理

在浏览器中输入查找内容,浏览器是怎样将页面加载出来的?以及JavaScript代码在浏览器中是如何被执行的?

大概流程可观察以下图:

  • 首先,用户在浏览器搜索栏中输入服务器地址,与服务器建立连接;
  • 服务器返回对应的静态资源(一般为index.html);
  • 然后,浏览器拿到index.html后对其进行解析;
  • 当解析时遇到css或js文件,就向服务器请求并下载对应的css文件和js文件;
  • 最后,浏览器对页面进行渲染,执行js代码;

那么在输入服务器地址,敲下回车那一刻会发生什么?

  • 对浏览器输入的地址进行DNS解析,将域名解析成对应的IP地址;
  • 然后向这个IP地址发送http请求,服务器收到发送的http请求,处理并响应;
  • 最终浏览器得到服务器响应的内容;

浏览器的内核

浏览器从服务器下载的文件最终要进行解析,那么内部是谁在帮助解析呢?这里就涉及到浏览器内核。不同的浏览器由不同的内核构成,以下是几个常见的浏览器内核:

  • Gecko:早期被Netscape和Mozilla Firefox浏览器使用过;
  • Trident:由微软开发的,IE浏览器一直在使用,但Edge浏览器内核已经转向了Blink;
  • Webkit:苹果基于KHTML开发,并且是开源的,用于Safari,Google Chrome浏览器早期也在使用;
  • Blink:Google基于Webkit开发的,是Webkit的一个分支,目前应用于Google Chrome、Edge、Opera等等;

事实上,浏览器内核指的是浏览器的排版引擎(layout engine) ,也称为浏览器引擎、页面渲染引擎或样版引擎。

浏览器的渲染过程

浏览 器从服务器下载完文件后,就需要对其进行解析和渲染,流程如下:

  • HTML Parser将HTML解析转换成DOM树
  • CSS Parser将样式表解析转换成CSS规则树
  • 转换完成的DOM树和CSS规则树Attachment(附加)在一起,并生成一个Render Tree(渲染树)
  • 需要注意的是,在生成Render Tree并不会立即进行绘制,中间还会有一个Layout(布局)操作,也就是布局引擎
  • 为什么需要布局引擎再对Render Tree进行操作?因为不同时候浏览器所处的状态是不一样的(比如浏览器宽度),Layout的作用就是确定元素具体的展示位置和展示效果;
  • 有了最终的Render Tree,浏览器就进行Painting(绘制),最后进行Display展示;
  • 可以发现图中还有一个紫色的DOM三角,实际上这里是js对DOM的相关操作;
  • 在HTML解析时,如果遇到JavaScript标签,就会停止解析HTML,而去加载和执行JavaScript代码;

那么,JavaScript代码由谁来执行呢?下面该JavaScript引擎出场了。

JavaScript引擎

首先由两个问题来认识一下JavaScript引擎。

(1)为什么需要JavaScript引擎?

  • 首先,我们需要知道JavaScript是一门高级编程语言,所有的高级编程语言都是需要转换成最终的机器指令来执行的;
  • 而我们知道编写的JS代码可以由浏览器或者Node执行,其底层最终都是交给CPU执行;
  • 但是CPU只认识自己的指令集,也就是机器语言,而JavaScript引擎主要功能就是帮助我们将JavaScript代码翻译CPU所能认识指令,最终被CPU执行;

(2)JavaScript引擎有哪些?

  • SpiderMonkey:第一款JavaScript引擎,由Brendan Eich开发(JavaScript作者);
  • Chakra:用于IE浏览器,由微软开发;
  • JavaScriptCore:Webkit中内置的JavaScript引擎,由苹果公司开发;
  • V8:目前最为强大和流行的JavaScript引擎,由Google开发;

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

这里以Webkit内核为例。

  • 实际上,Webkit由两部分组成:

    • WebCore:负责HTML解析、布局、渲染等相关的操作;
    • JavaScriptCore(JSCore):解析和执行JavaScript代码;
  • 小程序中编写的JavaScript代码就是由JSCore执行的,也就是小程序使用的引擎就是JavaScriptCore:

    • 渲染层:由Webview来解析和渲染wxml、wxss等;

    • 逻辑层:由JSCore来解析和执行JS代码;

    • 以下为小程序的官方架构图:

V8引擎

下面一起深入了解一下强大的V8引擎。

V8引擎的原理

先了解一下官方对V8引擎的定义:

  • V8引擎使用C++编写的Google开源高性能JavaScript和WebAssembly引擎,它用于Chrome和Node.js等,可以独立运行,也可以嵌入到任何C++的应用程序中。。

  • 所以说V8并不单单只是服务于JavaScript的,还可以用于WebAssembly(一种用于基于堆栈的虚拟机的二进制指令格式),并且可以运行在多个平台

  • 下图简单的展示了V8的底层架构:

    注意 Deoptimization, 下文 TurboFun 模块会详细讲解这一过程。

V8引擎的架构

V8的底层架构主要有三个核心模块(Parse、Ignition和TurboFan),接下来对上面架构图进行详细说明。

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

  • 该过程主要对JavaScript源代码进行词法分析和语法分析;

  • 词法分析:对代码中的每一个词或符号进行解析,最终会生成很多tokens(一个数组,里面包含很多对象);

    • 比如,对const name = 'curry'这一行代码进行词法分析:

      // 首先对const进行解析,因为const为一个关键字,所以类型会被记为一个关键词,值为const
      tokens: [
        { type: 'keyword', value: 'const' }
      ]
      
      // 接着对name进行解析,因为name为一个标识符,所以类型会被记为一个标识符,值为name
      tokens: [
        { type: 'keyword', value: 'const' },
        { type: 'identifier', value: 'name' }
      ]
      
      // 以此类推...
      
  • 语法分析:在词法分析的基础上,拿到tokens中的一个个对象,根据它们不同的类型再进一步分析具体语法,最终生成AST;

  • 以上即为简单的JS词法分析和语法分析过程介绍,如果想详细查看我们的JavaScript代码在通过Parse转换后的AST,可以使用AST Explorer工具:

  • AST在前端应用场景特别多,比如将TypeScript代码转成JavaScript代码、ES6转ES5、还有像vue中的template等,都是先将其转换成对应的AST,然后再生成目标代码;

  • 如果函数没有被调用,那么是不会被解析为AST的。

    function a() {
        console.log(test)
    }
    
    function b() {
        console.log('b')
    }
    
    b()
    

    虽然 test 未定义,但不会报错。因为 函数a 只有声明,没有被调用,也就不会被解析为AST树。

  • 参考官方文档:v8.dev/blog/scanne…

V8还有一核心模块Orinoco(garbage collector),即垃圾回收模块。

(2)Ignition模块:一个解释器,可以将AST转换成ByteCode(字节码)。

  • 字节码(Byte-code):是一种包含执行程序,由一序列 op 代码/数据对组成的二进制文件,是一种中间码。

  • 将JS代码转成AST是便于引擎对其进行操作,前面说到JS代码最终是转成机器码给CPU执行的,为什么还要先转换成字节码呢?

    • 解决启动问题:生成字节码的时间很短;

    • 解决空间问题:字节码占用内存不多,缓存字节码会大大降低内存的使用;

    • 代码架构清晰:采用字节码,可以简化程序的复杂度,使得 V8 移植到不同的 CPU 架构平台更加容易。

      因为JS运行所处的环境是不一定的,可能是windows或Linux或iOS,不同的操作系统其CPU所能识别的机器指令也是不一样的。字节码是一种中间码,本身就有跨平台的特性,然后V8引擎再根据当前所处的环境将字节码编译成对应的机器指令给当前环境的CPU执行。

    • 参考文档:图解 Google V8 -- V8为什么又重新引入字节码? - 掘金 (juejin.cn)

  • 参考官方文档:v8.dev/blog/igniti…

(3)TurboFan模块:一个编译器,可以将字节码编译为CPU认识的机器码。

  • 在了解TurboFan模块之前可以先考虑一个问题,如果每执行一次代码,就要先将AST转成字节码然后再解析成机器指令,是不是有点损耗性能呢?强大的V8早就考虑到了,所以出现了TurboFan这么一个库;

  • TurboFan可以获取到Ignition收集的一些信息,如果一个函数在代码中被多次调用,那么就会被标记为热点函数,然后经过TurboFan转换成优化的机器码,再次执行该函数的时候就直接执行该机器码,提高代码的执行性能;

  • 图中还存在一个Deoptimization过程,其实就是机器码被还原成ByteCode,比如,在后续执行代码的过程中传入热点函数的参数类型发生了变化(如果给sum函数传入number类型的参数,那么就是做加法;如果给sum函数传入String类型的参数,那么就是做字符串拼接),可能之前优化的机器码就不能满足需求了,就会逆向转成字节码,字节码再编译成正确的机器码进行执行;

    function sum(a, b) {
        return a + b
    }
    
    sum(1, 2)
    

    假定上述代码被多次调用,那么就会被标记会热点函数,并经过TurboFan转换成优化的机器码。

    但当遇到一个这样的调用时:

    sum(1, '2')
    

    就需要进行 Deoptimization 的过程。

  • 从这里就可以发现,如果在编写代码时给函数传递固定类型的参数,是可以从一定程度上优化我们代码执行效率的,所以TypeScript编译出来的JavaScript代码的性能是比较好的;

  • 参考官方文档:v8.dev/blog/turbof…

V8引擎执行过程

V8引擎的官方在Parse过程提供了以下这幅图,最后就来详细了解一下Parse具体的执行过程。

  • ①Blink内核将JS源码交给V8引擎;

  • ②Stream获取到JS源码进行编码转换

  • ③Scanner进行词法分析,将代码转换成tokens;

  • ④经过语法分析后,tokens会被转换成AST,中间会经过Parser和PreParser过程:

    • Parser:直接解析,将tokens转成AST树;

    • PreParser:预解析(为什么需要预解析?)

      • 因为并不是所有的JavaScript代码,在一开始时就会执行的,如果一股脑对所有JavaScript代码进行解析,必然会影响性能,所以V8就实现了Lazy Parsing(延迟解析) 方案,对不必要的函数代码进行预解析,也就是先解析急需要执行的代码内容,对函数的全量解析会放到函数被调用时进行。
  • ⑤生成AST后,会被Ignition转换成字节码,然后转成机器码,最后就是代码的执行过程了;

image.png

相关参考链接