预加载系列二:让 File Prefetching 丝丝润滑无痛无痒

所谓 File Prefetching 就是在一个页面加载成功后,默默去预加载后续可能会被访问到的页面的资源。
前端资源预加载其实没啥新鲜的,我们倒腾这个事情的过程却是很有有意思也很有启发性。

第一个版本,简单粗暴有点痛

1、建一个独立的页面,里面索引了各种需要预加载的 css、js,代码类似下面这样。

<html>
<head>
    <link rel="stylesheet" href="//su.yzcdn.cn/v2/build_css/stylesheets/wap/showcase_d0fbaaef124a8691398704216ccd469a.css">
    ...其他需要预加载的css
</head>

<body>
    <script src="//su.yzcdn.cn/v2/build/wap/common_08b03c7826.js" onerror="_cdnFallback(this)"></script>
    ...其他需要预加载的js
</body>
</html>

2、 在每个页面加入一个 iframe(一般通过 base 模板统一加),这样每个页面打开的时候都会加载上面这个页面。假设上面的页面的 url 是 https://xxx.com/common/prefetching.html 那么我们每个页面底部都有这么一行代码:

<iframe src="https://youzan.com/common/prefetching.html" sytle="display:none;"></iframe>

如何验证

要验证某个 file prefetching 的方案是否真的有效,无非就是以下几步:
(假设 A 页面使用了showcase_d0fbaaef124a8691398704216ccd469a.css,而 B 页面不会)

  1. 让 chrome 终端打开的时候 cache 功能依旧有效
  2. 清空所有本地 cache
  3. 打开 B 页面,在控制台 Networking 里看 prefetching.html 以及附属的资源文件是否被下载了
  4. 打开 A 页面,注意:是在地址栏里输入 A 的网址然后回车,不要打开 A 页面后习惯性地按 Command/Ctrl+R 来刷新,不出意外,我们会看到如下图这样的结果:

    这说明,这 2 个 css 文件是从 cache 里读的。如果 Command/Ctrl+R 来刷新页面,我们会看到这样的结果:

    两者的差别是,Command/Ctrl+R 的时候,浏览器会从 cache 里找该静态文件,如果找到了,会根据上次请求这个文件时得到的cache-control信息判断该静态文件是否已经过期了,如果没有,会以 if-modified-sinceEtag 等信息作为 request headers 向服务器请求这个文件,服务器如果认为文件没有变过,会返回 Http code 为 304,浏览器于是直接读 cache。具体不展开啦,可以看 《HTTP caching
    《Understanding HTTP/304 Responses》

操作指引

让 chrome 终端打开的时候 cache 功能依旧有效:Chrome 终端的配置里把Disable cache (while DevTools is open)的勾选去掉
清空所有 cache:地址栏里输入 chrome://settings/clearBrowserData 打开后勾上 Cached images and filesClear browsing data
查看浏览器当前 cache 的资源列表chrome://cache/

第二个版本,依样画葫芦

目前看来,上面这个 File Prefeching 的方案是有效的。不过这种是最简陋的试验版,存在几个问题:

  1. prefetching.html 里的 js 会被执行,然后不可避免地会有一堆 js 错误 —— 看着难受 ~
  2. 通过 iframe 加载 prefetching.html 会影响到当前页面相关资源的加载速度
  3. 每次打开页面都会加载一次 prefetching.html,虽然里面的静态文件都已经在第一次打开的时候被 cache 住了不会重复下载,但无谓多一个请求终究是没必要。

于是,我们上线使用的版本是这样的:

1、有一段每个页面都会被执行到的 js:

// 打开一个iframe,下载之后页面可能需要的js/css
setTimeout(function() {
    var lastOpenTime = 0;
    var nowTime = (new Date()).getTime();
    try {
        lastOpenTime = window.localStorage.getItem('staticIframeOpenTime');
    } catch (e) {}

    if (lastOpenTime > 0 && (nowTime - lastOpenTime < 24 * 3600 * 1000)) {
        // 24小时打开一次iframe
        return;
    }

    var iframe = $('<iframe>').css('display', 'none');
    iframe
        .attr('src', 'https://youzan.com/common/prefetching.html')
        .appendTo(document.body);

    try {
        window.localStorage.setItem('staticIframeOpenTime', nowTime);
    } catch (e) {}
}, 3000);
// 延时3秒钟加载prefetching.html

2、prefetching.html 里的资源想办法让他下载但不执行,基本上都是把这些 css/js 文件当做其他类型的文件来加载,最后参照了《Preload CSS/JavaScript without execution》这篇文章,prefetching.html 中加载 js 文件的代码大概是这样的:

<script type="text/javascript">
  window.onload = function () {
    var i = 0,
      max = 0,
      o = null,
      preload = [
        '需要预加载的文件路径'
      ],
      isIE = navigator.appName.indexOf('Microsoft') === 0;

    for (i = 0, max = preload.length; i < max; i += 1) {
      if (isIE) {
        new Image().src = preload[i];
        continue;
      }
      // firefox不兼容 new Image().src 这种方式,所以除了IE都借用 object 来加载
      o = document.createElement('object');
      o.data = preload[i];
     
      o.width  = 0;
      o.height = 0;
    
      document.body.appendChild(o);
    }
  };
</script>

通过对预加载的js文件只下载不执行延时加载prefetching.html、借助localstorage的记录一天只加载一次prefetching.html,基本上解决了版本一的 3 个问题。

效果和问题

移动页面全站上线后,平均 loaded 时间减少了 0.15s,首屏时间没有数据,不过收益应该是可观的

不过,这个版本上线后,我们发现页面在 prefetching 的时候会假死,最后定位到是因为 object 加载 js 导致的(具体为什么会这样还没细究),考虑到我们主要的页面都是在手机端访问的,基本上都是 webkit 内核(Image 的方式在 firefox 中不兼容也不甚关系),所以我们决定改用 Image 来加载所有 JS。

第三个版本,完美

这个版本除了解决第二个版本的假死问题,还加入了 dns-prefetch,关于这部分的背景和思路可以参考我另外一篇文章:《预加载系列一:DNS Prefetching 的正确使用姿势》

<!DOCTYPE html>
<html>
<head>
  <?php // dns prefething here ?>
  <link rel="dns-prefetch" href="//youzan.com/">
  ...

  <?php // css prefething here ?>
  <link rel="stylesheet" href="//su.yzcdn.cn/v2/build_css/stylesheets/wap/showcase_d0fbaaef124a8691398704216ccd469a.css">
  ...
</head>
<body>
  <?php // js prefething here ?>
  <script type="text/javascript">
    (function(){
      window.onload = function () {
        var i = 0,
          max = 0,
          preloadJs = [
            'js文件路径',
            ...
          ];

        for (i = 0, max = preloadJs.length; i < max; i += 1) {
          new Image().src = preloadJs[i];
        }
      };
    })();
  </script>
</body>
</html>

上线后,丝丝润滑无痛无痒,完美

第四个版本,可以做更多

注意哦,重点来咯!
尽早加载 css 是减少首屏时间的关键(引申阅读),直接把 css inline 到 html 里是个不错的方案。但是,这种方案的缺点是无法充分利用浏览器缓存。所以,我们尝试在现有的 File Prefetching 的基础上,再进一步,让首次访问足够快(用 css line),后续访问又能利用起浏览器缓存。

我们对一部分重点页面的 css 文件改用类似加载 js 的方式去加载,并在加载成功的回调里加一条 cookie 记录标示该 css 文件已经被下载。这样在后端输出 html 的时候,可以根据 cookie 的信息知道这几个 css 文件是不是已经在浏览器里 cache 住了。如果是则正常输出一个标签。如果不是,说明用户是第一次访问这个页面,则直接把 css 文件的内容 inline 到 html 里以求最快出首屏。当然,也会出现从 cookie 上看客户端已经 cache 了某个 css 文件,但实际上没有的情况,由于这种情况下 html 里输出的还是一个 link 标签,并不会影响正常的流程。

相关代码大概是这样的,需要的朋友可以参考下:

var loadCss = function(key, url) {
  var image = new Image();
  var date = new Date();

  date.setTime(+date + 1 * 86400000);
  // 因为下载的不是图片,实际触发的是onerror事件
  image.onload = image.onerror = function () {
    document.cookie = key + '=' + url.slice(url.indexOf('build_css')) + ';path=/;domain=.youzan.com;expires=' + date.toGMTString();
  };
  image.src = url;
}

preloadCss = {
  key1: '文件路径',
  key2: '文件路径2'
  ...
}

for (var key in preloadCss) {
  loadCss(key, preloadCss[key]);
}

总结

在做 File Prefetching 的过程当中,每一个版本的优化都是不同的人在做的:
A 起了个头 ->
B 改进到能上线的标准 ->
发现有问题,C 改进了它 ->
D 又在这个基础上做出了最后一个版本。

这种感觉非常好:)

TODO

  1. 其实还有一类资源可以加到这个 prefetching.html 里,那就是常用的图片,不过我们还没这么做。
  2. 现在我们有赞全部移动 web 页只使用一个 prefetching.html,并还没有针对不同的条件进行针对性的的 prefetching。

本文首发于我的
个人技术博客:http://delai.me/code/file-frefetching/