解释在 grunt 中实现运行时模块降级加载
解读
“运行时模块降级加载”在国内前端面试语境里通常指:在浏览器端,当高版本模块(ESM、ES2015+、WebAssembly 等)加载失败或不被支持时,自动回退到兼容性更好的低版本模块(CommonJS、ES5、UMD、nomodule 包)。
Grunt 本身运行在 Node 侧,属于“构建时”工具,并不能直接干预浏览器运行时的加载逻辑;因此题目真正考察的是——如何用 Grunt 在构建阶段产出“多份降级产物”并注入对应的运行时降级策略,从而保证线上页面“无感降级”。
面试官期望听到:
- 构建层如何一次性产出多份产物(es2015、es5、nomodule)。
- 如何自动注入
<script type="module">+<script nomodule>或import.meta.resolve失败回退的代码片段。 - 如何与 CDN、版本号、文件指纹、性能预算等国内工程化规范结合。
知识点
- Grunt 多目标(multi-task)机制:一个任务可配置多组
targets,对应不同输出环境。 - Babel 降级转译:
grunt-babel配合@babel/preset-env,按browserslist生成 ES5 产物。 - Webpack/Rollup 二次打包:
grunt-webpack或grunt-rollup可输出esm与cjs/umd两份 bundle。 - 文件指纹与
grunt-filerev:保证降级产物与高级产物文件名可区分,缓存安全。 - HTML 注入与
grunt-processhtml:根据占位符自动插入
<script type="module" src="<%= esmFile %>"></script>
<script nomodule src="<%= legacyFile %>" defer></script> - 运行时降级脚本(极小 loader):
- 检测
HTMLScriptElement.supports('module'); - 若不支持,动态
document.write或通过System.import加载 legacy 包; - 若支持但网络 404/超时,监听
error事件并再次拉取 legacy 包。
- 检测
- 国内 CDN 双链路:高级产物走“加速域名/2.0/bundle.js”,降级产物走“兼容域名/1.0/bundle.legacy.js”,方便灰度与回滚。
- 性能预算:
grunt-performance-budget保证 legacy 包体积不超过 120 kB(gz),否则报警。
答案
在 Grunt 生态里实现“运行时模块降级加载”分三步:构建多产物、注入降级标记、提供极小回退脚本。
-
多产物构建
在Gruntfile.js中定义两组targets:modern:源码 →grunt-babel(仅语法转换,保留 ESM)→grunt-rollup输出dist/bundle.[hash].esm.js。legacy:同源码 →grunt-babel(preset-env强制ie>=9)→grunt-webpack输出dist/bundle.[hash].legacy.js(UMD 格式,内置core-js)。
使用grunt-concurrent并行跑两组任务,保证 CI 耗时可控。
-
文件指纹与 HTML 注入
产物出来后,grunt-filerev生成带 hash 的新文件名;grunt-processhtml把模板里的占位符<!-- build:js modern --> <script type="module" src="bundle.esm.js"></script> <!-- endbuild --> <!-- build:js legacy --> <script nomodule src="bundle.legacy.js" defer></script> <!-- endbuild -->替换成真实 hash 路径,并自动加入
crossorigin="anonymous"方便 CDN 缓存。 -
运行时降级脚本
在 HTML 头部手动写入 500 字节以内的loader:<script> (function(){ if(!HTMLScriptElement.supports||!HTMLScriptElement.supports('module')){ document.write('<script defer src="dist/bundle.'+'<%= legacyHash %>.legacy.js"><\/script>'); return; } var s=document.createElement('script'); s.type='module';s.src='dist/bundle.'+'<%= esmHash %>.esm.js'; s.onerror=function(){ var f=document.createElement('script'); f.src='dist/bundle.'+'<%= legacyHash %>.legacy.js';f.defer=true; document.head.appendChild(f); }; document.head.appendChild(s); })(); </script>该脚本先检测模块支持度,不支持直接拉 legacy;支持则加载 ESM,若 404/超时自动回退。
通过以上三步,Grunt 在构建阶段就完成了“降级产物 + 降级标记 + 回退逻辑”的闭环,浏览器端无需任何额外框架即可实现运行时模块降级加载,完全符合国内“兼容 IE11、秒开首屏、CDN 独立缓存”的上线规范。
拓展思考
- 双产物体积膨胀:legacy 包常因
core-js与regenerator-runtime增大 30%–50%,可结合grunt-webpack的optimization.splitChunks把polyfill拆成独立chunk,仅在不支持Promise.prototype.finally的浏览器里按需加载。 - 灰度与监控:利用
grunt-replace在loader里注入window.SLS_LOG_URL,当降级触发时立即上报 SLS/阿里云日志,方便观察降级率;若降级率突增可一键回滚 CDN 目录。 - 微前端场景:主应用使用
SystemJS,子应用通过grunt-systemjs-builder同时输出esm与system格式;import-map里优先指向esm地址,失败时System.import自动回退到system包,实现“子应用级”降级。 - 未来可迁移到 esbuild/swc:Grunt 社区已提供
grunt-esbuild插件,利用 Go 原生速度把“双产物”构建耗时从 40 s 降到 6 s,配合grunt-cache做增量编译,可在超大型 Monorepo 中继续沿用 Grunt 工作流而不过时。