浏览器加载网页资源的原理
JS 与 CSS 阻塞
重排与重绘
一, 浏览器加载网页资源的原理
1,html 支持的组要资源类型
在浏览器内核有一个管理资源的对象 CachedResource 类, 在 CachedResource 类下有很多子类来分工不同的资源管理, 这些资源管理子类分别是:
资源 | 资源管理类 |
HTML | MainResource ===> CachedRawResource |
JavaScript | CachedScript |
CSS | CachedCSStyleSheet |
图片 | CachedImage |
SVG | CachedSVGDocument |
CSS Shader | CachedShader |
视频、音频、字幕 | CachedTextTrack |
字体文件 | CachedFont |
XSL 样式表 | CachedXSLStyleSheet |
2, 资源缓存
资源的缓存机制是提高资源使用效率的有效方法. 基本思想就是建立一个资源缓存池, 当 web 需要请求资源时, 会先从资源池中查找是否存在相应的资源, 如果有的话就直接取缓存, 如果没有就创建一个新的 CachedResource 子类的对象, 并发送请求给服务器 (由网络模块完成), 请求回来的资源会被添加到资源池, 并且将资源(数据信息: 比如在资源池中的物理地址) 设置到该资源的对象中去, 以便下次使用.
下面是一个缩减版的资源请求原理图:
实质上的操作是在资源对象中找到对应资源的物理地址(url), 然后返回给渲染引擎, 渲染引擎在渲染页面时根据 url 获取物理内存中的资源数据. 由于资源的唯一特性是 url, 所以当两个资源有不同的 url, 但是他们的内容完全相同时, 也不会被认定是同一个资源.
注: 这里所说的缓存是内存, 不是磁盘.
3, 资源加载器
在 WebKit 中共有三种类型的资源加载器, 分别是:
3.1 针对每种资源类型的特定加载器, 用来加载某一类资源. 例如 "image" 这个元素, 该元素需要图片资源, 对应的顶资源加载器是 ImageLoader 类.
3.2 资源缓存机制的资源加载器, 特点是所有特定加载器都共享它来查找并插入缓存资源 --CachedResourceLoader 类. 特定加载器是通过缓存机制的资源加载器来查找是否有缓存资源, 它属于 HTML 的文档对象.
3.3 通用的资源加载器 --ResourceLoader 类, 是在 WebKit 需要从网络或者文件系统获取资源的时候使用该类只负责获得资源的数据, 因此被所有特定资源加载器所共享, 它属于 CachedResource 类, 与 CachedResourceLoader 类没有继承关系.
如果说资源缓存和网络资源是浏览器要渲染页面的资源实体, 那资源加载器就是为浏览器实现页面渲染提供资源数据的搬运工. 前面的资源请求相当于就是资源地址寻址的过程, 真正为渲染提供资源的过程是下面这样的:
这个资源加载看起来很复杂, 但是模块分工很明确, 基于资源对象与内存资源缓存的对应关系(每个缓存资源在资源对象上有一个实例), 当浏览器触发资源请求时先通过判断资源是否有缓存资源, 如果有的话就就直接拿缓存资源给渲染引擎, 如果没有就通过网络请求获取资源给渲染引擎, 并且同时会将资源缓存到内存中.
同 CachedResourceLoader 对象一样, 资源池也属于 HTML 文档对象, 所以资源池不能无限大, 对于资源容量不能无限大的问题浏览器的解决方法有两种: 第一种是采用 LRU(Least Recent Rsed 最近最少使用原则)算法. 第二种方法是通过 HTTP 协议决定是否缓存, 缓存多久, 以及什么时候更新缓存, 然后我们开发时还可决定资源如何拆分, 拆分可以让我决定哪些资源缓存, 哪些资源不缓存.
当请求协议指定可以取缓存数据, 请求资源会先判断内存中是否有资源, 然后将资源的信息 (版本, 缓存时常等) 通过 HTTP 报文一起发送给服务器, 服务器通过报文判断缓存的资源是否是最新的, 资源缓存是否超时来决定是否重新获取服务端的资源, 如果不需要重新获取服务端的资源, 服务器会返回状态码 304, 告诉浏览器取本地缓存资源.
下面通过 Chrome 浏览器来请求饿了吗官网, 在控制台查看数据请求的资源加载过程, 并且通过刷新页面查看当页面刷新时浏览器在缓存中取了哪些信息:
接着我们再来刷新页面看看取了哪些缓存数据:
可以看到饿了吗官网的缓存机制是将 document 主文件和 JS 文件做了缓存处理. 这样的处理方式可以很大程度上提高页面性能和降低服务器请求压力, 至于为什么就是接下来的内容了.
二, 解析 HTML 标签和 CSS 样式表, 生成 DOMTree 和 CSSTree
前面介绍了浏览器资源请求与资源加载的基本原理, 看上去好像是一个简单的线性步骤, 但是实质上浏览器内部是多进程异步加载这些资源的, 我们知道网页的效果是基于 DOM 结构和 CSS 样式表来完成基本的页面效果呈现, 但是 JS 代码又可以对 DOM 节点进行增删该查操作, 还可以修改 DOM 的 CSS 样式, 那必然就是需要先有 DOM 结构, 然后添加 CSS 样式, 再就这两个资源的基础通过 JS 修改后才能呈现出来, 但是什么时候加载(指的是下载资源, 并不是前面的资源加载到页面上的整个过程)? 什么时候执行? 什么时候渲染页面? 按照什么规则来完成这些工作呢.
通常我们给某个服务器发送一个 Web 请求时, 首先返回的是一个 HTML 资源. 假设这个资源的内部代码如下:
- <!DOCTYPE HTML>
- <HTML>
- <head>
- <meta charset="utf-8">
- <meta http-equiv="X-UA-Compatible" content="IE=edge">
- <title>
- </title>
- <link rel="stylesheet" type="text/css" href=".../css/xxx.css">
- </head>
- <body>
- <div>
- <p>
- <span>
- </span>
- </p>
- <ul>
- <li>
- <img src=".../image/xxx.png" alt="">
- </li>
- <li>
- <img src=".../image/xxx.png" alt="">
- </li>
- <li>
- <img src=".../image/xxx.png" alt="">
- </li>
- </ul>
- </div>
- <script src=".../javascripts/xxx.js" type="text/javascript">
- </script>
- </body>
- </HTML>
本地获取到了 HTML 资源后第一步就是解析 HTML, 也就是常说的 DOM 解析, 首先是创建一个 document 对象, 然后通过 DOM 解析出每个节点. 通过 DOM 解析发现页面中有 CSS 外部样式表需要加载, 就立即通过 CSS 加载器执行加载. 解析到 img 元素发现需要加载图片, 就立即通过图片加载器执行加载, 这个过程不会等待前面加载的资源加载完成才启动第二个加载, 而是通过异步的方法开启多个加载线程, 并且浏览器底层会开启多个进程来处理这些线程(Chrome 会开启五个进程). 同样解析到了 script 元素时发现需要外部 JS 资源会立即加载 JS 文件资源.
深度优先原则解析构建 DOM 树和 CSS 树:
深度优先原则就是对每一个结构顺着第一个内部节点一直往内部解析, 直到结构尽头, 然后再回退到上一个节点, 再对第二个节点执行深入优先原则的解析构建. 下图是上面示例请求到的 HTML 资源的解析流程图:
按照示例 HTML 解析流程图, 根据编号顺序按照 1-->1.1-->1.2-->1.3-->1.4-->2-->2.1-->2.1.1-->2.1.1.1-->2.1.2-->2.1.2.-->2.1.2.1-->2.1.2.2-->2.1.2.3-->2.2. 用一句来表达这种解析原则就是一条道走到黑, 开玩笑, 但是的确很形象哈. CSS 样式表解析和构建 CSS 树也同样使用这个原则. 当 DOMTree 和 CSSTree 都构建完成以后就会被合并成渲染树(randerTree). 渲染树解析完毕以后就开始绘制页面.
三, JS 与 CSS 阻塞
了解了 DOMTree 和 CSSTree 的构建原理, 然后合成 randerTree 绘制页面, 但是这个过程怎么能缺少 JS 呢? 有了 JS 的参与, 这个过程就会变得复杂了. 首先, CSS 资源是异步加载 (下载), 在 CSS 资源加载的过程中, DOM 解析会继续执行操作. 但是当遇到 script 标签的时候, 如果是外部资源就要立即加载(下载), 如果是内部资源就会立即执行 JS 代码, 立即执行 JS 代码会阻断 HTML 的解析(因为 JS 会操作 DOM 节点增删改查什么的, 还会操作元素样式), 霸道总裁 JS 就这样让傻媳妇 HTML 傻呆着让它为所欲为了. 就算是外部 JS 资源加载(下载) 的过程 HTML 的解析也是被阻断的, 这个过程是必须等到 JS 加载 (下载) 完, 然后还要等他执行完才能继续解析 HTML.
- <img class="img1" src="https://img.baidu.com/search/img/baidulogo_clarity_80_29.gif" alt="Baidu" align="bottom" border="0">
- <script type="text/javascript">
- // 循环 5 秒钟
- var n =Number(new Date());
- var n2 = Number(new Date());
- while((n2 - n) <(10*1000)){
- n2 = Number(new Date());
- }
- console.log(document.querySelectorAll(".img1"));//NodeList [img.img1]
- console.log(document.querySelectorAll(".img2"));//NodeList []
- </script>
- <img class="img2" src="https://gss1.bdstatic.com/9vo3dSag_xI4khGkpoWK1HF6hhy/baike/w=268;g=0/sign=7aa2c00bdd58ccbf1bbcb23c21e3db03/908fa0ec08fa513defeb0567316d55fbb3fbd9c2.jpg">
- <script>
- var n3 = Number(new Date() - n2);
- console.log(n3);//13
- console.log(document.querySelectorAll(".img1"));//NodeList [img.img1]
- console.log(document.querySelectorAll(".img2"));//NodeList [img.img2]
- </script>
由上面的示例可以说明 JS 执行会阻塞 DOMTree 构建, 不然在 JS 等待的 10 秒里足够解析一个 img 元素, 但是 10 秒后只能查询到 img1,img2 查询不到(打印空 DOM 节点对象). 当第二次打印的时候两个 img 节点就都获取到了. 接着我们来看看外部 JS 加载会不会阻塞 DOMTree 构建:
<script> var n =Number(new Date()); </script> <!-- 设置网速 30kb/s 测试 js 是否阻塞渲染 --> <script src="https://cdn.staticfile.org//vue/2.2.2//vue.min.js"></script> <script> var n3 = Number(new Date() - n); console.log(n3);//30~40 秒 ---- 注释外部 JS 加载代码测试时间差为 0 秒 </script>
测试结果是外部 JS 的加载也会阻塞 HTML 解析构建 DOMTree. 所以结论是 JS 的加载和执行都会阻塞 DOMTree 的构建, 接着问题又来了, 我们前面提到过 JS 代码会操作 DOM 还会操作 CSS, 所以从理论上讲 JS 肯定得需要等到 CSS 加载解析完才会执行, CSS 阻塞 JS 执行是肯定的, 再思考 CSS 的加载 (下载) 会阻塞 JS 的加载 (下载) 吗?
<!DOCTYPE HTML> <HTML> <head> <meta charset="UTF-8"> <title> Title </title> <link type="text/css" rel="stylesheet" href="https://cdn.staticfile.org/twitter-bootstrap/3.3.7/css/bootstrap.min.css" /> <script src="https://cdn.staticfile.org//vue/2.2.2//vue.min.js" type="text/javascript" charset="utf-8" async defer> </script> </head> <body> </body> </HTML>
我们来看 Chrome 控制台的时间线:
由 Chrome 控制台的时间线可以看到外部 JS 和外部 CSS 几乎是同时开始加载, CSS 加载并没有阻塞 JS 的加载. 既然这样我们再来测试以下 CSS 加载阻塞 JS 执行是否是真的?
<script> var n = Number(new Date()); </script> <link type="text/css" rel="stylesheet" href="https://cdn.staticfile.org/twitter-bootstrap/3.3.7/css/bootstrap.min.css" /> <script> console.log(Number(new Date()) - n);// 外部 CSS 阻塞 JS 执行 40~200 毫秒 --- 注释外部 CSS 代码测试差值 0~1 毫秒 </script>
可能有人会疑惑我为什么不测试外部 CSS 会不会阻塞 HTML 解析, 你想想如果 CSS 阻塞 HTML 解析那 JS 加载必须会被阻塞吧, 所以 CSS 加载也就不会阻塞 HTML 解析了. 但是, CSS 会阻塞 JS 执行, 也就间接的阻塞了 JS 后面的 DOM 解析.
其实相对来说 JS 与 CSS 阻塞还是比较好理解的, 毕竟还有可参考的数值和可视的图像信息, 接下来的问题就只能依靠逻辑推理了.
四, JS 时间线
在阐述 JS 时间线之前, 我另外总结了一部分非常重要的内容: JS 的异步加载(JS 异步加载的三种方案),JS 异步加载与下面的内容相关联的内容比较多, 建议在了解下面内容之前先了解一下 JS 异步加载.
在前面的内容中解析了访问网站获取资源的基本原理, 然后资源被访问到本地后怎么解析, 解析时发什么的异步资源加载, 同步资源加载, 同步执行等一系列内容. 然后在 JS 异步加载中提到了 script.onload 事件, script.onreadystatechange 事件, script.readyState 状态, 然后还有 document.readyState="interactive" 文档状态和 docuement.readyState="complete" 文档状态. 这些内容都发生在打开网页的那一瞬间, 但是这一瞬间不只是检验物理配置的性能, 浏览器内核的性能以及网络的性能, 还关系到 Web 开发者基于这些已定的基础平台的代码优化, 所以我们有必要对这整个过程有非常清晰的理解, 才能实现友好的程序设计. 下面我们就通过 JS 时间线来描述这个过程如何发生的:
页面加载的五个步骤和 JS 时间线的十个环节:
五个步骤:
解析 HTML 生成 DOMTree
解释 CSS 样式表生成 CSSTree
合并 DOMTree 和 CSSTree 生成 randerTree
randerTree 生成完以后开始绘制页面
浏览器在解析页面时同时下载页面内容内容数据(异步: 图片, src)
JS 时间线之十个环节:
要说是 JS 时间线的话, 可能不是很恰当, 或者应该说是文档模型初始化构建过程的 JS 表示, 能够操作 DOM 对象接口的语言有很多, 这里就是用 JS 来表示 DOM 对象模型初始化的整个过程.
1. 创建 document 对象, 开始解析 Web 页面. 解析 HTML 原始和他们的文件内容添加 Element 对象和 Text 节点到文档中. 阶段: document.readyState = "loading".(表示可以触发一次 document.onreadystatechange 事件)
2. 遇到 link 外部 CSS, 创建线程加载, 并继续解析文档.
3. 遇到 script 外部 JS, 并没有设置 async,defer, 浏览器加载, 并阻塞, 等待 JS 加载完成并执行脚本, 完后继续解析文档.
4. 遇到 script 外部 JS, 并且设置 async,defer, 浏览器创建线程加载, 并继续解析文档.
5. 遇到 img 等外部内容数据, 先正常解析 DOM 结构, 然后浏览器异步加载 src, 并且继续解析文档.
6. 当文档解析完成后. document.readyState = "interactive".(表示可以触发一次 document.onreadystatechange 事件)
7. 文档解析完成后, 所有设置有 defer 的脚本会按照顺序执行.(禁止使用 document.wrlte()).
8.document 对象触发 DOMContentLoaded 事件, 这也标志着程序执行从同步执行阶段, 转化为事件取动阶段.(这里开始绘制页面)
9. 当所有 async 的脚本加载完成, img 等加载完成后, document.readyState = "complete",Windows 对象触发事件.(表示可以触发一次 document.onreadystatechange 事件或者标准浏览器可以触发 Windows.onload 事件了)
10. 从此, 以异步响应方式处理用户输入, 网络事件等.
//readyState 属性返回当前文档的状态
uninitialized - 还未开始载入
loading - 载入中
interactive - 已加载, 文档与用户可以开始交互
complete - 载入完成 --loaded
五, 重排 / 回流与重绘
关于重排 / 回流 (reflow) 重绘 (repaint) 简单来说就是会将已经计算好的布局和构建好的渲染树 (randerTree) 重新计算和构建全部或者部分. 这部分发生在 DOMTree 和 CSSTree 解析完成以后, 也就是会发生在构建 randerTree 时和之后, 这里我们重点关注发生在 randerTree 构建时的重排 / 回流和重绘问题, 也是网页渲染除了 JS,CSS 阻塞之后的性能优化区间.
发生重排 / 回流与重绘其本质上重新布局和构建 randerTree, 如果将 DOM 之前的执行过程理解为同步, 这个时候浏览器转为事件取动的异步阶段, 浏览器内核在构建 randerTree 的同时 JS 也会被事件取动参与修改文档的结构和样式, 也是触发重排 / 回流与重绘行为的关键所在, 而本质上做的事情就是重新计算布局和构建 randerTree 树, 所以在解析重排与重绘之前先来了解以下布局计算和 randerTree 构建:
布局
在构建 randerTree 时并不会把 CSS 样式表或者行内样式表示元素大小和位置的数据添加到 RanderObject 上, 而是要基于样式设置 (如):width,height,font-size,display,left,top,bottun,right 还有 borde,padding,margin 的大小, 结合上下文的相互作用(比如有子元素自适应父级元素大小和位置或者父元素基于子元素定义自身大小和位置), 最后使用 RanderObject 上的 layout() 方法计算出确定的元素大小和位置, 这个过程 layout()方法是递归完成整个计算操作.
因为布局计算需要基于元素上下节点来进行, 元素的大小和位置变化都有可能会影响到父级和子级的元素大小和位置变化, 所以 randerTree 上的某个 RanderObject 的相关数据发生变化除了自身的 layout()方法需要重新执行计算, 还可能会触发上下级的节点的 layout()方法的重新执行计算.
所以当构建 randerTree 的时候由 document.onreadystatechange 事件, defer 的脚本, DOMContentLoaded 事件还有不确定的 src 异步加载的 JS 脚本都可能在这时候修改元素的大小和位置, 甚至修改 DOM 结构.
除了脚本的影响外, 还有可能是浏览器窗口发生产生变化导致全局的 randerTree 重新布局计算, 另外如果脚本修改了全局的样式也同样可能会触发全局的重新布局计算.
重排 / 回流(reflow)
有了前面对布局的介绍, 重排 / 回流就一目了然了, 当由于脚本执行或者浏览器窗口变化, 引发 RanderObject 上的 layout()方法重新计算机布局数据, 就叫做重排 / 回流. 从字面上的含义来理解重排很容易, 就是由于元素的大小和位置变化页面重新排列布局. 回流就存在一些逻辑上的理解了, 在布局中因为元素节点的位置和大小是存在上下级和同级之间相互影响的, 所以如果有脚本修改 DOM 节点或者大小位置样式, 就会对相关连的元素进行判断查找修改的范围指定修改逻辑, 制定 layout()方法的递归顺序的最优方案, 这个查询判断和修改过程就是需要在节点之间来回操作, 这也就是回流. 实质上重排 / 回流说的都是一回事.
重绘(repaint)
重绘不会影响布局, 但是当脚本触发了样式修改, 而修改的部分是背景(图片和颜色), 字体颜色, 边框颜色等, 而这些修改也存在嵌套的节点链级相互影响, 所以也是需要遍历操作, 重绘不至于影响到布局, 但也是一个相对损耗性能的操作, 毕竟都需要 DOM 文档和 JS 引擎结构之间的桥梁通道来执行操作. 不过重绘相对于重排来说就要快的多了.
重排 / 回流与重绘是会发生在 randerTree 构造时, 也会发生在 randerTree 构造结束后, 都是相对损耗 CPU 甚至 GPU 的操作, 只是页面首次渲染更值得的我们关注.
绘制(paint)
当 randerTree 构建完成以后就会开始绘制页面了, 在绘制页面过程中仍然可能发生重排与重绘, 但这里需要重点关注的是图层合并, 绘制主要是基于 CPU 的计算来实现, 同时浏览器基本上都采用 GPU 加速的混合模式, 其实浏览器本身不需要操作图层合并, 因为绘图不管是 CPU 还是 GPU 来实现都是基于元素的大小和位置将它们实现的图层, 图们本身就在同一个位置, 所以无需合并操作.
CPU 主要负责 randerTree 的绘制工作, 它与 GPU 的配合在不同浏览器内核中会略微不同, 但是在同一个位置出现的图层越多, 肯定是对性能的损耗就越大. 而且由于 CPU 主要负责 randerTree 的绘制, 多图层就会对 GPU 带来很大的工作负载, 具体包括: CSS3 3D 变形, CSS3 3D 变换, WebGL 和 视频. 也有浮动, 定位, 溢出隐藏, z 坐标重叠等都是在绘制过程中比较损耗性能的行为.
最后经过这样艰难的过程过后, 网页终于呈现在我们桌面, 但是注意 Windows 事件交互不会等待绘制完成, 决定 Windows 事件交互的是资源是否全部加载完成, 这里指的资源是 HTML 文档包含内容资源, 并不包含外部脚本加载的资源.
(减少重排与重绘的一些要点)
1: 不要通过父级来改变子元素样式, 最好直接改变子元素样式, 改变子元素样式尽可能不要影响父元素和兄弟元素的大小和尺寸
2: 尽量通过 class 来设计元素样式, 切忌用 style
3: 实现元素的动画, 对于经常要进行回流的组件, 要抽离出来, 它的 position 属性应当设为 fixed 或 absolute
4: 权衡速度的平滑. 比如实现一个动画, 以 1 个像素为单位移动这样最平滑, 但 reflow 就会过于频繁, CPU 很快就会被完全占用. 如果以 3 个像素为单位移动就会好很多.
5: 不要用 tables 布局的另一个原因就是 tables 中某个元素一旦触发 reflow 就会导致 table 里所有的其它元素 reflow. 在适合用 table 的场合, 可以设置 table-layout 为 auto 或 fixed,
6: 这样可以让 table 一行一行的渲染, 这种做法也是为了限制 reflow 的影响范围.
7:CSS 里不要有表达式 expression
8: 减少不必要的 DOM 层级(DOM depth). 改变 DOM 树中的一级会导致所有层级的改变, 上至根部, 下至被改变节点的子节点. 这导致大量时间耗费在执行 reflow 上面.
9: 避免不必要的复杂的 CSS 选择器, 尤其是后代选择器(descendant selectors), 因为为了匹配选择器将耗费更多的 CPU.
10: 尽量不要过多的频繁的去增加, 修改, 删除元素, 因为这可能会频繁的导致页面 reflow, 可以先把该 dom 节点抽离到内存中进行复杂的操作然后再 display 到页面上.
11: 请求如下值 offsetTop, offsetLeft, offsetWidth, offsetHeight,scrollTop/Left/Width/Height,clientTop/Left/Width/Height, 浏览器会发生 reflow, 建议将他们合并到一起操作, 可以减少回流的次数.
View Code
来源: http://www.bubuko.com/infodetail-2991072.html