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

原文连接: v8.dev/blog/code-c…

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

原文做者 Leszek Swirski 给那些但愿经过充分利用代码缓存来提高网站启动效率的 JS 开发者们提供了几条建议,这些建议侧重于 Chrome/V8 中的代码缓存实现,其中的大多数原理也一样适用于其余浏览器的代码缓存实现,也具有较高的参考价值,但愿对你们能有所启发,内容翻译以下:css

代码缓存概述

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

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

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

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

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

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

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

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

  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 对缓存的使用。

相关文章
相关标签/搜索