esbuild 凭借极快的构建速度,成了很多前端项目的首选打包工具。但实际用起来,大家会发现一个很有意思的现象:第一次启动项目时确实快得离谱,可一旦改了依赖、切了分支、或者换台电脑,esbuild 就要重新吭哧吭哧干一遍活儿。这背后藏着一个容易被忽略的“缓存魔法”,而缓存一旦失效或者误判,反而会让冷启动变慢,甚至出现诡异的构建问题。想要让提速变得稳定可靠,就得把缓存从“碰运气”变成“可验证的持久化机制”。今天咱们就用大白话,把这个事儿掰开揉碎了聊一聊。

一、esbuild 的缓存到底在缓存什么?

先别急着写代码,咱们得先搞清楚 esbuild 的缓存逻辑。esbuild 在构建时,会把每个文件转换后的结果(比如 TypeScript 转成 JavaScript、JSX 编译成普通 JS)存在本地临时目录里。下次构建时,如果发现文件没变,就直接复用之前的结果,跳过转换过程。这就是它快的一个原因。

但是,esbuild 的缓存是“默认开启”的,而且缓存的命中判断非常“粗糙”。它主要看的是文件内容和依赖图内容的哈希值。也就是说,如果你改了某个文件,esbuild 会重新转换这个文件,但其它没变的文件可能还能命中缓存。听起来挺合理,对吧?问题恰恰出在这个“可能”上。

1.1 缓存目录失效的坑

esbuild 默认根据当前用户、当前项目路径等生成一个缓存目录。比如在 Linux 上可能长这样:


# ${XDG_CACHE_HOME}/esbuild 或者 ~/.cache/esbuild
# 你可以手动查看这个目录
ls -la ~/.cache/esbuild

你会发现里面有一堆乱码目录名,每个目录对应一个依赖的构建版本。但这些目录的命名规则依赖于“解析路径”和“平台信息”。一旦你的项目路径变了(比如把项目从 /home/user/project 移到 /home/user/project-v2),或者你换了 Node.js 版本,esbuild 就认为“环境变了”,之前的缓存全部作废。

更坑的是,用 Docker 构建的时候,每次 docker build 都会创建新的容器层,因为路径可能有细微差异,缓存根本没法跨层复用。这时候冷启动就变成了“每次都从头再来”,esbuild 引以为傲的提速优势直接没了。

1.2 依赖变更造成的误判

esbuild 处理依赖时,会解析 node_modules 里的包。假设你执行了 npm install,某个依赖的版本从 1.0.0 升到了 1.1.0,哪怕只是补丁版本,esbuild 也要重新转换。但麻烦的是,esbuild 的缓存键只包含“解析后的绝对路径”和“文件内容哈希”,并不包含完整的依赖树哈希。如果两个依赖互相引用,但它们的缓存键没有联动更新,就可能出现“构建产物里新旧代码混在一起”的奇怪问题。

这种误判最典型的表现是:改了一个依赖的配置,但 esbuild 却还在用旧的缓存,导致线上出来的包不对。开发者通常会提醒“删除 node_modulesdist 再重装”,实际上本质就是强制清掉 esbuild 的缓存。

二、为什么需要“可验证的持久化缓存”而不是“魔法缓存”?

上面说得天花乱坠,核心就一句话:esbuild 自带的缓存是“黑盒”式的,你没法确认它到底缓存了什么、什么时候失效。当团队协作时,每个人本地都有自己的缓存目录,没人能保证这些缓存内容是一致的。CI 环境更是如此,每次跑构建都是新机器,缓存等于零。

而“可验证的持久化缓存”意味着:我们可以把缓存内容保存到一个固定的、可被检查的位置(比如项目里的 .cache 目录、远程对象存储、或者同一个 Docker 卷里),并且给缓存加上一个“明确的键”,这个键能反映所有影响构建的因素。只要键没变,就能放心大胆地复用缓存;键变了,就必然重新生成。这样缓存不再是猜,而是“算出来的确定性结果”。

2.1 从“撞大运”到“逻辑闭环”

生活里有个类似的例子:你做饭的时候,如果调料摆放位置固定,你闭着眼也能拿到。但要是别人帮你挪了地方,你按老位置去拿,就抓空了。esbuild 自带的缓存就像是从不通知你的“乱挪调料”,而可验证缓存就是“每次做完饭,把调料按清单放回原位”。

具体到技术实现,我们需要做三件事:

  1. 固定缓存目录:不让 esbuild 随便找地方。通过环境变量或命令行参数,指定一个项目内的目录,比如 node_modules/.cache/esbuild
  2. 计算缓存键:把所有影响构建的因素(源代码、依赖 lock 文件、esbuild 版本、Node 版本、平台类型、自定义配置等)生成一个哈希值。
  3. 校验缓存有效性:在复用缓存前,先检查这个哈希值是否和之前记录的哈希值一致。如果不一致,就清理旧缓存并重新构建。

这三步形成了一个闭环,让缓存变得可预测、可解释。

三、动手实现一套可验证的持久化缓存方案

我们用 Node.js 作为技术栈,写一个小工具。它做的事情很简单:在启动 esbuild 构建前,先计算当前项目环境的缓存键,然后对比之前保存的键,决定是清理缓存还是直接复用。这样无论谁在什么机器上跑,只要“输入一致”,输出缓存就是一致的。

3.1 准备一个基础示例项目

先创建一个项目,里面有一个 src/index.js 文件:

// src/index.js
// 这是入口文件,esbuild 会将它打包成 dist/bundle.js
const message = 'Hello, 可验证缓存!';

// 定义一个简单函数,用于验证构建结果
function greet(name) {
  return `${message} 我是 ${name}`;
}

console.log(greet('esbuild'));

然后安装 esbuild 和 Node.js 内置的 crypto 模块(不需要额外装)。


# 初始化 package.json
npm init -y

# 安装 esbuild 作为开发依赖
npm install --save-dev esbuild

# 创建一个用于存放缓存的目录
mkdir -p .cache/esbuild

3.2 写一个构建脚本,带缓存键计算

我们写一个 build.js 文件,放在项目根目录。这个脚本会做四件事:

  1. 读取 package-lock.json(因为锁文件锁定所有依赖的精确版本,是判断依赖变更的最可靠来源)。
  2. 读取 esbuild 版本和 Node.js 版本。
  3. 读取源码目录下所有文件的内容,计算出哈希值。
  4. 综合以上信息生成一个 cacheKey,并写入 .cache/cache-key.txt。如果本次生成的键和上次不同,就删掉 .cache/esbuild 目录里的所有内容,确保下次 esbuild 重新生成缓存。

完整代码如下,注释请仔细看:

// build.js
// 技术栈:Node.js + esbuild

const { execSync } = require('child_process');
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');

// 1. 定义项目相关路径
const projectRoot = __dirname;
const srcDir = path.join(projectRoot, 'src');
const esbuildCacheDir = path.join(projectRoot, '.cache', 'esbuild');
const cacheKeyFile = path.join(projectRoot, '.cache', 'cache-key.txt');

// 2. 计算整个项目的“指纹”
function calculateCacheKey() {
  const hash = crypto.createHash('sha256');

  // 2.1 加入依赖锁文件的内容(package-lock.json)
  const lockFile = path.join(projectRoot, 'package-lock.json');
  if (fs.existsSync(lockFile)) {
    const lockContent = fs.readFileSync(lockFile, 'utf-8');
    hash.update(`lock:${lockContent}`);
    console.log('✓ 已读取 package-lock.json,锁定依赖版本');
  } else {
    // 如果锁文件都不存在,说明依赖管理很不规范,需要警告
    console.warn('⚠️ 未找到 package-lock.json,缓存键将无法跟踪依赖变更');
  }

  // 2.2 加入 esbuild 的版本号
  const esbuildVersion = require('esbuild/package.json').version;
  hash.update(`esbuild:${esbuildVersion}`);
  console.log(`✓ esbuild 版本:${esbuildVersion}`);

  // 2.3 加入 Node.js 版本(不同版本可能影响原生模块编译)
  const nodeVersion = process.version;
  hash.update(`node:${nodeVersion}`);

  // 2.4 遍历 src 目录下所有文件,把内容逐个喂给哈希
  const files = fs.readdirSync(srcDir, { recursive: true });
  for (const fileName of files) {
    const filePath = path.join(srcDir, fileName);
    // 只处理文件,忽略目录
    if (fs.statSync(filePath).isFile()) {
      const content = fs.readFileSync(filePath);
      hash.update(`${filePath}:${content}`);
    }
  }

  console.log('✓ 已扫描源码目录所有文件');

  // 2.5 生成最终的十六进制哈希串
  return hash.digest('hex');
}

// 3. 检查缓存键是否变化,如果变化则清理旧缓存
function preparePersistentCache() {
  const newKey = calculateCacheKey();
  console.log(`计算出的缓存键:${newKey}`);

  // 确保 .cache 目录存在
  fs.mkdirSync(path.dirname(cacheKeyFile), { recursive: true });

  let needsClean = true;
  // 如果缓存键文件存在,读取里面的旧键
  if (fs.existsSync(cacheKeyFile)) {
    const oldKey = fs.readFileSync(cacheKeyFile, 'utf-8').trim();
    if (oldKey === newKey) {
      needsClean = false;
      console.log('✅ 缓存键未变化,复用已有 esbuild 缓存');
    } else {
      console.log('❌ 缓存键已变化,需要清空旧缓存');
    }
  } else {
    console.log('首次构建,设置缓存键');
  }

  // 如果不需要清理,直接返回 false,表示“不用动缓存”
  if (!needsClean) {
    return false;
  }

  // 需要清理时,删除 esbuild 自己的缓存目录
  if (fs.existsSync(esbuildCacheDir)) {
    fs.rmSync(esbuildCacheDir, { recursive: true, force: true });
    console.log('🧹 已删除旧 esbuild 缓存目录');
  }

  // 保存新的缓存键
  fs.writeFileSync(cacheKeyFile, newKey, 'utf-8');
  console.log('💾 已写入新的缓存键');
  return true;
}

// 4. 正式执行 esbuild 构建
async function build() {
  // 先做缓存准备
  preparePersistentCache();

  // 调用 esbuild 的 JS API 进行构建
  const esbuild = require('esbuild');

  // 这里开启 metafile 是为了方便查看依赖图,同时把缓存目录指到我们自己控制的位置
  await esbuild.build({
    entryPoints: ['./src/index.js'], // 入口文件
    bundle: true,                    // 打包所有依赖
    outfile: './dist/bundle.js',     // 输出文件
    cacheDir: esbuildCacheDir,       // 指定 esbuild 的缓存目录
    metafile: true,                  // 生成元信息,后续可分析
  });

  console.log('🚀 构建完成!');
}

// 执行构建
build().catch((err) => {
  console.error('构建失败:', err);
  process.exit(1);
});

3.3 测试缓存复用与失效

我们先运行两次构建,看看表现。

第一次运行:


node build.js

你会看到类似这样的输出:

✓ 已读取 package-lock.json,锁定依赖版本
✓ esbuild 版本:0.20.2
✓ 已扫描源码目录所有文件
计算出的缓存键:a1b2c3...(一串长哈希)
首次构建,设置缓存键
🚀 构建完成!

第二次再运行:


node build.js

输出会变成:

✓ 已读取 package-lock.json,锁定依赖版本
✓ esbuild 版本:0.20.2
✓ 已扫描源码目录所有文件
计算出的缓存键:a1b2c3...(和上次一样)
✅ 缓存键未变化,复用已有 esbuild 缓存
🚀 构建完成!

注意,第二次的构建速度明显会更快,因为 esbuild 直接复用了 .cache/esbuild 里的内容。

现在我们来模拟一次依赖变更:修改 package-lock.json(比如把某个依赖的版本号从 1.0.0 改成 1.0.1,只需要手动编辑一个没有引用的包就行)。然后再运行一次:


node build.js

这次你会看到:

❌ 缓存键已变化,需要清空旧缓存
🧹 已删除旧 esbuild 缓存目录
💾 已写入新的缓存键
🚀 构建完成!

缓存被正确清理,然后重新构建。这样我们就从“黑盒缓存”变成了“可验证的确定性缓存”。

四、应用场景:什么时候最需要这套机制?

4.1 大型项目的冷启动

对于有几千个模块的项目,esbuild 就算再快,全量转换也要几秒甚至十几秒。如果缓存不能命中,每次启动都要重新走一遍所有文件,体验会非常差。固定缓存后,只要没改代码,冷启动就是瞬开。

4.2 持续集成/持续部署(CI/CD)

这是最典型的场景。在 GitHub Actions 或者 Jenkins 里,每次构建都是全新环境。如果我们不自定义缓存,esbuild 每次都要重新生成。我们可以把 .cache 目录用 CI 的缓存服务(比如 actions/cache)保存起来。以下是 GitHub Actions 的一个配置片段:

# .github/workflows/build.yml
name: 构建

# 当代码推送到 main 分支时触发
on:
  push:
    branches: [ main ]

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      # 拉取代码
      - uses: actions/checkout@v4

      # 设置 Node.js
      - uses: actions/setup-node@v4
        with:
          node-version: 18

      # 恢复之前保存的 .cache 目录,key 是 lock 文件 + esbuild 版本
      - name: 恢复持久化缓存
        uses: actions/cache@v4
        with:
          path: .cache
          key: esbuild-${{ hashFiles('package-lock.json') }}-${{ runner.os }}
          restore-keys: |
            esbuild-${{ hashFiles('package-lock.json') }}-

      # 安装依赖
      - run: npm ci

      # 执行带缓存校验的构建脚本
      - run: node build.js

通过这个配置,CI 上只要 package-lock.json 没变化,就能复用上次构建的缓存,整体时间能降低一半以上。

4.3 一键切换 Git 分支

开发中经常切分支,不同的分支可能用到不同的依赖。如果你在 A 分支构建过,切到 B 分支后,esbuild 自带的缓存可能会“留下旧分支的残留”,造成怪异问题。我们的脚本里,缓存键包含了源码文件内容,分支切换后源码必然变化,所以缓存键肯定会变,从而自动清理旧缓存,避免污染。

五、技术优缺点分析

5.1 优点

  1. 确定性:缓存键完全由输入决定,不依赖本机环境。任何人在任何机器上计算出的键都一样,避免了环境差异。
  2. 可控性:缓存目录固定在我们指定的位置,可以随意清理、备份、迁移,没人找不到缓存在哪。
  3. 可调试:看到缓存键文件,就能明白当前缓存是基于什么情况生成的。如果构建出错,可以快速判断是不是缓存键漏了某个因素。
  4. 与 CI 友好:可以精确指定 CI 的缓存 key,让跨机器、跨流水线的缓存复用成为可能。

5.2 缺点

  1. 计算缓存键有额外开销:每次构建之前都要读一遍源码文件、计算哈希。对于超大项目,这一过程可能耗时几百毫秒,但这通常远小于 esbuild 的构建时间,所以总体还是划算的。
  2. 配置复杂:需要维护一个自定义构建脚本,对新手来说有点门槛。如果项目里同时有多个入口、多种构建模式,缓存键的规则也要同步扩展。
  3. 可能漏掉影响因素:比如构建时用了环境变量,或者读入了某些二进制文件,这些都要手动纳入缓存键计算。一旦漏掉,就会造成“缓存误命中”,反而比没有缓存更糟。

六、注意事项

6.1 别漏了环境变量和配置文件

esbuild 的很多行为受到环境变量影响,例如 NODE_ENVESBUILD_BINARY_PATH 等。如果你的构建脚本读取了环境变量,一定要把它们加进缓存键里。例如:

// build.js 中,在 calculateCacheKey 函数里增加如下代码
// 把关键的部署环境变量加入计算
const envVars = ['NODE_ENV', 'CUSTOM_FLAG'];
for (const varName of envVars) {
  hash.update(`${varName}:${process.env[varName] || ''}`);
}

6.2 注意 Node.js 版本和原生模块

esbuild 在 Linux、macOS、Windows 上使用的二进制文件不同,而且 Node.js 的大版本也可能影响依赖解析。所以在缓存键里需要包含 process.platformprocess.arch。上面示例没加,这里举个例子:

// 在 calculateCacheKey 函数中加入平台信息
hash.update(`platform:${process.platform}:${process.arch}`);

6.3 定期清理缓存,不要无脑堆砌

虽然缓存能加速,但缓存文件也会占用磁盘空间。建议设定一个总大小上限,或者每次构建后清理超过 30 天的缓存目录。

可以写一个简单的 clean-cache.js

// clean-cache.js
// 技术栈:Node.js
const fs = require('fs');
const path = require('path');

const cacheDir = path.join(__dirname, '.cache');

// 删除整个缓存目录,简单粗暴
fs.rmSync(cacheDir, { recursive: true, force: true });

// 再重新创建空目录
fs.mkdirSync(cacheDir, { recursive: true });

console.log('🧹 缓存已清理完毕');

6.4 别把缓存提交到 Git

.cache 目录应该加入 .gitignore,因为它是编译产物,不应该被多人共享。如果你想让团队共享缓存,建议使用远程缓存服务(比如 NFS、Redis、S3),而不是直接提交仓库。

你的 .gitignore 应该包含:

# .gitignore
node_modules/
dist/
.cache/

七、文章总结

esbuild 的速度不是魔法,它依赖的是缓存和高效的原生代码。然而,自带的缓存缺少“可验证性”,导致在团队协作和 CI 环境中很难复用。我们通过一个简单的 Node.js 脚本,将缓存键显式地建立在源码、依赖锁文件、工具版本等因素上,把缓存变成了项目的一部分。这样,无论谁在什么条件下构建,只要输入一致,缓存就一定能被准确命中;输入变了,旧缓存也会被及时清理,避免误判带来的诡异问题。

这种“可验证的持久化缓存”思路,不仅适用于 esbuild,也可以推广到其他构建工具(比如 Webpack、Vite),甚至将来你用别的语言写构建工具,这套方法论依然有效。核心就是一句话:想要稳定加速,就不要让工具自己猜,你需要掌握缓存的关键钥匙。

希望这篇文章能帮你彻底搞懂 esbuild 的缓存机制,并且在实际项目里用上这套可靠提速方案。下次再遇到“为什么改了依赖却不生效”这种问题,你第一个想到的就不再是“删 node_modules”,而是去检查你的缓存键到底少了什么。