☰
Midway 4.x ESModule 使用指南:从 CJS 平滑迁移到 ESM 的完整实践
2026/9/29 10:30:16 网站建设 项目流程
  • 后端
  • 微服务
  • 云原生

【免费下载链接】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. 🌈

项目地址:https://gitcode.com/gh_mirrors/mi/midway
点击查看免费下载

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/__filenamefileURLToPath(import.meta.url)还原
配置导入目录自动扫描importConfigs对象模式显式注入
文件探测器默认CommonJSFileDetectordetector: 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. 🌈

项目地址:https://gitcode.com/gh_mirrors/mi/midway
点击查看免费下载
上一篇:dex1/dex进阶教程:定制化索引分析与高级查询优化技巧
下一篇:React Image完全指南:打造可靠的React图片加载解决方案

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询