了解浏览器的本质

前言

作为一个FE,每天都会和浏览器打交道,写的代码最终也会在各种各样的浏览器中进行呈现;那么浏览器到底是什么?它是如何理解我们的代码并渲染得到我们所看到的画面的?

“私、気になります”

浏览器,简单地来说就是一个应用程序,只不过这个应用程序比较特殊,是专门用于从万维网中获取资源的;而网页就是其中一种最常见的资源。

A web browser is a software application for retrieving, presenting and traversing information resources on the World Wide Web. [1]

:以下探讨的是现代浏览器(Modern Browser),不包括IE这种;

浏览器的运行原理

既然浏览器就是一种应用程序,那么其势必运行在操作系统上(当然,也有人提出直接将浏览器作为一种操作系统,比如:Chrome OS);因此浏览器中执行的任务最终都要依靠操作系统提供的API,从而与相应的硬件进行交互;

img

浏览器的架构

架构方式

根据对进程的使用方式不同,浏览器的架构可以分为单进程和多进程,比如Chrome浏览器就是采用多进程架构;

进程/线程组成

浏览器算是一个比较庞大的应用程序,因此其内部实际上也会按功能拆分成很多个模块,每个模块专门负责一类特定的任务;在Chrome浏览器中,首先将主要的功能拆分成不同的进程,然后进程下面可能会有多个不同的线程来继续细分任务,主要的进程有:

  • 浏览器进程(Browser Process):用于控制浏览器这个应用本身,包括浏览器的导航栏及书签等等;还用于承担调度各个其他进程之间的通信
  • 渲染进程(Render Process):用于渲染一个标签内的网页内容;
  • GPU进程(GPU Process):用于处理GPU相关任务;
  • 插件进程(Plugin Process):控制网页内的插件;

img

img

上面的图[2]google官方博客给出的Chrome浏览器架构设计以及各进程作用的说明图;可以看到其中渲染进程可以存在多个,这是由于Chrome每个标签页都单独开设了一个渲染进程,但是由于内存有上限,因此开启的渲染进程数量会存在一个上限,这个上限取决于运行系统的硬件条件;

使用多进程来进行渲染的好处就是当其中一个渲染进程崩溃后不会影响到其他进程,但是相应的坏处就是很占内存……(这下终于明白Chrome占内存的原因了)

除此之外,Chrome用了一种技术来动态改变架构以适应不同的硬件情况;当硬件比较好的时候,Chrome会尽可能地将很多线程独立出来作为进程,那么硬件比较差的时候则相反,会将一些进程合并到其他进程之中:

img

关于浏览器的架构大致就了解这么一些,关于更多的架构细节可以查看Google的系列博客文章:

浏览器是如何理解前端代码的?

众所周知,运行在浏览器中的前端代码只有三类:htmlcssjshtml用于定义网页结构,css用于设置样式和布局,而js则作为脚本用来动态改变htmlcss以及交互行为;虽然这三者分工不同,但最终输入浏览器后得到的输出大多是网页画面(还有可能是I/O,多媒体,网络等底层交互),即渲染,因此理解这三种代码是如何影响浏览器渲染结果是十分必要的。

浏览器网页渲染机制

关于经典的从输入网址到网页渲染这一流程就不再赘述(上面提到的系列博客文章有比各网站流行的更详细的讲解,详细到线程级交互……),这里着重关注浏览器如何解析前端代码并输出像素到显存中的;

img

上面就是html + css代码输出像素到屏幕的一个大致流程,可以看到代码在浏览器中从文本变成屏幕中的像素是一个十分复杂的流程,里面每个流程单独拿出来讲估计都是充满了很多复杂的处理细节;从这个层面上来讲基于HTMLCSS的渲染是一套高度抽象但又很成熟的流程。

DOM Tree

这个术语大概都不会陌生,或多或少都有听说过;DOM是一个对HTML/XML文档的结构化描述及交互规范,因此它本身并不是与特定化编程语言强绑定的,但是现实中很多人都将DOM视为JS中的一部分,这是一个很大的认知误区;不过这也是无法避免的,因为目前浏览器使用的脚本语言就是JS,因此浏览器内置的DOM也就是基于JS的实现;

理论上一个编程语言只要可以实现OOP,其也就具备实现DOM的能力,因此DOM可以在多种编程语言中进行实现,比如MDN上就举了python实现DOM的例子[3]

1
2
3
4
5
# Python DOM example
import xml.dom.minidom as m
doc = m.parse("C:\\Projects\\Py\\chap1.xml");
doc.nodeName # DOM property of document object;
p_list = doc.getElementsByTagName("para");

DOM用于描述HTML中的结点,因此整个HTML解析得到的结构化描述就是一个DOM树;但是DOM树中会过滤伪元素结点;

computed style

computed style即计算样式,所谓计算样式就是根据DOM结点本身以及CSS样式选择器匹配到的样式所得到的关于当前结点所有的CSS样式信息,即涵盖所有CSS样式属性,当前结点没有赋予相应的样式信息就会从浏览器中内置的默认样式进行获取;尽管DOM中不会包含伪元素的结点信息,但是伪元素仍然会有相应的计算样式

通过window.getComputedStyle方法即可获取某一结点的计算样式:

window.getComputedStyle(element, [pseudoElt])[4]

  • elementDOM结点;
  • pseudoElt:伪元素匹配字符串,如:"::before"

Layout Tree

Layout tree,顾名思义就是生成DOM tree对应的布局信息,因此绘制图形时需要知道图形的形状和位置信息才行;根据DOM tree和相应的计算样式就可以计算出相应结点的布局信息,布局信息主要包括:当前结点的盒模型尺寸,以及坐标位置;需要注意的是display: none的结点不会纳入到布局树当中,因为此时该结点并不会被渲染,而伪元素结点则会被纳入到布局树中,因此伪元素本质上还是一个渲染结点,只是DOM不包含而已;

计算布局树是一个十分复杂的过程,因为有太多因素可以影响到布局了,比如定位,尺寸,文字排版,浮动等等;因此要得到一个结点最终的布局信息要经过一系列的判断和计算。

Paint Records

即便有了布局树和样式信息,仍然不足以绘制出最终的画面;熟悉图形渲染的人都应该知道,绘制的顺序很重要,如果绘制的顺序不对得到的结果可能就完全不同,因为不同的图形之间可能存在重叠的区域;所以Paint Records就是用于存储各个图形之间的绘制顺序,保证绘制重叠不会阴差阳错。

根据Layout tree的信息就可以自然的计算出相应的Paint Records,因为Layout tree的树状结构包含了相应的层级关系;

Layer Tree

由于全量式刷新渲染很耗费时间,因此浏览器采用了对元素进行分层,这个就像是PS中分图层处理;然后单独对每个图层进行光栅化处理,然后拼接处于视口(viewport)内的图层光栅化数据,合成为当前的渲染帧,这个过程称之为:composition

Layer tree则是根据Layout tree进行图层划分后得到图层层级信息,即哪些元素结点处于同一图层,图层间的关系如何;这样做的好处就是,在布局树没有发生改变时,滚动页面可以直接利用部分之前已经光栅化的图层,然后继续合成新的渲染帧即可,加快了渲染效率;

目前浏览器内部是按照什么规则进行图层的划分还不是很清楚,但是可以确定的是当一个属性设置了will-change属性后,该属性会被视为单独的一层,以便更加高效地应对此元素结点的变化;但是过多地设置will-change属性会导致图层过多,composition的耗时就会更多,反而效率降低;

此外,Chrome浏览器的开发者工具还提供了查看图层的功能:

img

点开这个面板我才发现这个面板的功能实在是太强大了,除了列出当前页面的所有图层,还可以查看图层的信息:

img

图层信息有:图层大小,位置,分层原因,占用内存等等;从上图我们可以得知,该图层大小为1425x152,坐标位置是(0,0),分层的原因是由于该元素是fixed或者sticky定位或者可能会覆盖其他的图层;不仅如此,还可以直接查看各个图层的位置和层叠关系,而且还支持结合绘制结果一起,还能3D查看!

img

上图中黑色边框标注的就是一个图层,图层之间有叠层顺序,图层关系和渲染结果简直一目了然;这个工具大大加深了对composition的认识,果然浏览器内部实际上就是按照3d进行渲染的?

Tile

Tile,中文译为“瓷砖”;不过在光栅化过程中可以把它理解为一个layer(图层)的矩形小块,当一个图层尺寸过大时,可能会被切分成多个tile,然后光栅化的最小单位就是tile[5]

img

Draw quads

Draw quadstile光栅化后得到的结果,意为“绘制四边形”;它包含了该tile像素信息位于内存中的地址已经对应在当前渲染帧(即当前页面)中的坐标位置;这个有点类似于着色器中常见的片元,只不过这个绘制四边形对于片元来说还是太大了。

Compositor frame

将当前视口内所有的图层光栅化后得到的所有Draw quads进行拼接,得到的帧信息就是Compositor frame,这个帧信息就是用于传送到GPU中,然后显示当前绘制画面,有点类似于帧缓冲;

浏览器网页事件触发机制

除了渲染,前端代码还可以用来处理各种各样的交互事件,以及修改DOM/CSSOM等操作,这些事情是JS来负责的;修改DOM这种操作无非就是借助浏览器暴露的接口进行底层控制而已,更重要的是理解浏览器中事件触发机制以及JS在其中的作用;

严格意义上来说,事件注册机制不也是DOM的一部分吗?

img

上图就是浏览器内部触发一个事件到执行的大致流程,其中确实有很多步骤和判断,有不少值得注意的细节;

非快速滚动区域

非快速滚动区域,即non-fast scrollable region;当一个元素被注册了事件,其对应的渲染区域就会被识别成非快速滚动区域,然后只有非快速滚动区域才会去进一步识别事件处理,也就是说没有注册事件的区域即便发生了事件也会被compositor线程忽略,继续它的帧渲染工作;

因此非快速滚动区域也是名副其实,因为这个区域在发生交互行为时Compositor线程需要等待渲染主线程的回应才能进行下一步的渲染,所以中间会有一定耗时,导致诸如滚动页面的操作相应的渲染结果有所延后;不过这个也是有解决方法的。

passive事件的本质

addEventListener方法第三个参数原本是用于标识事件是否发生在捕获阶段的,但是后面又新增了一种用法[6]

target.addEventListener(type, listener, options);

即第三个参数可以是一个配置对象(目前属性值都是布尔值):

  • capture:表示是否在捕获阶段触发事件;
  • once:表示事件是否只触发一次;
  • passive:表示事件是否不会使用preventDefault方法,当为true即便事件回调函数调用了preventDefault方法也会被忽略

这里将选项设置为passive:true的事件称之为“passive事件”;显然,看了流程图就会知道,passive事件本质上是给compositor线程一个信号:即当前事件不会阻塞渲染;也就是说注册了passive事件的元素区域即便被标识成了非快速滚动区域,但是仍然不会阻塞compositor线程的绘制工作,这样就既能触发事件又不会阻塞渲染工作;

事件委托的一个缺点

由于非快速滚动区域阻塞渲染的特点,当事件注册的区域过大时则会影响渲染效率;那么实践中被经常使用的事件委托技巧这个时候就会中枪了,因此事件委托正是将事件委托到触发元素的父级及以上层级的元素结点上,所以此时的非快速滚动区域不可避免的会变大,而且事件触发的精准度明显降低,因此被委托的结点明显要接收更多不相关的事件(因为其内部往往需要进行一个判断才会执行真正的事件,这样导致阻塞渲染的情况变多,且不相关事件也变多);

1
2
3
4
5
document.body.addEventListener('click', event => {
if (event.target === a) {
// do something
}
})

解决办法也是有的,那就是把事件变成passive事件,这样做就不能阻止事件默认行为了;

命中测试

compositor线程接收到主线程传来的事件触发坐标时,往往并不能直接确定这个事件具体触发的结点(即event target),因为这个坐标点可能存在多个元素结点,因此存在层叠关系,所以此时需要结束Paint Records来确认最终触发事件的结点;

合并事件

当一个事件在一帧内连续触发多次,那么该事件则会被合并成一次事件交给下一帧;这个可能会出现在手势操作相关的连续事件中,比如touchmove;对于一些高精度要求的交互中,比如说手势绘制轨迹等,如果合并事件则会丢失一定的数据,因此后续规范中增加了对这类事件的完整事件的获取方法,如:PointerEvent.getCoalescedEvents()

img

上面这个方法就是可以获取指针事件中当前帧内所有的连续触发事件,因此可以提高交互的精度;

相关文档


  1. https://en.wikipedia.org/wiki/History_of_the_web_browser ↩︎

  2. https://developers.google.com/web/updates/2018/09/inside-browser-part1 ↩︎

  3. DOM概述 - Web API 接口参考 | MDN ↩︎

  4. Window.getComputedStyle() - Web API 接口参考 | MDN ↩︎

  5. https://developers.google.com/web/updates/2018/09/inside-browser-part3#raster_and_composite_off_of_the_main_thread ↩︎

  6. EventTarget.addEventListener() - Web API 接口参考 | MDN ↩︎