浏览器工作原理
在深入了解浏览器工作原理之前,需要先了解几个概念。
概念
JS、JS执行引擎和浏览器
- JS:一门计算机语言,提供了表达程序逻辑的语法和实现基本功能的API
- JS执行引擎:也叫做主线程,宿主环境的一个功能模块,负责解析执行JS代码
- 浏览器:JS代码的运行环境,也称之为宿主环境
它们的关系如图:

进程和线程
- 进程:是操作系统分配资源的基本单位,简单来说,进程可以理解为一个正在运行的应用程序。在一个应用程序运行时,需要使用内存和CPU资源,这些资源需要向操作系统申请,操作系统会以进程为单位分配这些资源。一个应用程序要运行,就至少要有一个进程启动。一个进程就代表着一块独立于其他进程的内存空间。进程最大的特点就是独立,一个进程不能随意访问其他进程的资源,这就保证了多个程序在操作系统上运行互不干扰。

- 线程:是进程的一个执行单位,也就是说,线程是跑在进程里面的。一个进程里可能有一个或多个线程,而一个线程只能隶属于一个进程。线程和线程之间相互独立,但可以共享资源。

浏览器的多进程架构
在应用程序中,为了满足功能需要,启动的进程会创建另一个新的进程来处理其他任务,这些被创建出来的进程一样拥有独立的内存空间,与原来的进程互不干扰。如果这些进程之间需要通信,可以通过IPC机制(Inter Process Communication)来进行。

不同的浏览器使用不同的架构,它可以是单进程多线程应用程序,也可以是基于IPC通信的多线程应用程序。
我们主要以Chrome为例,介绍浏览器的多进程架构。
在Chrome中,主要的进程有4个:
- 浏览器进程 (Browser Process):负责浏览器的tab的前进、后退、地址栏、书签栏的工作和处理浏览器的一些不可见的底层操作,比如网络请求和文件访问。
- 渲染进程 (Renderer Process):负责一个tab标签页的内容显示的相关工作,也称渲染引擎。
- 插件进程 (Plugin Process):负责控制网页使用到的插件
- GPU进程 (GPU Process):负责处理整个应用程序的GPU任务

这4个进程之间的关系是什么呢?
首先,当我们是要浏览一个网页,我们会在浏览器的地址栏里输入URL,这个时候浏览器进程的HTML内容,然后将HTML交给渲染进程,渲染进程解析HTML内容,解析遇到需要请求网络的资源又返回来交给浏览器进程进行加载,同时通知浏览器进程,需要插件进程完成后,渲染进程计算得到图像帧,并将这些图像帧交给GPU进程,GPU进程将其转化为图像显示屏幕。

多进程架构的优点
稳定性
Chrome会为每个tab标签页单独分配一个属于它们自己的渲染进程,这样即使有一个tab标签页崩溃,也不会影响到其他tab标签页的正常运行。可是如果所有的tab都跑在同一个进程的话,它们就会有连带关系,一个挂全部挂。
安全性和沙箱处理
浏览器会对不同进程限制不同的权限。举个例子,由于渲染进程可能会处理来自用户的随机输入,这很容易遭遇一些恶意的代码,它们会利用这些漏洞在你的电脑上安装恶意的软件,针对这一问题,浏览器限制了渲染进程读写文件的能力。
多进程架构的缺点
内存开销大
由于每个进程都有各自独立的内存空间,所以它们不能像存在于同一个进程的线程那样共用内存空间,这就造成了一些基础的架构(例如V8引擎)会在不同进程的内存空间中同时存在的问题,这些重复的内容会消耗更多的内存。所以为了节省内存,Chrome会限制被启动的进程数目,当进程数达到一定的界限后,Chrome会将访问同一个网站的tab都放在一个进程里面跑。
在浏览器地址栏输入url到显示页面,发生了什么?
上面的4个进程介绍中,可以看出浏览器进程负责了tab标签页外的大部分工作。针对不同的工作,浏览器进程划分了几个线程:
- UI线程(UI thread):负责渲染和处理浏览器顶部按钮和地址栏等组件
- 网络线程(Network thread):负责处理网络请求
- 存储线程(Storage thread):控制文件的读写
1、处理输入
用户在浏览器地址栏输入内容时,UI线程会判断输入的内容是关键词搜索还是URL。如果是关键词搜索,则将关键词发送给搜索引擎;如果是URL,则自动补全协议端口号,自动进行URL编码,然后请求URL。
浏览器会根据缓存策略判断是否命中缓存,若命中则直接使用缓存,不再发出请求,详情了解浏览器的缓存策略。

2、开始导航
当用户按下回车后,UI线程会通知网络线程去发起网络请求获取内容。此时tab标签页的图标变为加载状态,而网络线程会进行DNS查找、建立TLS链接等操作。

如果网络线程收到服务器响应的状态码为301重定向,那么网络线程会通知UI线程并重新发起一次请求。
3、读取响应
网络线程接收到服务器的响应后,开始解析HTTP报文,然后根据响应头中的Content-Type字段来确定响应主体的媒体类型(MIME Type),如果媒体类型是一个HTML文件,则将响应数据交给渲染进程(Renderer process)来进行下一步的工作,如果是 zip 文件或者其它文件,会把相关数据传输给下载管理器。
与此同时,浏览器会进行 Safe Browsing安全检查,如果域名或者请求内容匹配到已知的恶意站点,网络线程会展示一个警告页。除此之外,网络线程还会做Cross-Origin Read Blocking(CORB)检查来确定那些敏感的跨站数据不会被发送至渲染进程。
4、查找渲染进程
各种检查完毕以后,网络线程确认浏览器可以导航到请求网页,网络线程会通知UI线程数据已经准备好,UI线程会查找到一个渲染进程来渲染页面。

由于网络请求会花费几百毫秒时间来获取响应,浏览器为查找渲染进程这一步骤进行了优化。在第二步开始导航时,UI线程已经尝试寻找并启动渲染进程了。如果中间步骤一切顺利,当网络线程接收到数据时,渲染进程已经准备好了,但是如果遇到重定向,这个准备好的渲染进程也许就用不上了,这个时候会重新启动一个渲染进程。
5、提交导航
到了这一步,数据和渲染进程都已经准备就绪,浏览器进程会通过IPC通知渲染进程去提交导航。它也会传递数据流,所以渲染进程可以保持接收 HTML 数据。当浏览器进程收到渲染进程已经完成提交导航的消息时,文档加载解析阶段正式开始。

这个时候导航栏会更新,安全指示符更新(地址前面的小锁),访问历史列表(history tab)更新,即可以通过前进后退来切换该页面。
6、初始化加载完成
当导航提交完成后,渲染进程会开始加载资源和渲染页面。当页面渲染完毕后(页面及内部的iframe都触发了onload事件),渲染进程会通过IPC告知浏览器进程,此时UI线程将tab标签页上的加载图标隐藏。
浏览器的渲染原理
渲染进程负责tab标签页内发生的所有事情,它的核心工作就是将 HTML、CSS 和 JavaScript 转换为用户可以与之交互的网页。渲染进程内部包含了以下几个线程:
- 一个主线程(Main thread)
- 多个工作线程(Work thread)
- 一个合成器线程(Compositor thread)
- 多个光栅化线程(Raster thread)

1、构建 DOM
当渲染进程接收到导航的确认信息并且开始接收HTML数据后,主线程开始解析文本字符串(HTML)并将其转换为DOM。
2、子资源加载
在构建DOM的过程中,会遇到图片、CSS、JavaScript脚本等资源,这些资源是需要从网络或者缓存中获取的,主线程在构建DOM过程中如果遇到了这些资源,会逐一发起请求去获取。为了提升效率,浏览器会运行预加载扫描(preload scanner)程序,如果HTML中存在img、link、script等标签,预加载扫描程序会把这些请求传递给浏览器进程的网络线程进行资源下载,最终形成一颗DOM树(DOM Tree)。

3、JavaScript 阻塞解析
在构建DOM的过程中,如果遇到了script标签,渲染进程会停止解析HTML,去加载并执行JS代码。原因是JS代码可能会改变DOM的结构。(比如执行document.write()等API)
不过开发者也有多种方式来告知浏览器应该如何应对某个资源,比如说,如果在<script> 标签上添加了 async 或 defer 等属性,浏览器会异步的加载和执行JS代码,而不会阻塞渲染。
4、样式计算 - Style calculation
只有DOM还无法确定页面的外观,我们还需要确定每个DOM节点的样式。主线程解析 CSS 并确定每个 DOM 节点计算后的样式。
主线程会根据CSS Selector(CSS样式选择器)计算出每个DOM元素应该具备的具体样式,即使你的页面没有设置任何自定义的样式,浏览器也会提供其默认的样式。

5、布局 - Layout
DOM树和计算样式完成后,我们还需要知道每一个节点在页面上的位置,布局其实就是找到所有元素的几何关系的过程。
主线程会遍历DOM 及相关元素的计算样式,构建出包含每个元素的页面坐标信息及盒子模型大小的布局树(Layout Tree)。布局树可能与 DOM 树结构类似,但它仅包含页面上可见内容相关的信息。如果某个DOM元素应用了CSS样式display: none,那么这个DOM元素就不会存在于布局树(但 visibility: hidden 的元素和伪元素会存在布局树中)。另外,伪元素虽然在DOM树上不可见,但是在布局树上是可见的。

6、绘制 - Paint
我们有了DOM结构、样式、布局,但还不足以绘制出页面。我们还需要知道每个元素的绘制顺序,在绘制阶段,主线程会遍历布局树(Layout Tree),生成一系列的绘画记录(Paint Records)。绘画记录可以看做是记录各元素绘制先后顺序的笔记。

7、合成 - Compositing
现在浏览器知道文档的结构、每个元素的样式、页面的几何形状和绘制顺序,它是如何绘制页面的?把这些信息转换为屏幕上的像素,我们称为光栅化。
绘制页面最简单的一个方法,就是先在光栅化视窗内的画面,如果用户滚动页面,则移动光栅框,并光栅化填充缺少的部分。这就是 Chrome 首次发布时处理光栅化的方式。
这一方式唯一的缺点就是每当页面滚动,光栅线程都需要对新移进视图的内容进行光栅化,这是一定的性能损耗,为了优化这种情况,Chrome采取了一种更加复杂的做法叫做合成(Compositing)。
那么,什么是合成?合成是一种将页面分成若干层,然后分别对它们进行光栅化,最后在一个单独的线程 - 合成器线程(Compositor thread)里面合并成一个页面的技术。当用户滚动页面时,由于页面各个层都已经被光栅化了,浏览器需要做的只是合成一个新的帧来展示滚动后的效果罢了。页面的动画效果实现也是类似,将页面上的层进行移动并构建出一个新的帧即可。
为了分清哪些元素位于哪些图层,主线程会遍历布局树创建图层树(Layer Tree)。如果页面的某些部分应该是单独图层(如滑入式侧面菜单)但没拆分出来,你可以使用 CSS 中的 will-change 属性来提示浏览器。

你可能会想要给页面上所有的元素一个单独的层,然而当页面的层超过一定的数量后,层的合成操作要比在每个帧中光栅化页面的一小部分还要慢,因此衡量你应用的渲染性能是十分重要的一件事情。
一旦Layer Tree被创建,渲染顺序被确定,主线程会把这些信息通知给合成器线程,合成器线程开始对层次数的每一层进行光栅化。有的层的可以达到整个页面的大小,所以合成器线程需要将它们切分为一块又一块的小图块(tiles),之后将这些小图块分别进行发送给各个光栅线程(raster threads)进行光栅化,结束后各个光栅线程会将它们各自负责的光栅结果存在GPU Process的内存中。

合成器线程会给不同的光栅线程设置优先级,以便视窗或视窗附近内的画面可以先被光栅化。图层还具有多个不同分辨率的块,可以处理放大操作等动作。
当图层上面的图块都被栅格化后,合成器线程会收集图块上面叫做绘画四边形(draw quads)的信息来构建一个合成帧(compositor frame)。
- 绘画四边形:包含图块在内存的位置以及图层合成后图块在页面的位置之类的信息。
- 合成帧:代表页面一个帧的内容的绘制四边形集合。
以上所有步骤完成后,合成器线程就会通过IPC向浏览器进程(browser process)提交(commit)一个渲染帧。这个时候可能有另外一个合成帧被浏览器进程的UI线程(UI thread)提交以改变浏览器的UI。这些合成帧都会被发送给GPU从而展示在屏幕上。如果合成器线程收到页面滚动的事件,合成器线程会构建另外一个合成帧发送给GPU来更新页面。

合成的好处在于这个过程没有涉及到主线程,所以合成器线程不需要等待样式的计算以及JavaScript完成执行。这就是为什么合成器相关的动画最流畅,如果某个动画涉及到布局或者绘制的调整,就会涉及到主线程的重新计算,自然会慢很多。
浏览器对事件的处理
当页面渲染完毕以后,TAB内已经显示出了可交互的WEB页面,用户可以进行移动鼠标、点击页面等操作了,而当这些事件发生时候,浏览器是如何处理这些事件的呢?
以点击事件为例,让鼠标点击页面时候,首先接受到事件信息的是浏览器进程,但是它只知道事件发生的类型和位置,具体怎么对这个点击事件进行处理,还是由tab标签页中的渲染进程来处理。因此浏览器进程会把事件类型和位置发送给渲染进程,渲染进程会根据事件发生的位置,找到目标对象(target),并且运行这个目标对象的点击事件绑定的监听函数(listener)。

合成器线程接收事件
前面我们说到,合成器线程可以独立于主线程之外通过已光栅化的层创建组合帧,例如页面滚动,如果没有对页面滚动绑定相关的事件,组合器线程可以独立于主线程创建组合帧,如果页面绑定了页面滚动事件,合成器线程会等待主线程进行事件处理后才会创建组合帧。那么,合成器线程是如何判断出这个事件是否需要传递给主线程处理的呢?
由于执行 JS 是主线程的工作,当页面合成时,合成器线程会标记页面中绑定有事件处理器的区域为非快速滚动区域(non-fast scrollable region),如果事件发生在这些存在标注的区域,合成器线程会把事件信息发送给主线程,等待主线程进行事件处理。如果事件不是发生在这些区域,合成器线程则会直接合成新的帧而不用等到主线程的响应。

web 开发中常用的事件处理模式是事件代理。因为事件会冒泡,所以你可以在最顶层的元素中添加一个事件处理器,用来代理事件目标产生的任务。如下面的代码:
document.body.addEventListener('touchstart', event => {
if (event.target === area) {
event.preventDefault()
}
})在开发者角度看,这段代码似乎没什么问题,但从浏览器角度来看,这等于把整个页面都标记成了“非立即可滚动区”,意味着你的应用原本不用关注其他区域的事件,合成器线程也必须在事件发生后,把事件信息发送给主线程,等待主线程进行事件处理。如此则得不偿失,使原本能保障页面滚动流畅的合成器没了用武之地。

针对这种情况,我们可以在事件监听时添加一个passive: true的选项,这会告诉浏览器,你仍需要监听事件,但合成器线程不必等待,可以直接创建合成帧。
document.body.addEventListener('touchstart', event => {
if (event.target === area) {
event.preventDefault()
}
}, { passive: true });查找事件的目标对象
当主线程接收到事件信息时,会第一时间执行命中检测(hit test)去查找事件的目标对象。命中检测是渲染流程中由渲染进程在绘制阶段中生成的一系列绘画记录(paint records),根据这个数据可以找到事件发生的目标对象。

浏览器对事件的优化
一般我们屏幕的帧率是每秒60帧,也就是60fps,但是某些事件触发的频率超过了这个数值,比如wheel,mousewheel,mousemove,pointermove,touchmove,这些连续性的事件一般每秒会触发60~120次,假如每一次触发事件都将事件发送到主线程处理,由于屏幕的刷新速率相对来说较低,这样使得主线程会触发过量的命中测试以及JS代码执行,容易导致不必要的性能消耗和页面闪烁问题。

出于优化目的,浏览器会合并这些连续的事件,延迟到下一帧渲染时执行,也就是requestAnimationFrame之前。

而对于非连续性的事件,如keydown,keyup,mousedown,mouseup,touchstart,touchend等,会直接派发给主线程去执行。
使用 getCoalescedEvents 获取帧内事件
事件合并可以帮助大多数 web 应用构建良好的用户体验。然而,如果你开发的是一款绘图类的应用,需要基于mousemove事件的坐标绘制线路。那么当你试图流畅的绘制一条线时,两点之间的一些坐标点也可能应为事件合并而丢失。针对这种情况,你可以使用getCoalescedEvents方法来获取所有坐标点的信息。

window.addEventListener('pointermove', event => {
const events = event.getCoalescedEvents();
for (let event of events) {
const x = event.pageX;
const y = event.pageY;
// 使用 x、y 坐标画线
}
});总结
当今主流浏览器基本都采用了多进程架构,它会根据不同的功能划分不同的进程,每个进程中又会根据不同的任务划分不同的线程。
当用户在浏览器地址栏中输入内容后:
- 浏览器进程会处理输入内容,如果内容是关键词搜索,则将关键词发送给搜索引擎,如果内容是URL,则请求URL
- 开始导航,即发起网络请求获取内容。此时tab标签页的图标变为加载状态,而网络线程会进行DNS寻址、建立TSL链接等操作
- 读取响应,根据响应头中的
Content-Type字段来确定响应体的媒体类型并做相应的工作 - 查找渲染进程,在第二步开始导航的同时,UI线程会尝试查找并启动渲染进程
- 提交导航,浏览器进程将 HTML 数据传递给渲染进程并通知它可以开始渲染页面,此时渲染进程中的主线程开始解析 HTML 构建DOM。
- 在构建DOM的过程中,如果遇到
link标签,则会异步加载并解析CSS - 如果遇到
script标签,则会暂停解析HTML,先加载JS脚本并执行(因为JS脚本有可能改变DOM结构) - 如果遇到
img、video等媒体标签,则会正常解析DOM,然后异步加载src资源。 - 由渲染进程中的主线程遍历DOM树和相关元素的样式计算,构建出布局树并进行布局、绘制、合成,最终渲染出一个完整的页面。
- 在构建DOM的过程中,如果遇到
当用户触发事件行为时,浏览器进程接收事件信息并传递给渲染进程,由渲染进程中的主线程进行命中检测,查找目标对象并执行监听事件的回调函数,完成页面的交互。