使用 grunt 将 TypeScript 打包为符合 Cloudflare Workers 的 ES 模块
解读
面试官抛出这道题,并不是想听你背诵“npm install grunt-ts”,而是考察三点:
- 你是否理解 Cloudflare Workers 对产物格式的硬性约束——必须是纯 ES Module、无 CommonJS、无 Node 内置 polyfill、无动态 require。
- 你是否能在 Grunt 生态里组合多个插件(编译、打包、语法降级、产物校验、体积优化)并写出可维护的 Gruntfile。
- 你是否具备**“踩坑意识”**:国内网络下依赖安装慢、TS 版本与 @cloudflare/workers-types 冲突、Webpack 5 实验性 ESM 输出与 Grunt 流式任务如何衔接、sourcemap 上传边界、__STATIC_CONTENT_MANIFEST 变量被 tree-shaking 误杀等真实场景问题。
一句话:考的是“用 2015 年的工具链做出 2025 年边缘计算平台能直接消费的产物”的端到端落地能力。
知识点
-
Cloudflare Workers 运行时约束
- 仅支持 ES Module(export default / import)
- 禁止 eval / new Function / wasm 同步加载
- 全局变量必须是 ES2022 子集 + Workers 扩展(caches、fetch、WebCrypto)
- 打包产物单文件 ≤ 1 MB(压缩后),不含 node_modules 绝对路径
-
Grunt 任务链设计
- grunt-contrib-clean:清理 dist
- grunt-ts 或 grunt-webpack(配合 ts-loader):把 TS 编译成 ESM
- grunt-rollup 或 grunt-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 时间
-
国内加速细节
- 使用 npmmirror 镜像注册表,grunt-webpack 内置 terser 时,把 swcMinify: true 可让压缩阶段提速 40%
- 把 @cloudflare/workers-types 锁在 4.x,避免 5.x 与 typescript 5.3+ 冲突导致 “Cannot find name ExecutionContext”
- 若公司内网需 Artifactory,在 .npmrc 里加 @types:registry 与 workers-types:registry 两条独立路由,防止类型包被私有库覆盖
-
产物校验
- 用 node --input-type=module --eval 快速检测 “SyntaxError: Cannot use import statement”
- 用 wrangler dev --local 在 miniflare 里跑 单元测试(miniflare Jest 环境),确保 fetch 事件响应码 200
- 用 grunt-size-snapshot 把 gzip 后体积写进 dist/.size-snapshot.json,MR 门禁超过 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,确保产物纯净。”
拓展思考
-
Monorepo 场景:如果项目里还有前端 SPA,如何把 Workers 脚本与前端产物共用一份 TypeScript 配置?
答:在 tsconfig.workers.json 里 extends 基础配置,paths 别名指向 packages/shared,再用 grunt-ts 的 project 参数指定不同 tsconfig,避免把 DOM 类型带入 Workers。 -
灰度发布:国内大厂常用 “蓝绿灰度”,如何在 Grunt 任务里注入环境变量(如 GRAY_TAG)?
答:在 rollup 插件列表里加 @rollup/plugin-replace,把代码里的 GRAY_TAG 替换成 process.env.GRAY_TAG,然后在 GitLab CI 的 deploy 阶段通过 wrangler deploy --name worker-${GRAY_TAG} 实现多版本并存。 -
WebAssembly 加速:Workers 支持 wasm 异步实例化,但 Grunt 流默认把 wasm 当静态资源拷贝。
答:写 自定义 grunt 任务,用 base64 内联 wasm 到 js,再用 WebAssembly.instantiateStreaming(new Response(base64ToArrayBuffer(inlined))),既满足单文件约束,又避免额外往返。 -
未来替代方案:esbuild / rollup / vite 已成主流,为什么还要维护 Grunt?
答:存量 CI 脚本沉淀了 200+ 自定义任务(压缩图片、上传 CDN、钉钉通知),全部迁移成本高;通过 grunt-rollup 桥接,可以在保持 Grunt 调度能力的同时享受新打包器性能,这是国内很多“老项目”渐进式演进的现实路径。