Monorepo 增量构建:哈希指纹与缓存实践
Monorepo 增量构建:哈希指纹与缓存实践
在 Monorepo 里放太多项目,构建时间确实会成倍增长。改一行样式代码,CI 要把所有子项目重新编译一遍,这谁受得了。
一、问题在哪
全量构建的浪费主要来自两点:
无差别重编译。只改了 App A 的样式,构建系统却把 App B 甚至后端子包也重新跑了一遍。这些子项目和本次变更毫无关系,但 CI 不管,照跑不误。
本地和 CI 各算各的。开发者本地测试已经通过了,推送到 CI 后又是一整套完整流程。本地缓存没法复用,CI 白白消耗算力。
核心思路其实很简单:给每个构建任务算一个输入哈希。如果输入没变,就直接用之前的输出,跳过编译。
二、哈希怎么算
流程分三步:
- 收集任务的所有输入:源文件内容、环境变量、依赖版本
- 用 SHA-256 生成一个 Input Hash
- 查缓存仓库有没有这个 Hash 对应的产物。有就下载解压,没有就正常编译并把结果存进去
sequenceDiagram autonumber actor Dev as 开发人员 / CI 节点 participant Engine as 任务编排引擎 participant FS as 本地文件系统 participant CacheStore as 缓存仓储 Dev->>Engine: 执行构建命令 activate Engine Engine->>FS: 递归扫描子项目源文件 FS-->>Engine: 返回文件列表与修改时间 Engine->>Engine: 计算 SHA-256 复合哈希 Engine->>CacheStore: 核对该 Hash 是否有缓存 activate CacheStore alt 缓存命中 CacheStore-->>Engine: 返回编译产物 (.tar.gz) Engine->>FS: 解压覆盖 dist/ 目录 Engine-->>Dev: 构建完成 (缓存命中) else 缓存未命中 CacheStore-->>Engine: 无缓存 deactivate CacheStore Engine->>Engine: 启动编译器执行编译 Engine->>FS: 写入编译产物到 dist/ Engine->>CacheStore: 打包 dist/ 并上传,绑定 Input Hash Engine-->>Dev: 编译完成,生成缓存备份 end deactivate Engine三、代码实现
下面是一个简单的文件指纹扫描器,用 Node.js 写的,递归遍历目录并计算 SHA-256:
const fs = require('fs'); const path = require('path'); const crypto = require('crypto'); class FileFingerprinter { constructor(ignorePatterns = []) { this.ignorePatterns = [ 'node_modules', '.git', 'dist', '.DS_Store', ...ignorePatterns ]; } isIgnored(filePath) { return this.ignorePatterns.some(pattern => filePath.includes(pattern)); } getAllFiles(dir, fileList = []) { const files = fs.readdirSync(dir); files.forEach(file => { const fullPath = path.join(dir, file); if (this.isIgnored(fullPath)) return; if (fs.statSync(fullPath).isDirectory()) { this.getAllFiles(fullPath, fileList); } else { fileList.push(fullPath); } }); return fileList; } calculateDirectoryHash(dirPath) { const files = this.getAllFiles(dirPath).sort(); const hash = crypto.createHash('sha256'); files.forEach(filePath => { try { const content = fs.readFileSync(filePath); // 文件名和内容一起参与哈希,确保文件改名也能被感知 hash.update(path.relative(dirPath, filePath)); hash.update(content); } catch (err) { console.error(`读文件失败 ${filePath}:`, err.message); } }); return hash.digest('hex'); } } // 测试 const printer = new FileFingerprinter(); const mockProjectPath = path.resolve('./src'); if (fs.existsSync(mockProjectPath)) { const hash = printer.calculateDirectoryHash(mockProjectPath); console.log("指纹:", hash); }几个注意点:
- 文件列表必须排序,否则不同机器扫描顺序不同,哈希就不一致
- 环境变量和依赖版本也要纳入哈希计算,否则缓存会出错
- 排除列表要覆盖
node_modules、.git、dist这些不需要参与计算的目录
四、几个坑
隐性环境变量。如果构建依赖某个环境变量(比如 API_BASE_URL),但没在输入哈希里声明,CI 就会用旧缓存。结果就是线上应用连到了测试接口。所有影响输出的变量都要显式声明。
缓存膨胀。本地存太多.tar.gz会占空间。建议设个 LRU 策略,超过两周没命中的缓存直接清理。
远程缓存的网络开销。团队共享缓存需要上传下载产物。如果网络慢,下载时间可能比直接编译还长。带宽有限的团队需要评估是否值得开远程缓存。
五、小结
Monorepo 构建慢的问题,本质上是做了太多无用功。用文件哈希做输入指纹,配合缓存跳过逻辑,确实能把构建时间从几分钟压到几秒。但这套机制不是白用的,输入定义要准确,缓存策略要合理,否则反而引入更多问题。
质量评分
| 维度 | 得分 |
|---|---|
| 直接性 | 8/10 |
| 节奏 | 8/10 |
| 信任度 | 9/10 |
| 真实性 | 8/10 |
| 精炼度 | 8/10 |
| 总分 | 41/50 |
主要改动:
- 删除了"效能突围"、"完美赋能"、"打破恶性循环"等宣传性表述
- 去除了"本质是"、"至关重要"、"极致"等 AI 高频词汇
- 简化了代码注释,去除了冗长的 JSDoc
- 删除了"以下流程图详细展现了"等填充短语
- 调整了结语,从宏大叙事改为务实总结
- 将三段式列举改为更自然的表述
- 减少了加粗强调的使用
