0

    浏览器渲染原理及性能优化

    2023.04.12 | admin | 237次围观

    内容导读

    呈现树构建完毕之后,进入“布局”处理阶段,也就是为每个节点分配一个应出现在屏幕上的确切坐标。语法分析:浏览器中的 解析器 负责根据语言的语法规则分析文档的结构,从而构建解析树, HTML 的定义采用了 ==DTD== 格式。下面这段 WebKit 代码描述了根据 display 属性的不同,针对同一个 DOM 节点应创建什么类型的呈现器。HTML 采用基于流的布局模型,这意味着大多数情况下只要一次遍历就能计算出几何信息。但是,浏览器已经很智能了,会尽量把所有的变动集中在一起,排成一个队列,然后一次性执行,尽量避免多次重新渲染。上面代码中,div元素有两个样式变动,但是浏览器只会 触发一次重排和重绘 。

    一. 浏览器简介1. 浏览器种类

    2. 浏览器结构

    用户界面 : 包括地址栏、前进/后退按钮、书签菜单等。除了浏览器主窗口显示的您请求的页面外,其他显示的各个部分都属于用户界面。

    浏览器引擎 : 在用户界面和呈现引擎之间传送指令。

    呈现引擎 : 负责显示请求的内容。如果请求的内容是 HTML,它就负责解析 HTML 和 CSS 内容,并将解析后的内容显示在屏幕上。

    网络 : 用于网络调用,比如 HTTP 请求。其接口与平台无关,并为所有平台提供底层实现。

    用户界面后端 : 用于绘制基本的窗口小部件,比如组合框和窗口。其公开了与平台无关的通用接口,而在底层使用操作系统的用户界面方法。

    JavaScript 解释器 : 用于解析和执行 JavaScript 代码。

    数据存储 : 这是持久层。浏览器需要在硬盘上保存各种数据,例如 Cookie。新的 HTML 规范 (HTML5) 定义了“网络数据库”,这是一个完整(但是轻便)的浏览器内数据库。

    3. 渲染流程简介

    呈现引擎将开始解析 HTML 文档,并将各标记逐个转化成“内容树”上的 DOM 节点。同时也会解析外部 CSS 文件以及样式元素中的样式数据。HTML 中这些带有视觉指令的样式信息将用于创建另一个树结构:呈现树.

    呈现树包含多个带有视觉属性(如颜色和尺寸)的矩形。这些矩形的排列顺序就是它们将在屏幕上显示的顺序。

    呈现树构建完毕之后,进入“布局”处理阶段,也就是为每个节点分配一个应出现在屏幕上的确切坐标。下一个阶段是绘制 - 呈现引擎会遍历呈现树,由用户界面后端层将每个节点绘制出来。

    二. 浏览器渲染流程详解1. 解析

    什么是解析:解析是呈现引擎中非常重要的一个环节,解析文档是指将文档转化成为有意义的结构,也就是可让代码理解和使用的结构。解析得到的结果通常是代表了文档结构的节点树,它称作解析树或者语法树。

    解析的过程:词法分析和语法分析

    解析示例

    构成语言的语法单位是表达式、项和运算符。

    我们用的语言可以包含任意数量的表达式。

    表达式的定义是:一个“项”接一个“运算符”,然后再接一个“项”。

    运算符是加号或减号。

    项是一个整数或一个表达式

    文档:

    2 + 3 - 1

    词法定义:词汇通常用==正则表达式==表示

    词法:我们用的语言可包含整数、加号和减号

    INTEGER :0|[1-9][0-9]* 
    PLUS : +
    MINUS: -

    语法定义:语法通常使用一种称为==BNF==的格式来定义

    expression := term operation term
    operation := PLUS | MINUS
    term := INTEGER | expression

    解析树:

    2. DOM树构建

    文档:

    
    
    
     

    demo

    词法分析: 浏览器中的词法分析器负责将输入内容分解成一个个有效标记.

    语法分析:浏览器中的解析器负责根据语言的语法规则分析文档的结构,从而构建解析树, HTML 的定义采用了 ==DTD== 格式。此格式可用于定义 SGML 族的语言。它包括所有允许使用的元素及其属性和层次结构的定义

    DOM解析树

    3. CSSOM树构建

    文档:

    p, div {
     margin-top: 3px;
    }
    .error {
     color: red;
    }

    词法:

    comment \/\*[^*]*\*+([^/*][^*]*\*+)*\/
    num [0-9]+|[0-9]*"."[0-9]+
    nonascii [\200-\377]
    nmstart [_a-z]|{nonascii}|{escape}
    nmchar [_a-z0-9-]|{nonascii}|{escape}
    name {nmchar}+
    ident {nmstart}{nmchar}*

    语法:

    ruleset
     : selector [ ',' S* selector ]*
     '{' S* declaration [ ';' S* declaration ]* '}' S*
     ;
    selector
     : simple_selector [ combinator selector | S+ [ combinator? selector ]? ]?
     ;
    simple_selector
     : element_name [ HASH | class | attrib | pseudo ]*
     | [ HASH | class | attrib | pseudo ]+
     ;
    class
     : '.' IDENT
     ;
    element_name
     : IDENT | '*'
     ;
    attrib
     : '[' S* IDENT S* [ [ '=' | INCLUDES | DASHMATCH ] S*
     [ IDENT | STRING ] S* ] ']'
     ;
    pseudo
     : ':' [ IDENT | FUNCTION S* [IDENT S*] ')' ]
     ;

    CSSOM解析树

    4. 呈现树构建

    在 DOM 树构建的同时,浏览器还会构建另一个树结构:呈现树。这是由可视化元素按照其显示顺序而组成的树,也是文档的可视化表示。它的作用是让您按照正确的顺序绘制内容。

    Firefox 将呈现树中的元素称为“框架”. WebKit 使用的术语是呈现器或呈现对象。

    呈现器知道如何布局并将自身及其子元素绘制出来。

    WebKits RenderObject 类是所有呈现器的基类,其定义如下:

    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 框,这一点在 CSS2 规范中有所描述。它包含诸如宽度、高度和位置等几何信息。

    框的类型会受到与节点相关的“display”样式属性的影响(请参阅样式计算章节)。下面这段 WebKit 代码描述了根据 display 属性的不同,针对同一个 DOM 节点应创建什么类型的呈现器。

    呈现树和DOM树对照关系:

    呈现树和DOM树对照关系

    呈现树构建示例:

    
     
     

    this is a big error this is also a very big error error

    another error

    1. div {
     margin:5px;color:black
     }
    2. .err {
     color:red
     }
    3. .big {
     margin-top:3px
     }
    4. div span {
     margin-bottom:4px
     }
    5. #div1 {
     color:blue
     }
    6. #div2 {
     color:green
     }

    样式表解析完毕后,系统会根据选择器将 CSS规则添加到某个哈希表中。这些哈希表的选择器各不相同,包括ID、类名称、标记名称等,还有一种通用哈希表,适合不属于上述类别的规则。如果选择器是 ID,规则就会添加到 ID 表中;如果选择器是类,规则就会添加到类表中,依此类推。这种处理可以大大简化规则匹配。我们无需查看每一条声明,只要从哈希表中提取元素的相关规则即可。这种优化方法可排除掉 95% 以上规则,因此在匹配过程中根本就不用考虑这些规则了

    显现树样式计算:使用规则树计算样式上下文树

    CSS样式:

    HTML文档:

    5. 布局(layout)

    呈现器布局:

    呈现器在创建完成并添加到呈现树时,并不包含位置和大小信息。计算这些值的过程称为布局或重排。HTML 采用基于流的布局模型,这意味着大多数情况下只要一次遍历就能计算出几何信息。处于流中靠后位置元素通常不会影响靠前位置元素的几何特征,因此布局可以按从左至右、从上至下的顺序遍历文档。根呈现器的位置左边是 0,0,其尺寸为视口(也就是浏览器窗口的可见区域)。所有的呈现器都有一个“layout”或者“reflow”方法,每一个呈现器都会调用其需要进行布局的子代的 layout 方法。

    Dirty 位系统 :

    为避免对所有细小更改都进行整体布局浏览器工作原理是怎样的,浏览器采用了一种dirty 位系统。如果某个呈现器发生了更改,或者将自身及其子代标注为dirty,则需要进行布局。有两种标记:dirty和children are dirty。children are dirty表示尽管呈现器自身没有变化,但它至少有一个子代需要布局。

    全局布局和增量布局

    影响所有呈现器的全局样式更改,例如字体大小更改。

    屏幕大小调整。

    全局布局: 是指触发了整个呈现树范围的布局,触发原因可能包括:

    增量布局: 可以采用增量方式,也就是只对 dirty 呈现器进行布局(这样可能存在需要进行额外布局的弊端)。

    当呈现器为 dirty 时,会异步触发增量布局。例如,当来自网络的额外内容添加到 DOM 树之后,新的呈现器附加到了呈现树中。

    布局步骤:

    父呈现器确定自己的宽度。

    父呈现器依次处理子呈现器,并且:

    放置子呈现器(设置 x,y 坐标)。如果有必要,调用子呈现器的布局(如果子呈现器是 dirty 的,或者这是全局布局,或出于其他某些原因),这会计算子呈现器的高度。

    父呈现器根据子呈现器的累加高度以及边距和补白的高度来设置自身高度,此值也可供父呈现器的父呈现器使用。

    将其 dirty 位设置为 false。

    6. 绘制(paint)

    呈现器绘制: 本质上就是填充像素的过程。包括绘制文字、颜色、图像、边框和阴影等,也就是一个DOM元素所有的可视效果。一般来说,这个绘制过程是在多个层上完成的。

    在绘制阶段,系统会遍历呈现树,并调用呈现器的“paint”方法,将呈现器的内容显示在屏幕上。绘制工作是使用用户界面基础组件完成的。

    全局绘制和增量绘制

    绘制顺序: 绘制的顺序其实就是元素进入堆栈样式上下文的顺序。这些堆栈会从后往前绘制,因此这样的顺序会影响绘制。块呈现器的堆栈顺序如下:

    背景颜色

    背景图片

    边框

    子代

    轮廓

    三、浏览器事件模型1. 呈现引擎的线程

    呈现引擎采用了单线程。几乎所有操作(除了网络操作)都是在单线程中进行的。在 Firefox 和 Safari 中,该线程就是浏览器的主线程。而在 Chrome 浏览器中,该线程是标签进程的主线程。

    2. 事件循环:

    浏览器的主线程是事件循环。它是一个无限循环,永远处于接受处理状态,并等待事件(如布局和绘制事件)发生,并进行处理。

    3. Javascript单线程模式

    为什么是单线程 : JavaScript的单线程,与它的用途有关。作为浏览器脚本语言,JavaScript的主要用途是与用户互动,以及操作DOM。这决定了它只能是单线程,否则会带来很复杂的同步问题。比如,假定JavaScript同时有两个线程,一个线程在某个DOM节点上添加内容,另一个线程删除了这个节点,这时浏览器应该以哪个线程为准?

    任务队列: 单线程就意味着,所有任务需要排队,前一个任务结束,才会执行后一个任务。如果前一个任务耗时很长,后一个任务就不得不一直等着。

    如果排队是因为计算量大,CPU忙不过来,倒也算了,但是很多时候CPU是闲着的,因为IO设备(输入输出设备)很慢(比如Ajax操作从网络读取数据),不得不等着结果出来,再往下执行。

    JavaScript语言的设计者意识到,这时主线程完全可以不管IO设备,挂起处于等待中的任务,先运行排在后面的任务。等到IO设备返回了结果,再回过头,把挂起的任务继续执行下去。

    于是,所有任务可以分成两种,一种是同步任务,另一种是异步任务。同步任务指的是,在主线程上排队执行的任务,只有前一个任务执行完毕,才能执行后一个任务;异步任务指的是,不进入主线程、而进入任务队列(task queue)的任务,只有"任务队列"通知主线程,某个异步任务可以执行了,该任务才会进入主线程执行。

    Event Loop

    jsEventLoop

    四. 性能优化及调试1. 回顾网页渲染过程:

    HTML代码转化成DOM

    CSS代码转化成CSSOM(CSS Object Model)

    结合DOM和CSSOM,生成一棵渲染树(包含每个节点的视觉信息)

    生成布局(layout),即将所有渲染树的所有节点进行平面合成

    将布局绘制(paint)在屏幕上

    这五步里面,第一步到第三步都非常快,耗时的是第四步和第五步。

    "生成布局"(flow)和"绘制"(paint)这两步,合称为"渲染"(render)。

    2. 重排和重绘

    网页生成的时候,至少会渲染一次。用户访问的过程中,还会不断重新渲染。

    以下三种情况,会导致网页重新渲染。

    重新渲染,就需要重新生成布局和重新绘制。前者叫做"重排"(reflow),后者叫做"重绘"(repaint)。

    需要注意的是,"重绘"不一定需要"重排",比如改变某个网页元素的颜色,就只会触发"重绘",不会触发"重排",因为布局没有改变。但是,"重排"必然导致"重绘",比如改变一个网页元素的位置,就会同时触发"重排"和"重绘",因为布局改变了。

    3. 对于性能的影响

    重排和重绘会不断触发,这是不可避免的。但是,它们非常耗费资源浏览器工作原理是怎样的,是导致网页性能低下的根本原因。

    提高网页性能,就是要降低"重排"和"重绘"的频率和成本,尽量少触发重新渲染。

    前面提到,DOM变动和样式变动,都会触发重新渲染。但是,浏览器已经很智能了,会尽量把所有的变动集中在一起,排成一个队列,然后一次性执行,尽量避免多次重新渲染。

    div.style.color = 'blue';
    div.style.marginTop = '30px';

    上面代码中,div元素有两个样式变动,但是浏览器只会触发一次重排和重绘。

    如果写得不好,就会触发两次重排和重绘。

    div.style.color = 'blue';
    var margin = parseInt(div.style.marginTop);
    div.style.marginTop = (margin + 10) + 'px';

    上面代码对div元素设置背景色以后,第二行要求浏览器给出该元素的位置,所以浏览器不得不立即重排。

    一般来说,样式的写操作之后,如果有下面这些属性的读操作,都会引发浏览器立即重新渲染。

    - offsetTop/offsetLeft/offsetWidth/offsetHeight
    - scrollTop/scrollLeft/scrollWidth/scrollHeight
    - clientTop/clientLeft/clientWidth/clientHeight
    - getComputedStyle()

    所以,从性能角度考虑,尽量不要把读操作和写操作,放在一个语句里面。

    // bad
    div.style.left = div.offsetLeft + 10 + "px";
    div.style.top = div.offsetTop + 10 + "px";
    // good
    var left = div.offsetLeft;
    var top = div.offsetTop;
    div.style.left = left + 10 + "px";
    div.style.top = top + 10 + "px";

    一般的规则是:

    4、提高性能的九个技巧

    有一些技巧,可以降低浏览器重新渲染的频率和成本。

    第一条: 是上一节说到的,DOM 的多个读操作(或多个写操作),应该放在一起。不要两个读操作之间,加入一个写操作。

    第二条:如果某个样式是通过重排得到的,那么最好缓存结果。避免下一次用到的时候,浏览器又要重排。

    第三条: 不要一条条地改变样式,而要通过改变class,或者csstext属性,一次性地改变样式。

    // bad
    var left = 10;
    var 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;";

    第四条: 尽量使用离线DOM,而不是真实的网面DOM,来改变元素样式。比如,操作Document Fragment对象,完成后再把这个对象加入DOM。再比如,使用 cloneNode() 方法,在克隆的节点上进行操作,然后再用克隆的节点替换原始节点。

    第五条:先将元素设为display: none(需要1次重排和重绘),然后对这个节点进行100次操作,最后再恢复显示(需要1次重排和重绘)。这样一来,你就用两次重新渲染,取代了可能高达100次的重新渲染。

    第六条: position属性为absolute或fixed的元素,重排的开销会比较小,因为不用考虑它对其他元素的影响。

    第七条: 只在必要的时候,才将元素的display属性为可见,因为不可见的元素不影响重排和重绘。另外,visibility : hidden的元素只对重绘有影响,不影响重排。

    第八条: 使用虚拟DOM的脚本库,比如React等。

    第九条:使用 window.requestAnimationFrame()、window.requestIdleCallback() 这两个方法调节重新渲染。

    五. 附录1. 渲染总流程图

    浏览器渲染流程

    2. 参考文章

    版权声明

    本文仅代表作者观点。
    本文系作者授权发表,未经许可,不得转载。

    发表评论