前端组件的缓存共享策略优化

0

如果一个前端页面中可能引入多个独立的组件,这些独立的组件与页面的技术栈无关,如一个使用 React、Vue 开发的页面引入了一个原生的 Web 组件,此时这些独立组件的共享缓存就是一个值得关注的问题。

常规做法

按照常规的思路,页面如果要引入 A 和 B 两个组件,两个组件都依赖了相同的模块 @lib,如果希望将模块 @lib 缓存共享,那么一般的做法是将共享模块单独提供一个 JS 包,无论是 A 或 B 的包在加载前都先加载好共享缓存包,此时就会存在三个包,对应着三个独立的请求。对于移动 App 来说,原本是两个组件包两个请求,但此时请求数的增加意味着由于为了实现缓存共享而降低了可用性。

更优的缓存共享策略

常规做法面临着缓存和请求数增加的冲突,有没有办法能做到鱼和熊掌兼得呢?答案是肯定的。

可能的策略是当加载运行第一个组件时,假设这里先加载 A 组件,A 组件的包中包含了共享模块 @lib,当 A 模块运行时会主动将共享模块进行缓存,而 B 组件开始加载时检测到有共享模块 @lib 的缓存则加载不包含共享模块的组件包,此时就做到了引入了共享缓存后并没有增加请求数,鱼和熊掌兼得。

实现方案分析

上面的描述可能有点绕,简单梳理一下方案需要具备如下条件: 1. 组件包在打包时需要提供两种包,一种包含共享模块,一种不包含; 2. 组件包在运行时可以缓存共享模块; 3. 加载第二个组件包需要根据本地是否有共享缓存来决定加载哪种包。

条件 1 比较好实现,关键是如何实现 2 和 3,这里需要定义好缓存的协议用于读写缓存,同时按需引入哪种组件包的功能也需要独立抽取出来,此时增加一个 loader.js 模块,来处理这些逻辑,这不就是 require.js 和 sea.js 干的事吗,本质上是,但一方面不需要那么复杂,一方面 require.js 和 sea.js 对 JS 模块有要求,整体改造的成本会过高。

那么关键的问题就变成了缓存协议到底该如何定义? 在回答这个问题前还有一个更关键的问题,缓存到哪?如果连缓存到哪都没有确定,那么缓存协议也不好定义。

如果缓存仅对当前页面有效,那么缓存到内存中即可,如果缓存要跨页面,那么就不能直接缓存到内存中,只能缓存到磁盘中,如果是纯 Web 环境,可能唯一的选择是 LocalStorage 中,但是 LocalStorage 对域名又有限制。如果是 App 环境,那么就可以缓存到 App 提供的缓存中,一般 App 都会有桥 API 来读写缓存。比较好的策略是优先使用 App 的缓存,兜底方案为 LocalStorage。

确定了缓存到哪后,那么缓存协议的定义就有了些眉目。缓存到磁盘就意味着缓存的内容必须是字符串,我们希望 A 模块在运行时主动缓存共享模块,共享模块既要可运行,又要可以转成字符串内容。

  • 方案1:先运行,再转换成字符串进行缓存,这里需要使用函数的 toString 方法,实测一个未压缩 200KB 的 JS 文件,toString 耗时基本为 0,推测 JS 引擎本身就会缓存函数的字符串内容。
  • 方案2:构建阶段将 JS 代码先转换成字符串内容,在运行时将字符串内容转换成可运行的代码,同时可以将字符串内容直接缓存。

无论使用方案 1 还是方案 2 都会需要在读取缓存时将字符串内容转换成可运行的代码,这里都要用到 eval 或 Function。

而方案 1 和方案 2 的主要的区别是写入缓存前的处理,前者只需要运行对性能无损耗的 toString,而后者需要使用 eval 或 Function 来将字符串转换成可运行的代码,显然方案 1 会更优。当然无论如何都需要在构建阶段做一些处理。

eval 还是 Function

上面提到将字符串转换为可运行代码要用到 eval 或 Function,那到底用哪个好?先来看看相对权威的官方解释。

eval() 是一个危险的函数,它使用与调用者相同的权限执行代码。如果你用 eval() 运行的字符串代码被恶意方(不怀好意的人)修改,您最终可能会在您的网页/扩展程序的权限下,在用户计算机上运行恶意代码。更重要的是,第三方代码可以看到某一个 eval() 被调用时的作用域,这也有可能导致一些不同方式的攻击。相似的 Function 就不容易被攻击。

现代 JavaScript 解释器将 JavaScript 转换为机器代码。这意味着任何变量命名的概念都会被删除。因此,任意一个 eval 的使用都会强制浏览器进行冗长的变量名称查找,以确定变量在机器代码中的位置并设置其值。另外,新内容将会通过 eval() 引进给变量,比如更改该变量的类型,因此会强制浏览器重新执行所有已经生成的机器代码以进行补偿。但是(谢天谢地)存在一个非常好的 eval 替代方法:只需使用 window.Function

详细对比可见 永远不要使用 eval!

实测下来 eval 确实要比 Function 慢一些,但是由于测试的样本不够,实际相差没那么大,但不管怎样,还是建议相信权威解释,优先使用 Function。

最终方案

有了上面的方案分析,接下来的实现其实就简单了。

共享模块的改造

在模块打包时就要将模块代码进行转换,转换成可以主动缓存模块的包。运行时阶段先运行模块,然后调用 toString 得到模块的字符串内容,最终将字符串写入缓存。

假如我们想将 @lib 共享模块的 lib.js 进行可被缓存的改造:

// 定义一个模块函数,用于封装待转换的模块
var _module_lib_fn = function () {
    // 模块实际的内容,将模块引用导出到 window 上
    window.moduleLib = function () {
          // ...
      };
};

// 运行模块
_module_lib_fn();

if (!window.__cacheModules) {
    window.__cacheModules = [];
}
// 将模块存入运行时的临时缓存中
window.__cacheModules.push({
    moduleName: '@lib',
    version: '1.0.0',
    fn: _module_lib_fn
});

共享缓存模块打包输出为一个特殊的模块,实际使用时可通过 require 方式来引入,引入后再从全局命名空间上来获取模块引用。

在组件 com.js 中引入改造过的 @lib 模块,由于 @lib 改造后并不是一个标准的模块,所以无法使用 import 来引入,但仍然可以通过 require 来引入。

// 引入模块
require('@lib');
// 获取模块入口
const moduleLib = window.moduleLib;

组件 com.js 需要输出两种包类型: 1. com.app.js 是包含了 @lib 的完整包,用于无缓存时的加载。 2. com.js 不包含 @lib,用于有缓存的加载。

读写缓存的处理

loader.js 模块写入缓存:

function setCache () {
    window.__cacheModules.forEach((item) => {
        // 保存模块的字符串内容
        var code = item.fn.toString();
    
        var cache = JSON.stringify({
            moduleName: item.moduleName,
            version: item.version,
            code: code
        });
    
        // 写入缓存
        setStorage(item.moduleName, cache);
    });
}

loader.js 模块读取缓存:

// 读取缓存
function getCache (moduleName) {
    const value = getStorage(moduleName);
    const moduleFn = new window.Function(value);

    if (typeof moduleFn === 'function') {
        moduleFn();
        // 返回命中缓存的标志
        return true;
    }

    return false;
}

设计好缓存读写模块后,就可以实现读取缓存按需加载组件包的功能了。

loader.js 中增加按需加载的功能:

function loadCom (comName) {
    // 读取缓存
    const hitCache = getCache('@lib');
    const url = `https://example.com/${comName}${hitCache ? '' : 'app'}.js`;
    // 加载组件包
    loadScript(url);
}

结语

通过对共享模块打包构建的改造和引入共享模块时的改造两个改造点,配合新增的缓存控制的 loader.js 模块,这就低成本实现了组件级别的共享模块的缓存。

由于上面的代码都只提供了关键的代码片段,并未完全贴出实现,实际实现时还需要注意的一些点有:

  • 共享模块的缓存最好增加版本号,实际引入共享模块时也需要带上版本号,这部分工作可以使用构建工具来实现;
  • 如缓存被损坏无法正常运行,应及时清除缓存。
  • 写入缓存可以在空闲时候进行,且写缓存时同时预加载好不含共享模块的组件包,以加速同一个组件的下一次请求。