开启掘金成长之旅!这是我参与「掘金日新计划 · 12 月更文挑战」的第19天,点击查看活动详情
在开始介绍渲染流水线之前,我们需要先介绍一下 Chromium 的浏览器架构与 Chromium 的进程模型作为前置知识。
一、两个公式
公式 1:浏览器 = 浏览器内核 + 服务
-
Safari = WebKit + 其他组件、库、服务
-
Chrome = Chromium + Google 服务集成
-
Microsoft Edge (Chromium) = Chromium + Microsoft 服务集成
-
Yandex Browser = Chromium + Yandex 服务集成
-
360 安全浏览器 = Trident + Chromium + 360 服务集成
-
Chromium = Blink + V8 + 其他组件、库、服务
公式 2:内核 = 渲染引擎 + JavaScript 引擎 + 其他
Browser
Rendering Engine
JavaScript Engine
Internet Explorer
Trident (MSHTML)
JScript/Chakra
Microsoft Edge
EdgeHTML → Blink
Chakra → V8
Firefox
Gecko
SpiderMonkey
Safari
KHTML → WebKit
JavaScriptCore
Chrome
WebKit → Blink
V8
Opera
Presto → WebKit → Blink
Carakan → V8
这里我们可以发现除了 Firefox 和已经死去的 IE,市面上大部分浏览器都朝着 Blink + V8 或是 WebKit + JavaScriptCore 的路线进行演变。
二、渲染引擎
负责解析 HTML, CSS, JavaScript,渲染页面。
以 Firexfox 举例,有以下工作组:
1.Document parser (handles HTML and XML)
2.Layout engine with content model
3.Style system (handles CSS, etc.)
4.JavaScript runtime (SpiderMonkey)
5.Image library
6.Networking library (Necko)
7.Platform-specific graphics rendering and widget sets for Win32, X, and Mac
8.User preferences library
9.Mozilla Plug-in API (NPAPI) to support the Navigator plug-in interface
10.Open Java Interface (OJI), with Sun Java 1.2 JVM
11.RDF back end
12.Font library
13.Security library (NSS)
接下来,我们看看 WebKit 的发展历程。
Apple 2001 年基于 KHTML 开发了 WebKit 作为 Safari 的内核,之后 Google 在 2008 年时基于 WebKit 自研 Chromium,那时候的 Chrome 渲染引擎采用的也是 Webkit。2010 年时,Apple 升级重构了 WebKit,其就是如今 WKWebView 与 Safari 的渲染引擎 WebKit2。2013 年时,Google 基于 WebKit 开发了自己的渲染引擎—— Blink,其作为如今 Chromium 的渲染引擎。因为开源协议的关系,我们如今看 Blink 源码依然能看到很多 Apple 和 WebKit 的影子。
WebKit 的演变路线大致历程如下图所示:
通过 Web Platform Tests 的测试报告可见 Chromium 渲染引擎的兼容性也是极好的:
三、JavaScript 引擎
JavaScript 引擎在浏览器中通常作为渲染引擎内置的一个模块,但同时它的独立性非常好,也可以作为独立的引擎移植到其他地方使用。
这里列举几个业内有名的 JavaScript 引擎:
1.SpiderMonkey: Mozilla 的 JavaScript 引擎,使用 C/C++ 编写,作为 Firefox 的 JavaScript 引擎。
2.Rhino: Mozilla 的开源 JavaScript 引擎,使用 Java 编写。
3.Nashorn: Oracle Java Development Kit (JDK) 8 开始内置的 JavaScript 引擎,使用 Java 编写。
4.JavaScriptCore: WebKit 内置的 JavaScript 引擎,其作为系统提供给开发者使用,iOS 移动端应用可以直接零增量引入 JavaScriptCore(但这种场景下无法开启 JIT)。
5.ChakraCore: Microsoft 的开源 JavaScript 引擎,而如今已全面使用 Chromium 作为 Edge,因此除了 Edge iOS 移动端以外(Chromium iOS 端使用 JavaScriptCore 作为 JavaScript 引擎),其他端的 Edge 使用的都是 V8 引擎。
6.V8: Google 的开源 JavaScript 引擎,使用 C++ 编写,作为 Chromium(或者更进一步可以说 Blink)的内置 JavaScript 引擎,同时也是 Android 系统 WebView 的内置引擎(因为 Android WebView 也是 Chromium 嘛,笑)。性能优异,开启 JIT 之后的性能吊打一众引擎。此外,ES 语法兼容性表现也比较优秀(可见后文表格)。
7.JerryScript: Samsung 开源的 JavaScript 引擎,被 IoT.js 使用。
8.Hermes: Facebook 的开源 JavaScript 引擎,为 React Native 等 Hybrid UI 系统打造的引擎。支持直接加载字节码,从而使得 JS 加载时间缩短,让 TTI 得到优化。此外引擎还对字节码做过优化,且支持增量加载,对中低端机更友好。但是其设计为胶水语言解释器而存在,故不支持 JIT。(移动端 JS 引擎会限制 JIT 的使用,因为开 JIT 之后预热时间会变得很长,从而影响页面首屏时间;此外也会增加包体积和内存占用。)
9.QuickJS: 由 FFmpeg 作者 Fabrice Bellard 开发,体积极小(210 KB),且兼容性良好。直接生成字节码,且支持引入 C 原生模块,性能优异。在单核机器上有着 300 μs 极低的启动时间,内存占用也极低,使用引用计数,内存管理优秀。QuickJS 非常适用于 Hybrid 架构、游戏脚本系统或其他嵌入式系统。
各引擎性能表现如下图所示:
ECMAScript 标准支持情况:
Chromium 进程模型
Chromium 有 5 类进程:
1.Browser Process:1 个
2.Utility Process:1 个
3.Viz Process:1 个
4.Plugin Process:多个
5.Render Process:多个
抛开 Chrome 扩展的 Plugin Process,和渲染强相关的有 Browser Process、Render Process、Viz Process。接下来,我们重点看看这 3 类进程。
一、Render Process
-
数量:多个
-
职责:负责单个 Tab 内单个站点(注意跨站点 iframe 的情况)的渲染、动画、滚动、Input 事件等。
-
线程:
1.Main thread x 1
2.Compositor thread x 1
3.Raster thread x 1
4.worker thread x N
Render Process 负责的区域是 WebContent:
-
Main thread
职责:
-
执行 JavaScript
-
Event Loop
-
Document 生命周期
-
Hit-testing
-
事件调度
-
HTML、CSS 等数据格式的解析
-
Compositor Thread
职责:
-
Input Handler & Hit Tester
-
Web Content 中的滚动与动画
-
计算 Web Content 的最优分层
-
协调图片解码、绘制、光栅化任务(helpers)
其中,Compositor thread helpers 的数目取决于 CPU 核心数。
二、Browser Process
-
数量:1 个
-
职责:负责 Browser UI (不包含 WebContent 的 UI)的全部能力,包括渲染、动画、路由、Input 事件等。
-
线程:
Render & Compositing Thread
Render & Compositing Thread Helpers
三、Viz Process
-
数量:1 个
-
职责:接受 Render Process 和 Browser Process 产生的 viz::CompositorFrame,并将其合成 (Aggregate),最后使用 GPU 将合成结果上屏 (Display)。
-
线程:
GPU main thread
Display Compositor Thread
四、Chromium 的进程模式
-
Process-per-site-instance:老版本的默认策略,如果从一个页面打开了另一个新页面,而新页面和当前页面属于同一站点(根域名与协议相同)的话,那么这两个页面会共用一个 Render Process。
-
Process-per-site
-
Process-per-tab:如今版本的默认策略,每个 Tab 起一个 Render Process。但注意站点内部的跨站 iframe 也会启动一个新的 Render Process。可看下文 Example。
-
Single Process:单进程模式,启动参数可控,用于 Debug。
示例:
假设现在有 3 个 Tab,分别打开了 foo.com,bar.com,baz.com 三个站点,其中 bar.com、baz.com 不涉及 iframe;但 foo.com 涉及,它的代码如下所示:
<html> <iframe id=one src="foo.com/other-url"></iframe> <iframe id=two src="bar.com"></iframe></html>
那么按照 Process-per-tab 模式,最终的进程模型如下图所示:
Chromium 渲染流水线
至今前置知识已介绍完毕,开启本文的核心部分 —— Chromium Rendering Pipeline。
所谓渲染流水线,就是从接受网络的字节码开始,一步步处理这些字节码把它们转变成屏幕上像素的过程。经过梳理之后,包括以下 13 个流程:
1.Parsing
2.Style
3.Layout
4.Pre-paint
5.Paint
6.Commit
7.Compositing
8.Tiling
9.Raster
10.Activate
11.Draw
12.Aggregate
13.Display
整理了一下各自流程所在的模块与进程线程,绘制的最终流水线如下图所示:
下文,我们一步步来看。
注:本文属于 Overview,所以力求简洁、不贴源码,但是会把设计到源码的部分打上源码链接,读者们可以自己索引阅读。同时,有些环节我撰写了更详细的流程分析文章,会贴在对应章节的开头处,感兴趣的读者可以点进去详细阅读。
一、Parsing
本节推荐阅读该系列的文章《Chromium Rendering Pipeline - Parsing》以深入了解 Parsing。
模块:blink
进程:Render Process
线程:Main thread
职责:解析 Browser Process 网络线程传过来的 bytes,经过解析处理,生成 DOM Tree
输入:bytes
输出:DOM Tree
这个环节设计的数据流为:bytes → characters → token → nodes → object model (DOM Tree)
我们把数据流的每次扭转进行梳理,得到以下 5 个环节:
1.Loading:Blink 从网络线程接收 bytes
2.Conversion: HTMLParser 将 bytes 转为 characters
3.Tokenizing: 将 characters 转为 W3C 标准的 token
4.Lexing: 通过词法分析将 token 转为 Element 对象
5.DOM construction: 使用构建好的 Element 对象构建 DOM Tree
-
Loading
职责:Blink 从网络线程接收 bytes。
流程:
1.Browser process 下载网页内容
2.传给 Render Process 的 Content 模块
3.blink::DocumentLoader
4.blink::HTMLDocumentParser
-
Conversion
职责:将 bytes 解析为 characters。
核心堆栈:
#0 0x00000002d2380488 in blink::HTMLDocumentParser::Append(WTF::String const&amp;) at /Users/airing/Files/code/chromium/src/third_party/blink/renderer/core/html/parser/html_document_parser.cc:1037#1 0x00000002cfec278c in blink::DecodedDataDocumentParser::UpdateDocument(WTF::String&amp;) at /Users/airing/Files/code/chromium/src/third_party/blink/renderer/core/dom/decoded_data_document_parser.cc:98#2 0x00000002cfec268c in blink::DecodedDataDocumentParser::AppendBytes(char const*, unsigned long) at /Users/airing/Files/code/chromium/src/third_party/blink/renderer/core/dom/decoded_data_document_parser.cc:71#3 0x00000002d2382778 in blink::HTMLDocumentParser::AppendBytes(char const*, unsigned long) at /Users/airing/Files/code/chromium/src/third_party/blink/renderer/core/html/parser/html_document_parser.cc:1351
-
Tokenizing
职责:将 characters 解析为 token。
核心函数:
-
HTMLDocumentParser::Append
-
HTMLTokenizer::NextToken
需要注意的是,这一步中如果解析到 link、script、img 标签时会继续发起网络请求;同时解析到 script 时,需要先执行完解析到的 JavaScript,才会继续往后解析 HTML。因为 JavaScript 可能会改变 DOM 树的结构(如 document.write() 等),所以需要先等待它执行完。
-
Lexing
职责:将 token 解析为 Element。
核心函数:
HTMLConstructionSite::CreateElement
注意这一步在处理的过程中,就会使用栈结构存储 Node (HTML Tag),以便后续构造 DOM Tree —— 例如对于 HTMLToken::StartTag 类型的 Token,就会调用 ProcessStartTag 执行一个压栈操作,而对于HTMLToken::EndTag 类型的 Token,就会调用 ProcessEndTag 执行一个出栈操作。
如针对如下所示的 DOM Tree:
<div> <p> <div></div> </p> <span></span></div>
各 Node 压榨与出栈流程如下:
-
DOM construction
职责:将 Element 实例化为 DOM Tree。
最终 DOM Tree 的数据结构可以断点从 blink::TreeScope 中预览:
我们可以使用 DevTools 查看页面的 Parsing 流程:
但是这个火焰图看不到 C++ 侧的栈调用情况。如果想深入查看内核侧的堆栈情况, 可以使用 Perfetto 进行页面录制与分析,它不仅能看到 C++ 侧的堆栈情况,还能分析每个调用所属的线程,以及跨进程通信时也会连线标出发出通信与接收到通信的函数调用。