浏览器是怎么工作的
# 浏览器是怎么工作的
URL后输入后按回车,浏览器内部究竟发生了什么,读完本文后,你将了解到:
- 浏览器内有哪些进程,这些进程都有些什么作用
- 浏览器地址输⼊URL后,内部的进程、线程都做了哪些事
- 我们与浏览器交互时,内部进程是怎么处理这些交互事件的
# 🍐浏览器构架
在讲浏览器架构之前,先理解两个概念, 进程
和 线程
进程(process)是程序的⼀次执行过程,是⼀个动态概念,是程序在执行过程中分配和管理资源的基本单位,线程(thread)是CPU调度和分派的基本单位,它可与同属⼀个进程的其他的线程共享进程所拥有的全部资源
简单的说呢,进程可以理解成正在执⾏的应⽤程序,而线程呢,可以理解成我们应⽤程序中的代码的执⾏器。而他们的关系可想而知,线程是跑在进程里面的,⼀个进程里面可能有⼀个或者多个线程,而⼀个线程,只能隶属于⼀个进程
⼤家都知道,浏览器属于⼀个应⽤程序,⽽应⽤程序的⼀次执⾏,可以理解为计算机启动了⼀个 进程
,进程启动后,CPU会给该进程分配相应的内存空间,当我们的进程得到了内存之后,就可以使⽤ 线程
进⾏资源调度,进⽽完成我们应⽤程序的功能
⽽在应⽤程序中,为了满⾜功能的需要,启动的进程会创建另外的新的进程来处理其他任务,这些创建 出来的新的进程拥有全新的独⽴的内存空间,不能与原来的进程内向内存,如果这些进程之间需要通信,可以通过IPC机制(Inter Process Communication)来进⾏
很多应⽤程序都会采取这种多进程的⽅式来⼯作,因为进程和进程之间是互相独⽴的它们 互不影响
, 也就是说,当其中⼀个进程挂掉了之后,不会影响到其他进程的执⾏,只需要重启挂掉的进程就可以恢复运⾏
# 浏览器的多线程构架
假如我们去开发⼀个浏览器,它的架构可以是⼀个单进程多线程的应⽤程序,也可以是⼀个使⽤IPC通 信的多进程应⽤程序。
不同的浏览器使⽤不同的架构,下⾯主要以Chrome为例,介绍浏览器的多进程架构。 在Chrome中,主要的进程有4个:
在Chrome中,主要的进程有4个:
- 浏览器进程 (Browser Process):负责浏览器的TAB的前进、后退、地址栏、书签栏的⼯作和处理浏 览器的⼀些不可⻅的底层操作,⽐如⽹络请求和⽂件访问。
- 渲染进程 (Renderer Process):负责⼀个Tab内的显示相关的⼯作,也称渲染引擎。
- 插件进程 (Plugin Process):负责控制⽹⻚使⽤到的插件
- GPU进程 (GPU Process):负责处理整个应⽤程序的GPU任务
这4个进程之间的关系是什么呢?
⾸先,当我们是要浏览⼀个⽹⻚,我们会在浏览器的地址栏⾥输⼊URL,这个时候 Browser Process
会向这个URL发送请求,获取这个URL的HTML内容,然后将HTML交给 Renderer Process
, Renderer Process
解析HTML内容,解析遇到需要请求⽹络的资源⼜返回来交给 Browser Process
进⾏加载,同时通知 Browser Process
,需要 Plugin Process
加载插件资 源,执⾏插件代码。解析完成后, Renderer Process
计算得到图像帧,并将这些图像帧交给 GPU Process
, GPU Process
将其转化为图像显示屏幕
# 多进程架构的好处
Chrome为什么要使⽤多进程架构呢?
更⾼的容错性。当今WEB应⽤中,HTML,JavaScript和CSS⽇益复杂,这些跑在渲染引擎的代码, 频繁的出现BUG,⽽有些BUG会直接导致渲染引擎崩溃,多进程架构使得每⼀个渲染引擎运⾏在各⾃的 进程中,相互之间不受影响,也就是说,当其中⼀个⻚⾯崩溃挂掉之后,其他⻚⾯还可以正常的运⾏不 收影响
更⾼的安全性和沙盒性(sanboxing)。渲染引擎会经常性的在⽹络上遇到不可信、甚⾄是恶意的 代码,它们会利⽤这些漏洞在你的电脑上安装恶意的软件,针对这⼀问题,浏览器对不同进程限制了不 同的权限,并为其提供沙盒运⾏环境,使其更安全更可靠
更⾼的响应速度。在单进程的架构中,各个任务相互竞争抢夺CPU资源,使得浏览器响应速度变 慢,⽽多进程架构正好规避了这⼀缺点
# 多进程架构优化
之前的我们说到, Renderer Process
的作⽤是负责⼀个Tab内的显示相关的⼯作,这就意味着,⼀个 Tab,就会有⼀个Renderer Process,这些进程之间的内存⽆法进⾏共享,⽽不同进程的内存常常需要包 含相同的内容
# 浏览器的进程模式
为了节省内存,Chrome提供了四种进程模式(Process Models),不同的进程模式会对 tab 进程做不同的处理
- Process-per-site-instance (default) - 同⼀个 site-instance 使⽤⼀个进程
- Process-per-site - 同⼀个 site 使⽤⼀个进程
- Process-per-tab - 每个 tab 使⽤⼀个进程
- Single process - 所有 tab 共⽤⼀个进程
这⾥需要给出 site 和 site-instance 的定义
site 指的是相同的 registered domain name(如:google.com ,bbc.co.uk)和scheme (如:https://)。 ⽐如a.baidu.com和b.baidu.com就可以理解为同⼀个 site(注意这⾥要和 Same-origin policy 区分开 来,同源策略还涉及到⼦域名和端⼝)
site-instance 指的是⼀组 connected pages from the same site,这⾥ connected 的定义是 canobtain 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使⽤的是共⼀个进 程,因为这两个⻚⾯的site相同,⽽如此⼀来,如果其中⼀个tab崩溃了,⽽另⼀个tab也会崩溃
Process-per-site-instance
是最重要的,因为这个是 Chrome 默认使⽤的模式,也就是⼏乎所有 的⽤户都在⽤的模式。当你打开⼀个 tab 访问 a.baidu.com ,然后再打开⼀个 tab 访问 b.baidu.com,这 两个 tab 会使⽤两个进程。⽽如果你在 a.baidu.com 中,通过JS代码打开了 b.baidu.com ⻚⾯,这两个 tab 会使⽤同⼀个进程
# 默认模式选择
那么为什么浏览器使⽤ Process-per-site-instance
作为默认的进程模式呢? Process-per-site-instance
兼容了性能与易⽤性,是⼀个⽐较中庸通⽤的模式
- 相较于 Process-per-tab,能够少开很多进程,就意味着更少的内存占⽤
- 相较于 Process-per-site,能够更好的隔离相同域名下毫⽆关联的 tab,更加安全
# 🍐导航过程都发生了什么
前⾯我们讲了浏览器的多进程架构,讲了多进程架构的各种好处,和Chrome是怎么优化多进程架构的, 下⾯从⽤户浏览⽹⻚这⼀简单的场景,来深⼊了解进程和线程是如何呈现我们的⽹站⻚⾯的
# 网页加载过程
之前我们我们提到,tab以外的⼤部分⼯作由浏览器进程 Browser Process
负责,针对⼯作的不同, Browser Process 划分出不同的⼯作线程:
- UI thread:控制浏览器上的按钮及输⼊框
- network thread:处理⽹络请求,从⽹上获取数据
- storage thread:控制⽂件等的访问
# 第一步:处理输入
当我们在浏览器的地址栏输⼊内容按下回⻋时, UI thread
会判断输⼊的内容是搜索关键词(search query)还是URL,如果是搜索关键词,跳转⾄默认搜索引擎对应都搜索URL,如果输⼊的内容是URL, 则开始请求URL
# 第二步:开始导航
回⻋按下后, UI thread
将关键词搜索对应的URL或输⼊的URL交给⽹络线程 Network thread ,此 时UI线程使Tab前的图标展示为加载中状态,然后⽹络进程进⾏⼀系列诸如DNS寻址,建⽴TLS连接等操 作进⾏资源请求,如果收到服务器的301重定向响应,它就会告知UI线程进⾏重定向然后它会再次发起 ⼀个新的⽹络请求
# 第三步:读取响应
network thread
接收到服务器的响应后,开始解析HTTP响应报⽂,然后根据响应头中的 Content-Type
字段来确定响应主体的媒体类型(MIME Type),如果媒体类型是⼀个HTML⽂件,则将响应数据 交给渲染进程(renderer process)来进⾏下⼀步的⼯作,如果是 zip ⽂件或者其它⽂件,会把相关数据 传输给下载管理器。
与此同时,浏览器会进⾏ Safe Browsing 安全检查,如果域名或者请求内容匹配到已知的恶意站点, network thread 会展示⼀个警告页。除此之外,网络线程还会做 CORB(Cross Origin Read Blocking)检查来确定那些敏感的跨站数据不会被发送⾄渲染进程
# 第四步:查找渲染进程
各种检查完毕以后,network thread 确信浏览器可以导航到请求⽹⻚,network thread 会通知 UI thread 数据已经准备好,UI thread 会查找到⼀个 renderer process 进⾏⽹⻚的渲染
浏览器为了对查找渲染进程这⼀步骤进⾏优化,考虑到⽹络请求获取响应需要时间,所以在第⼆步开 始,浏览器已经预先查找和启动了⼀个渲染进程,如果中间步骤⼀切顺利,当 network thread 接收到数 据时,渲染进程已经准备好了,但是如果遇到重定向,这个准备好的渲染进程也许就不可⽤了,这个时 候会重新启动⼀个渲染进程
# 第五步:提交导航
到了这⼀步,数据和渲染进程都准备好了, Browser Process
会向 Renderer Process
发送IPC消 息来确认导航,此时,浏览器进程将准备好的数据发送给渲染进程,渲染进程接收到数据之后,⼜发送 IPC消息给浏览器进程,告诉浏览器进程导航已经提交了,⻚⾯开始加载
这个时候导航栏会更新,安全指示符更新(地址前⾯的⼩锁),访问历史列表(history tab)更新,即 可以通过前进后退来切换该⻚⾯
# 第六步:初始化加载完成
当导航提交完成后,渲染进程开始加载资源及渲染⻚⾯(详细内容下⽂介绍),当⻚⾯渲染完成后(⻚ ⾯及内部的iframe都触发了onload事件),会向浏览器进程发送IPC消息,告知浏览器进程,这个时候UI thread会停⽌展示tab中的加载中图标
# 网页渲染原理
导航过程完成之后,浏览器进程把数据交给了渲染进程,渲染进程负责tab内的所有事情,核⼼⽬的就是 将HTML/CSS/JS代码,转化为⽤户可进⾏交互的web⻚⾯。那么渲染进程是如何⼯作的呢?
渲染进程中,包含线程分别是
- ⼀个主线程(main thread)
- 多个⼯作线程(work thread)
- ⼀个合成器线程(compositor thread)
- 多个光栅化线程(raster thread)
不同的线程,将有不同的工作职责
# 构建DOM
当渲染进程接受到导航的确认信息后,开始接受来⾃浏览器进程的数据,这个时候,主线程会解析数据 转化为DOM(Document Object Model)对象。
DOM为WEB开发⼈员通过JavaScript与⽹⻚进⾏交互的数据结构及API。
# 子资源加载
在构建DOM的过程中,会解析到图⽚、CSS、JavaScript脚本等资源,这些资源是需要从⽹络或者缓存中 获取的,主线程在构建DOM过程中如果遇到了这些资源,逐⼀发起请求去获取,⽽为了提升效率,浏览 器也会运⾏预加载扫描(preload scanner)程序,如果HTML中存在 img
、 link
等标签,预加载扫描 程序会把这些请求传递给 Browser Process
的network thread进⾏资源下载
# JavaScript的下载与执行
构建DOM过程中,如果遇到 <script>
标签,渲染引擎会停⽌对HTML的解析,⽽去加载执⾏JS代码, 原因在于JS代码可能会改变DOM的结构(⽐如执⾏ document.write()
等API)
不过开发者其实也有多种⽅式来告知浏览器应对如何应对某个资源,⽐如说如果在 <script>
标签上 添加了 async
或 defer
等属性,浏览器会异步的加载和执⾏JS代码,⽽不会阻塞渲染
# 样式计算 - Style calculation
DOM树只是我们⻚⾯的结构,我们要知道⻚⾯⻓什么样⼦,我们还需要知道DOM的每⼀个节点的样式。 主线程在解析⻚⾯时,遇到 <style>
标签或者 <link>
标签的CSS资源,会加载CSS代码,根据CSS代 码确定每个DOM节点的计算样式(computed style)
计算样式是主线程根据CSS样式选择器(CSS selectors)计算出的每个DOM元素应该具备的具体样式,即 使你的⻚⾯没有设置任何⾃定义的样式,浏览器也会提供其默认的样式
# 布局 - Layout
DOM树和计算样式完成后,我们还需要知道每⼀个节点在⻚⾯上的位置,布局(Layout)其实就是找到 所有元素的⼏何关系的过程。
主线程会遍历DOM 及相关元素的计算样式,构建出包含每个元素的⻚⾯坐标信息及盒⼦模型⼤⼩的布局 树(Render Tree),遍历过程中,会跳过隐藏的元素(display: none),另外,伪元素虽然在DOM上不 可⻅,但是在布局树上是可⻅的。
# 绘制 - Paint
布局 layout 之后,我们知道了不同元素的结构,样式,⼏何关系,我们要绘制出⼀个⻚⾯,我们要需要 知道每个元素的绘制先后顺序,在绘制阶段,主线程会遍历布局树(layout tree),⽣成⼀系列的绘画 记录(paint records)。绘画记录可以看做是记录各元素绘制先后顺序的笔记。
# 合成 - Compositing
⽂档结构、元素的样式、元素的⼏何关系、绘画顺序,这些信息我们都有了,这个时候如果要绘制⼀个 ⻚⾯,我们需要做的是把这些信息转化为显示器中的像素,这个转化的过程,叫做 光栅化 (rasterizing)
那我们要绘制⼀个⻚⾯,最简单的做法是只光栅化视⼝内(viewport)的⽹⻚内容,如果⽤户进⾏了⻚ ⾯滚动,就移动光栅帧(rastered frame)并且光栅化更多的内容以补上⻚⾯缺失的部分,如下
Chrome第⼀个版本就是采⽤这种简单的绘制⽅式,这⼀⽅式唯⼀的缺点就是每当⻚⾯滚动,光栅线程都 需要对新移进视图的内容进⾏光栅化,这是⼀定的性能损耗,为了优化这种情况,Chrome采取⼀种更加 复杂的叫做合成(compositing)的做法
那么,什么是合成?合成是⼀种将⻚⾯分成若⼲层,然后分别对它们进⾏光栅化,最后在⼀个单独的线 程 - 合成线程(compositor thread)⾥⾯合并成⼀个⻚⾯的技术。当⽤户滚动⻚⾯时,由于⻚⾯各个层 都已经被光栅化了,浏览器需要做的只是合成⼀个新的帧来展示滚动后的效果罢了。⻚⾯的动画效果实 现也是类似,将⻚⾯上的层进⾏移动并构建出⼀个新的帧即可
为了实现合成技术,我们需要对元素进⾏分层,确定哪些元素需要放置在哪⼀层,主线程需要遍历渲染 树来创建⼀棵层次树(Layer Tree),对于添加了 will-change
CSS 属性的元素,会被看做单独的⼀ 层,没有 will-change
CSS属性的元素,浏览器会根据情况决定是否要把该元素放在单独的层
你可能会想要给⻚⾯上所有的元素⼀个单独的层,然⽽当⻚⾯的层超过⼀定的数量后,层的合成操作要 ⽐在每个帧中光栅化⻚⾯的⼀⼩部分还要慢,因此衡量你应⽤的渲染性能是⼗分重要的⼀件事情。
⼀旦Layer Tree被创建,渲染顺序被确定,主线程会把这些信息通知给合成器线程,合成器线程开始对层 次数的每⼀层进⾏光栅化。有的层的可以达到整个⻚⾯的⼤⼩,所以合成线程需要将它们切分为⼀块⼜ ⼀块的⼩图块(tiles),之后将这些⼩图块分别进⾏发送给⼀系列光栅线程(raster threads)进⾏光栅 化,结束后光栅线程会将每个图块的光栅结果存在 GPU Process
的内存中
为了优化显示体验,合成线程可以给不同的光栅线程赋予不同的优先级,将那些在视⼝中的或者视⼝附 近的层先被光栅化
当图层上⾯的图块都被栅格化后,合成线程会收集图块上⾯叫做绘画四边形(draw quads)的信息来构 建⼀个合成帧(compositor frame)
绘画四边形:包含图块在内存的位置以及图层合成后图块在⻚⾯的位置之类的信息
合成帧:代表⻚⾯⼀个帧的内容的绘制四边形集合
以上所有步骤完成后,合成线程就会通过IPC向浏览器进程(browser process)提交(commit)⼀个渲 染帧。这个时候可能有另外⼀个合成帧被浏览器进程的UI线程(UI thread)提交以改变浏览器的UI。这 些合成帧都会被发送给GPU从⽽展示在屏幕上。如果合成线程收到⻚⾯滚动的事件,合成线程会构建另 外⼀个合成帧发送给GPU来更新⻚⾯
合成的好处在于这个过程没有涉及到主线程,所以合成线程不需要等待样式的计算以及JavaScript完成 执⾏。这就是为什么合成器相关的动画最流畅,如果某个动画涉及到布局或者绘制的调整,就会涉及到 主线程的重新计算,⾃然会慢很多
# 🍐浏览器对事件的处理
当⻚⾯渲染完毕以后,TAB内已经显示出了可交互的WEB⻚⾯,⽤户可以进⾏移动⿏标、点击⻚⾯等操 作了,⽽当这些事件发⽣时候,浏览器是如何处理这些事件的呢?
以点击事件(click event)为例,让⿏标点击⻚⾯时候,⾸先接受到事件信息的是 Browser Process
,但是Browser Process只知道事件发⽣的类型和发⽣的位置,具体怎么对这个点击事件进⾏处 理,还是由Tab内的 Renderer Process
进⾏的
Browser Process接受到事件后,随后便把事件的信息 传递给了渲染进程,渲染进程会找到根据事件发⽣的坐标,找到⽬标对象(target),并且运⾏这个⽬ 标对象的点击事件绑定的监听函数(listener)
# 渲染进程中合成器线程接收事件
前⾯我们说到,合成器线程可以独⽴于主线程之外通过已光栅化的层创建组合帧,例如⻚⾯滚动,如果 没有对⻚⾯滚动绑定相关的事件,组合器线程可以独⽴于主线程创建组合帧,如果⻚⾯绑定了⻚⾯滚动 事件,合成器线程会等待主线程进⾏事件处理后才会创建组合帧。那么,合成器线程是如何判断出这个 事件是否需要路由给主线程处理的呢?
由于执⾏ JS 是主线程的⼯作,当⻚⾯合成时,合成器线程会标记⻚⾯中绑定有事件处理器的区域为 ⾮快速滚动区域
(non-fast scrollable region),如果事件发⽣在这些存在标注的区域,合成器线程会把事件信 息发送给主线程,等待主线程进⾏事件处理,如果事件不是发⽣在这些区域,合成器线程则会直接合成 新的帧⽽不⽤等到主线程的响应
⽽对于⾮快速滚动区域的标记,开发者需要注意全局事件的绑定,⽐如我们使⽤事件委托,将⽬标元素 的事件交给根元素body进⾏处理,代码如下
document.body.addEventListener('touchstart', event => {
if (event.target === area) {
event.preventDefault()
}
})
2
3
4
5
在开发者⻆度看,这⼀段代码没什么问题,但是从浏览器⻆度看,这⼀段代码给body元素绑定了事件监 听器,也就意味着整个⻚⾯都被编辑为⼀个⾮快速滚动区域,这会使得即使你的⻚⾯的某些区域没有绑 定任何事件,每次⽤户触发事件时,合成器线程也需要和主线程通信并等待反馈,流畅的合成器独⽴处 理合成帧的模式就失效了
其实这种情况也很好处理,只需要在事件监听时传递 passtive
参数为 true, passtive
会告诉浏览 器你既要绑定事件,⼜要让组合器线程直接跳过主线程的事件处理直接合成创建组合帧
document.body.addEventListener('touchstart', event => {
if (event.target === area) {
event.preventDefault()
}
}, {passive: true})
2
3
4
5
# 查找事件的目标对象 (event target)
当合成器线程接收到事件信息,判定到事件发⽣不在⾮快速滚动区域后,合成器线程会向主线程发送这 个时间信息,主线程获取到事件信息的第⼀件事就是通过命中测试(hit test)去找到事件的⽬标对象。 具体的命中测试流程是遍历在绘制阶段⽣成的绘画记录(paint records)来找到包含了事件发⽣坐标上 的元素对象
# 浏览器对事件的优化
⼀般我们屏幕的帧率是每秒60帧,也就是60fps,但是某些事件触发的频率超过了这个数值,⽐如 wheel,mousewheel,mousemove,pointermove,touchmove,这些连续性的事件⼀般每秒会触发 60~120次,假如每⼀次触发事件都将事件发送到主线程处理,由于屏幕的刷新速率相对来说较低,这样 使得主线程会触发过量的命中测试以及JS代码,使得性能有了没必要是损耗
出于优化的⽬的,浏览器会合并这些连续的事件,延迟到下⼀帧渲染是执⾏,也就 是 requestAnimationFrame
之前
⽽对于⾮连续性的事件,如keydown,keyup,mousedown,mouseup,touchstart,touchend等,会直 接派发给主线程去执⾏
# 🍐总结
浏览器的多进程架构,根据不同的功能划分了不同的进程,进程内不同的使命划分了不同的线程,当⽤ 户开始浏览⽹⻚时候,浏览器进程进⾏处理输⼊、开始导航请求数据、请求响应数据,查找新建渲染进 程,提交导航,之后渲染⼜进⾏了解析HTML构建DOM、构建过程加载⼦资源、下载并执⾏JS代码、样 式计算、布局、绘制、合成,⼀步⼀步的构建出⼀个可交互的WEB⻚⾯,之后浏览器进程⼜接受⻚⾯的 交互事件信息,并将其交给渲染进程,渲染进程内主进程进⾏命中测试,查找⽬标元素并执⾏绑定的事 件,完成⻚⾯的交互