通过异步加载非关键js脚本与css样式表来加速网页网页渲染

如果我们想要让网页首次渲染的时间尽量提前,那么我们必须加快关键路径上的资源加载。而网页内嵌关键资源,异步加载非关键资源能够让网页的渲染速度达到极致。

在建博客之初,我是把css样式表和js脚本都放在本站,但是从国外的服务器请求静态资源的延迟实在太大了,因此我后来使用jsDelivr这个在国内也有服务器的CDN来加载静态资源(详情见上一篇文章,使用jsdelivr CDN加速hexo的图片等静态资源加载 )。但是为了追求极致的速度,我不能让html网页传过来后还要去请求css样式表才能开始渲染,即使是使用CDN也是有不可忽视的请求延迟的,因此我现在将渲染需要的关键css样式表内联在html网页中,而异步加载非关键的资源。

什么拖慢了网页的渲染?

要知道这个问题的答案,就需要对网页渲染的流程有一定的了解。网页渲染过程中有一个很重要的概念叫关键渲染路径(Critical Rendering Path),Google的Web教学网站上比较详细地阐释了这一概念,简单地概括,关键渲染路径指的是浏览器将HTML文件、CSS样式表与Javascript脚本转换为图像的一系列步骤,Figure 1用流程图概括了这一流程。

Figure 1: 关键渲染路径

关键渲染路径可以用文字描述为下面的步骤,其中步骤1和步骤2是可以并行的:

  1. 处理 HTML 文件并构建 DOM 树。
  2. 处理 CSS 样式表并构建 CSSOM 树。
  3. 将 DOM 与 CSSOM 合成一个渲染树。
  4. 根据渲染树来计算布局,获得每个节点的位置信息与大小。
  5. 将各个节点绘制为像素。

我们都知道HTML可以被解析为DOM树,而CSS样式表同样可以被解析为CSSOM树,每个节点是对某一DOM节点的样式描述。而我们需要同时有DOM树与CSSOM树才能够构建渲染树,因此如果CSSOM树的构建阻塞的话,渲染树的构建同样会被阻塞。

那么,javascript脚本在关键渲染路径的什么位置呢?当在构建DOM树时,如果碰到非异步加载的<script>标签,那么浏览器将阻塞构建DOM树而执行脚本。如果执行脚本时CSSOM尚未构建完成,脚本将被阻塞而等待CSS的加载和CSSOM树的构建。

可以看到,js脚本让关键渲染路径的变得更加复杂,因此在HTML的前部引入同步加载的javascript是需要慎重考虑的事情,尤其是脚本需要从外部加载或脚本需要较多运算时。因此我们一般把需要同步加载的js脚本放在</body>的前面,这样当解析到脚本时DOM树已经基本构建完成了。而更多的js脚本我们可以使用deferasync来异步加载它们。

而CSS是没有默认的异步加载属性的,因为在关键渲染路径上,渲染树的构建必须以CSSOM树的构建为前提,因此虽然使用外部的CSS时样式表文件的下载和DOM树的构建会同步进行,但是在CSS样式表加载好并构建完CSSOM之前,渲染树都无法开始构建。因此只要是常规方式加入的CSS,都是同步加载的,即CSS文件的下载会阻塞整个关键渲染路径。

浏览器为什么不展示没有样式的HTML内容呢?其实也是可以展示的,有一些浏览器应该也是采取了先展示DOM单独构成的渲染树的内容的策略,但是由于缺少样式,网页会变得十分简陋,这种现象被称为“内容样式短暂失效”(FOUC)。

内联CSS与js脚本

虽然说,将CSS样式表和js脚本放在CDN可以比从国外服务器上加载更快,但是仍然会有一定的网络传输延迟,例如上篇文章中的网络请求timeline,如Figure 2所示,可以看到style.css文件应该是没有被CDN cache,加上了从原服务器回源的时间后传输时间就较大了。而整个页面的渲染树都要等所有的CSSOM树构建完后才能构建,因此该从外部加载CSS即使是使用CDN,仍然避免不了在首次渲染之前多了一次网络传输的时间。

从Figure 3的实际渲染路径可以看到,首次渲染(First Paint)发生在style.css加载好之后,而style.css是在加载HTML时解析到<style>节点后再去请求的,因此即使已经有了完整的HTML文本,也无法渲染内容在屏幕上,这就会造成白屏时间。

Figure 2: 网络请求timeline
Figure 3: 实际渲染路径

既然如此,那么我可以将CSS样式表和需要立即执行的js脚本内嵌在HTML网页中,这样只要传输一次HTML内容,就可以立刻进行渲染,我评估了一下自己的style.css的大小,11KB左右,原HTML页面大小20KB左右,似乎加起来再压缩后也不会有多大,那么内联应该是可行的。

实际操作后,首页的HTML文件大小为40KB不到,压缩传输时10KB左右,这看上去只需要几毫秒的时间就可以完成,比起DNS请求和建立https链接的时间似乎不值一提。

Figure 4显示了将CSS内嵌后的渲染路径,可以看到在Parse HTML完成后不久,首次渲染就开始了,这样用户的白屏时间就大大缩短了,一切似乎都很完美。

Figure 4: 内嵌CSS后的渲染路径

我内联CSS样式的网页的压缩大小在10KB量级,这本应和原来的5KB量级没有什么区别,但是万万没有想到的是,在用各路国内的多点网站测速服务器测速之后,发现网页的下载时间变长了很多,一看它们的下载网速,有一些都是1KB/s的水平。这代表着,如果用1KB/s的网速,首页内容的下载可能会延长5秒。

在国内的网络关口与国外服务器相结合的情况下,造成的魔幻现实主义的1KB/s的网速。出于国情,我不得不继续考虑这个问题。

精简与异步加载CSS

对于javascript与CSS的压缩已经是老生常谈了,部署时去掉空白字符、合并可以合并的样式、并压缩传输。

同时,我们也可以删去没有用到的CSS样式(这激进而有风险),并异步加载非关键的CSS。首屏渲染用到的样式并不会很多,因此可以把关键的CSS内联。

如何知道哪些CSS样式是关键的,哪些是非关键的呢?我们可以用Chrome开发工具中的Coverage来查看哪些CSS样式与javascript脚本是被使用覆盖的。

Figure 5显示的是我的主页的加载后,首屏对资源的使用覆盖率。我们可以看到style.css有66%左右的样式是首屏没有用到的。

如果直接删除没有用到的样式会有很大风险,因为你很难保证你测试了所有浏览器、所有可能访问到的页面与所有的访问行为。而将那些测试中没有用到的样式异步加载则风险较小,即使有一些没有覆盖到的样式,它们加载后重新计算布局所导致的Layout偏移也不会太大。

因此我测试了网站中的大部分网页,将没有用到的样式都放进了另一个样式表中,并准备异步加载它们,而把关键的CSS内联在HTML中,这样下载HTML文件后浏览器就可以直接开始渲染。由于我的博客使用了stylus组织CSS样式表,我还为我的主题将CSS内联进网页模板写了个插件。

Figure 5: 使用Coverage工具显示使用覆盖率

现在的问题变为,如何异步加载CSS呢?由于浏览器的关键渲染路径设计,CSS本身并没有被设计为异步渲染,因此<style>也不像<script>那样有可选的异步加载的属性。我在网上一阵搜索,发现了两个说得过去的方法(只有一个方法浏览器的支持更好)。

第一个方法是使用preload属性,下面的HTML文本描绘了异步加载样式表的方法。拥有preload属性的<link>链接中的文件都会被以高优先级下载,如果之后HTML的某个节点用到了该处预加载的文件则可以直接使用缓存。这里的使用技巧是将CSS预加载,而浏览器不会对预加载的文件进行解析,因此浏览器不会因为这个样式表而阻塞渲染,而在加载完成后,这里的onload代码让该链接的rel变为了stylesheet,因此该节点变成了一个常规的样式表节点,浏览器就会立刻进行重新渲染。

而第二行的<noscript>则是在浏览器不支持js脚本时使用正常的同步加载。

1
2
<link rel="preload" href="styles.css" as="style" onload="this.onload=null;this.rel='stylesheet'">
<noscript><link rel="stylesheet" href="styles.css"></noscript>

这种方法看起来很美好,但是preload居然不被Firefox浏览器默认支持,那显然就不能采用这个方法了。

而第二种方法则历史更悠久,浏览器支持也更好,甚至你也可以给他加上preload来优化,但是它的运行机制不依赖prelaod。

如下面的HTML文本,这种方法起作用的原理在于,当我们指定CSS适用于某一种media,但是media的类型却不存在时(例如none),浏览器会加载该样式表但不会用它来渲染。而onload对应的脚本让样式表下载完成后修改media类型为all,相当于没有设置media属性,那么该节点就会变为常规的CSS节点,页面会重新渲染。第二行的<noscript>作用和上面是一样的。

1
2
<link rel="stylesheet" href="style.css" media="none" onload="if(media!='all')media='all'">
<noscript><link rel="stylesheet" href="style.css"></noscript>

如果你的该CSS样式表没有那么重要,但是还是有一点重要(比如说比图片要重要一点),那么你也可以在最前面使用preload,来提升该样式表的加载优先级。

1
<link rel="preload" href="style.css" as="style">

知道怎么做之后,我便将自己的网页上的关键样式表内联,非关键样式表异步加载,而仅有的一个同步加载的外部js脚本因为是放在整个HTML的最后,因此也不用管他。

效果呈现

那么,现在的效果是如何的呢,不知道你那边的网络状况如何,我就展示一下现在的渲染路径吧,如Figure 6所示,首次渲染发生时,尚有两个CSS样式表在加载,这说明了,现在非关键的CSS样式表的加载并不会阻塞渲染树的生成与最终的Painting。而且,由于关键的CSS表已经内联,新的CSS加载后也不会让布局产生过大的偏移。

Figure 6: 异步加载CSS时的渲染路径

似乎离更快又更近了一步,但是其实还是有优化空间的,即使对于一个国外的服务器来说。例如,如果将服务器直接部署在Cloudflare上,那么就不会有HTML页面CDN的回源时间,那样就能进一步加快访问速度。

当然,更彻底的方法还是部署在国内服务器,这样也省的这么多优化了,还能有访问时间的数量级上的提升。