作为一名 Web Developer,有时候一天中大部分的工作都是围绕浏览器展开,了解浏览器渲染的过程和原理,对于提高部分代码质量和改善页面性能有很大的帮助,也因此成为面试中经常被问及的一个点。当然,浏览器渲染涉及的知识点其实也不少,本文将着重介绍浏览器渲染的过程和原理,其他一些重要的概念,例如布局/绘制、渲染阻塞、Chrome 浏览器开发者工具的使用等,本文点到即止,但会拆成单独的文章来说明。
不同浏览器底层实现的细节可能不同,但流程大体上类似,因此文本将主要围绕 Chrome Webkit 展开。
渲染引擎和 JavaScript 引擎
浏览器有两个关键的引擎:
- 渲染引擎(Rendering Engine),也被习惯称为浏览器内核,用于解析 HTML 和 CSS, 并将解析的结果显示出来;
- JavaScript 引擎(JavaScript Engine),用于解释执行 JavaScript 代码;
不同浏览器使用的引擎不一样,例如 Chrome 是 Webkit(渲染引擎)和 V8(JavaScript 引擎);而 Firefox 则是 Gecko 和 SpiderMonkey。两个引擎分别独立于两个线程,但是不会同时工作,具体可见本文的 “浏览器的多线程” 部分。
浏览器的多进程
Chrome 刚推出的时候便是采用了多进程架构,带来流畅稳定的使用体验后一时被人津津乐道,很快占领了浏览器的半边江山。后来 Firefox 甚至牺牲了长久以来建立的一套稳定强大的扩展机制,特地升级为多进程架构,可见多进程架构对现代浏览器的发展起着关键性作用。
Chrome 的主要进程如下:
- Browser Process(浏览器主进程):除了承载本身的功能,还负责处理一些不可见的底层操作(例如文件访问),以及负责协调管理其他进程;
- Renderer Process(渲染进程):每个 Tab 对应一个进程,负责该 Tab 下的页面渲染,脚本执行,事件处理等;
- Plugin Process(插件进程):浏览器安装的扩展和插件,在启用的时候都会创建一个与之对应的进程;
- Utility Process(工具进程):浏览器安装的扩展和插件,在启用的时候都会创建一个与之对应的进程;
- GPU Process(GPU 进程):负责处理 GPU 相关的任务;
多进程架构带来的好处:
- 稳定性:一个 Tab 或插件对应的进程 Crash 不会影响主进程和其他 Tab;
- 安全性:不同 Tab 和插件运行于不同进程,受限沙箱机制,不能随便获取数据;
多进程架构的不足:
- 新建进程的成本大,不同进程之间的内存不共享,需要占用更多内存资源。Chrome 支持根据设备的硬件配置来限制最大进程数,超过最大进程数会根据内部机制复用某个已存在的进程。
考虑到浏览器自身复杂的功能,基于多进程架构牺牲部分资源来保证稳定性和安全性,确实更加符合现代浏览器的发展趋势。
浏览器的多线程
Chrome 的主要线程如下:
- GUI 渲染线程:页面渲染的核心进程;负责解析 HTML,构建 DOM 树,CSSOM 树,渲染树等核心工作;
- JavaScript 引擎线程:每个 Tab 只有一个线程,负责执行 JavaScript 代码,用于接收和处理事件触发进程中的待处理任务队列中的一个任务;
- 事件触发线程:控制事件轮询,维护待处理任务队列,遵循 Event Loop 机制;由于 JavaScript 引擎始终只有一个线程,需要另外一个线程来维护待处理任务队列;
- 定时触发器线程:setInterval 和 setTimeout 定时器计数的所在线程;由于 JavaScript 引擎始终只有一个线程,需要另外一个线程来维护定时器计数;
- 异步请求线程:通过 XMLHttpRequest 建立请求会新建一个线程来处理,当检测到有状态变化并且有回调函数,会将回调函数放入事件触发线程中的待处理任务队列中等待执行;
- Composite 线程:通过 GPU 执行,真正将页面绘制并输出到屏幕上的,其实是这个线程,网上很多旧文章并没有提及,但涉及性能优化的话,Composite 这一步有很多可以做的;
JavaScript 引擎是单线程执行
JavaScript 前期诞生的需求是为了操作页面 DOM 来丰富页面交互,如果采用多线程执行的方式需要考虑操作冲突的问题并用锁来解决,但是单线程执行就不用考虑这些问题,实现也更加简单。当然也有不足,当 JavaScript 代码执行较复杂的运算时,GUI 渲染线程此时又挂起(与 JavaScript 引擎线程互斥),容易出现浏览器未响应的问题,无法充分利用现代 CPU 多核多线程的优势(现在可以用 Web Worker 来弥补)。
GUI 渲染线程和 JavaScript 引擎线程互斥
GUI 渲染线程和 JavaScript 引擎线程是互斥的,不会存在同时工作的情况。这么做的原因是为了避免两者同时执行时,JavaScript 代码的可操作 DOM 范围前后不一致且不可控的问题。
浏览器渲染的过程
浏览器渲染的是一个很复杂的过程,很难在一篇文章里做到面面俱到,但文本会尽量把完整的骨架呈现出来,更深的点并且有兴趣的话可以进一步阅读 Chrome 的相关文档。对于一些重要的概念和常见问题,作者也会在后期写新的文章说明。
浏览器渲染过程大致如下图:
主要有几个步骤:
- 解析 HTML 并构建 DOM Tree;
- Computed Style(样式计算);
- 构建 Layout Tree(布局树);
- 构建 Layer Tree;
- Paint(绘制);
- 分块;
- 栅格化;
- 合成和显示;
1. 解析 HTML 并构建 DOM Tree
这个过程分为以下 5 个步骤:
- Bytes(字节),从网络或者本地磁盘获取 HTML 字节数据;
- Characters(字符串),将字节数据转换成字符串;
- Tokens(令牌化),根据 W3C HTML5 标准将字符串转换成令牌;
- Nodes(对象化),将令牌转换成 Node 对象(有自己的属性和规则);
- DOM(DOM 树构建),根据关系将 Node 对象联系在一块组成 DOM Tree;
HTML5 定义了一部分规范要求浏览器具备一定的容错性,Webkit 在 HTML 解析器类的开头注释中对此做了很好的概括。
The parser parses tokenized input into the document, building up the document tree. If the document is well-formed, parsing it is straightforward. Unfortunately, we have to handle many HTML documents that are not well-formed, so the parser has to be tolerant about errors. We have to take care of at least the following error conditions:
- The element being added is explicitly forbidden inside some outer tag. In this case we should close all tags up to the one which forbids the element, and add it afterwards.
- We are not allowed to add the element directly. It could be that the person writing the document forgot some tag in between (or that the tag in between is optional). This could be the case with the following tags: HTML HEAD BODY TBODY TR TD LI (did I forget any?).
- We want to add a block element inside an inline element. Close all inline elements up to the next higher block element.
- If this doesn't help, close elements until we are allowed to add the element–or ignore the tag.
2. Computed Style(样式计算);
主要有 3 个步骤:
- 格式化样式表,将 CSS 字节数据转换为浏览器可以识别的结构 styleSheets,styleSheets 可以通过 window.styleSheets 获取;
- 标准化样式表,有些 CSS 属性的值可能有多种规范,例如 color 的值可以是 white,也可以是 #fff,这个步骤会统一成 rgb(255, 255, 255);
- 根据 styleSheets 计算每个 DOM 节点的具体样式,这个步骤遵循继承和层叠两个原则,每个 DOM 节点计算之后的样式可以通过 window.getComputedStyle(node) 获取;
继承:每个 DOM 节点会默认去继承父节点的样式,如果父节点中找不到,就会采用浏览器默认的样式。 层叠:因为继承的特性,一个 DOM 节点可能有多个 CSS 属性值的定义,因此需要会有一套规则去合并属性值,也就是我们经常遇到的调整 CSS 属性优先级的情况。
3. 构建 Layout Tree(布局树)
有了 DOM Tree 和 Computed Style,再通过布局计算来构建另一棵树 Layout Tree,用来完整说明页面 DOM 节点的具体位置和样式。
对于不可见的节点,例如 meta,header 以及指定了属性 display: none 的节点,都不会添加到 Layout Tree。Layout Tree 每个节点在 Chrome Webkit 代码中对应的类是 LayoutObject。
布局计算主要有以下几个步骤:
- 宽度/高度计算;
- margin 和 padding 计算;
- 构建盒模型数据结构;
- 位置计算;
- 浮动;
错误的使用 JavaScript 代码操作 DOM 节点的几何属性会导致不必要的布局计算,也就是我们经常说的重排。重排和重绘是我们经常会碰到的问题,后面会单独写一篇文章来说明。
附上一个很有意思且完整(滚动条部分也包括了)的盒模型图:
// ***** THE BOX MODEL *****
// The CSS box model is based on a series of nested boxes:
// http://www.w3.org/TR/CSS21/box.html
//
// |----------------------------------------------------|
// | margin-top |
// | |-----------------------------------------| |
// | | border-top | |
// | | |--------------------------|----| | |
// | | | | | | |
// | | | padding-top |####| | |
// | | | |####| | |
// | | | |----------------| |####| | |
// | | | | | | | | |
// | ML | BL | PL | content box | PR | SW | BR | MR |
// | | | | | | | | |
// | | | |----------------| | | | |
// | | | | | | |
// | | | padding-bottom | | | |
// | | |--------------------------|----| | |
// | | | ####| | | |
// | | | scrollbar height ####| SC | | |
// | | | ####| | | |
// | | |-------------------------------| | |
// | | border-bottom | |
// | |-----------------------------------------| |
// | margin-bottom |
// |----------------------------------------------------|
//
// BL = border-left
// BR = border-right
// ML = margin-left
// MR = margin-right
// PL = padding-left
// PR = padding-right
// SC = scroll corner (contains UI for resizing (see the 'resize' property)
// SW = scrollbar width
4. 构建 Layer Tree
Layout Tree 并不直接用于绘制页面,浏览器渲染引擎会遍历 Layout Tree 后拆分成多个 Layer,由多个 Layer 组成的 Layer Tree 才会直接用于绘制。
有 3 种 Layer 需要我们关注:
- PaintLayer(渲染层),对应 DOM 节点,即多个 DOM 节点有可能因为坐标空间相同而属于同一个 PaintLayer;
- GraphicsLayer(图形层),对应 PaintLayer,一个 PaintLayer 可以有自己的 GraphicsLayer,也可以跟它的父节点属于同一个 GraphicsLayer;
- CompositingLayer(合成层),特殊的 PaintLayer,CompositingLayer 有自己单独的 GraphicsLayer,可以通过 will-change 或 3D 变换的属性将 PaintLayer 提升为 CompositingLayer;
PaintLayer(渲染层)
PaintLayer 主要有 3 类:
- NormalPaintLayer;
- 根元素(HTML);
- 有明确的定位属性(relative、fixed、sticky、absolute);
- 透明的(opacity 小于 1);
- 有 CSS 滤镜(fliter);
- 有 CSS mask 属性;
- 有 CSS mix-blend-mode 属性(不为 normal);
- 有 CSS transform 属性(不为 none);
- backface-visibility 属性为 hidden;
- 有 CSS reflection 属性;
- 有 CSS column-count 属性(不为 auto)或者 有 CSS column-width 属性(不为 auto);
- 当前有对于 opacity、transform、fliter、backdrop-filter 应用动画;
- OverflowClipPaintLayer;
- overflow 不为 visible;
- NoPaintLayer;
- 不需要 paint 的 PaintLayer,比如一个没有视觉属性(背景、颜色、阴影等)的空 div
GraphicsLayer(图形层)
每个 GraphicsLayer 都有一个 GraphicsContext,GraphicsContext 会负责输出该层的位图,位图是存储在共享内存中,作为纹理上传到 GPU 去处理。
CompositingLayer(合成层)
为了改善页面渲染的性能,我们可以把 PaintLayer 提升为 CompositingLayer,提升的前提是 PaintLayer 必须为 SelfPaintingLayer(可以认为就是上面提到的 NormalPaintLayer)。
将 PaintLayer 提升为 CompositingLayer 有以下 3 种方式:
- 直接原因
- 本身就有硬件加速功能的元素,例如 iframe 和 video;
- 3D 或硬件加速的 2D Canvas 元素;
- 浏览器插件,例如 flash;
- backface-visibility 属性值为 hidden;
- 有 3D 转换的 transform;
- 对 opacity、transform、fliter、backdropfilter 应用了 animation 或者 transition;
- will-change 设置为 opacity、transform、top、left、bottom、right(其中 top、left 等需要设置明确的定位属性,如 relative 等);
- 后代元素导致
- 有合成层后代同时本身有 transform、opactiy(小于 1)、mask、fliter、reflection 属性;
- 有合成层后代同时本身 overflow 不为 visible(如果本身是因为明确的定位因素产生的 SelfPaintingLayer,则需要 z-index 不为 auto);
- 有合成层后代同时本身 fixed 定位;
- 有 3D transfrom 的合成层后代同时本身有 preserves-3d 属性;
- 有 3D transfrom 的合成层后代同时本身有 perspective 属性;
- Overlap 重叠导致
- 重叠或者说部分重叠在一个 CompositingLayer 之上;
- filter 效果同 CompositingLayer 重叠;
- transform 变换后同 CompositingLayer 重叠;
- overflow scroll 情况下同 CompositingLayer 重叠;
- 假设重叠在一个 CompositingLayer 之上(assumedOverlap);
- 层爆炸:每个 Layer 都会占用一定的内存资源,由于重叠或者错误的使用方式可能导致有大量的 CompositingLayer 生成,让页面崩溃。
- 层压缩(Layer Squashing):如果多个 PaintLayer 同一个 CompositingLayer 重叠时,这些 PaintLayer 会被压缩到一个 GraphicsLayer,可以一定程度避免层爆炸出现,但是并非万能;
5. Paint(绘制)
这步开始将一层一层绘制 Layer Tree,一个 Layer 的绘制会被拆分成许多个简单的绘制指令,然后按顺序将这些指令组成一个绘制列表。
注意,这步虽然叫 Paint,但实际上还不会绘制实际内容到屏幕上,仅仅是生成绘制列表,即告诉要绘制什么东西,但是还没开始绘制。绘制列表生成之后,渲染进程的主线程(GUI 渲染线程)会给 Composite 线程发送消息,把绘制列表给 Composite 线程开始绘制页面。
6. 拆分图块(tile)
Composite 线程接收到绘制列表后就会开始绘制每个 Layer 的工作。这里有一个分块的操作,会将 Layer 拆分成许多个小的图块。
拆分图块的目的是为了优先绘制首屏范围覆盖的内容,而不需要等一个 Layer 绘制完成才显示。同时,Chrome Webkit 考虑到首屏的内容依然可能非常复杂导致绘制的时间较长,为了改善用户体验,绘制图块时会先显示低分辨率的图片,当图块绘制完成时,再把低分辨率的图片绘制成完整的。
7. 栅格化
栅格化会将图块(tile)转换成位图(bitmap),如果开启 GPU 硬件加速,则转换过程在 GPU 进程中完成,生成的位图会缓存在 GPU 的内存中;如果没有开启 GPU 硬件加速,则由渲染进程完成,生成的位图缓存在共享内存中。
- 图块是栅格化执行的最小单位;
- 渲染进程中会专门维护一个栅格化线程池,专门用于把图块转换成位图的操作;
- 浏览器 viewport 范围内的图块会被优先提交给栅格化线程池转化成位图;
8. 合成和显示
在上一步的栅格化完成之后,Composite 线程会发送消息通知浏览器主进程从内存中获取位图数据,将位图绘制到屏幕上。
参考
- GPU Accelerated Compositing in Chrome
- Inside look at modern web browser (part 1)
- 无线性能优化:Composite
- 史上最全!图解浏览器的工作原理
- 浏览器的工作原理:新式网络浏览器幕后揭秘
- Accelerated Rendering in Chrome
- 写给女友的秘籍-浏览器工作原理(渲染流程)篇
本文链接:https://blog.wardchan.com/posts/understand-browser-rendering.html,参与评论 »
--EOF--
发表于 2020-07-18 11:26:18,并被添加「浏览器渲染」标签,最后修改于 2020-10-17 10:18:48。查看本文 Markdown 版本 »
Comments