使用 grunt 将 TypeScript 打包为符合 Cloudflare Workers 的 ES 模块

解读

面试官抛出这道题,并不是想听你背诵“npm install grunt-ts”,而是考察三点:

  1. 你是否理解 Cloudflare Workers 对产物格式的硬性约束——必须是纯 ES Module、无 CommonJS、无 Node 内置 polyfill、无动态 require
  2. 你是否能在 Grunt 生态里组合多个插件(编译、打包、语法降级、产物校验、体积优化)并写出可维护的 Gruntfile
  3. 你是否具备**“踩坑意识”**:国内网络下依赖安装慢、TS 版本与 @cloudflare/workers-types 冲突、Webpack 5 实验性 ESM 输出与 Grunt 流式任务如何衔接、sourcemap 上传边界、__STATIC_CONTENT_MANIFEST 变量被 tree-shaking 误杀等真实场景问题。

一句话:考的是“用 2015 年的工具链做出 2025 年边缘计算平台能直接消费的产物”的端到端落地能力。

知识点

  1. Cloudflare Workers 运行时约束

    • 仅支持 ES Module(export default / import)
    • 禁止 eval / new Function / wasm 同步加载
    • 全局变量必须是 ES2022 子集 + Workers 扩展(caches、fetch、WebCrypto)
    • 打包产物单文件 ≤ 1 MB(压缩后),不含 node_modules 绝对路径
  2. Grunt 任务链设计

    • grunt-contrib-clean:清理 dist
    • grunt-tsgrunt-webpack(配合 ts-loader):把 TS 编译成 ESM
    • grunt-rollupgrunt-webpack(output.module=True):二次打包成 “无外部依赖、全部内联” 的 single esm
    • grunt-terser:在 --mangle --compress 下把体积压到极限,同时保留 __STATIC_CONTENT_MANIFEST 等 Workers 关键字
    • grunt-eslint(@typescript-eslint):在编译前做 “禁止 require” 规则拦截
    • grunt-contrib-copy:把 wrangler.toml / _routes.json 等配置文件同步到 dist
    • grunt-contrib-watch + grunt-livereload(本地 miniflare):热更新调试
    • grunt-concurrent:把 lint + typecheck + build + size-limit 并行,缩短 CI 时间
  3. 国内加速细节

    • 使用 npmmirror 镜像注册表,grunt-webpack 内置 terser 时,把 swcMinify: true 可让压缩阶段提速 40%
    • @cloudflare/workers-types 锁在 4.x,避免 5.x 与 typescript 5.3+ 冲突导致 “Cannot find name ExecutionContext”
    • 若公司内网需 Artifactory,在 .npmrc 里加 @types:registryworkers-types:registry 两条独立路由,防止类型包被私有库覆盖
  4. 产物校验

    • node --input-type=module --eval 快速检测 “SyntaxError: Cannot use import statement”
    • wrangler dev --localminiflare 里跑 单元测试(miniflare Jest 环境)确保 fetch 事件响应码 200
    • grunt-size-snapshot 把 gzip 后体积写进 dist/.size-snapshot.jsonMR 门禁超过 900 KB 直接失败

答案

下面给出一份可直接在**国内 CI(GitLab Runner + cnpm 镜像)**跑通的 Gruntfile.js 核心片段,每一步都加了中文注释,方便面试时口述。

module.exports = function(grunt) {
  // 0. 国内镜像加速
  process.env.npm_config_registry = 'https://registry.npmmirror.com';

  grunt.initConfig({
    pkg: grunt.file.readJSON('package.json'),

    // 1. 清理旧产物
    clean: {
      dist: ['dist']
    },

    // 2. TS → ESM(grunt-ts 最新版已支持 ts5)
    ts: {
      default: {
        tsconfig: './tsconfig.json',
        options: {
          // 关键:module=es2022,target=es2022,与 Workers 运行时对齐
          module: 'es2022',
          target: 'es2022',
          sourceMap: false,               // 线上不需要
          inlineSources: false,
          removeComments: true,
          // 显式把 @cloudflare/workers-types 加入 types 数组
          types: ['@cloudflare/workers-types']
        }
      }
    },

    // 3. Rollup 二次打包,把散文件合成单文件 ESM
    rollup: {
      workers: {
        options: {
          input: 'dist/src/index.js',     // ts 编译后的入口
          output: {
            file: 'dist/worker.mjs',
            format: 'es',                 // 必须是 es
            exports: 'auto'
          },
          plugins: [
            // 自动把 node-resolve + commonjs 插件关掉,防止把 Node 内置打进去
            require('@rollup/plugin-node-resolve')({ browser: true, preferBuiltins: false }),
            require('@rollup/plugin-terser')({
              compress: {
                pure_funcs: ['console.log'] // 清掉调试日志
              },
              mangle: { reserved: ['__STATIC_CONTENT_MANIFEST'] } // 防止误杀
            })
          ]
        }
      }
    },

    // 4. 体积门禁
    size_snapshot: {
      workers: {
        src: 'dist/worker.mjs',
        maxSize: '1mb'
      }
    },

    // 5. 语法校验:禁止出现 require / module.exports
    eslint: {
      workers: {
        src: 'dist/worker.mjs',
        options: {
          configFile: '.eslintrc.workers.js', // 独立配置,env=worker
          rules: {
            'no-commonjs': 2
          }
        }
      }
    },

    // 6. 本地热更新
    watch: {
      workers: {
        files: ['src/**/*.ts'],
        tasks: ['clean', 'ts', 'rollup', 'size_snapshot'],
        options: { spawn: false, livereload: 1337 }
      }
    }
  });

  // 加载插件(国内 CI 提前缓存)
  grunt.loadNpmTasks('grunt-contrib-clean');
  grunt.loadNpmTasks('grunt-ts');
  grunt.loadNpmTasks('grunt-rollup');
  grunt.loadNpmTasks('grunt-eslint');
  grunt.loadNpmTasks('grunt-contrib-watch');
  grunt.loadNpmTasks('grunt-size-snapshot');

  // 注册复合任务
  grunt.registerTask('default', ['clean', 'ts', 'rollup', 'size_snapshot', 'eslint']);
  grunt.registerTask('dev', ['default', 'watch']);
};

口述要点

  • “ts 任务把 module 设成 es2022,与 Workers 运行时保持一致,避免 grunt-ts 默认 commonjs 输出。”
  • “rollup 阶段关掉 commonjs 插件,防止把 Node 内置打进去;同时用 terser 保留 __STATIC_CONTENT_MANIFEST。”
  • “size_snapshot 做门禁,超过 1 MB 直接失败,防止边缘节点上传失败。”
  • “eslint 独立配置,rules 里加 no-commonjs,确保产物纯净。”

拓展思考

  1. Monorepo 场景:如果项目里还有前端 SPA,如何把 Workers 脚本与前端产物共用一份 TypeScript 配置
    答:在 tsconfig.workers.jsonextends 基础配置paths 别名指向 packages/shared,再用 grunt-ts 的 project 参数指定不同 tsconfig,避免把 DOM 类型带入 Workers

  2. 灰度发布:国内大厂常用 “蓝绿灰度”如何在 Grunt 任务里注入环境变量(如 GRAY_TAG)?
    答:在 rollup 插件列表里加 @rollup/plugin-replace把代码里的 GRAY_TAG 替换成 process.env.GRAY_TAG然后在 GitLab CI 的 deploy 阶段通过 wrangler deploy --name worker-${GRAY_TAG} 实现多版本并存

  3. WebAssembly 加速:Workers 支持 wasm 异步实例化但 Grunt 流默认把 wasm 当静态资源拷贝
    答:写 自定义 grunt 任务用 base64 内联 wasm 到 js再用 WebAssembly.instantiateStreaming(new Response(base64ToArrayBuffer(inlined)))既满足单文件约束,又避免额外往返

  4. 未来替代方案esbuild / rollup / vite 已成主流为什么还要维护 Grunt?
    答:存量 CI 脚本沉淀了 200+ 自定义任务(压缩图片、上传 CDN、钉钉通知)全部迁移成本高通过 grunt-rollup 桥接,可以在保持 Grunt 调度能力的同时享受新打包器性能这是国内很多“老项目”渐进式演进的现实路径