身为前端,打交道最多的就是浏览器和node了,也是我们必须熟悉的。接下来我们讲一下浏览器工作原理和工作过程。从url到页面的过程,......,我们直接来到收到服务器返回内容部分开始。
先上很多人都见过的一幅图:
还有一幅图:
浏览器主要组成部分:
body
元素的width变化会影响其后代元素的宽度。因此,布局过程是经常发生的。词法分析器将输入内容分解成一个个有效标记,解析器负责根据语言的语法规则分析文档的结构来构建解析树。词法分析器知道如何将无关的字符(空格、换行符等)分离出来,所以我们平时写一些空格也不会影响大局。
在语法分析的过程中,解析器会向词法分析器请求一个标记(就是前面分解出来的标记),并尝试将其与某条语法规则(比如标签要闭合、正确嵌套)进行匹配。如果发现了匹配规则,解析器会将一个对应于该标记的节点添加到解析树中,然后继续请求下一个标记。
如果没有规则可以匹配,解析器就会将标记存储到内部,并继续请求标记,直至找到可与所有内部存储的标记匹配的规则(如div多层嵌套的情况,这样子能找到div闭合部分)。如果找不到任何匹配规则,解析器就会引发一个异常。这意味着文档无效,包含语法错误。
解析器类型有两种:
编译:将源代码编译成机器代码,源代码先走完解析的过程形成成解析树,解析树被翻译成机器代码文档,完成编译的过程
特殊的是,恰好html不能用上面两种解析方法。有一种可以定义 HTML 的正规格式:DTD,但它不是与上下文无关的语法,html明显是和上下文关系紧密的。我们知道 HTML 是有点“随意”的,对于不闭合的或者不正确嵌套标签有可能不报错,并且尝试解释成正确的样子,具有一定的容错机性,因此可以达到简化网络开发的效果。另一方面,这使得它很难编写正式的语法。概括地说,HTML 无法很容易地通过常规解析器解析(因为它的语法不是与上下文无关的语法),所以采用了 DTD 格式。
解析器解析html文档的解析树是由 DOM 元素和属性节点构成的树结构。它是 HTML 文档的对象表示,同时也是外部内容(例如 JavaScript)与 HTML 元素之间的api,其根节点是document。上面已经说到,不能使用常规的解析技术解释html,浏览器就创建了自定义的解析器来解析 。对于HTML/SVG/XHTML这三种文档,Webkit有三个C++的类对应这三种文档,并产生一个DOM Tree。解释html成dom的过程,由两个阶段组成:标记化和树构建。
对于一段html:
<html>
<body>
hi
</body>
</html>
复制代码
该算法使用状态机来表示。每一个状态接收来自输入信息流的一个或多个字符,并根据这些字符更新下一个状态。当前的标记化状态和树结构状态会影响进入下一状态的决定。
初始状态是数据状态。遇到字符 < 时,状态更改为“标记打开状态”。接收一个字母会创建“起始标记”,状态更改为“标记名称状态”。这个状态会一直保持到接收 > 字符,接收到将会进入“标记打开状态”。在此期间接收的每个字符都会附加到新的标记名称上。
<body>
标记也会进行同样的处理。现在 html 和 body 标记均已发出,而且目前是“数据状态”。接收到 hi中的 h 字符时,将创建并发送字符标记,直到接收 </body>
中的 <。我们将为hi的每个字符都发送一个字符标记。
</html>
输入也会进行同样的处理。
在创建解析器的同时也会创建 document 对象。在树构建阶段,以 Document 为根节点的 DOM 树也会不断进行修改,向其中添加各种元素。标记生成器发送的每个节点都会由树构建器进行处理。
结束后,此时文档被标注为交互状态,浏览器开始解析那些script标签上带有“defer”脚本,也就是那些应在文档解析完成后才执行的脚本,文档状态将设置为“完成”,执行完毕触发DOMContentLoaded事件(当初始的 HTML 文档被完全加载和解析完成之后,DOMContentLoaded 事件被触发,不会等待样式表、图像和iframe的完成加载)。
解析CSS会产生CSS规则树,前面已经说到,html不是与上下文无关的语法,而css和js是与上下文无关的语法,所以常规的解析方法都可以用。对于建立CSS 规则树,是需要比照着DOM树来的。CSS匹配DOM树主要是从右到左解析CSS选择器。解析CSS的顺序是浏览器的样式 -> 用户自定义的样式 -> 页面的link标签等引进来的样式 -> 写在style标签里面的内联样式
样式表不会更改 DOM 树,因此没有必要等待样式表并停止文档解析。而脚本在文档解析阶段会请求样式信息时还没有加载和解析样式,脚本就会获得错误的回复。Firefox 在样式表加载和解析的过程中,会禁止所有脚本。而对于 WebKit 而言,仅当脚本尝试访问的样式属性可能受尚未加载的样式表影响时,它才会禁止该脚本。
所以为了用户体验,后来有了async和defer,将脚本标记为异步,不会阻塞其他线程解析和执行。标注为“defer”的script不会停止文档解析,而是等到解析结束才执行;标注为“async”只能引用外部脚本,下载完马上执行,而且不能保证加载顺序。
脚本的预解析:在执行脚本时,其他线程会解析文档的其余部分,找出并加载需要通过网络加载的其他资源。通过这种方式,资源可以在并行连接上加载,从而提高总体速度。请注意,预解析器不会修改 DOM 树,而是将这项工作交由主解析器处理;预解析器只会解析外部资源(例如外部脚本、样式表和图片)的引用。
脚本主要是通过DOM API和CSSOM API来操作DOM Tree和CSS Rule Tree.
另外,我们又可以想到一个问题,为什么jsonp能response一个类eval字符串就马上执行呢?其实也是因为普通的script标签解析完成就马上执行,我们在服务器那边大概是这样子返回: res.end('callback('+data+')')
整个过程,就是:动态创建script标签,src为服务器的一个get请求接口,遇到src当然马上请求服务器,然后服务器返回处理data的callback函数这样子的代码。其实,我们可以看作是前端发get请求,服务端响应文档是js文件,而且这个文件只有一行代码:callback(data)。当然你可以写很多代码,不过一般没见过有人这么干。
html、css、js解析完成后,浏览器引擎会通过DOM Tree 和 CSS Rule Tree 来构造 Rendering Tree(渲染树)。
构建渲染树之前,需要计算每一个呈现对象的可视化属性。这是通过计算每个元素的样式属性来完成的。
Firefox:CSS 解析生成 CSS Rule Tree,通过比对DOM生成Style Context Tree,然后Firefox通过把Style Context Tree和其Render Tree(Frame Tree)关联上完成样式计算
Webkit:把Style对象直接存在了相应的DOM结点上了
样式被js改变过的话,会重新计算样式(Recalculate Style)。Recalculate被触发的时,处理脚本给元素设置的样式。Recalculate Style会计算Render树(渲染树),然后从根节点开始进行页面渲染,将CSS附加到DOM上的过程。所以任何企图改变元素样式的操作都会触发Recalculate,在JavaScript执行完成后才触发的,下面将会讲到的layout也是。
Firefox:系统会针对 DOM 更新注册展示层,作为侦听器。展示层将框架创建工作委托FrameConstructor,由该构造器解析样式并创建frame。
WebKit:解析样式和创建呈现器的过程称为“附加”。每个 DOM 节点都有一个“attach”方法。附加是同步进行的,将节点插入 DOM 树需要调用新的节点“attach”方法。
处理 html 和 body 标记就会构建渲染树根节点。这个根节点呈现对象对应于 CSS 规范中所说的容器 block,这是最上层的 block,包含了其他所有 block。它的尺寸就是视口,即浏览器窗口显示区域的尺寸。Firefox 称之为 ViewPortFrame,而 WebKit 称之为 RenderView。这就是文档所指向的呈现对象。渲染树的其余部分以 DOM 树节点插入的形式来构建。
呈现器在创建完成并添加到渲染树时,并不包含位置和大小信息。**计算这些值的过程**称为布局(layout)或重排(repaint)。这个得记住了,记准确了!为什么呢?计算offsetWidth和offsetHeight的、js操作dom、改变style属性时候,都会引发重排!
前面通过样式计算确定了每个DOM元素的样式,这一步就是具体计算每个DOM元素最终在屏幕上显示的大小和位置。Web页面中元素的布局是相对的,因此一个元素的布局发生变化,会联动地引发其他元素的布局发生变化。比如,元素的width变化会影响其后代元素的宽度。因此,layout过程是经常发生的。
HTML 是流式布局,这意味着大多数情况下只要一次遍历就能计算出几何信息。处于流中靠后位置元素通常不会影响靠前位置元素的几何特征,因此布局可以按从左至右、从上至下的顺序遍历文档。坐标系是相对于根节点而建立的,使用的是上坐标和左坐标。根呈现器的位置左边是 0,0,其尺寸为视口。layout过程计算一个元素绝对的位置和尺寸。Layout计算的是布局位置信息。任何有可能改变元素位置或大小的样式都会触发这个Layout事件。
layout是一个递归的过程。它从根呈现器(对应于 HTML 文档的 元素)开始,然后递归遍历部分或所有的框架层次结构,为每一个需要计算的呈现器计算几何信息。所有的呈现器都有一个“layout”或者“reflow”方法,每一个呈现器都会调用其需要进行布局的子代的 layout 方法。任何有可能改变元素位置或大小的样式都会触发这个Layout事件。
由于元素相覆盖,相互影响,稍有不慎的操作就有可能导致一次自上而下的布局计算。所以我们在进行元素操作的时候要一再小心尽量避免修改这些重新布局的属性。当你修改了元素的样式(比如width、height或者position等)也就是修改了layout,那么浏览器会检查哪些元素需要重新布局,然后对页面激发一个reflow过程完成重新布局。被reflow的元素,接下来也会激发绘制过程也就是重绘(repaint),最后激发渲染层合并过程,生成最后的画面。由于元素相覆盖,相互影响,稍有不慎的操作就有可能导致一次自上而下的布局计算。所以我们在进行元素操作的时候要一再小心尽量避免修改这些重新布局的属性。
如果呈现器在布局过程中需要换行,会立即停止布局,并告知其父代需要换行。父代会创建额外的呈现器,并对其调用布局。
为避免对所有细小更改都进行整体布局,浏览器采用了一种“dirty 位”系统。如果某个呈现器发生了更改,或者将自身及其子代标注为“dirty”,则需要进行布局。类似于脏检测。
有“dirty”和“children are dirty”两种标记方法。“children are dirty”表示尽管呈现器自身没有变化,但它至少有一个子代需要布局。dirty就是自己都变化了。
当呈现器为 dirty 时,会异步触发增量布局。例如,当来自网络的额外内容添加到 DOM 树之后,新的呈现器附加到了呈现树中。
增量布局是异步执行的。Firefox 将增量布局的“reflow 命令”加入队列,而调度程序会触发这些命令的批量执行。WebKit 也有用于执行增量布局的计时器:对呈现树进行遍历,并对 dirty 呈现器进行布局。 请求样式信息(例如“offsetHeight”)的脚本可同步触发增量布局。 全局布局往往是同步触发的。 有时,当初始布局完成之后,如果一些属性(如滚动位置)发生变化,布局就会作为回调而触发。
如果布局是由“大小调整”或呈现器的位置(而非大小)改变而触发的,那么可以从缓存中获取呈现器的大小,而无需重新计算。 在某些情况下,只有一个子树进行了修改,因此无需从根节点开始布局。这适用于在本地进行更改而不影响周围元素的情况,例如在文本字段中插入文本(否则每次键盘输入都将触发从根节点开始的布局)。
因为这个优化方案,所以你每改一次样式,它就不会reflow或repaint一次。但是有些情况,如果我们的程序需要某些特殊的值,那么浏览器需要返回最新的值,而会有一些样式的改变,从而造成频繁的reflow/repaint。比如获取下面这些值,浏览器会马上进行reflow:
offsetTop, offsetLeft, offsetWidth, offsetHeight scrollTop/Left/Width/Height clientTop/Left/Width/Height getComputedStyle(), currentStyle
大家倒背如流的老话,再啰嗦一遍:尽量减少重绘重排。具体:
重排(也叫回流)会计算页面布局(Layout)。某个节点Reflow时会重新计算节点的尺寸和位置,而且还有可能触其后代节点reflow。重排后,浏览器会重新绘制受影响的部分到屏幕,该过程称为重绘。另外,DOM变化不一定都会影响几何属性,比如改变一个元素的背景色不影响宽高,这种情况下只会发生重绘,代价较小。
当DOM的变化影响了元素的几何属性(宽或高),浏览器需要重新计算元素的几何属性,由于流式布局其他元素的几何属性和位置也受到影响。浏览器会使渲染树中受到影响的部分失效,并重新构造渲染树。 reflow 会从根节点开始递归往下,依次计算所有的结点几何尺寸和位置,在reflow过程中,可能会增加一些frame,如文本字符串。DOM 树里的每个结点都会有reflow方法,一个结点的reflow很有可能导致子结点,甚至父点以及同级结点的reflow。
当渲染树的一部分(或全部)因为元素的尺寸、布局、隐藏等改变而需要重新构建。所以,每个页面至少需要一次reflow,就是页面第一次加载的时候。
repaint(重绘)遍历所有节点,检测节点的可见性、颜色、轮廓等可见的样式属性,然后根据检测的结果更新页面的响应部分。当渲染树中的一些元素需要更新一些不会改变元素不局的属性,比如只是影响元素的外观、风格、而不会影响布局的那些属性,这时候就只发生重绘。当然,页面首次加载也是要重绘一次的。
光栅:光栅主要是针对图形的一个栅格化过程。现代浏览器中主要的绘制工作主要用光栅化软件来完成。所以元素重绘由这个元素和绘制层级的关系,来决定的是否会很大程度影响你的性能-,如果这个元素盖住的多层元素都被重新绘制,性能损耗当然大。
在绘制阶段,系统会遍历渲染树,并调用呈现器的“paint”方法,将呈现器的内容绘制成位图。绘制工作是使用用户界面基础组件完成的 你所看见的一切都会触发paint。包括拖动滚动条,鼠标选择中文字等这些完全不改变样式,只改变显示结果的动作都会触发paint。paint的工作就是把文档中用户可见的那一部分展现给用户。paint是把layout和样式计算的结果直接在浏览器视窗上绘制出来,它并不实现具体的元素计算,只是layout后面的那一步。
绘制顺序:背景颜色->背景图片->边框->子代->轮廓
其实就是元素进入堆栈样式上下文的顺序。这些堆栈会从后往前绘制,因此这样的顺序会影响绘制。
再说回来,在样式发生变化时,浏览器会尽可能做出最小的响应。因此,元素的颜色改变后,只会对该元素进行重绘。元素的位置改变后,只会对该元素及其子元素(可能还有同级元素)进行布局和重绘。添加 DOM 节点后,会对该节点进行布局和重绘。一些重大变化(例如增大“html”元素的字体)会导致缓存无效,使得整个渲染树都会进行重新布局和绘制。
概念不复杂,即是渲染层合并,我们将渲染树绘制后,形成一个个图层,最后把它们组合起来显示到屏幕。渲染层合并。前面也说过,对于页面中DOM元素的绘制是在多个层上进行的。在每个层上完成绘制过程之后,浏览器会将绘制的位图发送给GPU绘制到屏幕上,将所有层按照合理的顺序合并成一个图层,然后在屏幕上呈现。
对于有位置重叠的元素的页面,这个过程尤其重要,因为一量图层的合并顺序出错,将会导致元素显示异常。另外,这部分主要的是这涉及到我们常说的GPU加速的问题。
说到性能优化,针对页面渲染过程的话,我们希望的是代价最小,避免多余的性能损失,少一点让浏览器做的步骤。比如我们可以分析一下开头的那幅图:
明显,我们改的越深,代价越大,所以我们只改最后一个流程——合成的时候,性能是最好的。浏览器会为使用了transform或者animation的元素单独创建一个层。当有单独的层之后,此元素的Repaint操作将只需要更新自己,不用影响到别,局部更新。所以开启了硬件加速的动画会变得流畅很多。
因为每个页面元素都有一个独立的渲染进程,包含了主线程和合成线程,主线程负责js的执行、CSS样式计算、计算Layout、将页面元素绘制成位图(Paint)、发送位图给合成线程。合成线程则主要负责将位图发送给GPU、计算页面的可见部分和即将可见部分(滚动)、通知GPU绘制位图到屏幕上。加上一个点,GPU对于动画图形的渲染处理比CPU要快,那么就可以达到加速的效果。
注意不能滥用GPU加速,一定要分析其实际性能表现。因为GPU加速创建渲染层是有代价的,每创建一个新的渲染层,就意味着新的内存分配和更复杂的层的管理。并且在移动端 GPU 和 CPU 的带宽有限制,创建的渲染层过多时,合成也会消耗跟多的时间,随之而来的就是耗电更多,内存占用更多。过多的渲染层来带的开销而对页面渲染性能产生的影响,甚至远远超过了它在性能改善上带来的好处。
这是补充前面的html解析为dom部分的内容。
明显,CSSOM树和DOM树是互不关联的两个过程。平时我们把link标签放部头而script放body尾部,因为js阻塞阻塞DOM树的构建。但是js需要查询CSS信息,所以js还要等待CSSOM树构建完才可以执行。这就造成CSS阻塞了js,js阻塞了DOM树构建。所以我们只要设置link的preload来预加载css文件,解决了js执行时CSSOM树还没构建好的阻塞问题。当然,script异步加载也是另外的方法。
总的来说,参考一下很多人说过的规律: