如何在 grunt 中实现 Brotli 压缩并兼容旧浏览器

解读

面试官抛出此题,核心想验证三点:

  1. 你是否真正在生产环境落地过 Brotli,还是只停留在“听过”层面;
  2. 能否用 Grunt 生态闭环解决“压缩→回退→缓存策略”全链路,而不是简单调一个插件;
  3. 对国内“旧浏览器仍占 5%–10% 份额”的现实是否有体感,能否给出可灰度、可回滚的兼容方案。
    回答时务必体现“构建层双压缩 + 服务端协商 + 缓存键隔离”的完整思路,并指出 Grunt 在 2025 年国内大厂中的定位:老旧遗产项目维稳工具,因此方案必须零侵入、可脚本化、可审计

知识点

  1. Brotli 压缩率比 gzip 高 15%–25%,但 IE11 及以下、部分 Android 4.4 内核不支持
  2. Grunt 社区无官方 brotli 插件,需交叉使用 grunt-contrib-compress + Node 原生 zlib.brotli第三方 grunt-brotli-js
  3. 构建产物需双轨输出*.js.br + *.js.gz + 原文件,文件名必须带后缀以便 Nginx/Apache 做静态映射
  4. 服务端需开启Accept-Encoding 协商,并设置Vary: Accept-Encoding防止 CDN 缓存错乱;
  5. 国内主流 CDN(阿里云、腾讯云)已支持Brotli 边缘压缩,但源站仍需预压缩文件以降低回源耗时;
  6. Gruntfile 中需用grunt.file.copy + grunt-contrib-rename保证指纹(hash)一致,否则会出现“js/a.1234.js 与 js/a.1234.js.br 指纹对不上”的缓存灾难;
  7. 必须配套SourceMap 隐藏策略*.js.br*.js.map 分目录存放,禁止浏览器直接访问 map 文件,满足国内安全合规扫描

答案

  1. 安装依赖
npm i -D grunt-contrib-compress grunt-brotli-js grunt-contrib-clean grunt-contrib-copy grunt-filerev grunt-rename
  1. Gruntfile.js 核心配置
module.exports = function(grunt) {
  grunt.initConfig({
    // 1. 先清理旧产物
    clean: {
      dist: 'dist/**/*'
    },

    // 2. 常规打包、转译、指纹
    filerev: {
      assets: {
        src: ['dist/js/*.js', 'dist/css/*.css']
      }
    },

    // 3. 生成 gzip
    compress: {
      gzip: {
        options: { mode: 'gzip', level: 9 },
        expand: true,
        cwd: 'dist/',
        src: ['**/*.{js,css,svg}'],
        dest: 'dist/',
        ext: '.gz'
      }
    },

    // 4. 生成 brotli
    brotli: {
      options: { mode: 1, quality: 11 }, // 11 为最高压缩率,构建机需 4 GB+ 内存
      files: {
        expand: true,
        cwd: 'dist/',
        src: ['**/*.{js,css,svg}'],
        dest: 'dist/',
        ext: '.br'
      }
    },

    // 5. 保证指纹一致:把 *.js.br 重命名为 *.1234.js.br
    rename: function() {
      const mapping = grunt.filerev.summary; // filerev 输出的映射表
      Object.keys(mapping).forEach(oldFile => {
        const newFile = mapping[oldFile];
        ['.br', '.gz'].forEach(ext => {
          const oldCompressed = oldFile.replace(/\.(js|css|svg)$/, ext);
          const newCompressed = newFile.replace(/\.(js|css|svg)$/, ext);
          if (grunt.file.exists(oldCompressed)) {
            grunt.file.copy(oldCompressed, newCompressed);
            grunt.file.delete(oldCompressed);
          }
        });
      });
    }
  });

  grunt.loadNpmTasks('grunt-contrib-clean');
  grunt.loadNpmTasks('grunt-contrib-compress');
  grunt.loadNpmTasks('grunt-brotli-js');
  grunt.loadNpmTasks('grunt-contrib-copy');
  grunt.loadNpmTasks('grunt-filerev');
  grunt.registerTask('rename', 'Sync compressed names', function() {
    grunt.config.requires('filerev.summary');
    const mapping = grunt.config('filerev.summary');
    Object.keys(mapping).forEach(oldFile => {
      const newFile = mapping[oldFile];
      ['.br', '.gz'].forEach(ext => {
        const oldCompressed = oldFile.replace(/\.(js|css|svg)$/, ext);
        const newCompressed = newFile.replace(/\.(js|css|svg)$/, ext);
        if (grunt.file.exists(oldCompressed)) {
          grunt.file.copy(oldCompressed, newCompressed);
          grunt.file.delete(oldCompressed);
        }
      });
    });
  });

  grunt.registerTask('default', [
    'clean',
    'filerev',
    'compress:gzip',
    'brotli',
    'rename'
  ]);
};
  1. Nginx 配置片段(兼容旧浏览器)
brotli_static on;
gzip_static on;
gzip_vary on;
add_header Vary Accept-Encoding always;

# 低版本浏览器回退
map $http_user_agent $no_brotli {
  ~*MSIE\s[1-11]\.  1;
  ~*Android\s4\.[0-3] 1;
}
if ($no_brotli) {
  set $brotli_encoding "";
}
  1. 验证
curl -H "Accept-Encoding: br" -I https://cdn.example.com/js/a.1234.js
# 返回 content-encoding: br
curl -H "Accept-Encoding: gzip" -I https://cdn.example.com/js/a.1234.js
# 返回 content-encoding: gzip
curl -H "Accept-Encoding: gzip, br" -H "User-Agent: MSIE 10.0" -I https://cdn.example.com/js/a.1234.js
# 返回 content-encoding: gzip

拓展思考

  1. 灰度策略:通过 CDN 的边缘脚本(EdgeRoutine)按省份、运营商动态切流,先给 Chrome 96+ 用户 10% 流量,观察CPU 耗时与 404 日志,再全量;
  2. 构建耗时优化:grunt-brotli-js 单线程压缩大文件(>500 kB)时耗时 3–5 秒,可前置 grunt-concurrent 把 js/css 分队列并行,或在 CI 层改用 Rust 编写的 brotli-cli 再回拷到 dist;
  3. 回滚预案:在 OSS/COS 端保留最近 3 个版本的 .gz 与 .br,一旦监控发现解压失败率 >0.1%,通过CDN 刷新接口批量删除 .br 索引,5 分钟内回退到 gzip
  4. 合规审计:国内金融、政企项目要求构建产物可重现,需在 Gruntfile 里固化 brotli quality=11 的 timestamp 与 node 版本,并把生成的 SHA256 清单写入 dist/MANIFEST.brotli.txt,供安全审计扫码;
  5. 未来迁移:Grunt 已停止特性更新,若团队后续切到 Vite/Rollup,可把本次双压缩逻辑封装成独立 npm 包,通过post-build 钩子复用,实现**“老项目 Grunt 维稳 + 新项目 Vite 演进”**的混合架构。