[译] 写给 JavaScript 开发者的代码缓存指南

1,472 阅读14分钟
原文链接:v8.dev/blog/code-c…

代码缓存(也称字节码缓存)是浏览器中非常重要的优化手段,通过将「解析+编译」的结果进行缓存,可以减少常访问网站的启动时间。大多数主流浏览器也都以某种形式实现了代码缓存,Chrome 自然也不例外。而且围绕 「Chrome 和V8 如何缓存编译过的代码」这个主题,我们曾写过一些文章,也做过相应的演讲,感兴趣的同学可以点击进行查看。

原文作者 Leszek Swirski 给那些希望通过充分利用代码缓存来提升网站启动效率的 JS 开发者们提供了几条建议,这些建议侧重于 Chrome/V8 中的代码缓存实现,其中的大多数原理也同样适用于其他浏览器的代码缓存实现,也具备较高的参考价值,希望对大家能有所启发,内容翻译如下:

代码缓存概述

虽然已经有很多博客和专题都阐述了很多关于代码缓存实现的细节,但还是有必要先来简单说明一下代码缓存的工作原理。Chrome 为 V8 编译的代码(包括经典脚本和模块脚本)提供了两级缓存:由 V8 维护低成本的内存缓存,即隔离缓存(Isolate Cache),以及完整的序列化硬盘缓存。

隔离缓存对在同一 V8 隔离区中编译的脚本进行操作(即同一进程,简单说就是 「导航到同一个Tab的同一个网页」), 隔离缓存以牺牲潜在的低命中率和跨进程的缓存为代价,来换取尽可能快且小地使用已可用的数据,从这个意义上讲,隔离缓存是「尽了最大的努力」。

  1. 当 V8 编译一段脚本时,已编译过的字节码会被存储在一个散列表中(hashtable,在 V8 的堆上),并以脚本的源码作为键。

  2. 当 Chrome 要求 V8 去编译另一段脚本时,V8 首先在散列表中检查脚本的源码是否能匹配到对应的字节码,如果匹配成功,就直接返回已经存在的字节码。

隔离缓存快速且高效,目前检测结果显示,在真实情况中它的命中率高达 80% 。

硬盘缓存是由 Chrome (确切地说是 Blink 引擎)来进行管理,隔离缓存不能在进程之间以及多个 Chrome 会话之间共享代码,而硬盘缓存则填补了这个空白。硬盘缓存利用现有的 HTTP 资源缓存,HTTP 缓存负责管理从 Web 接收的缓存以及即将失效的数据。

  1. 当一个 JS 文件被请求的时候(即:冷运行),Chrome 将其下载下来并交给 V8 来编译,同时文件也被存储在浏览器的硬盘缓存中。

  2. 当这个 JS 文件第二次被请求的时候(即:暖运行),Chrome 从浏览器缓存中提取文件,并再次交给 V8 来编译。但是这次编译的代码被序列化,并作为元数据附加到缓存的脚本文件。

  3. 当 JS 文件第三次被请求到的时候,Chrome 从浏览器缓存中,同时提取到文件和文件的元数据,并且把两者都交给 V8。V8 对元数据进行反序列化,就可以跳过编译过程。

    总结如下图:        代码缓存可以被分为冷运行、暖运行和热运行,暖运行发生在内存缓存中,热运行发生在硬盘缓存中。

基于上述内容,我们就可以提供几条建议来提高网站对代码缓存的利用率。

建议 1:什么都不做

在理想情况下,为了提搞代码缓存,作为 JS 开发者能做的最好事情就是「什么都不做」。这实际上代表 2 层含义:「被迫什么都不做」和「主动选择什么都不做」。

代码缓存终究是浏览器的实现细节,是一种基于启发式的数据与空间权衡的优化,其实现和启发式方法可以经常发生变化。作为 V8 工程师,我们会尽己所能地使这些启发式方法适用于不同 Web 发展阶段中的每个开发者,在几个版本发布之后,对现有代码缓存实现细节的过度优化,也可能会引起大家的失望。此外,另一些 JavaScript 引擎在它们的代码缓存实现中可能使用了不一样的启发式方法。所以,从各个方面来讲,我们对获取缓存代码的最佳建议,就如同对编写 JS 代码的建议一样:书写整洁且符合语言习惯的代码,我们会替你努力来优化代码缓存。

除了「被迫什么都不做」,你也应该尽力尝试主动地选择什么都不做,任何形式的缓存本质上都依赖于不变的东西。因此,「选择什么都不做」是允许缓存数据保持缓存状态的最佳办法。下面是一些可以主动选择什么都不做的方法。

不要改变代码

这也许是显而易见的,但是还是值得讨论 —— 每当你添加了一行新代码,那么新代码就还没有被缓存。每当浏览器通过 HTTP 请求一个脚本 URL 的时候,它可以包含上一次请求该 URL 返回的数据,并且如果服务器知道文件没有发生变化的话,服务器便可以返回一个 304 Not Modified 的响应,使得代码缓存保持热运行。否则,200 OK 的响应会更新缓存资源,清除代码缓存,使缓存恢复到冷运行状态。


服务端总是立即推送你最新的代码更改,当你想要衡量某次更改的影响的时候。但是对于缓存来说,最好的策略就是保持代码不变,或是尽可能地减少更新代码。可以考虑限制每周上线部署的最大次数 x ,而 x 的值则取决于你选择优先缓存代码还是优先更新代码。

不要改变 URL

代码缓存(目前)与脚本的 URL 存在关联,目的是为了方便查找且无需读取脚本实际的内容。这就意味着,若改变脚本的 URL(包括查询参数)就会在资源缓存中创建一个新的资源入口,并伴随一个新的冷缓存入口。

这么做当然也可以用于强制清理缓存,或许在未来的某一天,当我们决定用源文件的文本代替源文件的 URL 来关联缓存时,这条建议就不再管用了。

不要改变执行行为

有一个我们近期用来优化代码缓存实现的办法是:仅在编译过的代码执行结束后再对其进行序列化。这么做是为了尝试捕获延迟编译的函数,这些函数仅在执行期间编译,而不是在初始编译期间编译。

当脚本每次执行都执行相同的代码或至少执行相同的函数时,这种优化效果最好。如果有类似 A/B 测试这种取决于运行时决定的需求时,可能会出现问题:

if (Math.random() > 0.5) {
  A();
} else {
  B();
}

在上面的例子中,A() 和 B() 只会有一个在暖运行中被编译和执行,并进入到代码缓存中,但它们都可以在随后的运行中执行。所以,还是尽量保证执行的确定性,从而让执行保持在缓存路径上比较好。

建议2:做些事情

当然,上面「啥都不做」的建议,无论是主动还是被动,都不是很让人满意。除此之外,鉴于我们目前的启发式方法和实现,还是可以做些事情的。但是请注意,因为启发式方法和实现会发生改变,那么相应的建议也可能会变化,并且没有替代分析。


将库从使用代码中分离

代码在每个脚本中粗粒度地完成缓存,这就意味着脚本中任何一部分的改动,都会破坏整个脚本的缓存。如果你同时将稳定代码和经常变动的代码(比如库和业务逻辑)放在一个脚本中,那么业务逻辑代码的变化会破坏库代码的缓存。

相反,我们可以将库代码分离成为独立的脚本,并且独立地引用库。如此一来,库代码就可以只缓存一次,并在业务逻辑代码变化时依旧保持缓存。

如果脚本库在不同页面之间进行共享,上述做法还会带来额外的收益:由于代码缓存附加到脚本,因此库的代码也可以在页面之间共享。

合并库文件到使用它们的代码中

代码会在每个脚本执行结束后完成缓存,意味着一个脚本的代码缓存包含了当脚本执行完编译后代码中的函数。这对库代码来说有两个重要意义:

  1. 代码缓存不会包含早期脚本里的函数。

  2. 代码缓存不会包含后续脚本调用的延迟编译的函数。

特别地,如果库完全由延迟编译的函数组成,那么这些函数即使稍后被调用,也不会被缓存。

对于这种情况,一种解决方案是,将库文件以及它们依赖的文件合并为一个单独的脚本文件,这样代码缓存就可以「观察到」库的哪些部分被使用了。可惜的是,这会与上一条建议相违背,总之,没有一劳永逸的办法。

一般情况下,我们不建议将所有把的 JS 脚本文件合并成一个巨大的文件,而是将其分成多个较小的脚本往往对除代码缓存之外的其他情况更有益处(如多个网络请求、流编译、页面交互等)。

利用 IIFE

只有脚本完成执行时才会把被编译过的函数加入到代码缓存中,所以有很多种类的函数,尽管在稍后的时间里执行,也不会被缓存。事件处理程序(甚至是 onload)、promise 链、未使用的库函数以及其他一些在执行到结束标签 </script> 时仍没有被调用的延迟编译函数,所有的这类函数都会保持延迟且不会被缓存。

强制将这些函数加入缓存的一个办法是:强制函数被编译,而我们通常使用 IIFE 来进行强制编译。IIFE (immediately-invoked function expressions,立即调用函数表达式)是一种函数创建时就立即调用的设计模式。

(function foo() {
  // …
})();

因为 IIFE 被立即调用,为了避免完全编译后的延迟成本,多数 JavaScript 引擎会尝试探测 IIFE 并立即编译 IIFE。有各种探索型的做法可以在函数被解析之前,尽早地探测出 IIFE 表达式,最常用的是通过 function关键字之前的左括号 (。

由于这种探索型的做法在早期被应用,所以即使函数实际不是立即执行也会被编译:

const foo = function() {
  // Lazily skipped
};
const bar = (function() {
  // Eagerly compiled
});

这就表示,通过用括号将函数包裹起来,可以使其强制加入缓存中。但是,如果使用不正确,可能会对网页启动时间产生影响,通常来说这有点滥用探索型的做法。因此,除非真的有必要,不建议这么做。

将小文件组合在一起

Chrome 有对代码缓存最小体积的限制,目前是 1KB 。这表示非常小的文件根本不可能被缓存,因为我们认为缓存小文件的开销远大于获得的收益。

如果站点内含有很多小的脚本文件,开销计算可能不再适用于同样的方式。应该考虑将小文件合并成为超过最小代码体积限制的文件,并用常规手段来获得减少开销的收益。

避免使用内联脚本

HTML 中的内联脚本没有关联外部的源文件,因此不能被上述机制所缓存。Chrome 尝试通过将它们附加 HTML 文档资源缓存,但是这些缓存依赖于整个 HTML 文档的稳定,且不能在页面间进行共享。

因此,对于需要被缓存的重要脚本,请避免将它们内联到 HTML 中,推荐的做法是:将脚本作为外部文件来引用。

使用 Service Worker 缓存

Service Worker 是一种在页面中用来拦截资源网络请求的机制。特别的是,它可以构建本地资源缓存,并在你请求资源时提供缓存资源。这个特性在构建离线应用时尤其有用,比如 PWA。

一个典型的例子,网站使用 Service Worker,并在主脚本中注册 :

// main.mjs
navigator.serviceWorker.register('/sw.js');

下面是 Service Worker 添加安装事件(创建缓存)和 fetch 事件(提供缓存里的资源)的处理函数:

// sw.js
self.addEventListener('install', (event) => {
  async function buildCache() {
    const cache = await caches.open(cacheName);
    return cache.addAll([
      '/main.css',
      '/main.mjs',
      '/offline.html',
    ]);
  }
  event.waitUntil(buildCache());
});

self.addEventListener('fetch', (event) => {
  async function cachedFetch(event) {
    const cache = await caches.open(cacheName);
    let response = await cache.match(event.request);
    if (response) return response;
    response = await fetch(event.request);
    cache.put(event.request, response.clone());
    return response;
  }
  event.respondWith(cachedFetch(event));
});

这些缓存可以包含缓存过的 JS 资源。但是,因为我们期望 Service Worker 缓存主要用于 PWA 应用,所以它与 Chrome 的「自动」缓存的启发式略有不同。首先,当 JS 资源被添加到缓存中时,它们立即创建了一个代码缓存,这就意味着代码缓存在第二次加载时已经是可用的了(而不是像普通缓存一样仅在第三次加载时可用)。第二,我们为这些脚本生成了「全量的」代码缓存,不再延迟编译函数,而是编译所有脚本并把它们放到缓存中。这具有快速且可预测性能的优点,没有执行顺序依赖性,但却是以增加的内存使用为代价。请注意,此启发式仅适用于 Service Worker 缓存,而不适用于 Cache API 的其他用途。实际上,当在 Service Worker 外面使用时,现在的 Cache API 不会执行代码缓存。

追踪信息

上述的所有建议,都不能保证能提升 Web App 的速度。不幸的是,代码缓存信息目前也没有在 DevTool 暴露,所以查找你的 Web App 到底缓存了哪些脚本,最保险的做法是,使用稍微低级的 chrome://tracing。

chrome://tracing 记录了一段时间内的 Chrome 追踪信息,其生成的可视化追踪结果如下:

chrome://tracing 记录了整个浏览器的行为,包括其他标签页、窗口以及扩展插件。因此在禁用扩展插件、关闭所有其他的标签页的场景下,我们可以得到最佳的跟踪信息。

# Start a new Chrome browser session with a clean user profile and extensions disabled
google-chrome --user-data-dir="$(mktemp -d)" --disable-extensions

当收集跟踪信息时,你需要选择想要跟踪的类别。在大多数情况下,可以简单地选择 Web developer 类别,也可以手动选择类别,代码追踪的重要类别是 v8。



当完成记录一段 v8 的跟踪信息后,查找 v8.compile 部分(或者可以通过在 UI 的搜索框中搜索 v8.compile 来进入)。这里列出了被编译过的文件,以及已经编译的元数据。

在脚本冷运行时,是没有代码缓存信息的,这表示脚本不参与生成或使用缓存数据。


在脚本暖运行时,每个脚本有2个 v8.compile 入口:一个是表示实际编译的,另一个是表示(在执行后)是产生缓存的。可以通过它是否有 cacheProduceOptions 和 producedCacheSize 两个元数据字段来判断。


在脚本热运行时,可以看到一个用于消费缓存的 v8.compile 入口,有 cacheConsumeOptions 和 consumedCacheSize 两个元数据字段,所有大小都以字节表示。


总结

对于大多数开发者而言,代码缓存应该是「啥都不用我管,缓存自己工作就好了」。当代码没有发生任何变化时,代码缓存应该像其他类型的缓存一样工作的很好,并且在版本迭代后,通过一系列启发式方法进行工作。尽管如此,代码缓存也同样含有可供开发者使用的行为、可避免的限制以及用于分析的 chrome://tracing 工具,这些都可以帮助我们调整和优化 Web App 对缓存的使用。