使用 Chrome Devtool 进行性能分析时,在 Performance 面板上,可以看到用绿线标出来的 First-Contentful-Paint
。浏览器何时进行首次渲染?网上只能查到一些模棱两可的资料,今天我们来探究这个问题。
注: 原始链接: www.404forest.com/2019/04/23/…
文章备份: github.com/jin5354/404…
1. 引子
1.1 术语堪明
- 首次渲染
- 首屏时间/首屏渲染
在掘金上用『首次渲染』进行搜索,查不到什么相关资料;使用『首屏时间』进行搜索,能搜出大量性能优化的文章。点进去看可以发现,大家常谈的『首屏时间』是一个业务概念,指的是业务的首屏内容全部渲染完毕的时间点,一般使用埋点进行手动上报。本文探索的则是浏览器进行首次渲染的时间点,此时可能只渲染出了网页的部分内容。
1.2 提出场景
举例说明:
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>demo</title>
</head>
<body>
<div id="app">
<p>俺是用来测试首屏渲染的文字。</p>
</div>
<script src="./bundle.js"></script>
</body>
</html>
复制代码
这是一个最常见的单页应用形态。bundle.js
下载完后,执行,构建 DOM 树,替换 div#app
节点,渲染应用。那么问题来了,这段用来测试首屏渲染的文字,会不会被渲染到屏幕上?查询已有的资料,主要从两个方面讲解:
- 浏览器解析页面流程:
- 解析 HTML,构建 DOM 树
- 解析 CSS,构建 CSSOM
- 合并 DOM 和 CSSOM,构建渲染树(render tree)
- 对渲染树进行布局,得到每个节点的位置、尺寸信息
- 对渲染树进行绘制。
由于脚本是阻塞 html 解析的,只有下载、执行完,html 解析才宣告结束,此时构建的渲染树是完全的,但也已经不再有测试文字节点了。而在脚本下载、执行完之前,这个『不完整的渲染树』会渲染吗?得不出确切的结论。
- 『需要着重指出的是,这是一个渐进的过程。为达到更好的用户体验,呈现引擎会力求尽快将内容显示在屏幕上。它不必等到整个 HTML 文档解析完毕之后,就会开始构建呈现树和设置布局。在不断接收和处理来自网络的其余内容的同时,呈现引擎会将部分内容解析并显示出来。』 - 来自『浏览器的工作原理:新式网络浏览器幕后揭秘』
这篇讲解浏览器工作内幕的经典文章表示:HTML 解析完毕之前,也是可以进行绘制的,那么测试文字一定就能绘制出来么?依然没有明确的答案,感觉像是浏览器的黑箱。没有办法啦,只能自己去尽量检索了。
2. 规范解读
2.1 stage1: paint timing 规范
在网上检索『首次渲染』、『when does browser first paint』找不到相关的资料。在搜索时,突然发现一个新的 API PerformancePaintTiming,可以通过 first-paint
和 first-contentful-paint
这两个 entry name
来获取首次渲染的时间。赶快去查阅它的规范:
4.1.1. Mark paint timing
Perform the following steps:
- Let
paint-timestamp
be the input timestamp.
- If this instance of update the rendering is the first paint, then record the timestamp as paint-timestamp and invoke the §4.1.2 Report paint timing algorithm with two arguments:
"first-paint"
andpaint-timestamp
.
NOTE: First paint excludes the default background paint, but includes non-default background paint.(这里可以发现,默认的白屏不算 first-paint,至少得设个背景色)
- Otherwise, if this instance of update the rendering is the first contentful paint, then record the timestamp as paint-timestamp and invoke the §4.1.2 Report paint timing algorithm with two arguments:
"first-contentful-paint"
andpaint-timestamp
.
NOTE: This paint must include text, image (including background images), non-white canvas or SVG.(写了字,放了图片,就算 first-contentful-paint 啦)
翻译:如果 update the rendering
实例是 first-paint
那么就记录时间戳,上报为 first-paint
时间。如果 update the rendering
实例是 first-contentful-paint
那么就记录时间戳,上报为 first-contentful-paint
时间。
[update the rendering
]((html.spec.whatwg.org/multipage/w…)是啥?点进去,规范直接跳到了 eventloop
。恍然大悟,update the rendering
不就是 eventloop
中的最后一个阶段吗!
原来浏览器对于首次渲染根本就没有什么『黑箱操作』,人家只是老老实实的按照 `eventloop` 来运行而已。`eventloop` 第一次进行到 `update the rendering` 阶段的时间点那就是 `first-paint` 的时间点了。于是我们下一步来研究,HTML 解析过程中,`eventloop` 是怎么运行的?
2.2 stage2: eventloop 规范
我们知道 eventloop
按照 task > microtask > render
的顺序执行。查阅规范中关于 task
的定义,得:
The HTML parser tokenizing one or more bytes, and then processing any resulting tokens, is typically a task.
HTML 解析是一个典型的 task
。task
执行完才能 render
,正如 HTML 解析完才能渲染,很合理。然而经典文章说了,明明可以边解析边绘制的,事情肯定不会这么简单。
2.3 stage3: html parser 规范
在 html parser
规范中检索 eventloop
得:
(原文很晦涩,这里为了方便理解,直接翻译最核心的几句:)
当解析到
</script>
时:如果当前文档存在阻碍 JS 执行的 CSS 或者当前的脚本 不处于
ready to be parser-executed
状态,spin the event loop,直到不再存在阻碍 JS 执行的 CSS 且该段脚本处于ready to be parser-executed
。
我们已经知道 CSS 的加载是会阻碍 JS 执行的。而脚本不处于这个 ready to be parser-executed
状态简单理解就是还没下载完。如果出现这两种情况,脚本就无法立刻执行,需要等待。此时要进行 spin the eventloop,查阅规范,该操作即为:
(简单翻译)
- 暂存此时正在执行的 task 或 microtask
- 暂存此时的 js 执行上下文堆栈
- 清空 js执行上下文堆栈
- 如果当前正在执行的是 task,执行 microtask checkpoint
- 停止执行当前的 task/microtask。继续执行 eventloop 的主流程。
- 当满足条件时,重新添加之前暂存的 task/microtask,恢复暂存的 js 执行上下文堆栈,继续执行。
简单的说就是让 eventloop
中断并暂存当前正在执行的 task/microtask,保持 eventloop
的继续执行,待一段时间之后满足条件了再恢复之前的 task/microtask。
那么问题就水落石出了:
如果在 HTML 解析过程中,『解析到了某个脚本,但这个脚本被 CSS 阻塞住了或者还没下载完』,则会中断暂存当前的解析 task
,继续执行 eventloop
,网页被渲染。
如果 JS 全部是内联的,或者网速好,在解析到</script>
时脚本全都已下载完了,则解析 task 不会被中断,也就不会出现渲染情况了。
3. 实战测试
对于 1.2 中的例子,我们禁用缓存,使用 chrome 模拟 3G 网速,测试结果:
可验证之前的结论:HTML 解析过程中遇到脚本且脚本处于等待执行状态(被CSS阻塞/没下载完),解析中断,进行渲染。我们开启缓存,不限速,让 bundle.js 走强缓存,瞬间加载:
此时解析 Task 不被中断,渲染只能等到 HTML 解析完成之后再执行啦。
4. 题外话
笔者弄清该问题,花了一两个小时,写这篇文章又花了仨小时,查了不少资料,还是小有收获的,比如骨架屏的原理就是在解析中断时提早渲染页面,顺带巩固了 eventloop 和浏览器渲染机制。在 sf 上看到了有人跟我有同样的问题:
哇,遇到同样的探索者真难得!本是开心的准备迎接知识的海洋,然后: