现代浏览器探秘(part1): 架构 https://mp.weixin.qq.com/s/1-IK04ZZRMZYCw7QVlUByQ
现代浏览器探秘(part2): 导航 https://mp.weixin.qq.com/s/6vWTWrtCcUCXHdQobzdhvA
渲染器进程的内部工作原理
这是关于浏览器内部工作原理系列的第 3 部分. 之前, 我们介绍了多进程架构 https://mp.weixin.qq.com/s/1-IK04ZZRMZYCw7QVlUByQ 和导航流程 https://mp.weixin.qq.com/s/1-IK04ZZRMZYCw7QVlUByQ . 在这篇文章中, 我们将看看渲染器进程内部发生了什么.
渲染进程涉及 web 性能的诸多方面. 由于渲染进程中发生了很多事情, 因此本文不能一一赘述. 如果你想深入挖掘, 可以在 Web 基础的性能部分找到更多内容.
渲染器进程处理 Web 内容
渲染器进程负责选项卡内发生的所有事情. 在渲染器进程中, 主线程处理你为用户编写的大部分代码. 如果你使用了 Web worker 或 a service worker, 有时 JavaScript 代码的一部分将由工作线程处理. 排版和栅格线程也在渲染器进程内运行, 以便高效, 流畅地呈现页面.
渲染器进程的核心工作是将 html,CSS 和 JavaScript 转换为用户可以与之交互的网页.
图 1: 渲染器进程内部有主线程, 工作线程, 排版线程和栅格线程
解析
构建 DOM
当渲染器进程收到导航的提交消息并开始接收 HTML 数据时, 主线程开始解析文本字符串 (HTML) 并将其转换为文档对象模型(DOM-Document Object Model ).
DOM 是页面在浏览器中的内部表示, 同时也是 Web 开发人员可以通过 JavaScript 与之交互的数据结构和 API.
HTML 标准 https://html.spec.whatwg.org/ 将 HTML 文档解析为 DOM. 你可能已经注意到, 将 HTML 提供给浏览器从不会引发错误. 例如, 缺少结束 </p > 标记是有效的 HTML. 像 Hi! <b>I'm <i>Chrome</b>!</i> 这样的错误标记(b 标签在 i 标签之前被关闭) 被看作是 Hi! <b>I'm <i>Chrome</i></b><i>!</i>. 这是因为 HTML 规范旨在优雅地处理这些错误. 如果你对如何完成这些工作感到好奇, 可以阅读 HTML 规范中的 "解析器中的错误处理和奇怪情况介绍" 部分.
子资源加载
网站通常使用图像, CSS 和 JavaScript 等外部资源. 这些文件需要从网络或缓存中加载. 主线程可以在解析构建 DOM 时会逐个请求它们, 但为了加快速度,"预加载扫描器" 也会同时运行. 如果 HTML 文档中存在 < img > 或 < link > 之类的内容, 则预加载扫描器会检查由 HTML 解析器生成的标记, 并在浏览器进程中向网络线程发送请求.
图 2: 主线程解析 HTML 并构建 DOM 树
JavaScript 可以阻止解析
当 HTML 解析器找到 < script > 标记时, 它会暂停解析 HTML 文档, 并且必须加载, 解析和执行 JavaScript 代码. 为什么要这样处理? 因为 JavaScript 可以使用像 document.write() 那样改变整个 DOM 结构的东西来改变文档的形状(HTML 规范中的解析模型概述有一个很好的示意图). 这就是 HTML 解析器在重新解析 HTML 文档之前必须等待 JavaScript 运行的原因. 如果你对 JavaScript 执行中发生的事情感到好奇, V8 团队的博客 https://mathiasbynens.be/notes/shapes-ics 对此进行了讨论.
提示浏览器如何加载资源
Web 开发人员可以通过多种方式向浏览器发送提示, 以便很好地加载资源. 如果你的 JavaScript 不使用 document.write(), 则可以向 < script > 标记添加 async 或 defer 属性. 然后, 浏览器异步加载和运行 JavaScript 代码, 不会阻止解析. 如果合适, 你也可以使用 JavaScript 模块. <link rel ="preload">是一种通知浏览器当前导航肯定需要这个资源的方法, 你希望尽快下载. 你可以在资源优先级找到更多信息.
样式表计算
拥有 DOM 不足以知道页面的外观, 因为我们可以在 CSS 中设置页面元素的样式. 主线程解析 CSS 并确定每个 DOM 节点的计算样式. 这是有关基于 CSS 选择器将哪种样式应用于每个元素的信息. 你可以在浏览器中开发者工具中的 computed 部分中看到此信息.
图 3: 主线程解析 CSS 以添加计算样式
即使你不提供任何 CSS, 每个 DOM 节点都具有计算样式. 比如 <h1 > 标签的显示要大于 < h2 > 标签, 同时为每个元素定义边距. 这是因为浏览器具有默认样式表. 如果你想知道 Chrome 的默认 CSS 是什么样的, 你可以在此处查看源代码.
布局
现在, 渲染器进程知道每个节点的文档和样式的结构, 但这还不足以呈现页面. 想象一下, 你正试图通过手机向朋友描述一幅画: "有一个大的红色圆圈和一个小的蓝色方块" 这并不能完全让你的朋友了解这幅画的外观.
图 4: 一个人站在一幅画, 通过电话线与另一个人联系
布局是查找元素几何的过程. 主线程遍历 DOM 并计算样式和创建布局树, 其中包含 x y 坐标和边界框大小等信息. 布局树可以是与 DOM 树类似的结构, 但它仅包含与页面上可见内容相关的信息. 如果 display:none, 则该元素不是布局树的一部分 (但是在布局树中包含 visibility:hidden 的元素). 类似地, 如果应用具有类似 p::before {content:"Hi!} 之类的内容的伪类, 则它将包含在布局树中, 即使它不在 DOM 中.
图 5: 主线程通过 DOM 树生成计算样式和布局树
确定页面布局是一项具有挑战性的任务. 即使是最简单的页面布局, 如从上到下的块流, 也必须考虑字体的大小以及在哪里划分它们, 因为它们会影响段落的大小和形状; 然后影响下一段所需的位置.
图 6: 由于换行符而移动的段落的框布局
CSS 可以使元素浮动到一侧, 掩盖溢出项, 并更改写入方向. 你可以想象, 这个布局阶段是一项艰巨的任务. 在 Chrome 项目中, 有一个完整的工程师团队负责布局. 如果你想看到他们工作的细节, 看看这些会议记录 https://www.youtube.com/watch?v=Y5Xa4H2wtVA 非常有意思.
绘制
拥有了 DOM, 样式和布局仍然不足以呈现页面. 假设你正在尝试重现一幅画. 你不仅需知道元素的大小, 形状和位置, 还需要判断绘制它们的顺序.
图 7: 一个在画布前拿着画笔的人, 正在思考是应该先画圆圈还是矩形
例如: 可以为某些元素设置 z-index, 在这种情况下, 按 HTML 中编写的元素顺序绘制将导致不正确的呈现.
图 8: 页面元素按 HTML 标记的顺序出现, 会导致错误的渲染图像, 因为没有考虑 z-index
在此绘制步骤中, 主线程遍历布局树以创建绘制记录. 绘制记录是绘制过程的一个注释, 如 "背景优先, 然后是文本, 最后是矩形". 如果你使用 JavaScript 绘制了 < canvas > 元素, 那么可能对此过程很熟悉.
图 9: 主线程遍历布局树并生成绘制记录
更新渲染通道的成本很高
在渲染通道中最重要的一件事就是在每个步骤中, 前一个操作的结果被用于创建新数据. 例如: 如果布局树中的某些内容发生更改, 则需要为文档的受影响部分重新生成绘制顺序.
图 10:DOM + Style, 布局和绘制树的生成顺序
如果要为元素设置动画, 则浏览器必须在每个帧之间运行这些操作. 我们的大多数显示器每秒刷新屏幕 60 次(60 fps); 当你在每一帧移动屏幕时, 动画对人眼来说会很平滑. 但是如果动画错过了其中的帧, 则页面将发生闪烁.
图 11: 时间轴上的动画帧
即使你的渲染操作能够跟上屏幕刷新, 这些计算也是在主线程上运行的, 这意味着当你的应用运行 JavaScript 时它可能会被阻止.
图 12: 时间轴上的动画帧, 但 JavaScript 阻止了一帧
你可以将 JavaScript 操作划分为小块, 并使用 requestAnimationFrame() 安排在每个帧上运行. 有关此主题的更多信息, 请参阅优化 JavaScript 执行. 你也可以在 Web Workers 中运行 JavaScript 来避免阻塞主线程 https://www.youtube.com/watch?v=X57mh8tKkgE .
图 13: 在动画帧的时间轴上运行的较小的 JavaScript 块
合成
你会如何绘制一个页面?
现在浏览器知道文档的结构, 每个元素的样式, 页面的几何形状和绘制顺序, 它是如何绘制页面的? 将此信息转换为屏幕上的像素称为光栅化.
图 14: 简单光栅化过程
也许处理这种情况的一种简单的方法是在视口 (viewport) 内部使用栅格部件. 如果用户滚动页面, 则移动光栅帧, 并通过更多光栅填充缺少的部分. 这就是 Chrome 首次发布时处理栅格化的方式. 但是, 现代浏览器运行一个称为合成的更复杂的过程.
什么是合成
合成是一种将页面的各个部分分层, 分别栅格化, 并在一个被称为合成器线程的独立线程中合成为页面的技术. 如果发生滚动, 由于图层已经被栅格化, 所以它所要做的就是合成一个新帧. 通过移动图层和合成新帧, 可以用相同的方式实现动画.
图 15: 合成过程的示意动画
你可以使用浏览器开发者工具的 "layout" 面板中查看你的网站如何划分为多个图层.
分为几层
为了找出哪些元素需要放在哪些层中, 主线程通过遍历布局树以创建层树 (此部分在 DevTools 性能面板中称为 "Update Layer Tree"). 如果页面某些应该是单独图层(如滑入式侧面菜单) 的部分但是没有分配到图层, 那么你可以使用 CSS 中的 will-change 属性提示浏览器.
图 16: 主线程生通过遍历布局树来成层树
也许你想要为每个元素提供图层, 但是过多的图层进行合成可能会导致比每帧光栅化页面的小部分更慢的操作, 因此测量应用程序的渲染性能至关重要. 有关主题的更多信息, 请参阅 Stick to Compositor-Only Properties and Manage Layer Count
光栅和复合关闭主线程
一旦创建了层树并确定了绘制顺序, 主线程就会将该信息提交给合成器线程. 合成器线程然后栅格化每个图层. 一个图层可能像页面的整个长度一样大, 因此合成器线程会将它们分成图块, 并将每个图块发送到光栅线程. 栅格线程栅格化每一个 tile 并将它们存储在 GPU 内存中.
图 17: 栅格线程创建 tile 位图并发送到 GPU
合成器线程可以优先考虑不同的 aster 线程, 以便视口 (或附近) 内的事物可以先被光栅化. 图层还具有多个不同分辨率的倾斜度, 可以处理放大操作等内容.
一旦 tile 被光栅化, 合成器线程会收集称为绘制四边形 (draw quads ) 的 tile 信息来创建合成器帧(compositor frame).
绘制四边形 | 包含信息,例如图块在内存中的位置以及在考虑页面合成的情况下绘制图块的页面中的位置。 |
---|---|
合成器帧 | 表示页面帧的绘制四边形的集合。 |
然后通过 IPC 将合成器帧提交给浏览器进程. 这时可以从 UI 线程添加另一个合成器帧以用于浏览器 UI 更改, 或者从其他渲染器进程添加扩充数据. 这些合成器帧被发送到 GPU 用来在屏幕上显示. 如果发生滚动事件, 合成器线程会创建另一个合成器帧并发送到 GPU.
图 18: 合成器线程创建合成帧. 帧先被发送到浏览器进程, 然后再发送到 GPU
合成的好处是它可以在不涉及主线程的情况下完成. 合成线程不需要等待样式计算或 JavaScript 执行. 这就是合成动画是平滑性能的最佳选择的原因. 如果需要再次计算布局或绘图, 则必须涉及主线程.
总结
在本文中, 我们研究了从解析到合成的渲染通道.
在本系列的下一篇文章中, 我们将更详细地介绍合成器线程, 并了解当用户进行鼠标移动和单击等操作时会发生什么.
原文首发于京程一灯公众号: jingchengyideng
来源: https://juejin.im/post/5c3d870d6fb9a049e12a764c