说说浏览器的工作原理

1,673 阅读18分钟

浏览器作为前端代码运行的环境,也作为前端工程师的底层知识,熟悉它的结构及工作方式,无论是对于开发高性能 Web 应用,还是对于建立完善的前端知识框架,都起着至关重要的作用。

浏览器的本质?

浏览器就是是运行在操作系统上的一个应用程序。

每个应用程序至少启动一个进程来执行其功能,每个程序往往有很多运行的任务,进程就会去创建一些线程去帮助它执行这些任务

浏览器架构

通过浏览器的任务管理器(快捷键 Shift + ESC)可以看到,当浏览器打开一个标签页时,启动了下面几个进程。

image.png

  • 浏览器进程

  • GPU 进程

  • Network Service 进程

  • V8 代理解析工具进程

    Chrome 支持使用 JavaScript 来写连接代理服务器脚本,称为 pac 代理脚本。 由于 pac 代理脚本是用 JavaScript 编写的,要能够解析 pac 代理脚本就必须要用到 JavaScript 脚本引擎,直接在浏览器主进程中引入 JavaScript 引擎并不符合进程“服务化”的设计理念,所以就把这个解析功能独立成一个进程。

  • 扩展程序进程

以Chrome进程功能不同拆解浏览器

假如我们去开发一个浏览器,它的架构可以是一个单进程多线程的应用程序,也可以是一个使用IPC通信的多进程应用程序。

不同的浏览器使用不同的架构,下面主要以Chrome为例,介绍浏览器的多进程架构。

image.png

在Chrome中,主要的进程有4个:

浏览器进程

负责控制出标签页外的用户界面(地址栏,后退/前进按钮,书签目录等),以及负责与浏览器的其他进程协调工作

  • UI线程

    捕捉地址栏输入内容

  • 网络线程

    DNS进行域名解析

渲染器进程

负责控制显示tab标签内的所有内容。浏览器默认为每个标签页都创建一个进程。

渲染进程的任务是将HTML、CSS 和 JavaScript转化为⽤户可以与之交互的网页,每个渲染进程都会启动单独的渲染引擎线程JavaScript 引擎线程

除此之外还包括事件触发线程,负责接收事件,并将回调函数放入 JavaScript 引擎线程的事件队列中, 以及负责处理定时任务的定时器线程。

这种设计保障了程序与系统的安全性,可以通过操作系统提供的 权限机制来为每个渲染进程建立一个沙箱运行环境,从而防止恶意破坏用户系统或影响其他标签页的行为。

同时也保障了渲染进程的稳定性,因为如果某个标签页失去响应,用户可以关掉这个标签页,此时其他标 签页依然运行着,可以正常使用。如果所有标签页都运行在同一进程上,那么当某个失去响应,所有标签 页都会失去响应

插件进程(扩展程序进程)

负责控制网站使用的插件(并不是指chrome市场里安装的扩展),如flash、Vue.js devtools

要是负责插件的运⾏,和渲染进程一样,也不是唯一的,浏览器会为每个插件都启动一个进程。这样的设计也是从安全性和稳定性考虑。

GPU进程

负责整个浏览器界面(用户界面+网页窗口)的渲染

网络进程(Network Service)

负责发起接受网络请求,负责⻚⾯的⽹络资源加载,比如在地址栏输入一个网页地址,网络进程会将请求后得到的资源交给渲染进程处理。

本来只是浏览器主进程的一个模块,现在为了将浏览器进程进行“服务化”,被抽取出来,成了一个单独的进程。

进程之间的关系

当我们是要浏览一个网页,我们会在浏览器的地址栏里输入URL,

这个时候浏览器进程会向这个URL发送请求,获取这个URL的HTML内容,然后将HTML交给渲染器进程

渲染器进程解析HTML内容,解析遇到需要请求网络的资源又返回来交给浏览器进程进行加载,同时通知浏览器进程,需要插件进程加载插件资源,执行插件代码。

解析完成后,渲染器进程计算得到图像帧,并将这些图像帧交给GPU进程GPU进程将其转化为图像显示屏幕。

浏览器渲染原理

从用户浏览网页这一简单的场景,当在地址栏输入地址后发生了什么? 深入了解是如何呈现我们的网站页面的。

为了方便理解,我们姑且自行将它分为几个阶段:

处理输入

当我们在浏览器的地址栏输入内容按下回车时,UI线程会判断输入的内容是搜索关键词(search query)还是URL,

  • 如果是搜索关键词,跳转至默认搜索引擎对应都搜索URL,
  • 如果输入的内容是URL,则开始请求URL。

浏览器进程的UI线程会捕捉输入内容,出现两种情况

  • 如果输入的是关键词:浏览器就会知道是要搜索,于是会使用默认的搜索引擎来搜索。
  • 如果输入的是网址:UI线程会启动一个网络线程来请求DNS进行域名解析,接着开始连接服务器获取数据。

连接服务器

回车按下后,UI线程将关键词搜索对应的URL或输入的URL交给网络线程,此时UI线程使Tab前的图标展示为加载中状态,然后网络进程进行一系列诸如DNS寻址,建立TLS连接等操作进行资源请求,如果收到服务器的301重定向响应,它就会告知UI线程进行重定向然后它会再次发起一个新的网络请求。

浏览器进程的网络线程接收到URL后,网络进程开始DNS进行域名解析,建立TLS连接等操作进行资源请求

获取数据

网络线程接收到服务器的响应后,开始解析HTTP响应报文,然后根据响应头中的Content-Type字段来确定响应主体的媒体类型(MIME Type),

  • 如果媒体类型是一个HTML文件,则将响应数据交给渲染进程来进行下一步的工作,
  • 如果是 zip 文件或者其它文件,会把相关数据传输给下载管理器。

与此同时,浏览器会进行 Safe Browsing 安全检查,域名或者请求内容会去匹配已知的恶意站点

  • 是恶意站点:展示个警告页面,告诉你这个站点有安全问题,浏览器会阻止你的访问。当然你也可以强行访问
  • 不是恶意站点:通知渲染进程准备好了(安全)

网络线程还会做 CORB(Cross Origin Read Blocking)检查来确定那些敏感的跨站数据不会被发送至渲染进程

image.png

查找渲染进程

各种检查完毕以后,网络线程 确信浏览器可以导航到请求网页,网络线程会通知UI线程 数据已经准备好,UI线程会查找到一个 渲染器进程进行网页的渲染。

浏览器为了对查找渲染进程这一步骤进行优化,考虑到网络请求获取响应需要时间,所以在连接服务器开始,浏览器已经预先查找和启动了一个渲染进程,如果中间步骤一切顺利,当 网络线程接收到数据时,渲染进程已经准备好了,但是如果遇到重定向,这个准备好的渲染进程也许就不可用了,这个时候会重新启动一个渲染进程。

UI线程会创建一个渲染器线程来渲染页面。浏览器进程通过IPC管道将数据传递给渲染器进程,正式进入渲染进程。

提交导航

到了这一步,数据和渲染进程都准备好了,浏览器进程 会向 渲染进程 发送IPC消息来确认导航,此时,浏览器进程将准备好的数据发送给渲染进程渲染进程接收到数据之后,又发送IPC消息给浏览器进程,告诉浏览器进程导航已经提交了,页面开始加载。

这个时候导航栏状态更新,安全指示符更新(地址前面的小锁),访问历史列表(history tab)更新,即可以通过前进后退来切换该页面。

初始化加载完成

当导航提交完成后,渲染进程开始加载资源及渲染页面(详细内容网页渲染原理中介绍),当页面渲染完成后(页面及内部的iframe都触发了onload事件),会向浏览器进程发送IPC消息,告知浏览器进程,这个时候UI线程 会停止展示tab中的加载中图标。

image.png

渲染到屏幕上

浏览器进程将合成器帧传送GPU进程GPU进程渲染到屏幕上

网页渲染原理

导航过程完成之后,浏览器进程把数据交给了渲染进程。渲染器进程核心任务就是把资源(html,js,css,image)渲染成用户可以交互的web页面。

渲染进程中,包含线程分别是:

  • 一个主线程(main thread)
  • 多个工作线程(work thread)
  • 一个合成器线程(compositor thread)
  • 多个光栅化线程(raster thread)

不同的线程,有着不同的工作职责。

构建DOM

渲染进程接受来自浏览器进程的数据,这个时候,主线程会解析数据转化为DOM对象。

image.png

词法分析

通过词法分析,将html的内容解析成多个标记

形如

/*HTML*/
<div id="app">
  <h1 style="color: green;">词法分析</h1>
</div>
​
/*词法分析结构*/
[ [ 'startTag', 'div' ],
  [ 'id', 'app' ],
  [ 'text', '\n' ],
  [ 'startTag', 'h1' ],
  [ 'style', 'color: green;' ],
  [ 'text', '词法分析' ],
  [ 'endTag', '/h1' ],
  [ 'text', '\n' ],
  [ 'endTag', '/div' ],
  [ 'text', '\n' ] ]
生成DOM tree

根据识别后的标记进行DOM树构造,生成DOM tree。在构建DOM的过程中,会解析到image、CSS、JavaScript脚本等资源,这些资源是需要从网络或者缓存中获取的,主线程在构建DOM过程中如果遇到了这些资源,逐一发起请求去获取,而为了提升效率,浏览器也会运行预加载扫描(preload scanner)程序,

  • 如果如果HTML中存在imglink等标签,预加载扫描程序会把这些请求传递给浏览器进程网络线程进行资源下载。
  • 如果遇到<script>标签,渲染引擎会停止对HTML的解析,而去加载执行JS代码,原因在于JS代码可能会改变DOM的结构(比如执行document.write()等API)
  1. 在DOM树构造过程中会创建document对象

  2. 以document为根节点的DOM树不断进行优化,向其中添加各种元素

    • css,image

      css,image这些资源需要通过网络下载或者缓存中直接加载,这些资源不会阻塞html的解析(因为不会影响DOM的生成)

    • JavaScript

      浏览器不知道js的执行是否会改变当前页面的html的结构,如果js调用document.write方法来修改html,那之前的html解析就没有任何意义了。这就是为什么要将script标签放在body的底部,或者使用async defer来异步加载执行js

      不过开发者其实也有多种方式来告知浏览器应对如何应对某个资源,比如说如果在<script> 标签上添加了 asyncdefer 等属性,浏览器会异步的加载和执行JS代码,而不会阻塞渲染。

解析css

在html解析完成后,生成DOM tree,但此时还不知道DOM tree上每个节点长什么样。主线程需要解析css并确定每个DOM节点的样式,即使没有提供自定义的css,浏览器也有自己的默认的样式表,比如h2的字体要比h3的大

DOM树只是我们页面的结构,我们要知道页面长什么样子,我们还需要知道DOM的每一个节点的样式。主线程在解析页面时,遇到<style>标签或者<link>标签的CSS资源,会加载CSS代码,根据CSS代码确定每个DOM节点的计算样式(computed style)。

计算样式是主线程根据CSS样式选择器(CSS selectors)计算出的每个DOM元素应该具备的具体样式,即使你的页面没有设置任何自定义的样式,浏览器也会提供其默认的样式。

构建layout tree

在知道DOM结构和每个节点的样式后,接下来需要知道每个节点需要放在页面上的哪个位置,也就是节点的坐标,以及该坐标需要占用多大的区域。这个阶段被称为layout布局。

通过遍历DOM和计算好的样式来生成layout tree,layout Tree 上的每个节点记录了坐标和尺寸;(layout Tree 并不等于 DOM Tree)

主线程会遍历DOM 及相关元素的计算样式,构建出包含每个元素的页面坐标信息及盒子模型大小的布局树(Render Tree),遍历过程中,会跳过隐藏的元素(display: none),另外,伪元素虽然在DOM上不可见,但是在布局树上是可见的。

  • 设置了display :none的节点

    在DOM Tree上有,在layout Tree 上无

  • 设置了::before{ content:'xx'}的节点

    在DOM Tree 上无,在layout Tree 上有

绘制

经历了布局 layout 之后,我们得到了layout Tree,现在我们要绘制出一个页面,我们要需要知道每个元素的绘制先后顺序,比如样式中的 z-index的层级关系会影响绘制顺序;

在绘制阶段,主线程会遍历布局树(layout tree),生成一系列的绘画记录(paint records)。绘画记录可以看做是记录各元素绘制先后顺序的笔记。

合成

文档结构、元素的样式、元素的几何关系、绘画顺序,这些信息我们都有了,这个时候如果要绘制一个页面,我们需要做的是把这些信息转化为显示器中的像素,这个转化的过程,叫做光栅化(rasterizing)。

光栅化

  1. 合成器线程将它们切分为多个图块,然后将每个图块发送给栅格线程。
  2. 栅格线程栅格化每个图块并将它们存储在GPU进程的内存中,对图块进行栅格化后,合成器线程可以给不同的栅格线程分配优先级,比如栅格化可视区域图块的栅格线程优先处理。

主线程将这些信息传递给compositor线程。合成器线程将每个图层栅格化。然后将每个图块发送给栅格线程。

合成器线程将收集

当图块栅格化完成后,合成器线程将收集称为“draw quads”的图块信息来构建一个合成帧(compositor frame),这些信息里记录了包含诸如图块在内存中的位置和在页面的哪个位置绘制图块的信息。

然后这个合成器frame通过IPC传送给浏览器进程

以上所有步骤完成后,合成线程就会通过IPC向浏览器进程(browser process)提交(commit)一个渲染帧,这些合成帧都会被发送给GPU从而展示在屏幕上。如果合成线程收到页面滚动的事件,合成线程会构建另外一个合成帧发送给GPU来更新页面。

知识支撑

进程和线程

进程(process)和线程(thread)它们比较抽象,不容易掌握。可以使用一种比拟的方式进行理解,然后进行单个详细的解释。

image.png

进程

是操作系统进行资源分配和调度的最小单元,可以申请和拥有计算机资源,进程是程序执行的基本执行实体。

  • 进程依赖于程序运行而存在;进程是操作系统进行资源分配和调度的一个独立单位;

    例如:当我们启动某个程序时,就会创建一个进程来执行任务代码,同时为该进程分配内存空间;该应用程序的状态都保存在该内存空间里。当应用关闭时、该内存空间就会被回收。

  • 每个进程拥有独立的地址空间,地址空间包括代码区、数据区和堆栈区,进程之间的地址空间是隔离的,互不影响。

多进程

进程可以启动更多的进程来执行任务,由于每个进程分配的内存空间是独立的,如果两个进程之间需要传递数据,就需要通过进程间通信管道IPC来传递。很多应用程序都是多进程的结构,这样是为了避免某个进程卡死,由于进程间相互独立,这样不会影响到整个应用程序。

线程

是操作系统能够进行运算调度的最小单位,一个进程中可以并发多个线程、每条线程并行执行不同的任务

进程可以将任务分解成更多细小的任务,通过创建多个线程并执行不同的任务;同一进程下的线程是可以直接通信共享数据的。

进程与线程的区别

进程线程
本质区别进程是操作系统资源分配的基本单位线程是处理器任务调度和执行的基本单位
包含关系一个进程至少有一个线程线程是进程的一部分,所以线程也被称为轻权进程或者轻量级进程。
资源开销每个进程都有独立的地址空间,进程之间的切换会有较大的开销;线程可以看做轻量级的进程,同一个进程内的线程共享进程的地址空间,每个线程都有自己独立的运行栈和程序计数器,线程之间切换的开销小。
影响关系一个进程崩溃后,在保护模式下其他进程不会被影响一个线程崩溃可能导致整个进程被操作系统杀掉,所以多进程要比多线程健壮。

image.png

通常程序需要执行多个任务,比如浏览器需要一边渲染页面一边请求后端数据同时还要响应用户事件,而单线程的进程在同一时间内只能执行一个任务,无法满足多个任务并行执行的需求。要解决这个问题,可以通过 3 种方式来实现:

  • 多进程
  • 多线程(同一进程)
  • 多进程和多线程

由于第 3 种方式是前两种方式的结合,所以这里只比较多进程和多线程的特点。

前面提到进程是操作系统资源分配的基本单位,这里隐含的意思就是,不同进程之间的资源是独享的,不可以相互访问。

这种特性带来的最大好处就是建立了进程之间的隔离性,避免了多个进程同时操作同一份数据而产生问题。

而多线程没有分配独立的资源,线程之间数据都是共享的,也就意味着创建线程的成本更小,因为不需要分配额外的存储空间。

但线程的数据共享也带来了很多问题:

首先是稳定性,进程中任意线程崩溃都会导致整个进程的崩溃,也就是说会“牵连”到进程中的其他线程。安全隐患就更容易理解了,如果有恶意线程启动,可以随意访问进程中的任意资源。

总而言之,多线程更轻量,多进程更安全更稳定。

进程的服务化

Chrome 官方团队在 2016年 提出了面向服务的设计模型,在系统资源允许的情况下,将浏览器主进程的各种模块拆分成独⽴的服务,每个服务在独立的进程中运行。通过高内聚、低耦合的结构让 Chrome 变得更稳定更安全。 同时这种设计也具有一定的伸缩性,当运行在资源有限的设备上时,会将这些服务聚合到浏览器主进程中,从而减少内存占用。


最后一句
学习心得!若有不正,还望斧正。