一种新的CSS加载方法

加载CSS是网页编写过程中不可或缺的一步,一周前,Google工程师Jake Archibald撰文阐述了一种新的CSS文件加载方法,可用于实现分块加载渲染页面 (https://jakearchibald.com/2016/link-in-body/)

传统的CSS加载方式

目前最常见的CSS加载方式就是在HTML文件的head标签内对CSS进行引用:

<head>  
  <link rel="stylesheet" href="/all-of-my-styles.css">
</head>  
<body>  
  …content…
</body>  

在这种做法中,一般会把整个网站的CSS打包成一个css文件(all-of-my-styles.css),然后让不同的页面都引用这一打包后的css。这样做的好处是减少页面对css文件的请求数(一张页面只请求一个css,如果这一打包后的css之前已经被缓存过了,那么网页就无需再重新下载css文件),这样做的坏处则是对于需要下载css文件的页面而言,因为只使用了css文件中一小部分的规则,所以其下载css所花的时间中有很大一部分其实并不是必需的。

另一种做法则是不将CSS进行打包,而把CSS按照组件、模块分成多个小文件,每张网页只加载自己所需要的那些css文件:

<head>  
  <link rel="stylesheet" href="/site-header.css">
  <link rel="stylesheet" href="/article.css">
  <link rel="stylesheet" href="/comment.css">
  <link rel="stylesheet" href="/about-me.css">
  <link rel="stylesheet" href="/site-footer.css">
</head>  
<body>  
  …content…
</body>  

可以看到,这种做法避免了CSS加载冗余的问题,所付出的代价则是增加了单张页面对css文件的请求数。请求数增加的问题可以通过支持SPDY和HTTP/2来进行解决 -- SPDY+HTTP/2可以降低多个小资源加载的额外开销。

但即使提供了SPDY和HTTP/2的支持,上述做法也不是完美的,其问题有二:

  1. 必须在编写页面的head标签时就知道该页面需要哪些css文件,这对页面的组件化编写不利 -- 当我们在页面的body中引入组件标签时,我们所需要的是引入即可用。如果需要在head标签中显式引入组件所依赖的css文件的话,那么任何一次对组件的使用都需要在页面中进行2处修改,这明显是不合理的。
  2. 在将所有的css文件加载完成之前,浏览器无法渲染任何内容(白屏)。比如,在上述例子中如果/site-footer.css加载非常缓慢,那么即使其它所有的css文件都已加载完成,页面依然还是白屏。这明显也是不合理的。

减少白屏时间的CSS加载方式

上面的两种做法存在相同的问题:在所引用的css文件加载完成前,浏览器无法渲染任何页面内容(白屏)。为了解决这一问题,以下方案在页面的head标签内插入一段用于初始渲染的内联CSS,然后再用JavaScript来异步加载别的css文件。因为内联的CSS规则不等其它css文件加载完成即可生效,因此这种做法可以大幅降低页面的白屏等待时间。

<head>  
  <script>
    // https://github.com/filamentgroup/loadCSS
    !function(e){"use strict"
    var n=function(n,t,o){function i(e){return f.body?e():void setTimeout(function(){i(e)})}var d,r,a,l,f=e.document,s=f.createElement("link"),u=o||"all"
    return t?d=t:(r=(f.body||f.getElementsByTagName("head")[0]).childNodes,d=r[r.length-1]),a=f.styleSheets,s.rel="stylesheet",s.href=n,s.media="only x",i(function(){d.parentNode.insertBefore(s,t?d:d.nextSibling)}),l=function(e){for(var n=s.href,t=a.length;t--;)if(a[t].href===n)return e()
    setTimeout(function(){l(e)})},s.addEventListener&&s.addEventListener("load",function(){this.media=u}),s.onloadcssdefined=l,l(function(){s.media!==u&&(s.media=u)}),s}
    "undefined"!=typeof exports?exports.loadCSS=n:e.loadCSS=n}("undefined"!=typeof global?global:this)
  </script>
  <style>
    /* The styles for the site header, plus: */
    .main-article,
    .comments,
    .about-me,
    footer {
      display: none;
    }
  </style>
  <script>
    loadCSS("/the-rest-of-the-styles.css");
  </script>
</head>  
<body>  
</body>  

如上面这个例子中所示,实际操作中,一般会在内联的CSS规则里,将外部css文件所对应的HTML区域设定为display: none;而在外部css文件中,则将这些HTML区域重新设定为可见,以此获得最佳的页面呈现效果。

不过,虽然这种方式能够有效的降低白屏等待时间,但该方式也存在一些问题:

  1. 需要引入一个小JavaScript插件(loadCSS)。
  2. 页面渲染分离成了两个阶段来进行,当有2个以上的外部css文件时,容易出现不正常的页面闪回现象。

新方法

基于对以上这些问题的思考,Jake提出了一个新的CSS加载方案,其代码示例为:

<head>  
</head>  
<body>  
  <link rel="stylesheet" href="/site-header.css">
  <header>…</header>

  <link rel="stylesheet" href="/article.css">
  <main>…</main>

  <link rel="stylesheet" href="/comment.css">
  <section class="comments">…</section>

  <link rel="stylesheet" href="/about-me.css">
  <section class="about-me">…</section>

  <link rel="stylesheet" href="/site-footer.css">
  <footer>…</footer>
</body>  

可以看到,这一方法的思路是让未加载完成的css文件只阻塞其后面的HTML内容显示,而不阻塞其前面的HTML渲染,以此实现页面的分块加载显示。

不幸的是,HTML标准不建议将link标签放置在body中(body元素的content model为Flow,而link并不是Flow元素),对于css文件加载所产生的页面渲染阻塞行为也没有做任何说明。因此,虽然各大浏览器均支持在body标签中放置CSS link标签,但其具体行为是有所差别的:

  • Chrome和Safari。当发现<link rel="stylesheet">后即停止渲染,在所有css加载完成之前,页面上不会显示任何东西。
  • Firefox。head标签中的<link rel="stylesheet">行为与Chrome/Safari中完全一致,这些link标签全部加载完成之前,页面上不显示内容;而body标签中的<link rel="stylesheet">则不阻塞任何内容显示。
  • IE/Edge。未加载完成的<link rel="stylesheet">标签只阻塞其后面的HTML内容显示,而对其前面的HTML内容则不阻塞。

可以看到,对于“分块加载显示”这一目的而言,IE/Edge的行为是最完美的。根据Jake的说法,Chrome浏览器正在做相应的修改,以表现与IE/Edge一致的行为。同时,Google也正在HTML标准上下工夫,争取让HTML标准支持在body中放置link标签的行为。对于Jake的这一新做法来说,Chrome/Safari上不会有任何改进,但也没有让情况变得更糟糕。

对于Firefox,由于body标签中的link不阻塞任何内容显示,因此直接使用上述代码会造成页面显示的闪回 -- 不等body中的css文件加载完成,所有的HTML内容都会显示出来,而当body中的css文件加载后,部分HTML内容的样式和展现又会发生改变。为了避免这一问题,可以对上述代码进行一个小修改,利用script标签的特性来人为的实现IE/Edge的行为:

<link rel="stylesheet" href="/article.css">  
<script> </script>  
<main>…</main>  

这一做法的原理是:script标签会等待其前面的css文件加载完成,而与此同时,script标签又会阻塞其后面的HTML的解析;这样,最终的效果就是在Firefox中也实现了与IE/Edge一样的行为。值得注意的是,script标签的内容不能为空,因此在上述代码中给script标签内打了一个空格。

综上所述,使用Jake所提出的新方法后,在Firefox与IE/Edge下可以实现页面分块加载渲染的效果,而在Chrome、Safari中,这一方法则不会有任何作用。未来,对于body中link标签的阻塞行为处理,Chrome会采用和IE/Edge一样的做法,因此对于页面样式较为复杂、所需加载的CSS规则较多的页面而言,不妨一试Jake的这一新方法。