相信各位前端同学,都碰到过这样一个面试题:浏览器自输入url开始,到页面展示的过程中,究竟发生了什么?
这个问题在掘金上已经有不少作者,发布了很完整的解答了。从解析 URL 、解析 DNS ,再到浏览器生成 Render Tree 并绘制页面,涵盖了网络层和应用层各方面的知识,属于一个比较开放的题目了。这篇文章将抛开网络协议层面,讲讲 Chrome 在这一过程中的工作。
Chrome的多进程架构
当你在使用 Chrome 时,打开系统进程列表,你会发现有很多 Google Chrome 的进程已经在运行中,
你可能会有点疑惑:为什么我只打开了一个Chrome应用,却有这么多个进程?
不同于普通的单进程应用,Chrome 是基于多进程架构设计而成,每个进程各司其职,它们之间通过 IPC 机制(Inter Process Communication)通信。
各进程介绍
- Browser: 控制应用程序的“chrome”部分,包括地址栏、书签、后退和前进按钮。还处理 Web 浏览器的不可见的特权部分,例如网络请求和文件访问。
- Renderer: 控制显示标签页内的任何内容,每个标签页,以及其中的iframe都会被分配一个Renderer进程。
- Plugin: 控制网站使用的任何插件,例如 flash。
- GPU: 处理独立于其他进程的 GPU 任务。它被拆分成一个单独的进程,因为 GPU 处理来自多个应用程序的请求并将它们绘制在同一个界面上。
- Extension: 扩展程序。
- Utility: 工具进程。
多进程架构的好处
Chrome利用多进程架构,保证工作的稳定性。假设你打开了3个标签页,每个标签页都由一个独立的渲染进程运行。如果其中一个标签页崩溃了,那么你可以关闭这个标签页,同时保持其他标签页正常运行。但如果所有标签页都运行在一个渲染进程上,那么一个标签页崩溃会连带所有的一起崩溃。
但更多的进程意味着分配更多的内存空间,为了节省内存,Chrome 限制了它可以启动的进程数。该限制取决于设备的内存和 CPU 能力,当 Chrome 达到限制时,它会把运行在同一网站的多个标签页的 Renderer Processes 合并为一个。
Chrome将同样的方法应用于 Browser Process ,将它的每个部分作为服务运行,从而可以轻松地拆分为不同的进程或聚合为一个进程。
导航阶段
Step 1:处理输入
用户在浏览器地址栏中输入内容,负责处理用户输入的是 Browser Process 的 UI thread,它需要决定将用户输入的内容导向搜索引擎,或是当做网站地址处理。
Step 2:建立网络连接
UI 线程通知网络线程,向对方站点发送网络请求,网络线程通过适当的协议,DNS 查找确定主机地址、建立 TCP 连接、通过 TLS 协商建立 HTTPS 连接。
Step 3:接收响应
接收到对方站点返回的响应,网络线程先检查状态码,如果是以4或5开头的错误码,则通知渲染器进程,将渲染错误页面。
如果是重定向的状态码,则请求新的URL地址。
如果是成功的状态码,则判断 Content-Type 响应头,如果是 HTML 格式,会对 HTML 做安全检测,确保其不属于危险网站,以及 CORB(Cross Origin Read Blocking)检测,把敏感的跨站数据剔除掉;如果是其他格式,例如pdf、zip等等,尽可能解析它并在浏览器中展示、播放,或者直接下载到本地。
Step 4:分配/创建一个渲染进程
网络线程的工作准备就绪之后,会先通知给 UI 线程,UI 线程再给这个网站分配一个渲染器进程。其实早在第二步中,UI 线程通知网络线程建立网络连接的时候,因为不清楚网络线程预期多久完成工作,为了缩短导航阶段耗费的时间,UI 线程就提前开始创建渲染器进程了。当网络线程完成工作后,渲染器进程就可以立马拿来用了。有一个例外就是,当请求被重定向后,这个提前创建好的渲染器进程就被浪费了,会被清除掉,再重新创建个新的进程。
Step 5:完成导航
现在数据和渲染器进程已经准备就绪,浏览器进程发送 IPC 到渲染器进程以提交导航,同时传递 HTML 数据。一旦浏览器进程接收到渲染器进程的确认信息,导航就完成了,并且向浏览器 history 插入一条新纪录,导航栏更新为新的站点地址。
如果引入Service Worker
Service worker是一个注册在指定源和路径下的事件驱动worker。它采用JavaScript控制关联的页面或者网站,拦截并修改访问和资源请求,细粒度地缓存资源。你可以完全控制应用在特定情形(最常见的情形是网络不可用)下的表现。
当导航发生时,网络线程会去 Service Worker 作用域中检查,当前请求的 URL 是否已在 Service Worker 注册,如果该 URL 已注册,则 UI 线程会查找渲染器进程以执行 Service Worker 代码。Service Worker 可能会从本地缓存中读取页面,从而无需发送网络请求。
渲染阶段
该阶段的工作主要由渲染器进程完成,渲染器进程的核心工作是将 HTML、CSS 和 JavaScript 转换为用户可以与之交互的网页。
渲染器进程又包括以下几个线程:
- 主线程处理大部分代码。
- Worker用于分担主线程的计算量,但无法访问DOM。
- 合成器线程和光栅线程用于高效流畅地渲染页面。
Step 1:解析文档
渲染器进程的主线程自上而下地解析 HTML 文档,生成一个名为 DOM 的树形结构。解析方式是由 HTML 规范决定的,同时在解析过程中为了保证容错率,HTML 规范也提供了更优雅的容错方案:An introduction to error handling and strange cases in the parser
.
一些外部的非阻塞资源,包括图片、CSS 文件等,主线程会在解析构建DOM的同时请求这些资源;但是对于<script>标签(特别是没有 async 或者 defer 属性)会阻塞渲染并停止HTML的解析。
预加载扫描器
为了加快文档解析速度,“预加载扫描器”会在主线程刚开始解析文档时就扫描所需要的高优先级资源,如 CSS、JavaScript 和 web 字体并报告给网络线程,网络线程开始下载它们。运气好的话,当主线程解析到当前节点时,对应的资源可能已经下载完成了。
<link rel="stylesheet" src="styles.css"/>
<script src="myscript.js" async></script>
<img src="myimage.jpg" alt="image description"/>
<script src="anotherscript.js" async></script>
script的加载方式
当解析到<script>标签的位置时,主线程会停止继续解析 HTML 文档,开始下载、执行脚本,直到脚本执行结束。换句话说,<script>标签的下载、执行会阻塞 HTML 文档的解析。现在可以通过设置async和defer属性,保证浏览器下载脚本的同时继续解析 HTML 文档:
async表示脚本下载完成后立即执行defer表示脚本下载完成并且文档解析完成后立即执行,执行完成后触发DOMContentLoaded事件- 但两者都不能在执行脚本的同时解析文档
rel="preload"
通过给<link>标签设置rel="preload"属性,可以使得资源尽早的得到加载并可用,且更不易阻塞页面的初步渲染,进而提升性能。用as属性可以声明它的资源类型,比方说script、style、audio、image等。
MDN对于这方面的解释:developer.mozilla.org/zh-CN/docs/…
Step 2:计算样式
主线程解析 CSS ,基于 CSS 选择器决定将哪种样式应用于哪个节点,计算每个 DOM 节点的样式,并与浏览器默认的 CSS 样式合并,生成 Style Rules。
Step 3:布局
这一步骤的目的是根据 DOM 结构和 Style Rules ,计算出节点的几何信息。
主线程遍历 DOM 树和 Style Rules,生成 Layout Tree,这个 Layout Tree 包含了节点的x、y轴坐标和盒子大小等信息。
Layout Tree 的结构和 DOM Tree 很像,但它只包括页面上可见的节点。例如,Layout Tree 不包括display: none的节点,但包括visibility: hidden的节点,这两者 DOM Tree 都包含。那有没有 Layout Tree 包含,但 DOM Tree 不包含的节点呢?有,那就是::after、::before这种伪元素。
确定页面的布局是一项具有挑战性的任务。即使是最简单的页面布局,比如从上到下的块流,也必须考虑字体有多大以及在哪里换行,因为这些会影响段落的大小和形状;这会影响下一段需要放在的位置。CSS 可以使元素浮动到一侧,屏蔽溢出项,并改变书写方向。可想而知,这个布局阶段任务艰巨。
Step 4:绘制
进入到绘制阶段,浏览器已经拥有了 DOM、Style Rules、Layout Tree,也就确定了节点的大小、形状、位置,现在需要根据以上数据确定绘制记录(Paint Records),即绘制它们的顺序。
如果是直接按照 HTML 标签顺序来绘制,那么对于某些设置了z-index属性的元素的安排很可能是错误的。
更新渲染流水线的成本很高
渲染流水线中最重要的一点是,在每一步都使用前一操作的结果来创建新数据。例如,脚本修改了 DOM 结构,新增了一个节点,渲染流水线被启动,为 Layout Tree 和 Paint Records 中受影响的部分重新生成。
把脚本拆分成小块执行
我们的大多数显示器每秒刷新屏幕 60 次 (60 fps),浏览器的刷新频率一般与其匹配,因为就算超出屏幕的刷新频率也没什么实际意义。两次刷新之间称为一帧,如果你给元素设置了动画,保证在每一帧之间移动元素,那么从视觉上来看这个动画就显得非常流畅。
但如果在这个过程中执行了一段耗时较长的脚本将主线程阻塞了(页面渲染和脚本都由主线程执行),那么时间轴上的动画就丢帧了,视觉上看会比较卡顿。
为了保证页面流畅运行,建议把 JavaScript 脚本拆分成小块,并使用requestAnimationFrame()让脚本运行在每一帧之间。
对于运算量特别大的一段脚本,尽量放在 Web Worker 中执行,避免抢占主线程资源。
Step 5:合成
浏览器获得了文档结构,每个元素的样式、几何形状以及绘制顺序,最后把以上信息转化为屏幕上的像素,这一过程称为光栅化(rasterizing)。
Chrome 最初版本的光栅化方式是这样的,先光栅化当前视口部分,即当前可见部分;在页面滚动时,移动光栅框架,光栅化更多的内容补足视口内缺失的部分。可想而知,这种方式会在页面滚动时造成比较差的用户体验。
后面 Chrome 实现了一种更复杂、体验更好的光栅化方式,叫做合成(compositing)。合成是一种将页面的各个部分分成多个层、单独光栅化它们,并在合成器线程中合成为一个页面的技术。如果发生滚动,因为图层已经被光栅化,它所要做的就是合成一个新的帧。动画可以通过移动图层并合成新帧以相同的方式实现。你可以在 F12 的 Layers 面板查看页面是如何分层的。
有一些特定的属性和元素可以实例化一个层,包括<video>和<canvas>,还有一些CSS属性例如opacity、transform、will-change。
合成的具体步骤
- 为了找出哪些元素需要在哪些层中,主线程遍历 Layout Tree 以创建/更新 Layer Tree,将该信息提供给合成器线程
- 合成器线程会将一个大的图层分割成较小的图块,交由光栅线程
- 光栅线程对小图块进行光栅化,光栅化后的信息称为绘制四边形(draw quads),并将其存储在 GPU 内存中
- 合成器线程收集绘制四边形并创建一个合成帧
- 通过 IPC 将合成帧发送给浏览器进程,浏览器进程再将它发送给 GPU 以显示在屏幕上
整个合成的过程还是挺绕的,搞出这么复杂的过程究竟为了什么?
合成的好处
首先是把大型图层碎片化,交由多个光栅线程并行计算,在页面仅有部分重新渲染的情况下及其有用,因为这样渲染器进程就可以只对需要重新渲染的图块做光栅化,其他不变的图块从 GPU 内存中取用。
其次,合成这个过程是独立于主线程的。上面有提到过渲染流水线,它运行在主线程并且更新成本比较高,将合成过程独立出来有利于浏览器获得更流畅的性能。现在给元素通过animation修改某些 CSS 属性,例如transform、opacity,是可以被 GPU 加速的,不需要经过渲染流水线,直接步入合成步骤。
总结
Chrome 线程间数据流:
这篇文章介绍了 Chrome 浏览器在页面渲染时的具体工作。其中大量参考了官方文档,为了简单清晰地讲明这个过程我简化了一些步骤。有些地方如果讲的不明白可以留言告诉我,或者直接去看原文档。