(注1:如果有问题欢迎留言探讨,一起学习!本文首发于我的简书,转载请注明出处,喜欢可以点个赞哦!)
(注2:更多内容请查看我的目录。)
在前面一篇文章中,讲到了用户从输入url到看到页面的过程,其中涉及到浏览器的工作机制这一块我们并没有去详细分析。这篇文章,将对浏览器的加载解析渲染机制进行深入地剖析。在这篇文章的写作过程中,我参考了网上大量相关资料,发现有不少文章只有文字,却没有去深入验证。有些看了似懂非懂,有些甚至互相矛盾。实践是检验真理的唯一标准,所以在这篇文章中我进行了实践探索,并在文中放出了详细的代码,大家可以照着这个思路去做更多的探索和验证。文章写的仓促,优化点还没有写完,也没有时间去校验自己的文章。如有错误,请大家不吝指正。写这篇文章前看了大量的参考资料,参考文章若有遗漏,请联系我,如果图片不允许引用,也请联系我删除。
浏览器的主要组件包括:
用户界面(user interface)- 包括地址栏、后退/前进按钮、书签目录等,也就是你所看到的除了用来显示你所请求页面的主窗口之外的其他部分。
浏览器引擎(browser engine)- 用来查询及操作渲染引擎的接口。
渲染引擎(rendering engine)- 用来显示请求的内容,例如,如果请求内容为html,它负责解析html及css,并将解析后的结果显示出来。
网络(Networking)- 用来完成网络调用,例如http请求,它具有平台无关的接口,可以在不同平台上工作。
UI 后端(UI backend)- 用来绘制类似组合选择框及对话框等基本组件,具有不特定于某个平台的通用接口,底层使用操作系统的用户接口。
JS解释器(JavaScript interpreter)- 用来解释执行JS代码。
数据存储(Data storage)- 属于持久层,浏览器需要在硬盘中保存类似cookie的各种数据,HTML5定义了web database技术,这是一种轻量级完整的客户端存储技术。
需要注意的是,不同于大部分浏览器,Chrome为每个Tab分配了各自的渲染引擎实例,每个Tab就是一个独立的进程。
浏览器种类众多,其市场份额如下:
图片摘自tatcounter,显示是2018年2月份的浏览器市场份额。
关于不同浏览器使用的内核,大家有兴趣的话可以阅读这篇文章(五大主流浏览器内核的源起以及国内各大浏览器内核总结)。可以看到目前为止,webkit内核仍然是主流。本篇文章将基于webkit来讨论浏览器工作原理。
渲染引擎是单线程的,除了网络操作以外,几乎所有的事情都在单一的线程中处理,在Firefox和Safari中,这是浏览器的主线程,Chrome中这是tab的主线程。
网络操作由几个并行线程执行,并行连接的个数是受限的(通常是2-6个)。
渲染引擎首先通过网络获得所请求文档的内容,通常以8K分块的方式完成。
下面是渲染引擎在取得内容之后的基本流程:
解析html以构建dom树->构建render树->布局render树->绘制render树
关于webkit的主流程,或者准确说页面的加载解析渲染流程,大家可以参考一下三幅图:
渲染引擎开始解析HTML/SVG/XHTML,并将标签转化为dom tree中的dom节点。接着,它解析外部CSS文件及style标签中的样式信息生成rule tree。dom tree和rule tree结合生成render tree。
Render tree由一些包含有颜色和大小等属性的矩形组成,它们将被按照正确的顺序显示到屏幕上。
Render tree构建好了之后,将会执行布局过程,它将确定每个节点在屏幕上的确切坐标。再下一步就是绘制,即遍历render tree,并使用UI后端层绘制每个节点。
值得注意的是,这个过程是逐步完成的,为了更好的用户体验,渲染引擎将会尽可能早的将内容呈现到屏幕上,并不会等到所有的html都解析完成之后再去构建和布局render tree。它是解析完一部分内容就显示一部分内容,同时,可能还在通过网络下载其余内容。
渲染引擎首先通过网络获得所请求文档的内容,通常以8K分块的方式完成。
html下载完成以后。浏览器的html paser开始对html从上至下进行解析生成DOM tree。
当遇到以下情况时,DOM树的构建会被阻塞:
1. HTML的响应流被阻塞在了网络中。
2. 有未加载完的脚本。
3. 遇到了script节点,但是此时还有未加载完的样式文件。
解析结束时,浏览器将文档标记为可交互的,并开始解析处于延时模式中的脚本——这些脚本在文档解析后执行。文档状态将被设置为完成,同时触发一个DomContendLoaded事件。
输出的树,也就是解析树,是由DOM元素及属性节点组成的。DOM是文档对象模型的缩写,它是html文档的对象表示,作为html元素的外部接口供js等调用。
树的根是“document”对象。
DOM和标签基本是一一对应的关系,例如,如下的标签:
<html>
<body>
<p>
Hello DOM
p>
<div><img src=”example.png” />div>
body>
html>
在html解析的过程中,遇到style标签会直接解析,而遇到link标签会去加载样式表。理论上,既然样式表不改变Dom树,也就没有必要停下文档的解析等待它们,然而,存在一个问题,脚本可能在文档的解析过程中请求样式信息,如果样式还没有加载和解析,脚本将得到错误的值,显然这将会导致很多问题,这看起来是个边缘情况,但确实很常见。Firefox在存在样式表还在加载和解析时阻塞所有的脚本,而chrome只在当脚本试图访问某些可能被未加载的样式表所影响的特定的样式属性时才阻塞这些脚本。
这里的阻塞js,是指阻塞其加载,还是阻塞其执行呢?稍后我们具体分析一下。
Webkit使用Flex和Bison解析生成器从CSS语法文件中自动生成解析器。Bison创建一个自底向上的解析器,Firefox使用自顶向下解析器。它们都是将每个css文件解析为样式表对象,每个对象包含css规则,css规则对象包含选择器和声明对象,以及其他一些符合css语法的对象。
web的模式是同步的,开发者希望解析到一个script标签时立即解析执行脚本,并阻塞文档的解析直到脚本执行完。如果脚本是外引的,则网络必须先请求到这个资源——这个过程也是同步的,会阻塞文档的解析直到资源被请求到。这个模式保持了很多年,并且在html4及html5中都特别指定了。开发者可以将脚本标识为defer,以使其不阻塞文档解析,并在文档解析结束后执行。Html5增加了标记脚本为异步的选项,以使脚本的解析执行使用另一个线程。
Webkit和Firefox都做了预解析的优化,当执行脚本时,另一个线程解析剩下的文档,并加载后面需要通过网络加载的资源。这种方式可以使资源并行加载从而使整体速度更快。需要注意的是,预解析并不改变Dom树,它将这个工作留给主解析过程,自己只解析外部资源的引用,比如外部脚本、样式表及图片。
当Dom树构建完成时,浏览器开始构建另一棵树——渲染树。渲染树由元素显示序列中的可见元素组成,它是文档的可视化表示,构建这棵树是为了以正确的顺序绘制文档内容。
每个渲染对象用一个和该节点的css盒模型相对应的矩形区域来表示,正如css2所描述的那样,它包含诸如宽、高和位置之类的几何信息。盒模型的类型受该节点相关的display样式属性的影响。
渲染对象和Dom元素相对应,但这种对应关系不是一对一的,不可见的Dom元素不会被插入渲染树,例如head元素。另外,display属性为none的元素也不会在渲染树中出现(visibility属性为hidden的元素将出现在渲染树中)。
还有一些Dom元素对应几个可见对象,它们一般是一些具有复杂结构的元素,无法用一个矩形来描述。例如,select元素有三个渲染对象——一个显示区域、一个下拉列表及一个按钮。同样,当文本因为宽度不够而折行时,新行将作为额外的渲染元素被添加。另一个多个渲染对象的例子是不规范的html,根据css规范,一个行内元素只能仅包含行内元素或仅包含块状元素,在存在混合内容时,将会创建匿名的块状渲染对象包裹住行内元素。
一些渲染对象和所对应的Dom节点不在树上相同的位置,例如,浮动和绝对定位的元素在文本流之外,在两棵树上的位置不同,渲染树上标识出真实的结构,并用一个占位结构标识出它们原来的位置。
前面几节,我们讲到了dom tree,cssom或者render tree的构建过程中,可能会被css的加载解析或者js的加载解析执行所阻塞,另外css资源和js资源之间也可能产生阻塞。现在我,我们来详细分析一下这些阻塞情况。
我们先建立几个文件,如下:
<html lang="en">
<head>
<meta charset="UTF-8">
<title>testtitle>
<script defer src="./test.js">script>
<style>
div {
width: 100px;
height: 100px;
background: red;
}
style>
<link rel="stylesheet" href="./test.css?defer=true">
head>
<body>
<div>div>
body>
html>
/*test.css*/
div {
background: blue;
}
// test.js
const div = document.getElementsByTagName('div')[0];
console.log(div);
// server.js
var http = require('http');
var URL = require('url');
var fs = require('fs');
var server = http.createServer(function (req, res) {
if (req.method != 'GET') {
return res.end('send me a get request\n');
} else {
var url = URL.parse(req.url, true);
var params = url.query;
if (url.pathname === '/test.html') {
res.writeHead(200, {'Content-Type': 'text/html'});
fs.createReadStream('test.html').pipe(res);
} else if (url.pathname === '/test.css') {
res.writeHead(200, {'Content-Type': 'text/css'});
if (params.defer) {
setTimeout(function(){fs.createReadStream('test.css').pipe(res)}, 3000);
} else {
fs.createReadStream('test.css').pipe(res);
}
} else if (url.pathname === '/test.js') {
res.writeHead(200, {'Content-Type': 'application/javascript'});
if (params.defer) {
setTimeout(function(){fs.createReadStream('test.js').pipe(res)}, 3000);
} else {
fs.createReadStream('test.js').pipe(res);
}
}
}
});
server.listen(8888);
console.log('sever start');
进入该文件夹,运行命令node server.js。打开localhost:8888/test.html,会发现控制台打印div以后3秒页面才出现一个蓝色方块。
我们来分析一下,defer 属性用来通知浏览器该脚本将在文档完成解析后,触发 DOMContentLoaded 事件前执行。设置这个属性,能保证 DOM 解析后马上打印出 div ,也就是说控制台打印div说明dom tree构建完毕。从gif图可以看出css文件的加载没有阻塞DOM tree的构建,但是阻塞了render tree的构建。
如果 CSS 不会阻塞页面阻塞渲染,那么 CSS 文件下载之前,浏览器就会渲染出一个红色的 div ,之后再变成蓝色。浏览器的这个策略其实很明智的,想象一下,如果没有这个策略,页面首先会呈现出一个原始的模样,待 CSS 下载完之后又突然变了一个模样。用户体验可谓极差,而且渲染是有成本的。
因此,基于性能与用户体验这两点的考虑,浏览器会尽量减少渲染的次数, CSS 顺理成章地阻塞页面渲染。
现在,将test.html修改如下:
<html lang="en">
<head>
<meta charset="UTF-8">
<title>testtitle>
<link rel="stylesheet" href="./test.css?defer=true">
<script src="./test.js">script>
<style>
div {
width: 100px;
height: 100px;
background: red;
}
style>
head>
<body>
<div>div>
body>
html>
会有如下情况发生:
会发现,css文件在js文件之前时,css和js文件虽然都下载了,但是js的执行被阻塞了(网上很多blog说这里css阻塞了js的加载是不对的,应该是阻塞了js的执行),导致DOM tree的构建被阻塞了。
这里,我们稍作修改,给script加上defer
<script defer src="./test.js">script>
这里,由于script延迟执行,所以就不会阻塞DOM tree的构建了。
所以,我们总结一下:
1. css如果在js之前,会阻塞js的执行,从而阻塞DOM tree构建
2. 要想不阻塞DOM tree构建,需要将js在body底部或者使用defer
我们将test.html修改如下:
<html lang="en">
<head>
<meta charset="UTF-8">
<title>testtitle>
<style>
div {
width: 100px;
height: 100px;
background: red;
}
style>
head>
<body>
<div>div>
<script src="./test.js?defer=true">script>
<link rel="stylesheet" href="./test.css">
<div>div>
body>
html>
会有如下情景:
可以看到,test.js的加载并没有阻塞test.css的加载,这是由于浏览器的预解析优化,会新开一个线程预加载后续资源。但是开始在页面只有一个DIV,说明DOM tree构建确实被阻塞了。而且在test.jss执行过程中,浏览器已经将渲染好的一个红色div呈现给了用户。
因为浏览器不知道脚本的内容,因而碰到脚本时,只好先渲染页面,确保脚本能获取到最新的DOM元素信息,尽管脚本可能不需要这些信息。
我们分析如上几种情况,总结如下:
html解析的过程中遇到script时,如果是嵌入脚本,会执行并阻塞dom tree构建;如果是外链JS脚本,则会进行加载后执行,并阻塞dom tree构建。但不管怎样,由于浏览器的预解析优化,会新开一个线程加载后续资源。并且,为了确保js能拿到最新的DOM元素信息 CSSOM信息,js执行前会等待css加载完毕并渲染页面。
看到这里,想必大家对浏览器加载解析渲染机制已经有了比较清晰的认识。下一篇,我们将对照这篇文章分析一下这个过程中可以帮助提高性能的优化点。
http://taligarsiel.com/Projects/howbrowserswork1.htm
[译]How browsers work
了解html页面的渲染过程
浏览器加载网页时的过程是什么?-creative web developer回答
浏览器加载、解析、渲染的过程
涨知识!原来CSS与JS是这样阻塞DOM解析和渲染的