【转】浏览器的渲染原理

How Browsers Work 这篇文章把浏览器的很多细节讲的很细,也有中文的翻译版本,现在转载的这篇是陈皓写的,目的的为了能在上班途中,或是坐马桶时就能读完,并能从中学会一些能用在工作上的东西。

无论是作为开发,还是作为黑客,企图从Web 端注入 SQL,或是XSS 的时候,编码和解码都是一个重要的问题。作为浏览器,有URL解析引擎,有HTML解析引擎,还有JS解析引擎。其执行的先后顺序往往决定了输出的结果。这种多标签语言嵌入的,同时又需要客户端服务交互技术,正是给了XSS 可趁之机。

 

我们为什么要了解浏览器加载、解析、渲染这个过程呢?

这是因为想写出一个最佳实践的页面,就要好好了解:

* 了解浏览器如何进行加载,可以在引用外部样式文件,如外部js时将它们放到合适的位置,使浏览器以最快的速度将文件加载完毕;

* 了解浏览器如何进行渲染,可以在构建DOM结构,组织css选择器时,选择最优的写法,提高浏览器的解析速率;

* 了解浏览器如何进行渲染,明白渲染的过程,在设置元素属性,编写JS文件时,可以减少“reflow”和“repaint”的消耗。

 

下面我们要做的就是去了解浏览器到底如何解码,该如何在解码过程中避免漏洞的产生。在此之上,我们先来看看整个浏览器的工作流程。

浏览器主要功能

浏览器的主要功能是将用户选择的Web资源呈现出来,它需要从服务器请求资源,并将其显示在浏览器窗口中,资源的格式通常是HTML,也包括PDF、Image和其他格式。用户通过URL(Uniform Resource Identifier 统一资源标识符)来指定所请求资源的位置,通过DNS查询,将网址转换为IP地址。整个浏览器工作的流程,概述如下:

1) 输入网址

2) 浏览器查找域名的IP地址

3) 浏览器给Web服务器发送一个HTTP请求

4) 网站服务的永久重定向响应

5) 浏览器跟踪重定向地址获得要访问的正确地址,然后会向服务器发送一个获取请求

6) 服务器接受到获取请求,处理并返回一个响应

7) 服务器发回一个HTML响应

8) 浏览器开始显示HTML

9) 浏览器发送请求,以获取嵌入在HTML中的对象。

在浏览器显示HTML时,它会注意获取其他地址内容的标签。这时,浏览器会发送一个获取请求来重新获得这些文件。这些文件就包括CSS/JS/图片等资源,这些资源的地址都要经历一个和HTML读取类似的过程。所以浏览器会在DNS中查找这些域名,发送请求,重定向等等。

 

那么一个页面,究竟是如何从我们输入一个网址到最后完整地呈现在我们面前呢?还需要了解浏览器是如何渲染的:

浏览器的渲染

下图是渲染引擎在取得内容后的基本流程

解析HTML以构建DOM树 —— 构建render树 —— 布局render树 —— 绘制render树

 

从图中,可以看到:

1) 浏览器会解析三个东西

* 一个 HTML/SVG/XHTML,解析这三种文件会产生一个DOM Tree
* CSS,解析CSS会产生CSS规则树
* JavaScript 脚本,主要是通过 DOM API 和 CSSOM API来操作 DOM Tree 和 CSS Rule Tree

当浏览器获得一个HTML文件时,会自上而下加载,并在加载过程中进行渲染。

* 浏览器会将HTML解析成一个DOM树,DOM的构建过程是一个深度遍历的过程:当前节点的所有子节点都构建好后才会去构建当前节点的下一个兄弟节点。
* 将CSS解析成 CSS Rule tree。

2) 解析完成后,浏览器引擎会通过 DOM Tree 和CSS Rule Tree 来构造 Rendering Tree。

注意:

* Rendering Tree 渲染树并不等同于 DOM Tree,因为一些像 Header 或 display:none 的东西就没必要放在渲染树中。
* CSS 的Rule Tree 主要是为了完成匹配并把CSS Rule附加上 Rendering Tree 上的每个 Element,也就是 DOM 节点,也就是所谓的 Frame。
* 有了Render Tree,浏览器已经能知道网页中有哪些节点、各个节点的CSS定义以及它们的从属关系。
* 然后,计算每个 Frame(也就是每个Element)的位置,这又叫 layout 和 reflow 过程。

3) 最后通过调用操作系统Native GUI的API 绘制 

即遍历render树,并使用UI后端层绘制每个节点

 

上述的这个过程是逐步完成的,为了更好的用户体验,渲染引擎将会将可能的将内容呈现在屏幕上,并不会等到所有的HTML都解析完成之后再去构建和布局render tree。

它是解析完一部分内容就显示一部分内容,同时可能还通过网络下载其他内容。

 

 

 

DOM 解析

HTML 的 DOM Tree解析如下:

<html>
<html>
<head>
    <title>Web page parsing</title>
</head>
<body>
    <div>
        <h1>Web page parsing</h1>
        <p>This is an example Web page.</p>
    </div>
</body>
</html>

上面这段HTML 会解析成这样:

下面是另一个有 SVG 标签的情况:

 

CSS 解析

CSS 的解析大概是下面这个样子(下面主要说的是 Gecko,也就是 Firefox的玩法),假设我们有下面的HTML文档:

<doc>
<title>A few quotes</title>
<para class="emph">
  Franklin said that <quote>"A penny saved is a penny earned."</quote>
</para>
<para>
  FDR said <quote>"We have nothing to fear but <span class="emph">fear itself.</span>"</quote>
</para>
</doc>

于是DOM Tree 是这个样子:

然后我们的CSS文档是这样的:

/* rule 1 */ doc { display: block; text-indent: 1em; }
/* rule 2 */ title { display: block; font-size: 3em; }
/* rule 3 */ para { display: block; }
/* rule 4 */ [class="emph"] { font-style: italic; }

于是,我们的CSS Rule Tree 会是这样的:

图中的第四条规则出现了两次,一次是独立的,一次是在规则3的子节点。所以,可以知道建立CSS Rule Tree是需要比照着 DOM Tree来的。CSS 匹配DOM Tree 主要是从右到左解析CSS 的 Selector。所以#nav li 我们以为这是一条很简单的规则,很容易就能匹配到想要的元素,但是CSS会先去找所有的li 元素,然后再去确定它的父元素是不是#nav。

注意:CSS 匹配HTML 元素是一个相当复杂和有性能问题的事情。所以你可能会在N多地方看到很多人都告诉你,DOM 树要小,CSS 尽量用id 和 class,千万不要过渡层叠下去。

* dom深度尽量浅。
* 减少inline javascript、css的数量。
* 使用现代合法的css属性。
* 不要为id选择器指定类名或是标签,因为id可以唯一确定一个元素。
* 避免后代选择符,尽量使用子选择符。原因:子元素匹配符的概率要大于后代元素匹配符。后代选择符;#tp p{} 子选择符:#tp>p{}
* 避免使用通配符,举一个例子,.mod .hd *{font-size:14px;} 根据匹配顺序,将首先匹配通配符,也就是说先匹配出通配符,然后匹配.hd(就是要对dom树上的所有节点进行遍历他的父级元素),然后匹配.mod,这样的性能耗费可想而知.

 

通过这两个树,我们可以得到一个叫做 Style Context Tree,也就是下面这样(把CSS Rule 节点 Attach 到 DOM Tree上):

所以,Firefox 基本上来说是通过 CSS解析生成 CSS Rule Tree。然后,通过比对 DOM生成 Style Context Tree,然后 Firefox通过把 Style Context Tree 和其 Render Tree(Frame Tree)关联上,就完成了。注意:Render Tree会把一些不可见的节点去除掉。而 Firefox 中所谓的Frame 就是一个 DOM节点

 

注:Webkit 不像Firefox 要用两个树来干这个,Webkit 也有 Style对象,它直接把这个 Style对象存在了相应的 DOM节点上了。

 

渲染

渲染的流程基本上如下(黄色的四个步骤):

1.计算CSS样式

2.构建Render Tree

3.Layout - 定位坐标和大小,是否换行,各种position,overflow,z-index属性等等

4.正式开画

注意:上图流程中很多连接线,这表示了 JavaScript动态修改了DOM 属性或CSS 属性,有些改变会导致重置 Layout,而不如那些指向天上的箭头的改变则不会,比如修改后的CSS Rule没有被匹配到等等。

 

这里重要说说两个概念,一个是  Repaint,一个是 Reflow,这两个不是一回事。

* Repaint(重绘) —— 屏幕一部分要重画,比如某个元素的背景颜色、文字颜色发生改变等,但是元素的几何尺寸没有变,并不影响元素周围和内容布局属性,将只会引起浏览器的 repaint,重画某一部分。
* Reflow(回流) —— 浏览器要花时间去渲染,当它发现了某个部分发生的变化影响了布局,即意味着元件的几何尺寸变了,我们需要重新验证并计算 Render Tree 进行重新渲染。Render Tree的一部分或全部发生了变化,这就是Reflow,或者说是Layout (HTML 使用的是 flow based layout,也就是流式布局,所以如果某元件的几何尺寸发生了变化,需要重新布局,也就是 Reflow)。reflow 会从 <html> 这个root frame 开始递归往下,依此计算所有节点的几何尺寸和位置。在 reflow 的过程中,可能会增加一些 frame,比如一个文本字符串必须被包装起来。

 

下面是一个打开 Wikipedia 时的Layout/Reflow 的视频(注:HTML 在初始化时也会做一次 reflow,叫 intial reflow),你可以感受下:

 

Reflow 的成本比Repaint的成本高很多。DOM Tree里的每个节点都会有 reflow 方法,一个节点的 reflow 很有可能导致子节点,甚至父节点以及同级节点的 reflow。在一些高性能的电脑上也许还没什么,但是如果 reflow 发生在一些手机上,那么这个过程是非常痛苦和耗电的。

 

所以,下面这些动作有很大可能是成本比较高的:

* 当你增加、删除、修改DOM节点时,会导致Reflow 或Repaint

* 当你移动DOM位置,或是搞个动画的时候

* 当你修改CSS样式的时候

* 当你Resize 窗口的时候(移动端没有这个问题),或是滚动的时候

* 当你修改网页的默认字体的时候

注:display:none 会触发 reflow,而 visibility:hidden 只会触发 repaint,因为没有发现位置变化

 

关于滚动,通常来说如果在滚屏的时候,我们的页面上的所有的图像都会跟着滚动,那么性能上没有什么问题,因为我们的显卡对于这种把全屏像素往上往下移的算法是很快的。但是如果你有一个 fixed的背景图,或是有些 Element不跟着滚动,有些 Element是动画,那么这个滚动的动作对于浏览器来说会是一个相当痛苦的过程。你可以看到很多这样的网页在滚动的时候性能有多差,因为滚屏也有可能造成 reflow。

 

基本来说,reflow 有如下几个原因:

* Initail:网页初始化的时候

* Incremental:一些JavaScript在操作DOM Tree的时候

* Resize:某些元件的尺寸发生变化时

* Style Change:CSS属性发生了变化

* Dirty:几个 Incremental 的reflow 发生在同一个 frame 的子树上

 

我们来看一个示例:

var bstyle = document.body.style; // cache
 
bstyle.padding = "20px"; // reflow, repaint
bstyle.border = "10px solid red"; //  再一次的 reflow 和 repaint
 
bstyle.color = "blue"; // repaint
bstyle.backgroundColor = "#fad"; // repaint
 
bstyle.fontSize = "2em"; // reflow, repaint
 
// new DOM element - reflow, repaint
document.body.appendChild(document.createTextNode('dude!'));

当然,我们的浏览器是聪明的,它不会像上面那样,每一次修改样式就会 reflow 或 repaint。一般来说,浏览器会把这样的操作积攒一批,然后做一次 reflow,这叫异步reflow 或增量异步 reflow。但是有些情况浏览器是不会这样做,比如:resize 窗口,改变了页面默认的字体等。对于这些操作,浏览器会马上进行 reflow。

 

但是有些时候,我们的脚本会阻止浏览器这么干,比如如果我们请求下面的一些 DOM值:

* offsetTop/Left/Width/Height

* scrollTop/Left/Width/Height

* clientTop/Left/Width/Height

* IE中的 getComputedStyle() 或 currentStyle

因为如果我们需要这些值,那么浏览器需要返回最新的值,而一样会会 flush出去一些样式的改变,从而造成频繁的 reflow/repaint

 

减少reflow/repaint

下面是一些Best Practices:

1) 不要一条一条的修改DOM样式。与其这样,还不如预先定义好css的class,然后修改DOM 的className

// bad
var left = 10,
top = 10;
el.style.left = left + "px";
el.style.top  = top  + "px";
 
// Good
el.className += " theclassname";
 
// Good
el.style.cssText += "; left: " + left + "px; top: " + top + "px;";

2) 把DOM离线后修改,如:

* 使用 documentFragment 对象在内存里操作 DOM
* 先把 DOM给 display:none(有一次 reflow),然后想怎么改就怎么改,比如修改100次,然后再把它显示出来
* clone 一个DOM 节点到内存里,然后想怎么改就怎么改,改完后再和在线的那个交换一下

3) 不要把DOM节点的属性值放在一个循环里或者当成循环里的变量。不然,这会导致大量的读写这个节点的属性。

4) 尽可能地修改层级比较低的DOM。当然,改变层级比较低的 DOM有可能造成大面积的reflow,但是也可能影响范围很小。

5) 为动画的HTML元件使用fixed或absolute的position。那么修改他们的CSS是不会reflow的。

6) 千万不要使用 table 布局。因为很小的改动就会造成整个 table 的重新布局。

Fixed layout, CSS 2.1 Specification

In this manner, the user agent can begin to lay out the table once the entire first row has been received. Cells in subsequent rows do not affect column widths. Any cell that has content that overflows uses the ‘overflow’ property to determine whether to clip the overflow content.

Automatic layout, CSS 2.1 Specification

This algorithm may be inefficient since it requires the user agent to have access to all the content in the table before determining the final layout and may demand more than one pass.

 

几个工具和几篇文章

有时候,你会发现在IE下,当你不知道修改了什么东西,结果CPU一下子就上到100%,然后过了好几秒钟 repaint/reflow 才完成,这种事情在IE时代时常发生。所以,我们需要一些工具来帮助我们查看我们的代码是否有什么不合适的东西。

* Chrome下,Google的 SpeedTracer 是个非常强悍的工具,能让你查看你的浏览器渲染本有多大。其实Safari 和 Chrome都可以使用开发者工具里的一个Timeline的工具。

* Firefox下基于Firebug 的叫 Firebug Paint Events 插件也不错

* IE下可以用一个叫 dynaTrace 的IE扩展

 

最后分享几篇提高浏览器性能的文章:

Google – Web Performance Best Practices

Yahoo – Best Practices for Speeding Up Your Web Site

Steve Souders – 14 Rules for Faster-Loading Web Sites

 

最后简单总结下浏览器加载、解析、渲染过程

1. 用户输入网址(假设是个html页面,并且是第一次访问),浏览器向服务器发出请求,服务器返回html文件; 
2. 浏览器开始载入html代码,发现<head>标签内有一个<link>标签引用外部CSS文件; 
3. 浏览器又发出CSS文件的请求,服务器返回这个CSS文件; 
4. 浏览器继续载入html中<body>部分的代码,并且CSS文件已经拿到手了,可以开始渲染页面了; 
5. 浏览器在代码中发现一个<img>标签引用了一张图片,向服务器发出请求。此时浏览器不会等到图片下载完,而是继续渲染后面的代码; 
6. 服务器返回图片文件,由于图片占用了一定面积,影响了后面段落的排布,因此浏览器需要回过头来重新渲染这部分代码; 
7. 浏览器发现了一个包含一行Javascript代码的<script>标签,赶快运行它; 
8. Javascript脚本执行了这条语句,它命令浏览器隐藏掉代码中的某个<div> (style.display=”none”)。突然少了这么一个元素,浏览器不得不重新渲染这部分代码; 
9. 终于等到了</html>的到来,浏览器泪流满面…… 
10. 等等,还没完,用户点了一下界面中的“换肤”按钮,Javascript让浏览器换了一下<link>标签的CSS路径; 
11. 浏览器召集了在座的各位<div><span><ul><li>们,“大伙儿收拾收拾行李,咱得重新来过……”,浏览器向服务器请求了新的CSS文件,重新渲染页面。

 

相关文章: 

陈皓 - 浏览器的渲染原理简介

德来 - JS 一定要放在 Body 的最底部么?聊聊浏览器的渲染机制

浏览器加载和渲染html的顺序-css渲染效率的探究

posted @ 2017-03-22 22:04 叶超Luka 阅读(...) 评论(...) 编辑 收藏