概要
前提概要
在今年第一篇文章中,我们已经讲述过,我们以后的行文路线会按照前端
Roadmap
的进行。
我们今天来简单介绍一下行文路径中 Browser and how they work - 浏览器是如何运行的
。
根据权威机构统计调查,常规的主流浏览器在全球范围内所在的比重如下图所示。
所以,本文以Chrome
浏览器来展示浏览器的工作流程。
浏览器架构总览
进程、线程
在开始介绍浏览器工作流程的时候,我们需要简单说一下,进程
、线程
。
进程:某个应用程序的执行程序。
线程:常驻在进程内部并负责该进程部分功能的执行程序。
当你启动一个应用程序,对应的进程就被创建。进程可能会创建一些线程用于帮助它完成部分工作,新建线程是一个可选操作。在启动某个进程的同时,操作系统(OS)也会分配内存以用于进程进行私有数据的存储。该内存空间是和其他进程是互不干扰的。
当应用程序被关闭,进程也随之关闭,同时OS会将进程所占的内存释放掉。
其实这是一个动图,由于掘金没法嵌入
svg
格式的图片,video
也不行。所以,如果想看流程可以通过 传送门进行了解。
有人的地方就会有江湖,如果想让多人齐心协力的办好一件事,就需要一个人去统筹这些工作,然后通过大喇叭将每个人的诉求告诉对方。而对于计算机而言,统筹的工作归OS系统负责,OS通过Inter Process Communication (IPC)
的机制去传递消息。
其实这是一个动图,由于掘金没法嵌入 svg
格式的图片,video
也不行。所以,如果想看流程可以通过 传送门进行了解。
下面,我们来看看Chrome架构是如何组织的。
Chrome 的默认策略是,每个标签对应一个
Render Process
。但如果从一个页面打开了另一个新页面,而新页面和当前页面属于同一站点
的话,那么新页面会复用父页面的渲染进程。官方把这个默认策略叫process-per-site-instance
同一站点:根域名
加上协议
,还包含了该根域名下的所有子域名和不同的端口
A.fake.com
www.fake.com
B.fake.com:789789
这三个域名就是同一站点。
站点隔离
我们之前介绍浏览器渲染进程 -> 一般情况下,一个Tab页对应一个渲染进程。这里存在一个漏洞,页面中存在跨域的 iframe
,该 iframe
就会有访问该渲染进程内存的权限,这就违背了,同源策略。 所以,在最新的chrome架构中,如果页面中存在跨域的 iframe
,这些跨域的 iframe
也会唤起一个属于自己的渲染进程。
渲染流程总览
虽然,该篇文章以 chrome浏览器来解释 浏览器的工作流程,但是我们还是需要对其他浏览器的渲染进程
配置做一个汇总。
导航阶段
浏览器的导航过程涵盖了从用户发起请求到提交文档给渲染进程的中间所有阶段。
UI进程👉拼装URL
当用户在地址栏中输入一个查询关键字时,地址栏会判断输入的关键字是搜索内容,还是请求的 URL。
- 如果是搜索内容,地址栏会使用浏览器默认的搜索引擎,来
合成
新的带搜索关键字的 URL。 - 如果判断输入内容符合 URL 规则,那么地址栏会根据规则,把内容加上协议,合成为完整的 URL。
网络进程👉获取数据
当最终的URL拼装好后,会由UI进程通过IPC
通知浏览器进程,而浏览器进程会将消息传递给网络进程。(此时的浏览器进程充当一个消息中转站的角色) 当网络进程接受到来自UI进程需要网络连接的消息后。随之会初始化一个网络连接。
该篇幅主要是讲解,浏览器如何渲染数据,所以浏览器如何和服务器建立连接等步骤,会一带而过。
首先,网络进程会查找本地缓存是否缓存了该资源。如果有缓存资源,那么直接返回资源给浏览器进程;如果在缓存中没有查找到资源,那么直接进入网络请求流程。这请求前的第一步
是要进行 DNS 解析,以获取请求域名的服务器 IP 地址。如果请求协议是 HTTPS,那么还需要建立 TLS 连接。
接下来就是利用 IP 地址和服务器建立 TCP 连接。连接建立之后,浏览器端会构建请求行、请求头等信息,并把和该域名相关的 Cookie 等数据附加到请求头中,然后向服务器发送构建的请求信息。
服务器接收到请求信息后,会根据请求信息生成响应数据(包括响应行、响应头和响应体等信息),并发给网络进程。等网络进程接收了响应行和响应头之后,就开始解析响应头的内容了。
重定向
在接收到服务器返回的响应头后,网络进程开始解析响应头,如果发现返回的状态码是 301
(永久性的移动) 或者 302
(临时性重定向),那么说明服务器需要浏览器重定向到其他 URL。这时网络进程会从响应头的 Location
字段里面读取重定向的地址,然后再发起新的 HTTP 或者 HTTPS 请求,一切又重头开始了。
在导航过程中,如果服务器响应行的状态码包含了 301、302 一类的跳转信息,浏览器会跳转到新的地址继续导航;如果响应行是 200,那么表示浏览器可以继续处理该请求。
根据Content-Type进行数据处理
Content-Type: XX => XX是MIME类型的字符格式。MIME指示文档、文件或字节分类的性质和格式。
Content-Type
告诉浏览器服务器返回的响应体数据是什么类型,然后浏览器会根据 Content-Type 的值来决定如何显示响应体的内容。
不同 Content-Type 的后续处理流程也截然不同。如果 Content-Type 字段的值被浏览器判断为下载类型,那么该请求会被提交给浏览器的下载管理器,同时该 URL 请求的导航流程就此结束。但如果是Content-Type:text/html
,那么浏览器则会继续进行导航流程。
唤起渲染进程
由于,浏览器和服务器之间数据的交互时间存在不确定性,在响应体回来以后,再唤起渲染进程是存在滞后的。
所以在UI进程向网络进程发送拼装好的URL的时候,已经知道后续的导航的信息。此时UI进程会尝试随机唤起一个渲染进程。以备在响应体数据满足渲染要求,直接进行渲染操作。
UI进程向网络进程发送URL 和 UI进程尝试唤起渲染进程是
同步进行
的。
更新Tab状态 -->导航阶段结束
在响应数据和渲染进程准备就绪后,网络进程通过IPC向渲染进程传递提交响应体数据的消息。
- 渲染进程和网络进程会建立数据通道。网络进程的数据就会源源不断的流向渲染进程。
- 当数据都传送完毕后,渲染进程会向UI进程发送确认提交的消息。
- UI进程在接收到确认提交消息,更新浏览器界面状态,包括了安全状态、地址栏的 URL、前进后退的历史状态,并更新 Web 页面。
此时导航阶段正式结束,接下来进入页面渲染阶段。
渲染阶段
下面我们来重点讲述下,Chrome浏览器是如何将 HTML
和 CSS
拼装成浏览器可识别的信息。
想必大家都见过这张图,该图显示的是 WebKit
内核的渲染页面主流程。之所以,我把这个拿出来,是因为 Chrome
/Safari
/Edge
的渲染引擎都是 基于 Webkit 改造而来的。所以,我们通过了解 Webkit 的渲染流程,就能通晓市面上大部分浏览器的运行流程。
而上面所展示的图,是 2011 年所绘制的,现在都 1202
年了,有些流程和细节有变更和填充。但是,大体的流程和处理方式是一脉相承的。
我们通过导航阶段,从服务器中获取到用于浏览器展示的数据信息-- HTML
/CSS
。但是,HTML和CSS都是文本信息,是无法被浏览器所识别和使用,所以就需要一个机制,让HTML等文本信息转换为浏览器能够识别的格式,而这个转换过程就是解析阶段。
HTML和(CSS/JS)的解析方式是不一样的。
编译器
大多数编译程序(compiler)分为三个步骤:Parsing(分析阶段)/Transformation(转换)/Code Generation(代码生成或者说生成目标代码)
- Parsing将源代码(raw code)通过词法分析和语法分析转换为AST(抽象语法树)。
- Transformation接收Parsing生成的AST,并且按照compiler内定的规则进行代码的转换。
- Code Generation 接受被compiler转换过的代码,按照一定的规则将代码转换为最终想要输出的代码格式
通过以上三个步骤,大部分程序会被编译为目标代码。如果想了解更多关于编辑器是如何运行的,可以参考我原来写的编译程序(compiler)的简单分析。 在这里就不多啰嗦了。
BNF
常规的与上下文无关的语言,是可以通过 BNF
格式来描述。
BNF: 一种
形式化符号
来 描述给定语言
的 语法
一种形式化的语法表示方法,用来描述语法的一种形式体系,是一种典型的元语言。
它不仅能严格地表示语法规则,而且所描述的语法是与上下文无关
的。它具有语法简单,表示明确,便于语法分析和编译的特点。
BNF表示语法规则的方式为:
①:非终结符用尖括号括起。
②:每条规则的左部是一个非终结符,右部是由非终结符和终结符组成的一个符号串,中间一般以::
=分开。
③:具有相同左部的规则可以共用一个左部,各右部之间以直竖“|”隔开。
例如,我们在解析 2 + 3 - 1
这个表达式时,
词法规则,我们可以用:
INTEGER: 0|[1-9][0-9]*
PLUS: +
MINUS: -
语法规则:
expression ::= term operation term
operation ::= PLUS | MINUS
term ::= INTEGER | expression
生成的 AST
结构
通过对AST进行个性化处理,最终生成指定机器和引擎能够识别的机器语言。
在讲述BNF是啥的时候,我们提到了 与上下文无关
这个概念。根据 维基百科的描述,
我们可以简单描述下,在常规的语言中,如果是CFG的话,是可以描述为,而
是一个非终端符号或者标识,
一般为终止符号或者说是Tokens(不可分割元素)。
而一个语言,不需要考虑左值的上下文语境的时候,就可以使用BNF来表示。
HTML 解析器
既然,我们说HTML想要被浏览器识别,是需要被解析的,而由于HTML的语言特性或者独特的解析过程,HTML是不能使用常规 上下文无法的编译器进行转换的。
理由如下:
- 语言的宽容本质
- 浏览器历来对一些常见的
无效
HTML 用法采取包容态度 - 解析过程需要不断地反复。源内容在解析过程中通常不会改变,但是在 HTML 中,脚本标记如果包含 document.write,就会添加额外的标记,这样解析过程实际上就更改了输入内容
DOM
而HTML解析器最终的目标就是为了,将HTML转换为浏览器能识别的数据结构 -- DOM
。
DOM(Document Object Model)的缩写,即文档对象模型。是针对
XML
并经过扩展用于HTML的应用程序编程接口(API)
所以DOM本质上是一种接口(API)
,是专门操作网页内容的API标准
DOM把整个页面映射为一个多层节点结构,HTML或XML页面中的每个组成部分都是某种类型的节点。借助DOM提供的API,开发人员可以删除、添加、替换或修改任何节点
由于不能使用常规的解析技术,浏览器就创建了自定义的解析器来解析 HTML。
此算法由两个阶段组成:标记化和树构建。
标记算法
标记化是词法分析过程,将输入内容解析成多个标记(tokens
)。HTML 标记包括起始标记、结束标记、属性名称和属性值。
这里有一个点需要注意:在 HTML2 -HTML4 中, 声明引用 DTD,因为 HTML 4.01 基于
SGML
。DTD
规定了标记语言的规则(tokens),这样浏览器才能正确地呈现内容。<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01//EN" "http://www.w3.org/TR/html4/strict.dtd">
HTML5 不基于 SGML,所以不需要引用 DTD。
DOM树构建算法
标记生成器识别标记,传递给树构造器,然后接受下一个字符以识别下一个标记;如此反复直到输入的结束。
在创建解析器的同时,也会创建
Document
对象。在树构建阶段,以 Document 为根节点
的 DOM 树也会不断进行修改,向其中添加各种元素。标记生成器发送的每个节点都会由树构建器进行处理。规范中定义了每个标记所对应的 DOM 元素在接收到相应的标记时创建。
处理子资源
网站中总是会嵌入图片
、CSS
、JS
等非文本资源,而这些非文本信息需要再次从服务器或者缓存中获取。在DOM树构建过程中,如果遇到此类HTML标签,主线程将会依次请求对应的数据信息。而为了加快构建速度,预加载扫描器会和构建DOM树同步运行。 在标记化过程中,如果遇到类似<img>
或者<link>
标签,预加载扫描器会通知网络进程发起获取对应标签数据信息的异步请求。(此时主线程和网络请求是同步的)
一切都归于完美,但是如果非文本标签是是<script>
,就是另外一回事了。当标记算法输出的是<script>
,此时HTML的解析过程就会停止,也就是说,主线程不会在继续解析tokens,转而去加载、解析、执行对应的JS代码。只有在JS代码执行完成以后,HTML的解析才会继续进行。
JS会阻塞DOM树的构建 -->是由于JS代码中可能掺杂着类似于
document.write()
这种对于已经构建好的DOM树来说属于毁灭性打击的操作(直接将原来的树,全部抛弃)。
由于<script>
会阻塞主线程构建DOM树,所以如果<script>
中不存在document.write()
这种对已构建DOM树毁灭性打击的行为,我们可以通过对script
设置defer
/async
属性来避免阻塞。
<script async src="A.js"></script>
有 async
,加载和渲染后续文档元素的过程将和 A.js 的加载与执行并行
进行(异步)。
<script defer src="B.js"></script>
有 defer
,加载后续文档元素的过程将和 B.js 的加载并行进行(异步),但是 B.js 的执行要在所有元素解析完成之后,DOMContentLoaded
事件触发之前完成。
从实用角度来说呢,首先把所有脚本都丢到 </body>
之前是最佳实践,因为对于旧浏览器来说这是唯一
的优化选择,此法可保证非脚本的其他一切元素能够以最快的速度得到加载和解析。
接着,我们来看一张图咯:
蓝色线代表网络读取,红色线代表执行时间,这俩都是针对脚本的;绿色线代表 HTML 解析。
此图告诉我们以下几个要点:
defer
和 async
在网络读取(下载)这块儿是一样的,都是异步的(相较于 HTML 解析)
它俩的差别在于脚本下载完之后何时执行,显然 defer 是最接近我们对于应用脚本加载和执行的要求的
Note:
defer
是按照加载顺序执行脚本的,async
是乱序执行的。
同时,我们可以通过<link preload/>
对资源进行预加载处理。
具体如何使用,可以参考 通过link的preload进行内容预加载,描述的很详细。或者对性能优化感兴趣,可以参考外文 Fast load times(后续有计划做整理和翻译,敬请期待)
经过一顿操作,HTM解析器终于将HTML转化为浏览器能够识别的 DOM
结构。
通过对百度首页渲染流程来简单看一下。
最下方是显示有一个棕色线条 和用小圆圈标注的调用堆栈,表示DOM构建的过程。从图中可以看出,DOM是在HTM 解析阶段生成。
将CSS附加(attachment)到DOM节点==>生成Render Tree
解析样式和创建呈现器的过程称为“附加
”。每个 DOM 节点都有一个“attach
”方法。附加是同步进行的,将节点插入 DOM 树需要调用新的节点“attach”方法。
处理 html
和 body
标记就会构建呈现树根节点。这个根节点呈现对象对应于 CSS 规范中所说的容器 block
,这是最上层的 block,包含了其他所有 block。它的尺寸就是视口,即浏览器窗口显示区域的尺寸。 WebKit 称之为 RenderView
。这就是文档所指向的呈现对象。呈现树的其余部分以 DOM 树节点插入的形式来构建。
CSS 解析器
由于HTML解析和CSS解析都是在渲染进程中,并且渲染进程只存在一个主线程,也就意味着主线程在同一时间只能做一件事。-->单线程特性。
然后在DOM构建完成,并且将位置靠前的<script>
也处理完,以后才会开始CSS的解析步骤。
由于CSS
是上下文无关语言,所以解析CSS可以使用常规的编译器。而W3C中CSS定义了相关的词法和语法。
解析器会将 CSS
文件解析成 StyleSheet
对象,且每个对象都包含 CSS 规则。CSS 规则对象则包含选择器和声明对象,以及其他与 CSS 语法对应的对象。
创建呈现器
这是由可视化元素
按照其显示顺序而组成的树,也是文档的可视化表示。它的作用是按照正确的顺序绘制内容。
class RenderObject{
virtual void layout();
virtual void paint(PaintInfo);
virtual void rect repaintRect();
Node* node; //the DOM node
RenderStyle* style; // the computed style
RenderLayer* containgLayer; //the containing z-index layer
}
每一个呈现器都代表了一个矩形的区域,通常对应于相关节点的 CSS 框,包含诸如宽度、高度和位置等几何信息。
框的类型会受到与节点相关的“display
”样式属性的影响。例如,针对 display:block
的元素,它矩形区域默认独占一行,而 display:inline
的元素,是具有包裹性。(其实,针对CSS中盒模型是一个很大的课题,这块可以参考张鑫旭大佬有关的讲解。同时,自己也会有一定的文档说明,最近在做总结和梳理,敬请期待!)
属性标准化
现在我们已经将 CSS 节点解析为 RenderObject
,但是在写CSS的时候,我们会写诸如font-size:2em
的条件,而这些em是相对值,不是一个定值,所以,我们就需要将如 2em
、blue
、bold
,这些不容易被渲染引擎理解的值转换为渲染引擎容易理解的、标准化的计算值,这个过程就是属性值标准化。
如果存在如下的样式信息
body { font-size: 2em }
p {color:blue;}
span {display: none}
div {font-weight: bold}
div p {color:green;}
div {color:red; }
样式计算并保存到ComputedStyle
在样式标准化后,渲染引擎已经可以识别每个 RenderObject
中所携带真正的数据信息了, 但是DOM 节点和 RenderObject
可能存在一对多的关系。所以,我们需要将这些信息进行融合,这样才可以将最后的样式信息作用到 DOM 节点上。
产生某个DOM 节点受多个样式影响的原因之一:CSS文件来源不定
CSS 样式来源主要有三种:
①: 通过link
引用的外部 CSS 文件
②:<style>
标记内的 CSS
③:元素的 style 属性内嵌的 CSS
某个样式属性的声明可能会出现在多个样式表中,也可能在同一个样式表中出现多次。这意味着应用规则的顺序极为重要。这称为“层叠”顺序。根据 CSS3 规范,层叠的顺序为(优先级从高到低,降序排序):
我们可以简化一下,就是
- CSS3的
transition
--> 优先级最高 - 浏览器重要声明 --> user agent stylesheet 中存在
!important
- 用户重要声明 --> 用户,就是直接在浏览器写的带有
!important
的属性 - 作者重要声明 -->
<link>
/<style>
/style
属性中带有!important
的属性 - 动画属性
- 作者普通声明 -->
<link>
/<style>
/style
属性 - 用户普通声明 --> 用户设置的自定义样式
- 浏览器声明 -->
user agent stylesheet
浏览器默认属性
相关连接请参考 www.w3.org 和 css-cascade-4。
同时,如果不同样式都作用于同一 DOM节点,就需要有一个权重计算的规则。
一图胜千言,有木有。
我们通过权重计算等操作,最后可以确定了针对指定DOM 节点所携带的在最终样式信息,而这些信息会被存到 ComputedStyle
结构中。
如果在实际开发中,你用过
style = window.getComputedStyle(element);
该方法,返回了指定element所有的属性。而这个的方法返回的数据信息,其实就是通过一系列计算得到的 ComputedStyle
结构。
最后,我们得到了,一棵通过向 DOM树 附加样式信息的
Render Tree
。
布局(Layout)
通过HTML 解析和 CSS 解析,已经将HTML和CSS信息融合到一起,并且知道了每个节点各自的外观样式信息。但是光有外观样式信息还是不能够将节点排布到他们真正需要渲染到页面中的位置。还需要该元素对应的位置和大小信息。
呈现器在创建完成并添加到呈现树时,并不包含位置和大小信息。计算这些值的过程称为布局或重排。
HTML 采用基于流的布局模型,处于流中靠后位置元素通常不会影响靠前位置元素的几何特征,因此布局可以按从左至右、从上至下
的顺序遍历文档。
坐标系是相对于根框架而建立的,使用的是
上坐标
和左坐标
。
布局是一个递归的过程。它从根呈现器(对应于 HTML 文档的 元素)开始,然后递归遍历部分或所有的框架层次结构,为每一个需要计算的呈现器计算几何信息。
根呈现器的位置左边是 0,0,
其尺寸为视口(也就是浏览器窗口的可见区域)。
所有的呈现器都有一个“layout
”或者“reflow
”方法,每一个呈现器都会调用其需要进行布局的子代的 layout 方法。
布局是一个寻找元素几何形状的过程。主线程遍历DOM和计算样式并创建布局树,布局树包含诸如x y坐标和边框大小等信息。呈现器是和 DOM 元素相对应的,但并非一一对应。非可视化的 DOM 元素不会插入布局树中,例如“head
”元素。如果元素的 display
属性值为“none
”,那么也不会显示在呈现树中(但是 visibility 属性值为“hidden
”的元素仍会显示)。
有一些呈现对象对应于 DOM 节点,但在树中所在的位置与 DOM 节点不同。
浮动定位
和绝对定位
的元素就是这样,它们处于正常的流程之外,放置在树中的其他地方,并映射到真正的框架,而放在原位的是占位框架。
主线程遍历Render Tree 然后生成Layout Tree (布局树)
绘制(Paint)-生成元素绘制顺序
通过,布局处理,已经知晓所以元素的大小,位置。但是,还是不能进行按部就班的进行页面渲染,虽然HTML 采用基于流(从左到右,从上到下
)的布局模型进行布局,但是通过样式,可以脱离了流的默认流向和渲染顺序。
例如我们可以通过z-index
将在Z-轴方向搞点事情,这里就涉及到一个新的概念--层叠上下文 (这玩意也是一个很大的课题,如果有兴趣了解的话,还是可以参考张鑫旭大佬写的深入理解CSS中的层叠上下文和层叠顺序 )
直接上图,具体实现和讲解,就先不讨论了。
所以渲染器就从布局树的根节点进行遍历,按照各个维度进行最后的渲染顺序的确认,并生成元素的绘制顺序(paint record)。
绘制一个元素通常需要好几条绘制指令,因为每个元素的背景、前景、边框都需要单独的指令去绘制。所以在图层绘制阶段,输出的内容就是这些待绘制列表。
页面合成(Compositing)-->先分层,栅格化->页面合成
页面合成是一种技术,将页面的各个部分分离成层,分别栅格化它们,并在称为复合线程
的单独线程中复合为页面。如果发生滚动,因为图层已经栅格化了,它所要做的就是合成一个新帧。动画也可以通过移动图层和合成新帧来实现。
分层
现在我们已经知道了,元素之间的绘制顺序,此时如果一股脑的从根节点开始渲染,将会是一项很大的工程,所以渲染引擎为特定的节点生成专用的图层,并生成一棵对应的图层树(LayerTree
)--> 分而治之
浏览器的页面实际上被分成了很多图层,这些图层叠加后合成了最终的页面
分层的依据就是根据层叠上下文
将页面分成不同的图层。
完成了图层树的分割,主线程就开始遍历图层,并且生成一系列渲染记录 -> 用于指示渲染引擎对该图层的渲染顺序。
栅格化(raster)操作
栅格化:将待绘制列表转换为屏幕中的像素
绘制列表只是用来记录绘制顺序和绘制指令的列表,而实际上绘制操作是由渲染引擎中的合成线程
来完成的。
通常一个页面可能很大,但是用户只能看到其中的一部分,我们把用户可以看到的这个部分叫做视口(viewport
)
在有些情况下,有的图层可以很大,比如有的页面你使用滚动条要滚动好久才能滚动到底部,但是通过视口,用户只能看到页面的很小一部分,所以在这种情况下,要绘制出所有图层内容的话,就会产生太大的开销,而且也没有必要。
基于这个原因,合成线程会将图层划分为图块(tile
),这些图块的大小通常是 256x256 或者 512x512
合成线程会按照视口附近的图块来优先生成位图,实际生成位图的操作是由
栅格化
来执行的。所谓栅格化,是指将图块转换为位图
。而图块是栅格化执行的最小单位。渲染进程维护了一个栅格化的线程池
,所有的图块栅格化都是在线程池内执行的
在处理完一帧的数据以后,就会向合成线程就会通过IPC将处理好的数据,返回给浏览器进程用于显示页面。而不占用渲染进程的主进程。
如此往复,直到合成线程中栅格化线程池中数据都被消费掉,页面也就渲染好了。
备注:该篇文章参考了很多资料,算是一个大杂烩。如果大家有兴趣,想看原文,可以直接参考。