前端静态资源缓存策略

背景

页面加载提速是战场,首当其冲要优化的就是 静态资源(js|css) 的加载速度。我们小组去年基于Vue开发了一个积分商城单页面应用。本文旨在与大家分享在单页应用中使用纯前端手段加速静态资源的获取,从而达到页面加速。

量化静态资源,分析问题所在

先让我们看看资源列表:

那么对于前端静态资源的度量,就有了一个量化:

总资源大小

必要资源加载

异步加载资源

1.44Mb

369kb

790Kb

用过Vue小伙伴都清楚,以下资源文件都是首页实时加载资源:

资源名

大小

manifest.hash.js

1.67kb

vendor.hash.js

307kb

app.hash.js

68.6kb

剩下的都是webpack异步按需加载。

接下来的问题就是:为什么vendor会这么大!?307kb

我们在通过webpack-bundle-analyzer分析的资源包大小:

上图大家可以无视eruda.js,这个是前端console工具,用于调试接口的,再者这个资源本身也是异步加载。

我们在vendor.js中看到swiper.js,体积几乎等于vue.js,这个时候前端小伙伴的反应肯定是:“我去!你你你...居然把这家伙引到h5项目里...”。好吧,我承认这家伙不应该出现在h5项目中,原因还不就是那熟悉的时间紧,任务重,横竖屏交互,最后迫于无奈就npm install一把梭

所以通过上述分析,工程学上正确的处理方式是使用体积更小的轮播图组件,或者是自己写一个。

缓存方式制定

现在我们商城已经去掉了swiper.js,但当时由于时间紧急,我们使用折中的方式:

由前端自行主动发起网络请求获取所需的静态资源,并存储在前端持久化介质中,自行管理维护静态资源版本,形成一套可被其他前端项目复用的【持久化存储模块/策略】,使我们可以更加精准地控制缓存,即使是在 http 缓存过期之后也可以使用。因此可以使我们防止不必要的重新请求资源,提升网站加载速度。

说人话就是:把首页实时加载的资源在首次加载时全部缓存到LocalStorage中,二次进入时就不需要发起网络请求了。

洋气的说法就是:static resources front-end storage solving strategy

上图是实现流程,除此之外,需要注意以下几点:

  • 支持资源的异步和顺序加载;e.g. vue项目里资源的加载顺序必须是:manifest.js->vendor.js->app.js,否则会报错,原因这里就不解释了。
  • 降级处理;如果请求资源失败怎么办?ajax请求失败的话需要存在降级处理的方式,这里我们使用的是用script标签加载资源,也就意味着放弃缓存,优先保证资源加载成功。
  • 过期/废弃资源清理;这就不多说了,除非你想LocalStorage被用满。

代码请看附录部分filecache.js

与webpack构建结合

这个部分需要你对vue脚手架构建流程有一定的了解,尤其是HtmlWebpackPlugin的参数使用。

打开build/webpack.prod.conf.js文件,会看到下面的代码片段

代码语言:txt
复制
new HtmlWebpackPlugin({
      filename: config.build.index,
      template: 'index.html',
      inject: true,
      minify: {
        removeComments: true,
        collapseWhitespace: true,
        removeAttributeQuotes: true
        // more options:
        // https://github.com/kangax/html-minifier#options-quick-reference
      },
      // necessary to consistently work with multiple chunks via CommonsChunkPlugin
      chunksSortMode: 'dependency'
    }),

我这里改成:

代码语言:txt
复制
new HtmlWebpackPlugin({
      filename: config.build.index,
      template: 'index.html',
      inject: false, // 这里不允许自动写入script标签
      minify: {
        removeComments: true,
        collapseWhitespace: true,
        removeAttributeQuotes: true
        // more options:
        // https://github.com/kangax/html-minifier#options-quick-reference
      },
      // necessary to consistently work with multiple chunks via CommonsChunkPlugin
      chunksSortMode: 'dependency',
      fileCache: fs.readFileSync('./src/lib/filecache.js', 'utf-8') // 使用自定义属性存储filecache.js内容
    }),

接下来我们来改造index.html文件,因为我们在前面的配置中禁止HtmlWebpackPlugin自动写入script标签,所以我们要自己去做这件事情:

代码语言:txt
复制
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no"/>
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black" />
<meta name="format-detection" content="telephone=no, email=no">
<link rel="shortcut icon" href="./static/images/favicon.ico">
<title></title>

<% if(htmlWebpackPlugin.options.nodeEnv === 'production') { %>
<script type="text/javascript">
<%= htmlWebpackPlugin.options.fileCache %>
(function() {
var cssFile = [];
<% for(var css in htmlWebpackPlugin.files.css) { %>
cssFile.push({
src: '<%= htmlWebpackPlugin.files.css[css] %>'
});
<% } %>
cache.require(cssFile);
})();
</script>
<% } %>
</head>
<body>
<div id="app" style="position: absolute; width: 100%; height: 100%; overflow-x: hidden; overflow-y: auto">
<!--<transition name="slideRight">-->
<router-view></router-view>
<!--</transition>-->
</div>
<!-- built files will be auto injected -->
<% if(htmlWebpackPlugin.options.nodeEnv === 'production') { %>
<script type="text/javascript">
(function() {
var jsFile = [];
<% for(var js in htmlWebpackPlugin.files.js) { %>
jsFile.push({
src: '<%= htmlWebpackPlugin.files.js[js] %>'
});
<% } %>
cache.require(jsFile);
})();
</script>
<% } %>
</body>
</html>

大家可以看到我用模板做的事情是仅限于生产环境的,本地开发时的逻辑和之前都不变。

我在模板中其实就做了三件事情:

  1. 引入filecache.js文件内容:<%= htmlWebpackPlugin.options.fileCache %>
  2. 使用cache模块加载css资源,样式资源以数组形式存放于htmlWebpackPlugin.files.css中;
  3. 使用cache模块加载js资源,js资源以数组形式存放于htmlWebpackPlugin.files.js中;

如此我们已经完成了所有工作,现在我们执行npm run build命令看看构建之后的index.html内容:

下面代码片段对应的是加载js资源的代码,manifest, vendor, app被按照顺序放入数组,并被cache模块加载

代码语言:txt
复制
<script type=text/javascript>
! function() {
var s = [];
s.push({
src: "static/js/manifest.600d614379f9d48f75f2.js"
}), s.push({
src: "static/js/vendor.ef068e458aeb13feeda7.js"
}), s.push({
src: "static/js/app.50f493665666a847cdde.js"
}), cache.require(s)
}()
</script>

优化结果呈现

Network

首次进入

首次加载我们看到所有的静态资源都变成已ajax方式去请求了,并且是按照顺序执行的。

二次进入

二次进入的时候明确看到静态资源的请求已经消失,因为cache模块已经检测到LocalStorage存在资源。

使用优测平台(http://utest.oa.com

从报表中清晰看到测试了五轮,除了第一轮,我们缓存在本地的静态资源均没有再被请求加载

过期策略

LocalStorage中存在一组key:

_gzh_cache_webuy.qq.com/static/js/vendor.js_gzh_cache_webuy.qq.com/static/js/vendor.js_factor,前者是存储静态资源的内容,后者是存放资源的时间和版本信息:

代码语言:txt
复制
{
"version": "3a9dcc63a7d9dbf123134a0d23ac0dc2", // 资源hash
"createTime": 1519458466451, // 本地缓存创建时间
"visitTime": 1519458513358 // 本地缓存访问时间
}

在加载资源时候,首先要比较version,如果一致则代表资源可用。那么资源过期如何判定呢?比如我们设置过期时间是3天,这里我并没有使用createTime去比较,而是使用visitTime,原因是访问时间每次都会被更新,也就能够延长缓存寿命。对于一个活跃用户,经常访问你的页面,在你没有发布新版变更的前提下,用户只要在3天之内再度访问,所有本地资源寿命会被再延长3天,这种机制尤其适用于你的vendor.js,因为这里面打包都是第三方库,变更频率是很低的,多半是你的app.js变更频繁。最后过期的资源,会在cache模块执行开始就去做清理工作。

结束语

本文中使用的filecache.js主要参考自basket.js,至于为什么不直接使用这个库,主要是在github上发现作者已经n年没有维护,另外就是出于自己学习的目的。

现在如果大家有使用前端持久化存储介质来优化页面加载速度的场景,建议大家使用ElemeFE/bowl,基本属于开箱即用~

附录

filecache.js

代码语言:txt
复制
/**

  • 文件持久缓存:使用LocalStorage技术实现
  • 支持缓存的文件类型:js|css
  • 根据文件MD5摘要或版本号比对缓存变化,若无变化,直接使用缓存的内容,若有变化,请求服务器,然后替换旧缓存内容
    */

var PREFIX = 'gzh_cache';
var SUFFIX = '_factor';
var EXPIRE = 3 * 24 * 3600 * 1000; // 超过3天未访问的缓存文件(根据项目需要随时调整)
var parse_reg = /(.*).(\w+).(js|css)$/;

// 根据真实地址解析出来文件名、md5摘要、文件类型
function parse(src, version) {
if (version) {
return {
src: src,
name: src,
version: version,
type: src.match(/.(js|css)$/)[1]
};
}

var match = src.match(parse_reg);
if (match) {
return {
src: src,
name: [match[1], match[3]].join('.'),
version: match[2],
type: match[3]
};
}
return null;
}

// ajax请求文件(不能跨域)
function request(file, isDebug, callback) {
// 当是调试模式,script或link加载文件
if (isDebug) {
load(file.src, file.type, callback);
return;
}

var xhr = new XMLHttpRequest();
xhr.open('GET', resolve(file.src));
xhr.onreadystatechange = function() {
if (xhr.readyState === 4) {
if (xhr.status === 200 || (xhr.status === 0 && xhr.responseText)) {
exec(file.type, xhr.responseText);
callback && callback();
cache.set(file.name, file.version, xhr.responseText);
} else {
load(file.src, file.type, callback);
}
}
};
xhr.onerror = function() {
load(file.src, file.type, callback);
};
xhr.send();
}

// script或link加载(ajax请求失败,降级加载)
function load(src, type, callback) {
if (type === 'js') {
var script = document.createElement('script');
script.src = src;
script.onload = callback;
script.onerror = callback;
document.body.appendChild(script);
} else {
var link = document.createElement('link');
link.href = src;
link.rel = 'stylesheet';
link.onload = callback;
link.onerror = callback;
document.head.appendChild(link);
}
}

// 去除域名的绝对路径
function resolve(src) {
return src.replace(/^.*.com/, '');
}

// 执行代码内容
function exec(type, content) {
var _exec = {
js: execScript,
css: execCSS
}[type];
_exec && _exec(content);
}

function execScript(content) {
var script = document.createElement('script');
script.setAttribute('type', 'text/javascript');
script.appendChild(document.createTextNode(content));
document.body.appendChild(script);
}

function execCSS(content) {
var style = document.createElement('style');
style.setAttribute('type', 'text/css');
style.appendChild(document.createTextNode(content));
document.head.appendChild(style);
}

function parseURL(url) {
var parser = document.createElement('a'),
searchObject = {},
queries, split, i;
// Let the browser do the work
parser.href = url;
// Convert query string to object
queries = parser.search.replace(/^?/, '').split('&');
for (i = 0; i < queries.length; i++) {
split = queries[i].split('=');
searchObject[split[0]] = split[1];
}
return {
protocol: parser.protocol,
host: parser.host,
hostname: parser.hostname,
port: parser.port,
pathname: parser.pathname,
search: parser.search,
searchObject: searchObject,
hash: parser.hash
};
}

var cache = {
isSupport: function() {
return ('localStorage' in window);
},

getNameSpace: function(name) {
var NAMESPACE = '';
if (!(/^.*.com/.test(name))) {
var urlObj = parseURL(window.location.href);
NAMESPACE = urlObj.hostname + urlObj.pathname;
}
return NAMESPACE;
},

get: function(name) {
try {
var NAMESPACE = this.getNameSpace(name);
var content = localStorage.getItem(PREFIX + NAMESPACE + name);
var factor = this.getFactor(name);
if (factor) {
factor.visitTime = +new Date();
localStorage.setItem(PREFIX + NAMESPACE + name + SUFFIX, JSON.stringify(factor));
}
return content;
} catch (e) {
console.error(e.message);
return null;
}
},

getFactor: function(name) {
try {
var NAMESPACE = this.getNameSpace(name);
var factor = localStorage.getItem(PREFIX + NAMESPACE + name + SUFFIX);
return factor ? JSON.parse(factor) : null;
} catch (e) {
console.error(e.message);
return null;
}
},

set: function(name, version, content) {
this.remove(name);
try {
var now = +new Date();
var factor = {
version: version,
createTime: now,
visitTime: now
};
var NAMESPACE = this.getNameSpace(name);
localStorage.setItem(PREFIX + NAMESPACE + name, content);
localStorage.setItem(PREFIX + NAMESPACE + name + SUFFIX, JSON.stringify(factor));
} catch (e) {
console.error(e.message);
}
},

remove: function(name) {
try {
var NAMESPACE = this.getNameSpace(name);
localStorage.removeItem(PREFIX + NAMESPACE + name);
localStorage.removeItem(PREFIX + NAMESPACE + name + SUFFIX);
} catch (e) {
console.error(e.message);
}
},

// 清除太久远的缓存文件,释放空间
clear: function() {
try {
var now = +new Date();
for (var key in localStorage) {
if (new RegExp('^' + PREFIX).test(key)) {
var data = JSON.parse(localStorage.getItem(key + SUFFIX) || '{}');
if (now - data.visitTime >= EXPIRE) {
localStorage.removeItem(key);
localStorage.removeItem(key + SUFFIX);
}
}
}
} catch (e) {
console.error(e.message);
}
},

/**

  • 导入指定资源文件
  • 1.导入单个资源文件
  • @param {String} src 资源文件绝对路径
  • @param {String} [version] 资源版本(md5摘要)
  • 2.批量导入资源
  • @param {Array} files 资源文件列表:[{src: ***, version: ***}, ...]
  • @param {Boolean} parallel 指定为并行加载资源(不管资源依赖顺序),默认串行加载(按资源文件列表顺序)
    */
    require: function(src, version) {
    var files = [];
    var parallel = false;
if (typeof src === &#39;string&#39;) {
  files.push({
    src: src,
    version: version
  });
} else {
  files = src;
  parallel = version;
}

// 并行加载,无视依赖顺序同时加载
if (parallel) {
  for (var i = 0; i &lt; files.length; i++) {
    var file = files[i];
    this._require(file.src, file.version);
  }
  return;
}

// 串行加载,按依赖顺序逐个加载
var index = 0;
var next = function() {
  var file = files[index++];
  if (!file) return;
  cache._require(file.src, file.version, next);
};
next();

},

_require: function(src, version, next) {
var file = parse(src, version);
if (!file) return next && next();

var isDebug = /debug=1/.test(location.search);
if (isDebug) {
  return request(file, isDebug, next);
}

// 不支持缓存,从服务器请求
if (!this.isSupport() || !file.version || file.version === &#39;null&#39;) {
  return request(file, false, next);
}

var factor = this.getFactor(file.name);
// 无缓存 || 缓存过期,从服务器请求
if (!factor || factor.version !== file.version) {
  return request(file, false, next);
}
// 无缓存文件,从服务器请求
var content = this.get(file.name);
if (!content) return request(file, false, next);

// 执行缓存
exec(file.type, content);
next &amp;&amp; next();

}
};

// 启动时,清除太久未访问的缓存文件,释放空间
cache.clear();