应该有很多前端开发人员都思考过这么一个问题:从输入 URL 到页面加载完成,中间都做发生了什么?
这个问题涉及的面非常广,每个涉及的点又很深入。从触屏/键盘如何到 CPU?CPU 如何到系统内核?如何从操作系统 GUI 到浏览器?浏览器如何向网卡发送数据?数据如何从本机网卡发送到服务器?服务器接收数据后如何处理?服务器返回数据后浏览器如何处理?浏览器如何将页面展现出来?等等等等,每一个过程都包含了大量且深入的知识体系,很难一以贯通。
但作为前端开发人员,浏览器是我们的主要工具之一,浏览器是如何将页面展现出来的则是我们更关注的部分。因此本文就从一些基本流程来简要描述这个过程。
从上面这个图中可以发现,虽然使用的 Javascript 是单线程语言,但浏览器本身是多进程的。
但是这并不是从一而终的状态,而是浏览器从早期的单进程结构逐渐发展发展而来。现代浏览器各进程根据负责的功能不同,分为浏览器进程、渲染器进程、网络进程、GPU 进程、缓存进程、插件进程等等。为了更好的理解浏览器页面的呈现过程,我们以最主流的 Chrome 为例,简要的说明一下各个进程的大致职能:
- 浏览器进程: 负责控制界面展示、用户交互、子进程管理等功能。
- 渲染器进程: 负责将 HTML\CSS\JS 转化为用户可以与之交互的网页。渲染引擎如 webkit、blink 和 JS 引擎 V8 都是在该进程之中。
- GPU 进程: GPU 进程原本是为了实现 3D CSS 效果,但是随后页面、Chrome 的 UI 都采用 GPU 来绘制,是 GPU 成为了重要需求,于是增加了 GPU 进程。
- 网络进程: 负责页面的网络资源加载。
- 插件进程:负责插件的运行,由于插件可能崩溃,需要插件进程其他进程隔离。注意,插件并不是我们常用的浏览器拓展,plugin 和 extension 是不同的。
- 缓存进程:负责处理页面资源缓存和清理。
我们本次需要重点关注的是渲染器进程。
回到问题,当我们在浏览器地址栏输入地址时,浏览器进程的 UI 线程会捕捉输入内容,如果访问的是网址,那么 UI 线程会启动一个网络线程来构建请求(这里我们暂时不考虑缓存,缓存又是另外一个故事了),它请求 DNS 进行域名解析然后连接服务器获取数据。如果我们输入的是关键词,浏览器则使用默认配置的搜索引擎来搜索。在获取到数据并通过安全校验后,网络线程会通知 UI 线程数据准备完毕,然后UI线程创建一个渲染器进程来进行页面的渲染,并将数据通过 IPC 管道传递给渲染器进程。
至此,我们的主角渲染器进程登场!
解析 HTML
渲染器进程接收到的是一个 HTML,需要把 HTML 解析成 DOM 数据结构。因为直接的 HTML 字节流是无法被渲染引擎所理解的,必须转化成可以理解的内部结构。这个内部结构就是 DOM,DOM 提供了对 HTML 文档的结构化表述。在渲染引擎中,DOM 有三个层面的作用:
- 从页面角度:DOM 是生成页面的基础数据结构。
- 从 js 角度:DOM 提供了 js 操作的接口。通过这套接口,js 可以对 DOM 接口进行访问,从而使开发者拥有改变文档结构、样式、内容的能力。
- 从安全角度:DOM 是 HTML 经过解析的内部数据结构,它将 web 页面和 js 链接起来,并过滤了一些不安全的内容。
渲染器进程内部使用 HTML Parser 将 HTML 解析成 DOM 结构。需要注意的是,HTML 解析器不会等待整个 HTML 文档加载完毕再去解析,而是加载多少了多少 HTML,就解析多少。
那么 HTML 字节流是如何转换成 DOM 的呢?
其实和 V8 解析 js 类似,也是做词法分析,通过分词器将字节流成功成一个个 token,包括 Tag token 和文本 token。HTML 解析器维护了一个 token 栈结构,token 会按照对应顺序入栈出栈,然后将 token 解析成 DOM 节点,并将 DOM 节点添加进 DOM 树中。
前面提到生成 DOM 可以过滤一些不安全内容。这主要是渲染引擎中的一个名为XSSAuditor 安全检查模块实现的。它会监测词法安全,在分词器解析出 token 之后,检查这些模块是否引用了外部脚本,是否符合 CSP 规范,是否存在跨站点请求等。如果出现不符合规范的内容。XSSAuditor 会对该脚本或下载任务进行拦截。
DOM 树在构建过程中会创建 document 对象,然后以 document 为根节点的 DOM 树不断修改向其中添加新的元素。
解析 CSS
前面已经将 HTML 解析成 DOM 树了,但是光拥有 DOM 树还不足以让我们知道页面的样貌。因为我们肯定会为页面设置一些样式。因此主进程还会解析页面中的 CSS 从而确定每个 DOM 节点的计算样式(computed style)。
CSS 的样式来源主要有三个:
- 通过 link 引用的外部 CSS 文件
- 使用<style>标签内的 CSS
- 元素的 style 属性内嵌的 CSS
同样,浏览器无法直接理解这些纯文本的 CSS 样式。所以渲染引擎在接受到 CSS 文本时,会通过 CSS parser 执行解析转换操作。解析过程和 HTML 是部分类似的。最终将 CSS 文本转换成浏览器可以理解的结构 styleSheets,这个结构具备查询和修改的能力,为后续的样式操作提供基础。
然后将 styleSheet 中的属性值进行标准化操作,比如我们在写样式时常常用到 font-size:1em、color:bule、font-weight:bold 等转换成标准的计算值。
最后根据层叠样式的继承规则和层叠规则,计算出的每个 DOM 节点的样式,被保存在 ComputedStyle 结构内。
渲染树 Render Tree VS 布局树 LayoutTree
到目前为止,我们已经在渲染器进程的主线程中走完了前两步。我们已经有了节点,又知道了节点的样式,是不是就可以开始渲染了 ?
不,进度条告诉我们事情远没有那么简单。
但是在进行下一步之前,我们还需要厘清些概念。这其中 Layout Tree 我们是常听的,那 Render Tree又是啥?它和 Layout Tree一样吗?
Layout Tree 不等于 Render Tree 。
从这篇开发者文档[https://developers.google.com...]中的配图可以看到,Render tree 是将 dom 和 cssom 结合的产物。也就是主线程解析 CSS 并把计算后的样式添加到 dom 节点上,进而得到了一个渲染树。
The main thread parses CSS and determines the computed style for each DOM node. This is information about what kind of style is applied to each element based on CSS selectors.
———《Inside look at modern web browser(part 3)》
如图所示,我们只是知道了节点是否可见和它们的可见样式,但是还不知道节点的精确位置和大小。也就是需要进行布局。
主线程从 render tree 的根节点开始遍历,按照一定规则处理后,将得到一个盒模型 。它会精确的捕获每个元素在视口内的确切位置和尺寸,所有的相对测量值都会转换为屏幕上的绝对元素。在得知了那些节点可见,计算样式和几何信息后,渲染引擎就可以把 render tree 上的每个节点都转换成屏幕上的像素,这一步称为 绘制 或者 栅格化
也就是说,Layout Tree 是 Render Tree 在进行布局计算后的结果,在 Render tree 的基础上,增加了节点的几何信息。
The main thread going over DOM tree with computed styles and producing layout tree ———《Inside look at modern web browser(part 3)》
图层树 Layer tree
真好,我们又走完一步,现在我们有了节点还有节点的精确位置和样式,可不可以渲染了?
抱歉,还是不行。
这里我们要先了解一个概念,栅格化或者说光栅化(Restering)。简单来说栅格化就是将这些节点信息转化为屏幕上的像素点。
那么栅格化跟我们渲染有什么关系呢?因为浏览器使用的正是这个技术将元素绘制在屏幕上。
Chrome 以前是在可视区域内将元素栅格化,随着用户滚动页面,不断调整栅格化的区域,继续栅格化并将内容填充到缺失部分效的方式。这样的问题是用户快速滚动页面的时候,会出现卡顿感。
现在的 chrome 栅格化是采用一种合成(composting)的技术,把页面中某些部分分到一些层中,分别栅格化它们,然后在栅格化线程中合成。这样在页面滚动时,原材料已经有了(已经栅格化好的那些层),只需要将视口内的层合成为一个新的帧就好了。
那这又跟 Layer Tree 又有啥关系呢?
前面说过目前 Chrome 使用的是将多个图层合成为一帧的技术。Layer Tree的作用就是,分层。
为了找到那些元素应该在哪些层里,主线程遍历 layout tree 来创建 layer tree (Chrome devtools 里称为 'update layer tree')
渲染引擎并不会为每个节点创建一个图层,如果一个节点没有图层,那么它属于父节点的图层。想要创建新图层,节点需要满足一定条件。
- 拥有层叠上下文属性的元素会被提成为新的一层
页面是二维平面,但是层叠上下文会让 HTML 元素具有三维概念。这些元素按照自身属性的优先级分布在垂直于这个二维平面的 Z 轴上。
明确定位属性的元素、定义透明属性的元素、使用 CSS 滤镜的元素等等,都拥有层叠上下文属性。具体参考MDN[https://developer.mozilla.org...]。
- 需要裁减的地方也会被创建为图层
当我们在一个 100100 的 div 中书写大量文字时,文字所显示的区域肯定会超出 100100。这时候就产生了剪裁,渲染引擎会裁剪文字内容的一部分用于显示在 div 区域。出现这种裁剪情况的时候,渲染引擎会为文字部分单独创建一个层。如果出现滚动条,滚动条也会被提升为单独的层。只要满足上述 2 条件任意之一,即会被提升为单独一层。
绘制 Paint
经历了上述步骤,终于我们到了绘制这一步了。
绘制是其实一个大的过程,包括生成绘制记录 Paint Records,合成器分图块,栅格线程光栅化(调用 GPU 生成位图),合成器帧提交等过程。
通过分层我们知道了一些特殊元素的层级关系。但是,我们还不知道同一层内的元素的层级关系,谁该覆盖谁。主线程根据前面的 Layer Tree 为每一层创建绘制记录表 Paint Records,决定谁先画谁后画。后画上去的肯定覆盖前面的,也就决定了同一层内的元素层级。绘制记录表也理解成一个类似单向链表的形式,遍历链表即可获得绘制顺序。
在查看文档的过程中,我们会发现不同文档对先生成 Layer Tree 还是先得到 Paint Records 有不同说法。
我理解的应该是先分层,然后对每一层创建 Paint Records。如果是先遍历整个 Layout tree 得到绘制记录再分层的话,会多了很多额外的工作,比如把绘制记录的某些绘制步骤挑出来和某些层绑定在一起。而且从 Chrome devtool 里的 profermance 可以看到,先创建了 layer tree,然后开始 paint。
有了图层和绘制记录表之后,将信息提交给合成器线程进行合成。由于一个图层可能非常非常大,超过了视口的面积,那么一次性将这么大的图层全绘制出来是没有必要的。所以还需要将图层分割成一个个图块 Tile, 优先绘制这些图块。图块大小通常是256256 或 512512,然后将图块信息传递给栅格化线程池。
栅格化线程池中都是栅格化线程,这些线程执行栅格任务 Raster Task 将图块生成位图,优先生成视口 viewport 附近的位图。通常栅格化过程使用 GPU 来加速,所以又称为快速栅格化、GPU 栅格化。
当所有的图块栅格化完毕,合成器线程收集 Draw Quads 的图块信息。Draw Quads 记录了图块在内存中的位置和在页面哪个位置绘制图块。
现在万事俱备,在主线程内将 Draw Quads 这些信息合成合成器帧 (Compositer Frame)并通过 IPC管道发送给浏览器进程。浏览器进程再将合成器帧发送给GPU。
GPU执行渲染,页面出现!!!
大功告成!!!但是这不是结束,我们还要考虑到重排重绘。
从线程角度看重排重绘
作为前端经常听到说重排比重绘的开销大,那我们从线程角度该如何理解呢?
重排(回流)
如果通过 js 或 CSS 修改元素的几何位置属性,如宽度、高度等,那么浏览器会触发重新布局。也就是重新生成 layout tree 及以后的所有流程,并全都再走一遍。这种开销是比较大的。
重绘
如果只是改变元素背景颜色,则不用修改 layout tree 和 layer tree,也不用修改进入绘制以及之后的流程。由于省略了布局和分层阶段,开销会小一些,效率较高。
直接合成
如果更改一个即不要布局也不需要绘制的属性,则渲染引擎将跳过布局和绘制阶段,只执行后续的合成操作,我们把这个过程称之为合成。
js 执行,重排,重绘都是运行在主线程的,都有可能因为大量的计算导致页面卡顿。而除了主线程外,还有合成器线程和栅格线程,如果能不使用主线程直接进行合成的话,就能使页面更加流畅。
css 3 transform 就是这样的一个属性,它实现动画效果可以避开重排和重绘,直接在非主线程上执行动画合成的操作。由于不占用主线程,并且也没有布局和绘制的阶段,所以效率是最高的。
另外,除了使用 transform 属性外,还可以使用 requestAnimationFrame 方法。requestAnimationFrame 传入的 callback 会在下一帧的重绘前调用,从而尽可能的提高动画帧率。可以参考这篇文档[https://zhuanlan.zhihu.com/p/...]。
延展阅读
浏览器的演进
按照目前发展情况来,未来 Chrome 整体架构会朝向现代操作系统所采用的“面向服务的架构” 方向发展,以达到简单、稳定、高速、安全的目标。
现有的各种模块将重构成独立的服务(Service),比如把 UI、数据库、文件、设备、网络等模块重构为类似操作系统底层的基础服务,并在各自独立的进程中运行。同时通过使用定义好的接口以及 IPC 来通信、访问,让系统更内聚、松耦合、易于维护和扩展。
同时 Chrome 还提供灵活的弹性架构,在强大性能设备上以多进程的方式运行基础服务,在资源受限的设备上(如下图),则会将很多服务整合到一个进程中,从而节省内存占用。谷歌开发者文档[https://developers.google.com...]
目前 Chrome 正在逐步构建 Chrome 基础服务(Chrome Foundation Service)这将是一个漫长的迭代过程,让我们一起拭目以待。
参考文献
Render-tree Construction,Layout,and Paint:
[https://developers.google.com...]
Inside look at modern web browser(par1 - part4):
[https://developers.google.com...]
Chrome 浏览器架构:
[https://xie.infoq.cn/article/...]
Chrome架构:仅仅打开了1个页面,为什么有4个进程:
[https://blog.poetries.top/bro...]
requestAnimationFrame 回调时机:
[https://zhuanlan.zhihu.com/p/...]
层叠上下文:
[https://developer.mozilla.org...]
Process Models:
[https://www.chromium.org/deve...]