Silent Reverie

Creating memories with the awesome stuff I've learnt.

缓存JS代码到本地localStorage的一种思路

| Comments

为了加快页面的加载速度和交互速度,缓存JS脚本文件内容到本地localStorage(以下简称缓存JS)是一种行之有效的手段。

以加载foo.js文件为例,缓存JS的逻辑大致如下:

在实际应用中,缓存JS需要解决以下两个问题:

  • 获取JS脚本文件的内容并写入缓存
  • JS脚本文件的内容变化时自动更新缓存

获取JS脚本文件的内容并写入缓存

前端JS文件一般使用单独的域名存放到CDN上,跨域问题使得我们无法通过ajax请求获取JS文件的内容。

那有其他办法可以获取脚本文件的内容吗?答案是有的。

https://www.xxx.com/static/js/foo.js为例,其文件内容如下:

1
2
console.log('first line');
console.log('second line');

大家都知道,函数是可以转换成字符串类型的,因此我们可以这种方式获取函数本身的代码,如下所示:

1
2
3
4
5
6
7
8
9
10
function foo() {
  console.log('first line');
  console.log('second line');
}

// 函数转换为字符串 => 函数代码的字符串形式
console.log(String(foo));

// 也可以这样调用得到
console.log(foo.toString());

得到函数完整的字符串代码后,需要将字符串进行剥离,只获取得到body部分,这一步可以使用正则表达式将body匹配出来:

1
2
3
4
5
6
7
// 正则表达式匹配完整的函数语句
// 这里使用`^`和`$`匹配头尾,匹配性能不成问题
var rfunc = /^function\s*(?:[^(]*)\(([^)]*)\)\s*{([\s\S]*)}$/;

// 变量`code`存放的就是`foo.js`的代码内容
var code = String(foo).match(rfunc) && RegExp.$2;
console.log(code);

所以,将原代码包裹一层function,我们可以成功拿到任意脚本文件的内容,而完全不用关心是否存在跨域问题。

于是我们可以将原先的foo.js自动转换成如下形式,这一步实际可以通过构建(打包)工具(如webpack)自动完成。

1
2
3
4
5
6
7
8
9
10
11
12
13
// 自执行函数
void function (fn) {
  // 执行代码逻辑
  fn.call(function () { return this; }());

  var rfunc = /^function\s*(?:[^(]*)\(([^)]*)\)\s*{([\s\S]*)}$/;
  var code = String(fn).match(rfunc) && RegExp.$2;

  // TODO: 将`code`写入到`localStorage`中(见下文)
}(function () {
  console.log('first line');
  console.log('second line');
});

JS脚本文件的内容变化时自动更新缓存

我们可以通过计算文件内容的md5值,然后取md5值前面若干位作为文件的版本号拼到文件名中(如foo.09c1ff3231.jsfoo.b9193b0ded.js等),以此标记文件内容发生了变化。

假设foo.09c1ff3231.js是旧版本文件,foo.b9193b0ded.js是新版本文件,用流程图可以清晰地呈现缓存自动更新的过程:

实际应用

在实际应用中,我们可以将缓存相关的逻辑封装到普通对象(如命名为StoreManager,并保存为文件store-manager.js,该对象应实现如下几个功能:

  • 获取指定脚本文件名的缓存内容
  • 判断是否已缓存指定版本号的文件内容
  • 将指定版本号的文件内容写入到缓存(或替换掉已缓存版本)
  • 将指定脚本文件名的内容从缓存中移除
  • 清理已过期的缓存内容(可选)

以下是StoreManager的实现代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
void function (global) {
  var storage = global.localStorage;

  // 缓存js时存储的key使用统一的前缀作为命名空间
  // 这样方便管理及尽可能避免冲突
  var prefix = 'store-manager/js/';

  // 匹配版本号(8~12位)
  var rkey = /^(.+)\.(\w{8,20})\.js$/;

  // 默认缓存时长
  // 30 days (单位:小时)
  var defaultExpiration = 30 * 24;

  var store = {
    // 获取指定脚本文件名的缓存内容
    get: function (key) {
      var item = storage.getItem(prefix + key);
      try {
        return JSON.parse(item || 'false');
      } catch (e) {
        return false;
      }
    },

    // 判断是否已缓存指定版本号的文件内容
    has: function (md5key) {
      var matches = String(md5key).match(rkey);
      return matches ? (this.get(matches[1]).md5 === matches[2]) : false;
    },

    // 将指定版本号的文件内容写入到缓存(或替换掉已缓存版本)
    set: function (md5key, code, opts) {
      var matches = String(md5key).match(rkey);

      if (matches) {
        var now = +new Date;
        var storeKey = prefix + matches[1];

        opts || (opts = {});

        var storeVal = {
          'md5': matches[2],
          'stamp': now,
          'expire': (now + (opts.expire || defaultExpiration) * 60 * 60 * 1000),
          'code': code,
        };

        storage.setItem(storeKey, JSON.stringify(storeVal));
      }

      return this;
    },

    // 将指定脚本文件名的内容从缓存中移除
    remove: function (key) {
      storage.removeItem(prefix + key);
      return this;
    },

    // 清理(已过期的)缓存内容
    clear: function (expired) {
      var now = +new Date;

      for (var item in storage) {
        var key = item.replace(prefix, '');

        if (key && (!expired || this.get(key).expire <= now)) {
          this.remove(key);
        }
      }

      return this;
    },
  };

  // 导出为全局变量
  // 注:一旦我们的脚本重命名后,缓存的旧版本会失去跟踪,
  // 执行`clear(true)`,这样失去跟踪的缓存内容过期后能够得到清理
  global.StoreManager = store.clear(true);

}(function () { return this; }());

有了StoreManager对象后,我们就可以轻松地缓存JS了。

  • 源文件foo.js
1
2
console.log('line 1');
console.log('line 2');
  • 通过构建(打包)工具得到foo.b9193b0ded.js(这里md5随便取的名,实际代码应该需要精简):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 自执行函数
void function (fn) {
  // 执行代码逻辑
  fn.call(function () { return this; }());

  // 容错检测
  if (typeof StoreManager === 'object' && StoreManager) {
    var rfunc = /^function\s*(?:[^(]*)\(([^)]*)\)\s*{([\s\S]*)}$/;
    var code = String(fn).match(rfunc) && RegExp.$2;

    if (code) {
      // 缓存一个月
      StoreManager.set('foo.b9193b0ded.js', code, { 'expire': 720 });
    }
  }
}(function () {
  console.log('line 1');
  console.log('line 2');
});
  • 通过条件判断是否从缓存中执行对应foo.b9193b0ded.js文件的代码逻辑(同上,实际代码应该需要精简):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
<!-- 预先引入`store-manager.js` -->
<script src="https://www.xxx.com/static/js/store-manager.js"></script>

<!-- 或者将`store-manager.js`内联到页面中 -->
<script> /* 精简过的`store-manager.js`代码 */ </script>

<!-- 通过构建(打包)工具得到如下<script>片段 -->
<script>
if (typeof StoreManager === 'object' && StoreManager && StoreManager.has('foo.b9193b0ded.js')) {
  try { eval(StoreManager.get('foo').code); }
  catch (e) {}
} else {
  document.write('<script src="https://www.xxx.com/static/js/foo.b9193b0ded.js"><\/script>');
}
</script>

<!-- 其他需缓存的js脚本文件,etc -->
<script>
if (typeof StoreManager === 'object' && StoreManager && StoreManager.has('bar.3e9a1fbe8c.js')) {
  try { eval(StoreManager.get('bar').code); }
  catch (e) {}
} else {
  document.write('<script src="https://www.xxx.com/static/js/bar.3e9a1fbe8c.js"><\/script>');
}
</script>

最后,localStorage针对每个域可存储容量很有限,需节制使用。

Comments