浏览器底层原理
浏览器架构
现代浏览器都采用多进程的架构设计,主要包括以下进程:
- 浏览器进程(主进程)
- 渲染进程(多个)
- 主线程(main thread):Blink渲染引擎,负责页面的渲染(HTML解析、CSS解析、Layout、Paint);Blink内置了V8引擎,负责JavaScript的解释执行。
- 合成器线程(impl thread ):在早期的Chrome设计中并不存在,后续引入合成器线程主要目的是将布局树拆分为多个图层Layer进行独立的光栅化和渲染,最终再重新合成为一张位图。
- GPU进程
- 内部通过2D图形库Skia间接调用OpenGL接口来实现画面的绘制。
- 网络进程
- 负责网络通信等功能。
- 插件进程(多个)
HTML和Canvas的渲染,本质都是在主线程产出绘制指令交给GPU进程的Skia来间接OpenGL绘制,这也是为什么他们都能享受到GPU提供的硬件加速能力(如借助GPU执行Shader来快速地实现类似高斯模糊的效果,如果在CPU主线程实现则是巨大的开销 )
而WebGL则是允许我们直接和GPU进行交互,少去了中间层层封装引入的开销,也允许我们更好的定制化功能。
浏览器渲染流程
- 构建DOM树(Parse HTML)
- 通过HTML解析器实现字符流 -> token流 -> 抽象语法树(即DOM树)
- 样式计算(Recalculate style)
- 通过CSS解析器生成styleSheets,也被称为CSSOM,可以通过
window.styleSheets
访问。 - 属性值标准化,把类似
color: red
等非标准值转化为color: rgb(255, 0, 0)
。 - 计算出每个DOM节点的最终样式并存在内部的一个被称作ComputedStyle的巨大Map中,可以通过
window.getComputedStyle
访问特定DOM节点的值。
- 通过CSS解析器生成styleSheets,也被称为CSSOM,可以通过
- 布局(Layout)
- 我们已经构建了DOM树和所有节点的样式信息,那么就可以生成一个布局树(有的地方也称作渲染树),这一步还会去掉原本DOM树上不可见的节点(比如
<head />
标签或应用了如{ display: none }
样式的节点) - 使用复杂的算法计算出每个节点的绝对坐标信息(x、y、width、height)
- 我们已经构建了DOM树和所有节点的样式信息,那么就可以生成一个布局树(有的地方也称作渲染树),这一步还会去掉原本DOM树上不可见的节点(比如
- 分层(Layerise)
- 分层是一个性能优化手段,在早期的Chrome架构实现其实是不存在分层这一步骤的。
- 简单来说的话,分层可以把一颗完整的布局树拆分成多棵子树(Layers),后续再独立光栅化来绘制,最终重新合成为一张完整的位图。这么做的一个好处是避免局部状态更新触发全局的渲染。
- 怎样的节点会被提升到一个独立的渲 染层当中:(TODO)
- 预先绘制(Pre Paint)
- 涉及到属性树的生成,暂且不提。
- 绘制(Paint)
- 这一步的绘制并不是真的把内容绘制到屏幕上。这里说的绘制的本质是对布局树的解释执行,对于每一个待绘制的节点都能得到一组绘制指令,这个绘制指令会在后续的流程中用来实现真正的画面绘制。
- 提交(Commit)
- 主线程Paint生成的绘制指定提交给合成器线程。这一步之前的操作都发生在主线程,这一步之后的操作主要发生在合成器线程,光栅化则是发生在GPU进程当中。
- 分块(tiling)
- 这也是一个性能优化手段。光栅化整个Layer是比较昂贵的开销,特别是我们不需要光栅化可视区域外的内容,因此我们可以把Layer切分成多个图块进行光栅化。
- 光栅化(raster)
- 光栅化即通过绘制指令生成位图的过程,也就是真正的绘制操作。在Chrome内部这是通过运行在GPU进程中的图形库Skia实现的,而Skia的底层其实是基于OpenGL的。通常光栅器的实现有基于CPU实现的软光栅器,比如使用线性扫描算法实现逐像素的填充;除此之外还有基于GPU实现的光栅器(即硬件加速),通过片段着色器VS实现并行的计算逻辑。
- 光栅化还包括图像的解码过程,如
<img src="./cat.png" />
- 合成
- 分块(tile)经过光栅化的产物得到了位图quads,然后通过合成器线程执行
draw quads
则会把这些位图合并成一张完整的位图写入帧缓冲当中,显卡读取后显示在显示器上被我们看到。
- 分块(tile)经过光栅化的产物得到了位图quads,然后通过合成器线程执行
补充:
上述流程是早先的Chrome渲染架构,和最新 版本的实现会有部分细节出入,但整个思想是一致的。最新版本Chromium已经把分层Layerise这一步骤放在绘制Paint这一步之后了,以及光栅化可能也会用一个新的worker线程来跑这些细节差异。
参考:
重排(Reflow)
当我们页面渲染完成后,即使我们什么都不操作显示器也会根据特定的频率读取帧缓冲的内容进行显示。为了让用户看到的内容发生变化,我们需要反复触发HTML渲染管线写入新的数据到帧缓冲当中。当然了,我们不需要重新走这个完整流程,而是可以复用之前创建的布局树这个数据源,比如只修改某个渲染层的某个节点的背景颜色,这样就不需要进行重新布局这样的复杂计算了。
但有的时候,比如我们需要新增DOM节点,这样会导致我们的布局信息发生变化,因此我们需要重新执行布局这一步骤。这也被称之为重排Reflow。布局涉及到复杂的坐标计算,是个比较花费性能的操作,频繁触发重排会影响整个页面的渲染体验。
哪些常见操作会触发重排:
- 添加或删除DOM元素
- 修改DOM元素的几何属性(x、y、width、height)
重绘(Repaint)
如果我们只是修改了某个DOM节点的背景颜色,那么我们的布局树只是某个属性发生了变化,不需要重新进行复杂的坐标计算。只是在绘制的步骤中需要解析成一个不同的绘制指令,整体的性能开销是比较小的。
哪些常见操作会触发重绘:
- 修改DOM节点的颜色、背景颜色等非几何信息
合成(Composite)
我们都知道,主线程中可用来渲染一帧的时间是宝贵的16ms。其中一部分时间会被用来执行JS,剩下的时间才是主线程的渲染阶段,渲染所需要花费的时间越长则越可能引起页面的卡顿。那么,我们有没有办法在修改DOM属性时不触发重排或者重绘,来优化页面性能呢?
答案是有的。我们可以把一些操作放到合成器线程中执行,比如CSS3的transform
与其在主线程中计算出新的绘制指令,我们可以在合成器线程中实现样式调整,从而缓解主线程的开销。除了transform
还有opacity
、filter
等属性也能享受到一样的性能提升,网上也称之为CSS硬件加速,我猜测可能是合成器线程又和渲染进程通信来借助了GPU的并行计算能力来实现的。
强制同步布局(forced synchronous layouts)
渲染一帧的顺序可以简化为上图。当我们通过JavaScript尝试访问某个DOM节点的几何信息时,实际上是通过上次渲染时的布局树拿到的几何信息。当我们先修改了DOM节点的样式后,浏览器会认为节点的几何信息也可能发生变更,因此当我们再去尝试读取节点的几何坐标信息时,浏览器会强制性重新计算样式并重新布局来获取到最新的几何信息。这样会带来高昂的性能成本。
// 先写后读。触发了强制同步布局,性能劣化
function logBoxHeight () {
box.classList.add('super-big');
console.log(box.offsetHeight);
}
一般来说我们先读后写就好,其实读取到的是上一帧渲染时布局树的几何信息也问题不大。下面的代码也是类似的,整个JS任务执行的过程中出现了多次先写后读,疯狂的重排导致性能劣化更加严重。
function resizeAllParagraphsToMatchBlockWidth () {
// 第一个循环中写入样式;第二个循环中读取样式
for (let i = 0; i < paragraphs.length; i++) {
paragraphs[i].style.width = `${box.offsetWidth}px`;
}
}
当触发强制同步布局时,我们可以在Chrome开发者工具性能面板中对应的样式计算中看到Recalculation forced的标识,以及对应的
TODO:
- 重排的范围
- 性能优化
- 渲染层提升
输入URL后发生了什么
经常遇到的问题,我们可以在浏览器渲染流程的基础上展开去聊,简单来说包括以下关键知识点(部分细节有省略):
-
网络通信过程
-
URL解析得到协议、域名、端口、路径
-
DNS域名解析
-
递归查找缓存。先依次尝试从浏览器缓存、操作系统缓存、Hosts文件、路由器缓存、本地DNS服务器(即本地网络中设置的DNS服务器地址)缓存,解析到域名对应的IP地址。
-
迭代DNS服务器。
- 向根DNS服务器查询顶级域DNS服务器的地址
- 向顶级域DNS服务器查询权威域名DNS服务器的地址
- 向权威域名DNS服务器查询目标域名对应的IP地址
-
-
三次握手建立TCP连接
- 客户端发送一个SYN=1,Seq=X的TCP包
- 服务端返回一个SYN=1,ACK=X+1,Seq=Y的TCP包
- 客户端发送一个ACK=Y+1,Seq=Y + 1的TCP包
-
(可选)如果是HTTPS(具体实现可见计算机网络章节)
- TLS握手,交换版本信息、加密算法、压缩算法、随机数(浏览器一个,客户端一个)。
- 服务端发送证书(包括公钥和CA私钥对该公钥的签名)给客户端,客户端使用CA公钥对签名进行验证。
- 使用服务器公钥生成对称密钥,用户后续的加密解密(实际的实现要复杂亿点点)
-
发送HTTP请求并接收响应。
- 查看浏览器内是否有资源的缓存
- 存在缓存,检查缓存是否过期
- 存在缓存,检查缓存是否过期
- 查看浏览器内是否有资源的缓存
-