描述将 grunt 插件迁移到 ESM 并保留 CommonJS 兼容

解读

面试官想知道你是否理解 Grunt 插件生态仍以 CommonJS 为主 的现实,同时能否在 国内公司渐进式迁移到 ESM 的大趋势下,给出一条“不破坏老项目、又能跑新规范”的落地路线。核心考点有三:

  1. 如何把插件源码本身写成 ESM
  2. 如何通过条件导出让旧 Gruntfile(require)无感调用
  3. 如何在 CI、私有 npm、企业防火墙等国内常见限制下保证双轨产物可安装、可调试、可回滚。

知识点

  • package.json 的 type 字段与条件导出(exports)
  • 双构建脚本:tsc / babel / rollup 同时输出 ESM 与 CJS
  • __dirname、__filename 在 ESM 中的替代方案(import.meta.url)
  • Grunt 的 task 注册机制:grunt.registerMultiTask 必须在运行时能被 require 到
  • 国内镜像源(npmmirror、tnpm)对“exports”字段的缓存延迟问题
  • Node 版本策略:企业内常锁 14.x,需降级使用 cjs-module-lexer 兼容
  • 灰度方案:peerDependenciesMeta + optionalRequire 做软降级

答案

  1. 源码改造
    把任务逻辑全部迁到 src/index.mjs,用 ESM 语法书写;对 __dirname 统一改写为 fileURLToPath(new URL('.', import.meta.url)),避免在 ESM 下报错。

  2. 双轨构建
    package.json 中保留 "type": "module",同时用 rollup + @rollup/plugin-commonjs + @rollup/plugin-babel 打出两条产物:

    • dist/index.mjs(原生 ESM)
    • dist/index.cjs(CommonJS,带 module.exports =
      并在 "scripts" 里写 "prepublishOnly": "npm run build:cjs && npm run build:esm",保证发布前产物最新。
  3. 条件导出
    新增 exports 字段做路由:

    "exports": {
      ".": {
        "import": "./dist/index.mjs",
        "require": "./dist/index.cjs",
        "default": "./dist/index.cjs"
      }
    }
    

    这样老项目 require('grunt-xxx') 会命中 cjs,新项目 import xxx from 'grunt-xxx' 会命中 esm,零配置切换

  4. Grunt 插件入口兼容
    由于 Grunt 通过 require.resolve 加载插件,必须在 cjs 入口里再包一层 grunt.task 注册

    // dist/index.cjs
    const plugin = require('./plugin.cjs'); // 真正的 task 实现
    module.exports = function(grunt) {
      grunt.registerMultiTask('xxx', plugin.description, plugin.run);
    };
    

    保证 grunt.loadNpmTasks('grunt-xxx') 依旧可用。

  5. 国内落地细节

    • 私有 npm 仓库(verdaccio/nexus)需 手动清空缓存才能识别新的 exports 字段;
    • 若企业锁 Node 14,避免使用顶层 await,防止 14.13 以下解析失败;
    • 提供 fallback 入口:在 main 字段仍指向 dist/index.cjs,防止老版本 npm(<7)无法识别 exports 时直接崩溃;
    • 在 README 中写明 “若安装后 grunt 无法识别任务,请删除 node_modules/.cache 与 package-lock”,减少运维沟通成本。
  6. 验证流程
    在 example/ 目录放两份 Gruntfile:

    • Gruntfile.js 使用 require
    • Gruntfile.mjs 使用 import
      在 CI 里跑 grunt xxx --gruntfile Gruntfile.jsgrunt xxx --gruntfile Gruntfile.mjs双轨任务必须同时通过,才允许合并主干。

拓展思考

  • 渐进式废弃 CommonJS:在下一个大版本把 "main" 指向 ESM,exports 里去掉 require,但提前一个 minor 版本给出 deprecation 日志,让国内存量项目有半年缓冲。
  • 微前端场景:如果插件还要被 vite/rollup 原生调用,可再导出 dist/index.browser.mjs,用 条件导出中的 browser 字段做环境隔离,避免 Node-only 的 fs 模块被打包进去。
  • monorepo 下的调试效率:用 pnpm workspace overrides 把本地插件链接到 example 项目,配合 nodemon --watch src --exec "npm run build",保存即刷新,解决“双构建慢”的痛点。