浅析 Turbopack 函数级别缓存的实现

image

Turbopack 为何打包速度如此之快?它的官网中提到了两点

  1. highly optimized machine code,第一点很好理解,近年来新出来的无论是基于 Go 的 esbuild 还是基于 Rust 的 Turbopack 相比于基于 JavaScript 的 webpack 在语言层面就存在天然的优势。对于 JIT 编译语言 JavaScript 来说,命令行应用程序是最糟糕的性能情况。每次运行打包时,JavaScript VM 都会第一次看到打包工具的代码,而没有任何优化提示。当 esbuild / Turbopack 忙于解析您的 JavaScript 业务代码时,JavaScript VM 正忙于解析您的打包工具的 JavaScript。当 JavaScript VM 完成解析您的打包工具的代码时,esbuild / Turbopack 可能已经退出,并且您的打包工具甚至还没有开始打包分析 JavaScript 业务代码
  2. a low-level incremental computation engine that enables caching down to the level of individual functions,第二点提到能够将缓存级别细分到函数级别,使得 Turbopack 不会运行同样的工作两次?!

看完第 2 点其实是比较疑惑的,何为函数级别?在常规的打包工具的缓存实现中,比如文件 src/index.js 第一次打包完成后,第二次由于该文件 contenthash 没变就可以直接缓存上一次打包的结果,难道 Turbopack 的函数级别指的是某个文件即使 contenthash 变了,如果文件中的某几个函数没变也能利用大部分缓存结果快速返回? 下面带着这些疑问让我们尝试从 Turbopack 源码中找到答案

当我粗略阅读了几遍 Turbopack 源码后貌似就找到了一些蛛丝马迹,比如如下有大量的函数被 #[turbo_tasks::function] 宏定义给修饰了

image

我们先使用 cargo expand 命令把这个宏修辞后的代码展开就是如下的代码

image

看到展开后的代码我似乎已经明白了,下面我把这个行为换成 JavaScript 代码便于大家理解,比如你的打包工具 my_turbopack 有个 build_internal 函数

1
2
3
async function build_internal(project_dir: string, root_dir: string) {
console.log('开始打包 📦 ...')
}

经过 #[turbo_tasks::function] 宏修饰后你的 build_internal 函数就大致变为了如下这样(当然实际 Turbopack 的实现会复杂很多!)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
const compiler_cache = {}

async function build_internal(project_dir: string, root_dir: string) {
// 最简模型的计算
const cache_key = project_dir + root_dir
if (compiler_cache[cache_key]) {
return compiler_cache[cache_key]
}
return compiler_cache[cache_key] = build_internal_inline_function(project_dir: string, root_dir: string)
}

async function build_internal_inline_function(project_dir: string, root_dir: string) {
console.log('开始打包 📦 ...')
}

看到这里是不是就豁然开朗了,所谓函数级别缓存 Turbopack 不会运行同样的工作两次的底层原理其实就是某一个构建任务函数如果入参没变(经过一定的规则计算后),那么就可以直接返回上一次运行的结果!

接下来让我们来验证下,于是写一个 #[turbo_tasks::function] 宏定义修辞的 turbo_test_fn 函数和一个普通的 normal_test_fn,最后运行时发现 turbo_test_fn 在参数没变的情况下只会运行一次而 normal_test_fn 会调用一次运行一次,符合预期!

image