天下武功唯快不破, 页面加载速度与用户体验息息相关, 也关乎企业收益: 最知名的当属 Amazon 加载速度每增加一秒一年少赚 16 亿美刀的例子(还是 N 年前的统计), 真是应验那句话:
时间 == 金钱!
对此 Google Developer 专门有一篇文章用于阐述页面载入速度的重要性, 此外还专门做了个工具 https://www.thinkwithgoogle.com/feature/mobile/ , 用于展示页面加载速度与收益关系. 除了说, 他们也这么做了: Google 直接把页面速度纳为搜索排名的指标.
优化页面加载速度环节众多, 今天要说的是关键渲染路径 (critical rendering path) 优化, 及缩短首次渲染页面的时间.
浏览器如何呈现页面的
知己知彼, 百战不殆. 我们先得了解页面渲染过程, 进而从中寻找出优化环节.
DOM
以下面的简单页面为例:
浏览器如何处理页面流程: 字节 → 字符 → 令牌 → 节点 → 对象模型, 最终得到的是文档对象模型 (DOM).
转换: 浏览器读取 HTML 原始字节, 根据指定编码转化为字符
令牌化: 将字符转化为 token 以及字符串
词法分析: 将 token 转化为定义其属性与规则的 node
DOM 构建: 将 node 连接在一起组成一颗树
DOM 树捕获文档标记的属性和关系, 至于如何定义外观样式. 那是 CSSOM 的责任.
CSS
浏览器处理 CSS 过程: 字节 → 字符 → 令牌 → 节点 → CSSOM.
浏览器在构建 DOM 时, 遇到了一个引用有 style.cssc 的 link 标记, 浏览器便发出请求, 获取到 CSS 资源文件, 与 HTML 类似, 我们最终会得到一个称为 "CSS 对象模型"(CSSOM) 的树结构.
- body { font-size: 16px }
- p { font-weight: bold }
- span { color: red }
- p span { display: none }
- img { float: right }
以上并非完整的 CSSOM 树, 因为浏览器还有默认样式(也称为 User Agent Stlyesheet). 处理 CSS 所需时间在开发者工具 performance 栏中为 Parse Style 与 Recalculate Style 事件.
渲染树构建, 布局及绘制
DOM 与 CSSOM 只是独立的数据结构, 还需将两者合并最终在浏览器上渲染出来. 浏览器做了以下几件事:
DOM 树与 CSSOM 树合并后形成渲染树,
渲染树只包含渲染网页所需的节点, 不可见元素以及 CSS 控制的 display: none 隐藏元素会被忽略
布局计算每个对象的精确位置和大小, 计算出盒模型, 重排.
最后一步是绘制, 使用最终渲染树将像素渲染到屏幕上
这些过程在 Chrome DevTools 中 Performance 栏中对应的事件是 Layout,Paint 与 Composite Layers.
最终, 浏览器完成的步骤有:
处理 HTML 标记并构建 DOM 树.
处理 CSS 标记并构建 CSSOM 树.
将 DOM 与 CSSOM 合并成一个渲染树.
根据渲染树来布局, 以计算每个节点的几何信息.
将各个节点绘制到屏幕上.
优化关键渲染路径就是指最大限度缩短执行上述第 1 步至第 5 步耗费的总时间.
阻塞渲染的 CSS
从浏览器完成页面渲染的过程看: 同时具有 DOM 和 CSSOM 才能构建渲染树, 所以 HTML 和 CSS 都是阻塞渲染的资源, 且 HTML 是必须的, 不过没有了样式, 页面也是不可用的!
CSS 是阻塞渲染的资源. 需要将它尽早, 尽快地下载到客户端, 以便缩短首次渲染的时间.
JavaScript
现在引入新的变量: JavaScript, 接下来看看浏览器遇到 script 标签会如何处理, 以下面的 HTML 为例:
- <!DOCTYPE html>
- <html>
- <head>
- <meta name="viewport" content="width=device-width,initial-scale=1">
- <link href="style.css" rel="stylesheet">
- <title>Critical Path: Script</title>
- </head>
- <body>
- <p>Hello <span>web performance</span> students!</p>
- <div><img src="awesome-photo.jpg"></div>
- <script>
- var span = document.getElementsByTagName('span')[0];
- span.textContent = 'interactive'; // change DOM text content
- span.style.display = 'inline'; // change CSSOM property
- // create a new element, style it, and append it to the DOM
- var loadTime = document.createElement('div');
- loadTime.textContent = 'You loaded this page on:' + new Date();
- loadTime.style.color = 'blue';
- document.body.appendChild(loadTime);
- </script>
- </body>
- </html>
在此之前我们先看看, JavaScript 的能力:
JavaScript 可以修改 CSSOM
JavaScript 可以修改 DOM
当浏览器遇到一个 script 标记时, DOM 构建将暂停, 直至脚本完成执行, 假如在该 script 之前还有外联的样式, JavaScript 的执行得等待 CSSOM 下载与构建完毕, 这么做因为 JavaScript 可能会查询或者修改样式.
结论:
CSS 会阻塞 JavaScript 脚本的执行.
JavaScript 的执行会注阻塞脚本后面的 DOM 的解析与渲染.
那么 JavaScript 脚本在文档中的位置就尤为重要了.
渲染阻塞
上文关于渲染阻塞已经说得差不多了这里再总结与补充一下:
CSS
CSS 会阻塞页面的渲染
CSS 会阻塞 Javascript 执行
动态插入的外链 CSS 不会阻塞 DOM 的解析或渲染
动态插入的内联 CSS 会阻塞 DOM 的解析或渲染
JavaScript
同步的 JavaScript 都会阻塞 DOM 的解析或渲染
异步的 JavaScript 不会阻塞 DOM 的解析或渲染
async 与 defer 的 JavaScript 脚本
动态插入的外链 JavaScript 脚本
首次渲染
那么如何判定页面发生了首次渲染 (first paint) 了呢, 最佳方式是使用 Chrome DevTools 中的 performance 工具查看: 例如下图 Frames 栏中绿色色块开始的地方就是首次渲染开始的时间点. 顺便说一下蓝线与红线分别代表文档 DOMContentLoaded 与 Load 事件产生的时间点.
DOMContentLoaded
当初始的 HTML 文档被完全加载和解析完成之后, DOMContentLoaded 事件被触发, 而无需等待样式表, 图像和子框架的完成加载.
注意: DOMContentLoaded 事件必须等待其所属 script 之前的样式表加载解析完成才会触发.
via MDN
DOMContentLoaded 一般表示 DOM 和 CSSOM 均准备就绪的时间点, 很多 JavaScript 逻辑都是在 DOMContentLoaded 之后执行的. 所以优化关键渲染路径对页响应用户操作是有积极意义的.
Load
对于文档而言表示所页面上有资源都已经加载完毕, 用户角度就是浏览器加载小圈停止旋转.
加载阻塞
对于外链的资源, 比如外链的样式, 脚本, 媒体文件, 字体等, 比起内联资源还需要一个加载过程, 那么浏览器是一个个串行去加载的吗?
考古 speculative preload(预先加载)
在早期浏览器 JavaScript 资源是阻塞加载的, 即 script 是串行加载的, 即下一个资源必须等待 script 加载并执行完成后才能加载. 这样会带来很严重的性能问题.
2008 年, IE 提出了 speculative preload(预先加载)策略, 即浏览器遇到 script 资源还会继续搜索其他资源并且加载, 随后 Firefox 3.5,Safari 4 和 Chrome 2 也采取了类似策略.
资源加载优先级
浏览器才不会那么傻傻的串行加载资源, 首先多个线程并行加载资源, 而且其会尽早的提前发现并加载资源, 比如 Chrome 在 DOM 解析的时候会提前扫描有那些资源并发送请求. 那么问题来了, 外部资源那么多, 浏览器还有同一域下并发请求资源数限制, 比如 Chrome 目前是 6 个. 那么在有限的加载通道下, 比如有谁先谁后的问题, 浏览器是如何确定资源加载优先级的?
比如 Chrome 有如下策略:
资源的类型, 如 CSS,script 这些阻塞渲染的优先级要高一些
资源位于文档的位置有关:<head> 内的资源优先级要高一些
以上只是举例, 资源加载策略都是与浏览器自己实现, 最好从源码与相关文档中学习. 且浏览器也在不断优化加载策略, 比如 Chrome 要开始对不在是视口内的图片资源执行懒加载了: Blink LazyLoad.
评估关键渲染路径
关键资源: 可能阻止网页首次渲染的资源.
关键路径长度: 获取所有关键资源所需的往返次数或总时间.
关键字节: 实现网页首次渲染所需的总字节数
理想状态下
- <!DOCTYPE html>
- <html>
- <head>
- <meta name="viewport" content="width=device-width,initial-scale=1">
- <title>Critical Path: No Style</title>
- </head>
- <body>
- <p>Hello <span>web performance</span> students!</p>
- <div><img src="awesome-photo.jpg"></div>
- </body>
- </html>
添加样式
- <!DOCTYPE html>
- <html>
- <head>
- <meta name="viewport" content="width=device-width,initial-scale=1">
- <link href="style.css" rel="stylesheet">
- </head>
- <body>
- <p>Hello <span>web performance</span> students!</p>
- <div><img src="awesome-photo.jpg"></div>
- </body>
- </html>
2 项关键资源
2 次或更多次往返的最短关键路径长度
9 KB 的关键字节
`
添加 JavaScript
- <!DOCTYPE html>
- <html>
- <head>
- <meta name="viewport" content="width=device-width,initial-scale=1">
- <link href="style.css" rel="stylesheet">
- </head>
- <body>
- <p>Hello <span>web performance</span> students!</p>
- <div><img src="awesome-photo.jpg"></div>
- <script src="app.js"></script>
- </body>
- </html>
3 项关键资源
2 次或更多次往返的最短关键路径长度
11 KB 的关键字节
异步 JavaScript
- <!DOCTYPE html>
- <html>
- <head>
- <meta name="viewport" content="width=device-width,initial-scale=1">
- <link href="style.css" rel="stylesheet">
- </head>
- <body>
- <p>Hello <span>web performance</span> students!</p>
- <div><img src="awesome-photo.jpg"></div>
- <script src="app.js" async></script>
- </body>
- </html>
2 项关键资源
2 次或更多次往返的最短关键路径长度
9 KB 的关键字节
将 CSS 的 Media Query 设置为 print
- <!DOCTYPE html>
- <html>
- <head>
- <meta name="viewport" content="width=device-width,initial-scale=1">
- <link href="style.css" rel="stylesheet" media="print">
- </head>
- <body>
- <p>Hello <span>web performance</span> students!</p>
- <div><img src="awesome-photo.jpg"></div>
- <script src="app.js" async></script>
- </body>
- </html>
1 项关键资源
1 次或更多次往返的最短关键路径长度
5 KB 的关键字节
优化关键渲染路径
"优化关键渲染路径" 在很大程度上是指了解和优化 HTML,CSS 和 JavaScript 之间的依赖关系谱.
优化 CSS
精简压缩 CSS
无需解释, 更小的 CSS 会减少加载字节, 加快 CSS 解析以及页面渲染速度.
将 CSS 位于 <head> 内
首先是便于浏览器尽早发现样式资源尽早执行加载.
PS: 比如 Chrome 对此有优化, 就算样式位于页面底部, 被浏览器资源扫描到也会尝试提升其加载优先级. 不过经自测, 这种优化策略有点迷并不具有规律, 另外更换网络环境, Chrome 也会采取不同的加载策略.
所以, 还是老老实实把样式放置在头部吧, 而且越靠前越好.
Fast 3G 模式下的资源加载瀑布流:
内联阻塞渲染的 CSS
这么做也能减少关键路径中的往返次数. 当然这也有缺陷, 比如增大了页面体积, 以及无法利用缓存 CSS 缓存, 我们需寻求一个平衡点, 比如只将关键样式内联到文档内.
这也能避免 FOUC(Flash of Unstyled Content)即内容样式短暂失效, 也就是我们通常所说的页面闪烁, 由于默认样式与网站样式切换带来的变化所致. 产生原因比如长时间等待获取样式资源或者 JavaScript 的阻塞等.
FOUC 在 Chrome 中很少见, 因为其会等待 CSSOM 构建完毕后才开始页面渲染, 但因其渲染模式会产生白屏现象.
使用 Media Query
- <link href="style.css" rel="stylesheet">
- <link href="style.css" rel="stylesheet" media="all">
- <link href="portrait.css" rel="stylesheet" media="orientation:portrait">
- <link href="print.css" rel="stylesheet" media="print">
符合媒体查询的样式才会阻塞页面的渲染, 当然所有样式的下载不会被阻塞, 只是优先级会调低.
避免使用 CSS import
被 @import 引入的 CSS 需依赖包含其的 CSS 被下载与解析完毕才能被发现, 增加了关键路径中往往返次数.
优化 JavaScript
优化精简 JavaScript, 按需加载
同理 CSS.
JavaScript 脚本位于底部
非异步的 JavaScript 会阻塞其之后的 DOM 解析与渲染.
async 与 defer
异步的 JavaScript 不会阻塞 DOM 的解析, async 与 defer 使得浏览器对 JavaScript 外链脚本进行异步处理.
defer
规范中要求 defer 循序执行, 且执行时机为 DOM 解析完毕之后执行.
async
规范中要求 defer 执行时机为当前脚本下载完毕之后执行, 不要求顺序执行.
其他优化
使用 preload 提升优先级
字体文件
参考
- Building the DOM faster: speculative parsing, async, defer and preload(中)
- Building the DOM faster: speculative parsing, async, defer and preload(原)
从 Chrome 源码看浏览器如何加载资源
Resource Fetch Prioritization and Scheduling in Chromium
CSS 是如何工作的: 关键渲染路径中的 CSS 解析和渲染 https://www.yanshuo.me/p/308547
用 preload 预加载页面资源
JS 和 CSS 的位置对资源加载顺序的影响
JavaScript 的性能优化: 加载和执行(文章较老, 部分内容已经过时)
异步渲染的下载和阻塞行为
前端魔法堂: 解秘 FOUC
来源: https://juejin.im/entry/5b9c77ce5188255c5a0cea17