浏览器的渲染过程
在我们面试过程中,经常会遇到面试官问我们,当我们从浏览器地址栏输入URL之后到页面显示,浏览器到底发生了什么
下面从浏览器的角度告诉你,在输入URL后到按下回车,浏览器内部发生了什么
目录:
- 浏览器内有哪些进程,这些进程的作用
- 浏览器地址输入URL后,内部的进程,线程都做了哪些事
- 我们与浏览器交互时,内部进程是怎么处理这些交互事件的
浏览器架构
关于线程和进程
在介绍浏览器架构之前,我们有必要先理解两个概念,线程和进程
进程(process)是程序的一次执行过程,是一个动态概念,是程序在执行过程中分配和管理资源的基本单位。线程(thread)是CPU调度和分派的基本单位,它可与同属一个进程的其他线程共享所拥有的全部资源。
简单来说,进程可以看作一列列车,线程则是列车的车厢。每节车厢之间是可以互通的。
而浏览器,属于一个应用程序,而应用程序执行,计算机就启动了一个进程,进程启动之后CPU会给该进程分配相应的内存空间,当我们的进程得到了内存之后,就可以使用线程进行资源调度。进而完成我们应用程序的功能。
而在应用程序中,为了满足功能的需求,启动的进程会创建另外的新的进程来处理其他任务,这些创建出来的新的进程拥有全新的独立的内存空间,不能与原来的进程内的内存共享。他们之间的通讯需要通过IPC机制来进行。
很多应用程序都采取这种多进程的形式,好处是可以让进程与进程之间互相独立,互不影响。也就是可以有效的防止因为其中一个进程挂掉之后,影响到其他进程的执行。
浏览器的多进程架构
浏览器的架构,也是属于这种多进程的应用程序,进程之间的通讯是利用IPC机制进行的。我们下面以Chrome为例,介绍浏览器的多进程架构
在Chrome中,主要的进程有4个:
浏览器进程(brower Process): 负责浏览器的TAB的前进,后退,地址栏,书签等工作。和处理浏览器的一些不可见的底层操作,比如网络请求和文件访问
渲染进程(render process)负责一个Tab内的显示相关的工作,也称渲染引擎
插件进程(plugin process)负责控制网页使用到的插件
GPU进程(GPU process)负责处理整个应用程序的GPU任务
进程之间的关系:
首先,当我们要浏览一个网页,我们会在浏览器的地址栏里输入URL,这个时候Browser Process会向这个URL发送请求,获取这个URL的HTML内容,然后将HTML内容交给Renderer Process。
Render Process 解析HTML内容,解析遇到需要请求网络的资源又返回回来交给Browser Process进行加载,同时通知Browser Process,需要plugin process加载插件资源,执行插件代码。
解析完成之后,Render Process计算得到图像帧,并将这些图像帧交给GPU process
GPU process将其转化成为图像显示在屏幕上
多进程架构的好处
- 更高的容错性。多进程的架构可以使得每一个渲染引擎允许在各自的进程中,相互之间不受影响。也就是即使其中一个页面奔溃挂掉了,其他页面还能正常运行
- 更高的安全性和沙盒性。渲染引擎会经常性的在网络上遇到一些不可信任甚至是恶意的代码。它们有可能会利用这些漏洞在你的电脑上安装恶意软件。针对这个问题浏览器可以对不同的进程限制不同权限,并未其提供沙盒运行环境。让他更安全可靠
- 更高的响应速度。多进程可以有效的规避多个任务相互竞争抢夺CPU资源的问题。
多进程架构优化
根据上面的讨论,我们现在已经知道了浏览器的每一个Tab都有一个render process。而这些进程的内存是不能共享的,但是有些时候不同的进程的内存需要包含相同的内容。针对这些情况Chrome浏览器提供了四种进程模式(process Models)来处理。
- process-per-site-instance (默认) : 同一个 site-instance 使用同一个进程
- process-per-site : 同一个 site 使用一个进程
- process-per-tab : 每个tab使用一个进程
- single process : 所有tab使用同一个进程
补充:关于 site 和 site-instance
site 指的是相同的 registered domain name(如:google.com, baidu.com) 和 scheme(如:https://)。就像a.baidu.com 和 b.baidu.com 就可以理解为同一个site
site-instance 指的是一组 connected pages from the same site 。
其中 connected 的含义是 can obtain references to each other in script code. 意思就是能否在脚本代码中获得彼此的引用。简单来说就是满足下面两种情况并且打开的新页面和旧页面属于上面定义的同一个site,就属于同一个site-instance
- 用户通过
<a target="_blank" >
这种方式点击打开的页面- js代码打开的新页面(比如window.open)
理解概念之后,下面解释四个进程模式
首先是Single process,顾名思义,单进程模式,所有tab都会使用同一个进程。
接下来是process-per-tab,也是一样,意思是每打开一个tab,都会新建一个进程
而process-per-site,当你打开a.baidu.com页面,再打开b.baidu.com页面的时候,这两个tab都会共用同一个进程。那么只要其中一个崩溃,那么另一个也回崩溃
最后,也是最重要的Process-per-site-instance,这个模式是chrome的默认使用模式,也就是几乎所有的用户都在使用的模式。当你打开一个tab访问 a.baidu.com, 然后再打开一个tab访问b.baidu.com,这两个tab会使用两个进程。而如果在 a.baidu.com中,通过js代码打开的b.baidu.com页面,这两个页面使用同一个进程
默认模式选择的原因
process-per-site-instance 兼容了性能与易用性,是一个比较中庸通用的模式
- 相较于 process-per-tab 能少开很多进程,意味着更少的内存开销
- 相较于 process-per-site 能够更好的隔离相同域名下毫无相关的tab,更安全
导航过程都发生了什么
下面我们开始深入了解进程和线程是如何呈现我们的网站页面的
网页加载过程
之前我们提到,tab以外的大部分工作都是由浏览器进程Browser process 负责,针对工作的不同,Browser process划分出不同的工作线程:
- UI thread : 控制浏览器上的按钮及输入框
- network thread : 处理网络请求,从网上获取资源
- storage thread : 按钮文件等访问
第一步:处理输入
当我们在浏览器的地址栏输入内容然后按回车时,UI thread会判断输入的内容是否是搜索关键字,还是URL。如果是关键字,跳转到默认搜索引擎对应的搜索URL。如果输入的内容是URL,则开始请求URL
第二步:开始导航
回车按下后,UI thread将关键词搜索对应的URL或输入的的URL交给网络线程Network thread,此时UI线程使Tab前的图标展示为加载中状态,然后网络进程进行一系列DNS寻址,建立TLS连接等操作进行资源请求,如果收到服务器的301重定向响应,它就会告知UI线程进行重定向然后再发起一个新的网络请求。
第三步:读取响应
network thread 接收到服务器响应后,开始解析HTTP响应报文,然后根据响应头中的Content-Type字段来确定响应主体的媒体类型,如果媒体类型是一个HTML文件。则将响应数据交给渲染进程(render process)来进行下一步工作,如果是zip文件或者其他文件,会把相关数据传输交给下载管理器
与此同时,浏览器会进程Safe Browsing安全检查,如果域名或者请求内容匹配到已知的恶意站点,network thread会展示一个警告页。除此之外,网络线程还会做CORB(cross origin read block)检查来确定那些敏感的跨站数据,不会被发送至渲染进程的内存中(这是为了提高攻击者尝试使用幽灵熔断攻击的成本的措施)
第四步:查找渲染进程
各种检查完毕之后,network thread 确信浏览器可以导航到请求网页, newtwork thread 会通知 UI thread 数据已经准备好了, UI thread 会查找到一个 renderer process 进行页面的渲染
浏览器为了对查找渲染进程这一步骤的优化,考虑到网络请求获取响应需要时间,所以在第二步开始,浏览器已经预先查找和启动了一个渲染进程,如果中间步骤一切顺利。当 network thread 接收到数据时,渲染进程已经准备好了,但是如果遇到重定向,这个准备好的渲染进程也许就不可用了,这个时候会重新启动一个渲染进程。
第五步:提交导航
到了这一步,数据和渲染进程都准备好了, Browser process 会向 renderer process 发送IPC消息,来确定导航,此时,浏览器进程将准备好的数据发送给渲染进程,渲染进程接收到数据之后又发送IPC消息给浏览器进程,告诉浏览器进程导航已提交,页面开始加载
这个时候,导航栏会更新,安全指示符,访问历史列表,即可以通过前进后退切换页面了。
第六步:初始化加载完成
当导航提交完成后,渲染进程开始加载资源以及渲染页面(下面介绍),当页面渲染完成后(页面及内部的iframe都触发了onload事件),会向浏览器进程发送IPC消息,告知浏览器进程,这个时候UI thread会停止展示tab中的加载图标
页面渲染原理
导航过程完成之后,浏览器进程把数据交给了渲染进程,渲染进程负责tab内的所有事情,核心目的就是将HTML/CSS/JS代码,转化为用户可进行交互的web页面。
渲染进程中,包含的线程分别是:
- 一个主线程(main thread)
- 多个工作线程(work thread)
- 一个合成器线程(compositor thread)
- 多个光栅化线程(raster thread)
不同的线程,有着不同的工作职责
构建DOM
当渲染进程接受倒导航的确认信息后,开始接受来自浏览器进程的数据,这个时候,主线程会解析数据转化为DOM对象
DOM 为 web开发人员通过js与网页进行交互的数据结构API
资源子加载
在构建DOM的过程中,会解析到图片,CSS,JS脚本等资源,这些资源是需要从网络或者缓存中获取的,主线程在构建DOM过程中如果遇到了这些资源,会逐一发起请求去获取,而为了提升效率,浏览器也会运行预加载扫描(preload scanner)程序,如果HTML中存在img,link
等标签,预加载扫描程序就会把这些请求传递给Browser process的network thread进行资源下载
JavaScript的下载与执行
构建DOM过程中,如果遇到<script>
标签,渲染引擎会停止对HTML的解析,而去加载执行JS代码,原因在于JS代码可能会改变DOM的结构(比如document.write()等API)
不过开发者其实也有多种方式来告知浏览器应该如何对待某个资源,比如说如果在script
上添加async
和defer
等属性,浏览器会异步的加载和执行js代码,而不会阻塞渲染。
样式计算
DOM树只是我们页面的结构,我们要知道页面长什么样,我们还需要知道DOM的没一个节点的样式。主线程在解析页面时,遇到style
标签,或者是link
标签的css资源,会加载css代码,根据css代码确定每个DOM节点的计算样式
计算样式是主线程根据css样式选择器计算出的每个DOM元素应该具备的具体样式,即使你的页面没有设置任何自定义样式,浏览器也会提供默认的样式
布局 - Layout
DOM树和计算样式完成后,我们还需要知道每一个节点在页面上的位置,布局其实就是找到所有元素的几何关系的过程。
主线程会遍历DOM及相关元素的计算样式,构建出包含每个元素的页面坐标信息及盒子模型大小的布局树(Render tree),遍历过程中,会跳过隐藏的元素(display:none),另外,伪元素虽然在DOM上不可见,但是在布局树上是可见的。
绘制 - paint
布局 layout之后,我们知道了不同元素的结构,样式,几何关系,我们要绘制出一个页面,我们要需要知道每个元素的绘制先后顺序,在绘制阶段。主线程会遍历布局树(layout tree),生成一系列的绘画记录(paint records)。绘画记录可以看做是记录各元素绘制先后顺序的笔记。
合成 -compositing
文档结构,元素的样式,元素的几何关系,绘画顺序,这些信息我们都有了,这个时候如果要绘制一个页面,我们需要做的就是把这些信息转化为显示器中的像素。这个转化的过程,叫做光栅化(rasterizing)
那我们要绘制一个页面,最简单的做法是只光栅化视窗内的网页内容,如果用户进行了页面滚动,就移动光栅帧(tastered frame)并且光栅化更多的内容以补上页面缺失的部分。
这种方式的缺点就是每当页面滚动,光栅线程都需要对新移进视图的内容进行光栅化,这是有一定的性能损耗的,为了优化这种情况,chrome采用了一种更加复杂的方式合成(composition)
合成的意思就是,将页面分成若干层,然后分别对它们进行光栅化,最后在一个单独的线程-合成线程(composition thread)里面合并成一个页面的技术。当用户滚动页面时,由于页面各个层都已经被光栅化了,浏览器需需要做的只是合成一个新的帧来展示滚动后的效果罢了。页面的动画实现也是类似,将页面上的层进行移动并构建出一个新的帧即可。
为了实现合成技术,我们需要对元素进行分层,确定哪些元素需要放置在哪一层,主线程需要遍历渲染树来创建一颗层次树(layer tree),对于添加了 will-change
css属性的元素,会被看作单独一层,没有 will-change
CSS属性的元素,浏览器会根据情况决定是否要把该元素放在单独的层
你可能会想给页面上所有的元素一个单独的层,然而当页面的层超过一定数量后,层的合成操作要比在每个帧中光栅化页面的一小部分还要慢,因此衡量你应用的渲染性能是是十分重要的一件事情。
一旦Layer tress被创建,渲染顺序被确定。主线程会把这些信息通知给合成线程,合成器线程开始对层次数的每一层进行光栅化。有的层的可以达到整个页面的大小,所以合成线程需要将它们切分为一块又一块的小图块(tiles),之后将这些小图块分别进行发送给一系列光栅化线程(raster thread)进行光栅化。结束后光栅化线程会将每个图块的光栅结果存在GPU Process的内存中
为了优化显示体验,合成线程可以给不同的光栅线程赋予不同的优先级,将那些在视口中的或者视口附近的层先被光栅化。
当图层上面的图块都被栅格化后,合成线程会收集图块上面叫做绘画四边形(draw quads)的信息来构建一个合成帧(compositor frame)
- 绘画四边形:包含图块在内存的位置以及图层合成后图块在页面的位置之类的信息
- 合成帧: 代表页面一个帧的内容的绘制四边形集合
以上所有步骤完成后,合成线程就会通过IPC向浏览器进程(browser process)提交一个渲染帧。这个时候可能有另外一个合成帧被浏览器进程的UI进程(UI thread)提交以改变浏览器的UI。这些合成帧都会被发送到GPU从而展示在屏幕上。如果合成线程收到页面滚动的事件,合成线程会构建另外一个合成帧发送给GPU来更新页面
合成的好处在于这个过程没有涉及到主线程,所以合成线程不需要等待样式的计算以及JavaScript完成执行。这就是为什么合成器相关的动画最流程。如果某个动画涉及到布局或者绘制的调整,就会涉及到主线程的重新计算,自然会慢很多
浏览器对事件的处理
当页面渲染完毕以后,TAB内已经显示出了可交互的WEB页面,用户可以进行移动鼠标,点击页面等操作。而当这些事件发生时候。浏览器会作出相应处理
以点击事件为例。让鼠标点击页面时,首先接受到事件信息的是 Browser process,但是browser process只知道事件发生的类型和发生的位置。具体怎么对这个点击事件,进行处理还是由Tab内的Render process进行的。Browser process接受倒事件后,随后便把事件的信息传递给了渲染进程,渲染进程会找到根据事件发生的坐标,找到目标对象,并且运行这个目标对象的点击事件绑定的监听函数
渲染进程中合成器线程接收事件
前面我们说到,合成线程可以独立于主线程之外,通过光栅化的层创建组合帧。例如页面滚动,如果没有对页面滚动绑定相关事件,组合器可以独立于主线程创建组合帧,如果页面绑定了页面滚动事件,合成器线程会等待主线程进行事件处理后才会创建组合帧。那么,合成器线程如何判断出这个事件是否需要给主线程处理呢?
由于执行js是主线的工作,当页面合成时,合成器线程会标记页面绑定有事件处理器的区域为非快速滚动区域,如果事件发生在这些存在标注的区域,合成器线程会把事件信息发送给主线程,等待主线程进行事件处理。如果事件不是发生在这些区域,合成器线程则会直接合成新的帧而不用等到主线程的响应。
而对于非快速滚动区域的标记,开发者需要注意全局的事件绑定,比如我们使用事件委托,将目标元素的事件交给根元素body进行处理
document.body.addEventListener('touchstart', evnet => {
if(event.target === area) {
event.prevenDefault()
}
})
在开发者角度看,这一段代码没什么,但是从浏览器的角度看,这一段代码给body元素绑定了事件监听器,也就意味着整个页面都被编辑为一个非快速滚动区,这会使得即使你的压面的某些趋于没有绑定任何事件,每次用户触发事件时,合成器线程也需要和主线程进行通信并等待反馈,流畅的合成器独立处理合成帧的模式就失效了
其实这种情况也很好处理,只需要在事件监听时传递passtive
参数为 true,passtive
会告诉浏览器你既要绑定事件,又要让组合器线程直接跳过主线程的事件处理直接合成创建组合帧。
document.body.addEventListener('touchstart', event => {
if (event.target === area) {
event.preventDefault()
}
}, {passive: true});
查找事件的目标对象(event target)
当合成器线程接收到事件信息,判定到事件发生不在非快速滚动区域后,合成器线程会向主线程发送这个时间信息,主线程获取到事件信息的第一件事就是通过命中测试(hit test)去找到事件的目标对象。具体的命中测试流程是遍历在绘制阶段生成的绘画记录(paint records)来找到包含了事件发生坐标上的元素对象。
[站外图片上传中...(image-ea5ace-1607161505956)]
浏览器对事件的优化
一般我们屏幕的帧率是每秒60帧,也就是60fps,但是某些事件触发的频率超过了这个数值,比如wheel,mousewheel,mousemove,pointermove,touchmove,这些连续性的事件一般每秒会触发60~120次,假如每一次触发事件都将事件发送到主线程处理,由于屏幕的刷新速率相对来说较低,这样使得主线程会触发过量的命中测试以及JS代码,使得性能有了没必要是损耗。
出于优化的目的,浏览器会合并这些连续的事件,延迟到下一帧渲染是执行,也就是requestAnimationFrame
之前。
而对于非连续性的事件,如keydown,keyup,mousedown,mouseup,touchstart,touchend等,会直接派发给主线程去执行
总结
浏览器的多进程架构,根据不同功能划分了不同进程,进程内不同的使命划分了不同的线程,当用户开始浏览网页时候,浏览器进程进行处理输入,开始导航请求数据,请求响应数据,请求响应数据,查找新建渲染进程,提交导航,之后渲染又进行了解析HTML构建DOM,构建过程加载子资源,下载并执行JS代码,样式计算,布局,绘制,合成,一步一步的构建出一个可交互的WEB页面,之后浏览器进程又接受页面的交互事件信息,并将其交给渲染进程,渲染进程内主进程进行命中测试,查找到目标元素并执行绑定的事件,完成页面交互。
以上