解释在 grunt 中实现运行时模块降级加载

解读

“运行时模块降级加载”在国内前端面试语境里通常指:在浏览器端,当高版本模块(ESM、ES2015+、WebAssembly 等)加载失败或不被支持时,自动回退到兼容性更好的低版本模块(CommonJS、ES5、UMD、nomodule 包)
Grunt 本身运行在 Node 侧,属于“构建时”工具,并不能直接干预浏览器运行时的加载逻辑;因此题目真正考察的是——如何用 Grunt 在构建阶段产出“多份降级产物”并注入对应的运行时降级策略,从而保证线上页面“无感降级”。
面试官期望听到:

  1. 构建层如何一次性产出多份产物(es2015、es5、nomodule)。
  2. 如何自动注入 <script type="module"> + <script nomodule>import.meta.resolve 失败回退的代码片段。
  3. 如何与 CDN、版本号、文件指纹、性能预算等国内工程化规范结合。

知识点

  • Grunt 多目标(multi-task)机制:一个任务可配置多组 targets,对应不同输出环境。
  • Babel 降级转译grunt-babel 配合 @babel/preset-env,按 browserslist 生成 ES5 产物。
  • Webpack/Rollup 二次打包grunt-webpackgrunt-rollup 可输出 esmcjs/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 生态里实现“运行时模块降级加载”分三步:构建多产物、注入降级标记、提供极小回退脚本

  1. 多产物构建
    Gruntfile.js 中定义两组 targets

    • modern:源码 → grunt-babel(仅语法转换,保留 ESM)→ grunt-rollup 输出 dist/bundle.[hash].esm.js
    • legacy:同源码 → grunt-babelpreset-env 强制 ie>=9)→ grunt-webpack 输出 dist/bundle.[hash].legacy.js(UMD 格式,内置 core-js)。
      使用 grunt-concurrent 并行跑两组任务,保证 CI 耗时可控。
  2. 文件指纹与 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 缓存。

  3. 运行时降级脚本
    在 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 独立缓存”的上线规范。

拓展思考

  1. 双产物体积膨胀:legacy 包常因 core-jsregenerator-runtime 增大 30%–50%,可结合 grunt-webpackoptimization.splitChunkspolyfill 拆成独立 chunk,仅在不支持 Promise.prototype.finally 的浏览器里按需加载。
  2. 灰度与监控:利用 grunt-replaceloader 里注入 window.SLS_LOG_URL,当降级触发时立即上报 SLS/阿里云日志,方便观察降级率;若降级率突增可一键回滚 CDN 目录。
  3. 微前端场景:主应用使用 SystemJS,子应用通过 grunt-systemjs-builder 同时输出 esmsystem 格式;import-map 里优先指向 esm 地址,失败时 System.import 自动回退到 system 包,实现“子应用级”降级。
  4. 未来可迁移到 esbuild/swc:Grunt 社区已提供 grunt-esbuild 插件,利用 Go 原生速度把“双产物”构建耗时从 40 s 降到 6 s,配合 grunt-cache 做增量编译,可在超大型 Monorepo 中继续沿用 Grunt 工作流而不过时。