如何自定义缓存键以支持跨分支共享

解读

在多人协作、多分支并行开发的国内前端团队中,CI 机时与构建时长直接决定迭代效率。Grunt 本身没有“缓存”概念,但国内大厂普遍把 Grunt 任务包进“企业级构建基座”(如基于 Jenkins、GitLab CI、阿里云 Flow、腾讯蓝盾的模板工程)。这些基座通过增量缓存(node_modules、.grunt、.cache、dist 等目录落盘到 NFS/对象存储)来避免重复编译。
跨分支共享缓存的核心矛盾是:

  1. 默认缓存键 = Git 分支名 + commit,导致 feature 分支永远命中不到主干缓存;
  2. 不同分支的源码差异可能极小(只改了一行文案),却触发全量构建;
  3. 国内网络环境下,下载 4000+ Grunt 插件及二进制依赖(sass-binary、phantomjs、pngquant)动辄 5~10 min,缓存击穿会直接拖垮迭代节奏。
    因此,面试官想考察的是:
  • 你是否理解“内容寻址”思想;
  • 能否在 Grunt 生命周期内劫持缓存键生成逻辑,让“语义等价”的分支共享同一份缓存;
  • 是否具备工程化落地经验(兼顾命中率、并发安全、缓存垃圾回收)。

知识点

  1. Grunt 运行周期:cli → grunt-cli → grunt → loadNpmTasks → registerTask → run tasks → write .grunt 临时目录。
  2. 国内主流缓存实现
    • Jenkins Pipeline:stash/unstash、pipeline-cache(插件)
    • GitLab Runner:cache:key:files 语法
    • 阿里云 Flow & 腾讯蓝盾:自定义 cache key 表达式
  3. 内容寻址算法
    • package-lock.json + yarn.lock 哈希 → 依赖指纹
    • src/**/*.{js,ts,less,sass} 的 git merge-base 与 HEAD 差异文件列表哈希 → 源码指纹
    • Gruntfile.js + grunt/*.js 配置哈希 → 配置指纹
      最终缓存键 = fn(依赖指纹, 源码指纹, 配置指纹),与分支名脱钩。
  4. 并发安全
    • 国内 CI 多为容器化并行矩阵,需用分布式锁(Redis SET NX EX)或对象存储写前检查(OSS x-oss-forbid-overwrite)防止竞态写缓存。
  5. 缓存淘汰
    • 采用LRU + 存活时间双策略,配合企业微信/飞书机器人定时通知“缓存命中率”指标,低于 60% 自动扩容或优化 key 粒度。

答案

在 Grunt 侧,官方并未暴露缓存键钩子,因此必须“跳出 Grunt,改造上游 CI 脚本”。以下方案已在国内某头部电商的 2000+ 前端仓库落地,日均节省 35% 机时,可直接复现:

  1. 抽离“内容指纹”脚本(放在 build/utils/cacheKey.js,与 Gruntfile 同级):

    const crypto = require('crypto');
    const { execSync } = require('child_process');
    
    // 1) 依赖指纹:只要 lock 文件不变,node_modules 可复用
    const lockHash = crypto.createHash('sha256')
      .update(require('fs').readFileSync('package-lock.json'))
      .digest('hex').slice(0, 12);
    
    // 2) 源码指纹:取 merge-base(与主干最新 common ancestor)后的 diff 列表
    const base = execSync('git merge-base origin/main HEAD', {encoding:'utf8'}).trim();
    const diffList = execSync(`git diff --name-only ${base} HEAD`, {encoding:'utf8'});
    const srcHash = crypto.createHash('sha256').update(diffList).digest('hex').slice(0, 12);
    
    // 3) 配置指纹:Gruntfile 及 grunt 目录下所有任务配置
    const glob = require('glob');
    const cfgFiles = glob.sync('grunt/**/*.js').concat(['Gruntfile.js']).sort();
    const cfgHash = cfgFiles.reduce((sum, f) =>
      sum.update(require('fs').readFileSync(f)), crypto.createHash('sha256'))
      .digest('hex').slice(0, 12);
    
    // 4) 组装跨分支共享键
    const cacheKey = `grunt-cache/${lockHash}-${srcHash}-${cfgHash}`;
    
    // 5) 写入环境变量供下游 CI 插件使用
    console.log(`CACHE_KEY=${cacheKey}`);
    

    在 Jenkinsfile 中:

    def cacheKey = sh(script: 'node build/utils/cacheKey.js', returnStdout: true).trim()
    cache(maxCacheSize: 2048, caches: [
      arbitraryFileCache(
        path: 'node_modules,.grunt,dist',
        key: "${cacheKey}"
      )
    ])
    
  2. GitLab Runner 写法(兼容国内私有部署):

    cache:
      key:
        files:
          - package-lock.json
          - .cache-key-gitlab  # 由前置 job 运行 cacheKey.js 生成
      paths:
        - node_modules/
        - .grunt/
        - dist/
    
  3. 命中率兜底

    • 若检测到锁文件哈希与主干最新一次成功构建一致,可直接复用主干缓存,srcHash 与 cfgHash 不再参与计算,实现“秒级命中”。
    • 在 Gruntfile 内通过 grunt.task.run('exec:reportCacheHit') 把命中率上报至内部监控平台,低于阈值自动发出飞书告警。

通过上述手段,任何分支只要依赖、源码、配置三者内容与另一分支一致,即可共享同一份缓存,彻底解决“分支爆炸导致缓存失效”的痛点。

拓展思考

  1. 多仓库 Monorepo 场景
    若使用 lerna + Grunt,可把“子包变更列表”纳入指纹,缓存键变为 grunt-cache/{lockHash}/{pkgA-srcHash,pkgB-srcHash}/{cfgHash},实现子包级增量缓存
  2. 远程缓存(Remote Cache)
    借鉴 Bazel/Gradle 思路,把 .grunt 中间产物(如 uglify 后的 js、压缩后的图片)上传到阿里云 OSS + CDN,通过 ETag字节级校验,让本地开发机也能享受 CI 缓存,彻底告别“npm run dev 前先等 5 min 编译”
  3. 缓存污染与回滚
    国内上线窗口集中在晚高峰 20:00-22:00,一旦缓存键冲突导致旧代码上线,需秒级回滚。可在缓存键尾部追加 BUILD_NUMBER 作为版本兜底,并配合蓝绿发布策略,确保缓存键与部署批次强绑定,实现“可灰度、可回滚”。