- 后端
- 微服务
- 云原生
【免费下载链接】midway
🍔 A Node.js Serverless Framework for front-end/full-stack developers. Build the application for next decade. Works on AWS, Alibaba Cloud, Tencent Cloud and traditional VM/Container. Super easy integrate with React and Vue. 🌈
Midway 在 Node.js 原生 ESM 支持稳定(自 Node.js v16 起)之后,正式提供了基于 ESM 格式的文件加载能力,业务代码可以完全采用import/export这一全新的模块体系来构建应用。本文以 site/docs/esm.md 为骨架,结合仓库中的核心加载实现与官方 ESM 示例工程 samples/koa-esm-app,完整梳理 ESM 脚手架的搭建、与 CJS 项目的四大差异(package.json、tsconfig.json、工具链、代码写法),并深入底层源码解释 ESM 文件是如何被 Midway 加载与识别的。
:::caution 在没有了解 ESM 之前,不建议用户直接使用。ESM 与 CommonJS 的互操作存在大量功能差异,请先阅读 Node.js 官方 ESM 文档与 TypeScript 官方 ESM 指南后再进行迁移。 :::
为什么 Midway 要支持 ESM
在过去的几年中,Node.js 一直致力于支持运行 ECMAScript 模块(ESM)。这是一个相当困难的功能,因为整个 Node.js 生态系统的根基是建立在另一个模块系统——CommonJS(CJS)之上的。两个模块系统之间的互操作带来了巨大的挑战,并且存在许多功能差异:例如模块解析规则不同、require与import的语义不同、__dirname/__filename等 Node 全局变量的缺失等。
自 Node.js v16 之后,ESM 的支持相对已经稳定,TypeScript 的相关配合功能(如Node16/NodeNext模块解析)也相继落地。在此基础上,Midway 支持了 ESM 格式的文件加载,业务也可以使用这种全新的模块加载方式来构建自己的业务。
从仓库源码看,Midway 在加载层面对两种模式做了明确区分:核心工具 loadModule 提供loadMode: 'commonjs' | 'esm'选项,其中 ESM 模式通过pathToFileURL(p)将文件路径转换为 file:// URL 后调用动态import()加载,JSON 文件则通过import(p, { with: { type: 'json' } })的导入断言方式读取;同时,fileDetector.ts 中定义了CommonJSFileDetector(同步require扫描)与ESModuleFileDetector(异步import扫描)两种文件探测器,ESModuleFileDetector通过重写getType()返回'module'来切换加载路径。
一、脚手架:快速创建一个 ESM 项目
由于 ESM 涉及的改动较多(工具链、目录约定、测试配置等),Midway 提供了全新的 ESM 格式脚手架。如有 ESM 需求,官方推荐重新创建项目后再进行业务开发,而不是在旧 CJS 项目上硬改。
$ npm init midway@latest -y在 v4 模板列表中选择koa-v4-esm。该模板生成的工程结构,可以参考仓库中的官方示例 samples/koa-esm-app,其目录布局如下:
koa-esm-app/ ├── src/ │ ├── config/ │ │ ├── config.default.ts │ │ └── config.unittest.ts │ ├── controller/ │ │ ├── api.controller.ts │ │ └── home.controller.ts │ ├── middleware/ │ │ └── report.middleware.ts │ ├── service/ │ │ └── user.service.ts │ ├── bootstrap.ts │ ├── configuration.ts │ └── interface.ts ├── test/ │ ├── controller/ │ │ ├── api.test.ts │ │ └── home.test.ts │ └── setup.ts ├── bootstrap.js ├── package.json └── tsconfig.json二、与 CJS 项目的差异
1、package.json 的变化
package.json中的type必须设置为module,这是 Node.js 判断整个包使用 ESM 语义的开关:
{ "name": "my-package", "type": "module", // ... "dependencies": { } }仓库示例 samples/koa-esm-app/package.json 中同样设置了"type": "module",同时脚本也体现了 ESM 工具链的变化:
{ "name": "@samples/koa-esm-app", "private": true, "version": "4.2.4", "type": "module", "scripts": { "build": "node -e \"require('fs').rmSync('dist',{ recursive: true, force: true })\" && tsc -p tsconfig.json", "start": "cross-env NODE_ENV=production node ./bootstrap.js", "dev": "cross-env NODE_ENV=local mwtsc --watch --run @midwayjs/mock/app", "test": "cross-env NODE_ENV=unittest mocha" } }注意示例中build脚本在构建前会先删除dist目录再执行tsc,这是因为 ESM 模式下非 JS 资源不会被自动拷贝(见下文"工具链的变化")。
2、tsconfig.json 中的变化
compilerOptions中与模块解析相关的选项必须设置为Node16或NodeNext,同时module建议设置为ESNext/NodeNext,并开启esModuleInterop:
{ "compilerOptions": { "target": "ESNext", "module": "ESNext", "moduleResolution": "Node16", "esModuleInterop": true, // ... } }仓库示例 samples/koa-esm-app/tsconfig.json 给出了更完整的生产级配置,其中module与moduleResolution均为NodeNext,并保留了装饰器相关选项:
{ "compilerOptions": { "target": "ES2022", "module": "NodeNext", "moduleResolution": "NodeNext", "strict": false, "types": ["node", "mocha"], "skipLibCheck": true, "noEmit": false, "rootDir": "src", "outDir": "dist", "sourceMap": true, "experimentalDecorators": true, "emitDecoratorMetadata": true, "esModuleInterop": true, "useDefineForClassFields": false }, "include": ["src/**/*.ts"] }需要特别注意的是:在Node16/NodeNext解析模式下,TypeScript 要求源码中的相对导入必须写.js后缀(而不是.ts),编译产物才能被 Node.js 的 ESM 解析器正确识别。
3、工具链的变化
由于原有开发工具链仅支持 CJS 代码,且社区的部分模块尚未做好 ESM 支持,Midway 在 ESM 模式下使用了一套新的工具链:
| 场景 | 命令 | 说明 |
|---|---|---|
| 开发命令 | mwtsc | 仅对tsc做了必要的包裹,支持--watch --run @midwayjs/mock/app的本地热开发 |
| 测试和覆盖率命令 | mocha + ts-node | 测试代码与测试配置都有所调整(详见下文测试部分) |
| 构建命令 | tsc | 直接使用 TypeScript 编译器输出 ESM 产物 |
从示例工程的 devDependencies 可以看到这套工具链的实际组成:mwtsc、mwts、mocha、ts-node、typescript等(参见 samples/koa-esm-app/package.json)。
一些不再支持的功能:
- alias path:请改用 Node.js 自带的子路径导出(subpath exports)机制,即通过
package.json中的exports字段定义别名映射; - 构建时非 js 文件的拷贝:构建产物中不再自动拷贝非代码文件(如静态资源、模板等)。解决方案有两种:将非代码文件放到
src目录外部,或者在build时添加自定义命令手动拷贝(示例工程正是在 build 脚本中先rmSync('dist', ...)再tsc,为自定义拷贝腾出干净的产物目录)。
4、一些代码差异
下面快速列出开发中 ESM 与 CJS 的主要差异,均已在示例工程 samples/koa-esm-app/src 中有对应实现。
1)ts 中 import 的文件必须指定后缀名,且后缀名为.js
import { helper } from "./foo.js"; // works in ESM & CJS示例工程中处处体现了这一约定,例如 api.controller.ts 中的import { UserService } from '../service/user.service.js'。
2)不能再使用module.exports或exports.来导出
// ./foo.ts export function helper() { // ... } // ./bar.ts import { helper } from "./foo"; // only works in CJS注意上例bar.ts中省略.js后缀的写法只在 CJS 下有效,ESM 下必须写作"./foo.js"。
3)不能在代码中使用require
只能使用import关键字。对于需要动态加载的场景,使用await import()或依赖 Midway 内部提供的加载工具(详见下文"源码视角")。
4)不能在代码中使用__dirname、__filename等路径相关关键字
ESM 中这些 Node 全局变量不再存在,需要通过import.meta.url手动还原:
// ESM solution import { dirname } from 'node:path' import { fileURLToPath } from 'node:url' const __filename = fileURLToPath(import.meta.url) const __dirname = dirname(fileURLToPath(import.meta.url))示例工程的 bootstrap.ts 展示了这一写法的完整形态,它还额外用pathToFileURL(resolve(entry)).href === import.meta.url判断当前文件是否被直接执行,从而决定是否自动启动应用:
import { Bootstrap } from '@midwayjs/bootstrap'; import { dirname, resolve } from 'node:path'; import { fileURLToPath, pathToFileURL } from 'node:url'; const appDir = dirname(fileURLToPath(import.meta.url)); export async function start() { await Bootstrap.configure({ baseDir: appDir, }).run(); } function isDirectRun() { const entry = process.argv[1]; if (!entry) { return false; } return pathToFileURL(resolve(entry)).href === import.meta.url; } if (isDirectRun()) { start().catch(err => { console.error(err); process.exit(1); }); }5)所有配置部分,必须使用对象模式
ESM 下无法再使用require()式的"目录即配置"隐式约定,配置必须显式以对象形式注入:
import { Configuration } from '@midwayjs/core'; import DefaultConfig from './config/config.default.js'; import UnittestConfig from './config/config.unittest.js'; @Configuration({ importConfigs: [ { default: DefaultConfig, unittest: UnittestConfig, }, ], }) export class MainConfiguration { // ... }示例工程的 configuration.ts 给出了更完整的写法,除了importConfigs之外,还显式指定了 ESM 文件探测器与组件导入:
import { Configuration, App, ESModuleFileDetector, IMidwayContainer, ILifeCycle, } from '@midwayjs/core'; import * as koa from '@midwayjs/koa'; import * as validation from '@midwayjs/validation'; import * as info from '@midwayjs/info'; import { ReportMiddleware } from './middleware/report.middleware.js'; import DefaultConfig from './config/config.default.js'; import UnittestConfig from './config/config.unittest.js'; @Configuration({ imports: [ koa, validation, { component: info, enabledEnvironment: ['local'], }, ], importConfigs: [ { default: DefaultConfig, unittest: UnittestConfig, }, ], detector: new ESModuleFileDetector(), }) export class MainConfiguration implements ILifeCycle { @App('koa') app: koa.Application; async onReady(container: IMidwayContainer) { this.app.useMiddleware([ReportMiddleware]); } }这里的detector: new ESModuleFileDetector()是关键——它告诉 Midway 使用 ESM 方式的文件扫描器加载目录下的模块(详见下一节源码分析)。
三、源码视角:ESM 文件是如何被加载的
理解 ESM 支持背后原理,可以看两个核心实现:
1)模块加载工具loadModule
位于 packages/core/src/util/index.ts,是 Midway 3.12.0 起提供的统一加载入口。当loadMode为'esm'时:
- 普通文件:通过
pathToFileURL(p)构造 file:// URL,再await import(fileUrl.href)动态加载; - JSON 文件:通过
import(p, { with: { type: 'json' } })使用导入断言读取; - 若配置了
importQuery,会向 URL 追加mwImportQuery查询参数(用于 HMR 场景强制刷新模块缓存,避免旧模块被 ESM 模块缓存复用)。
} else { // if json file, import need add options if (p.endsWith('.json')) { return (await import(p, { with: { type: 'json' } })).default; } else { const fileUrl = pathToFileURL(p); if (options.importQuery) { fileUrl.searchParams.set('mwImportQuery', options.importQuery); } return await import(fileUrl.href); } }2)文件探测器ESModuleFileDetector
位于 packages/core/src/common/fileDetector.ts。CommonJSFileDetector使用同步require(file)逐个加载扫描到的文件;而ESModuleFileDetector继承自它,仅重写getType()返回'module',从而让run()走进loadAsync分支——对所有匹配文件(默认 glob 为**/**.tsx+DEFAULT_PATTERN,默认忽略node_modules、logs、*.test.ts等)调用loadModule(file, { loadMode: 'esm', importQuery })异步加载,再通过container.bindClass(exports, ...)注册到依赖注入容器:
/** * CommonJS module loader */ export class CommonJSFileDetector extends AbstractFileDetector<{...}> { // loadSync: require(file) // loadAsync: await moduleLoader(file, { loadMode: 'esm', importQuery }) getType(): 'commonjs' | 'module' { return 'commonjs'; } } /** * ES module loader */ export class ESModuleFileDetector extends CommonJSFileDetector { getType(): 'commonjs' | 'module' { return 'module'; } }在 interface.ts 中,ModuleLoadType = 'commonjs' | 'esm'这一类型定义也印证了框架层面对双模式加载的原生支持。可以推断:在 ESM 模式下,模块扫描由同步变为异步,这也是 ESM 应用启动流程中组件注册阶段需要 await 的原因之一。
四、ESM 模式下的测试写法
ESM 工程使用mocha + ts-node进行测试,测试配置与 CJS 时代有显著差异。仓库示例 samples/koa-esm-app/test/setup.ts 展示了标准的全局 setup 写法——借助 mocha 的全局钩子mochaGlobalSetup/mochaGlobalTeardown创建并关闭应用,同时由于 ESM 下没有__dirname,同样需要用fileURLToPath(import.meta.url)还原路径:
import { createApp, close } from '@midwayjs/mock'; import { fileURLToPath } from 'url'; import { dirname, join } from 'path'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); let app; export async function mochaGlobalSetup() { // create app app = await createApp({ appDir: join(__dirname, '../'), }); } export async function mochaGlobalTeardown() { await close(app); }; export function getApp() { return app; }对应的package.json测试脚本只需"test": "cross-env NODE_ENV=unittest mocha",mocha 会按自身配置(.mocharc 或默认约定)加载 setup 与*.test.ts用例。示例工程的 test/controller/api.test.ts 与 test/controller/home.test.ts 可参考。
五、常见迁移问题小结
| 迁移项 | CJS 写法 | ESM 写法 |
|---|---|---|
| 相对导入 | import x from './foo' | import x from './foo.js' |
| 模块导出 | module.exports = .../exports.x = ... | export/export default |
| 动态加载 | require(path) | await import(pathToFileURL(path).href) |
| 路径全局变量 | __dirname/__filename | fileURLToPath(import.meta.url)还原 |
| 配置导入 | 目录自动扫描 | importConfigs对象模式显式注入 |
| 文件探测器 | 默认CommonJSFileDetector | detector: new ESModuleFileDetector() |
| 测试工具链 | jest 等 | mocha + ts-node(全局 setup 钩子) |
迁移前务必确认:项目所有第三方依赖是否已支持 ESM;package.json的type、tsconfig.json的module/moduleResolution是否按要求调整;所有相对导入是否补全.js后缀;代码中是否存在require、module.exports、__dirname等 CJS 专属写法。如果这些前提尚未满足,建议先在脚手架模板(koa-v4-esm)上验证整套工具链,再决定迁移方案。
- 后端
- 微服务
- 云原生
【免费下载链接】midway
🍔 A Node.js Serverless Framework for front-end/full-stack developers. Build the application for next decade. Works on AWS, Alibaba Cloud, Tencent Cloud and traditional VM/Container. Super easy integrate with React and Vue. 🌈
相关推荐
Tippy.js 升级迁移指南:从 4.x / 5.x 平滑迁移到 6.x 的完整攻略
Tippy.js 升级迁移指南:从 4.x / 5.x 平滑迁移到 6.x 的完整攻略 Tippy.js 是当前 Web 生态中最完整的 tooltip、pop
前端UI组件isort 5.0.0 升级指南:从 4.x 平滑迁移的完整实践手册
isort 5.0.0 升级指南:从 4.x 平滑迁移的完整实践手册 isort 5.0.0 是 isort 五年来第一个主版本发布,也是自项目诞生十余年以来最
开发工具代码质量格式化LintWxJava HttpClient 升级指南:从 4.x 平滑迁移到 Apache HttpClient 5.x 完整实战
WxJava HttpClient 升级指南:从 4.x 平滑迁移到 Apache HttpClient 5.x 完整实战 自 WxJava 4.7.x 版本起
后端即时通讯
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考