如何在 grunt 中实现 Brotli 压缩并兼容旧浏览器
解读
面试官抛出此题,核心想验证三点:
- 你是否真正在生产环境落地过 Brotli,还是只停留在“听过”层面;
- 能否用 Grunt 生态闭环解决“压缩→回退→缓存策略”全链路,而不是简单调一个插件;
- 对国内“旧浏览器仍占 5%–10% 份额”的现实是否有体感,能否给出可灰度、可回滚的兼容方案。
回答时务必体现“构建层双压缩 + 服务端协商 + 缓存键隔离”的完整思路,并指出 Grunt 在 2025 年国内大厂中的定位:老旧遗产项目维稳工具,因此方案必须零侵入、可脚本化、可审计。
知识点
- Brotli 压缩率比 gzip 高 15%–25%,但 IE11 及以下、部分 Android 4.4 内核不支持;
- Grunt 社区无官方 brotli 插件,需交叉使用 grunt-contrib-compress + Node 原生 zlib.brotli 或第三方 grunt-brotli-js;
- 构建产物需双轨输出:
*.js.br+*.js.gz+ 原文件,文件名必须带后缀以便 Nginx/Apache 做静态映射; - 服务端需开启Accept-Encoding 协商,并设置Vary: Accept-Encoding防止 CDN 缓存错乱;
- 国内主流 CDN(阿里云、腾讯云)已支持Brotli 边缘压缩,但源站仍需预压缩文件以降低回源耗时;
- Gruntfile 中需用grunt.file.copy + grunt-contrib-rename保证指纹(hash)一致,否则会出现“js/a.1234.js 与 js/a.1234.js.br 指纹对不上”的缓存灾难;
- 必须配套SourceMap 隐藏策略:
*.js.br与*.js.map分目录存放,禁止浏览器直接访问 map 文件,满足国内安全合规扫描。
答案
- 安装依赖
npm i -D grunt-contrib-compress grunt-brotli-js grunt-contrib-clean grunt-contrib-copy grunt-filerev grunt-rename
- 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'
]);
};
- 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 "";
}
- 验证
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
拓展思考
- 灰度策略:通过 CDN 的边缘脚本(EdgeRoutine)按省份、运营商动态切流,先给 Chrome 96+ 用户 10% 流量,观察CPU 耗时与 404 日志,再全量;
- 构建耗时优化:grunt-brotli-js 单线程压缩大文件(>500 kB)时耗时 3–5 秒,可前置 grunt-concurrent 把 js/css 分队列并行,或在 CI 层改用 Rust 编写的 brotli-cli 再回拷到 dist;
- 回滚预案:在 OSS/COS 端保留最近 3 个版本的 .gz 与 .br,一旦监控发现解压失败率 >0.1%,通过CDN 刷新接口批量删除 .br 索引,5 分钟内回退到 gzip;
- 合规审计:国内金融、政企项目要求构建产物可重现,需在 Gruntfile 里固化 brotli quality=11 的 timestamp 与 node 版本,并把生成的 SHA256 清单写入 dist/MANIFEST.brotli.txt,供安全审计扫码;
- 未来迁移:Grunt 已停止特性更新,若团队后续切到 Vite/Rollup,可把本次双压缩逻辑封装成独立 npm 包,通过post-build 钩子复用,实现**“老项目 Grunt 维稳 + 新项目 Vite 演进”**的混合架构。