- 后端
- 微服务
- 云原生
【免费下载链接】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. 🌈
本文基于仓库 openspec/changes/refactor-functional-dev-source-loader-boundary 变更提案及配套 设计文档、任务清单 与 需求规格 编写。它解答 Midway 前端一体化(functional)开发模式下,为什么要把开发期源码直跑兼容逻辑从
@midwayjs/core迁移到@midwayjs/mock、新引入的 dev source loader 如何工作、Vite/Rspack 如何共享同一套加载器,以及 Node 20 + ESM + TypeScript + decorator 场景下的回归保障。
背景:为什么源码加载逻辑出现在 core 里是一种“越界”
Midway 的 functional 一体化开发模式,允许开发者在项目根目录的src/server下直接用 TypeScript 编写 Controller、Service 与configuration.ts,由前端构建链路的开发插件在启动时直接加载源码(源码直跑),无需先编译到dist。
这条链路的完整调用链是:
Vite/Rspack dev plugin -> @midwayjs/mock.createApp() -> initializeGlobalApplicationContext() -> findProjectEntryFile() -> loadModule() 直接加载 src/server 源码在 Node 20 + ESM + TypeScript + decorator 场景下,源码直跑并不简单,需要额外处理五类问题(见 设计文档):
.jsspecifier 要能对应到.ts源文件(ESM 严格后缀解析与 TS 源文件不一致);- TypeScript ESM 转译;
- decorator 与
emitDecoratorMetadata(Midway 依赖装饰器元数据做依赖注入); - bare package import 解析;
- dev watch 对临时文件的排除(避免自己生成的临时产物触发重复 reload)。
此前,为兼容 Node 20 下的这套场景,源码 fallback/转译逻辑被临时塞进了@midwayjs/core的loadModule()。这带来明显的架构问题:
- 这些逻辑只属于开发期,且只在 mock 源码直跑链路中需要,却被沉入 core 这一基础加载层;
- core 承担了不属于"通用运行时"职责的开发期细节,职责边界模糊;
- 增加了 core 的维护负担与回归风险——基础加载器一旦被改动,影响面是全部运行时。
提案 proposal.md 明确指出:需要把逻辑从 core 迁回@midwayjs/mock,让loadModule()恢复"标准模块加载能力"。
重构目标与边界(Goals / Non-Goals)
设计文档 明确了本次重构的目标与不做的事:
Goals:
- 将开发期源码加载兼容逻辑归位到
@midwayjs/mock; - 保持 Vite 与 Rspack dev 插件共享同一套 source loader;
- 使
@midwayjs/core.loadModule()回到标准加载器职责; - 保持 functional 一体化开发链路在 Node 20 下可用。
Non-Goals(明确不在本次范围内):
- 不改变 functional API 用户侧 DSL;
- 不切换到新的默认 dev runtime(如
tsx); - 不把传统
dist驱动开发链路改为源码直跑。
设计文档特别解释了一条决策:本次先保留当前兼容策略思路,不立即切换到tsx/ts-node作为默认运行时。理由是历史上完整 TS runtime 在 Midway/Koa 开发链路里存在启动/重建性能成本,本次优先修正边界而非切换 runtime 路线(design.md)。这是一条重要的"克制"决策:重构的是职责归属,而不是运行策略本身。
核心决策:dev source loader 归位 mock,Vite/Rspack 共用一套
需求规格 spec.md 将三条核心决策固化为系统需求:
1. 开发期源码加载逻辑必须限定在 mock 内
系统 SHALL 将 functional 一体化开发期的源码加载兼容逻辑限定在
@midwayjs/mock的开发链路内,而不是放入@midwayjs/core的通用模块加载器。
对应场景:当用户通过 Vite 或 Rspack 开发插件启动 functional 一体化项目时,针对 TypeScript/ESM/decorator 的源码加载兼容逻辑由@midwayjs/mock提供,而@midwayjs/core不需要感知 dev-only 的源码 fallback 或临时文件机制。
2. core.loadModule() 保持标准加载职责
其职责限定为标准
require/import与safeLoad语义,不承担 functional 一体化开发期的源码转译或 specifier fallback 逻辑。
3. Vite 与 Rspack 复用同一套 mock source loader
两条链路都通过
@midwayjs/mock的统一 source loader 加载src/server源码,不为不同 bundler 维护两套独立的 TS/ESM 兼容实现。
规格还补充了一条容易被忽略的硬性要求:临时产物不触发重复 reload——当 mock source loader 在开发期生成内部临时产物或缓存文件时,Vite/Rspack watcher 不得把这些内部文件视为业务源码变更,应用不能因内部临时文件产生重复 reload。
落地实现:mock 内部的统一 dev source loader
重构的落地成果集中在新文件 packages/mock/src/sourceLoader.ts,它把此前散落/下沉在 core 里的兼容逻辑收敛为createSourceModuleLoader()一个出口:
import { loadModule, ModuleLoader, ModuleLoadOptions } from '@midwayjs/core'; // ... export function createSourceModuleLoader( baseLoader: ModuleLoader = loadModule ): ModuleLoader { return async (p: string, options: ModuleLoadOptions = {}) => { // 默认值:enableCache = true, safeLoad = false, loadMode = 'commonjs' // 若为 esm 模式且开启缓存且非 .json 文件,先尝试标准 baseLoader // 失败后进入 importWithSpecifierFallback()(见下) // 最终兜底 safeLoad 语义:加载失败时按需 warn 并返回 undefined }; }createSourceModuleLoader()是一个标准的ModuleLoader工厂,签名与 core 的loadModule对齐((p, options) => Promise<any>),因此它既能作为moduleLoader选项注入initializeGlobalApplicationContext(),也能在失败时优雅降级到safeLoad语义(warnOnLoadError且非MODULE_NOT_FOUND/ERR_MODULE_NOT_FOUND/ENOENT时才console.warn,最终返回undefined,见 sourceLoader.ts)。
内部机制一:相对 specifier 的扩展名解析(resolveRelativeEsmSpecifierPath)
ESM 的模块解析要求相对导入必须带完整后缀,而 TS 源码里通常写import { UserService } from '../service/user.service.js'去指向user.service.ts,或者干脆省略后缀。resolveRelativeEsmSpecifierPath()负责把./、../开头的 specifier 解析为真实存在的文件:
- 若 specifier 以
.mjs/.cjs/.js结尾,依次尝试替换为.mts/.cts/.ts/.tsx; - 若 specifier 无扩展名,则按顺序尝试
.mts/.cts/.ts/.tsx/.mjs/.cjs/.js/.json以及index.*系列。
见 sourceLoader.ts。这解决了".jsspecifier 对应.ts源文件"的问题。
内部机制二:ESM 源码改写(rewriteRelativeEsmSource)
对于from '...'和import('...')两处相对导入,将 specifier 改写为解析后的真实相对路径,且仅当from 'xxx.js'实际指向xxx.ts这类"表面相同、实际不同"时才改动(改写结果与原文一致则不动,见 sourceLoader.ts)。改写后若源码内容发生变化,即判定需要走 fallback。
内部机制三:TypeScript ESM 转译 + decorator metadata(createCompiledEsmFallbackGraph)
当标准import()直接失败(典型错误码是ERR_UNKNOWN_FILE_EXTENSION,即 Node 不认识.ts扩展名;或抛SyntaxError,即装饰器等 TS 语法无法被原生解析)时,进入 fallback 图编译流程(sourceLoader.ts):
- 在入口文件同目录下创建临时目录
.midway-esm-fallback-*; - 递归编译整个依赖图:每个源文件以 sha1 哈希命名生成
.mjs临时产物;.json直接转成export default {...}; - 所有相对导入在编译产物之间重写为相对 specifier;
- TypeScript 文件调用
ts.transpileModule()转译,compilerOptions固定为:
{ module: tsCompiler.ModuleKind.ESNext, target: tsCompiler.ScriptTarget.ES2020, moduleResolution: tsCompiler.ModuleResolutionKind.NodeNext, esModuleInterop: true, allowSyntheticDefaultImports: true, resolveJsonModule: true, experimentalDecorators: true, emitDecoratorMetadata: true, // 关键:保留装饰器元数据,Midway 注入依赖它 useDefineForClassFields: false, jsx: tsCompiler.JsxEmit.ReactJSX, }注意emitDecoratorMetadata: true与useDefineForClassFields: false的组合,正是为了确保@Inject/@Provide等 Midway 装饰器在开发期直跑时,属性注入与 reflect metadata 行为与标准tsc编译结果保持一致(对应规格中的 "Node 20 下保留 decorator metadata 行为" 场景)。
- 加载编译后的 entry
.mjs,并在finally中递归清理临时目录(rmSync(tempDir, { recursive: true, force: true }))。
TypeScript 编译器通过require.resolve('typescript', { paths: [dirname(sourceFile), process.cwd(), __dirname] })按"源码目录 → 项目根 → mock 自身"逐级寻找并缓存,避免依赖全局安装;若最终找不到编译器,会抛出明确错误:[mock]: can not transpile esm typescript file "...", please install "typescript" in current project。
内部机制四:bare package import 与 importQuery
fallback 图只重写相对导入,bare package import(如import { Inject } from '@midwayjs/core')在转译后的.mjs产物中由 Node 原生 ESM 解析器按node_modules查找,天然保持可解析。此外importWithSpecifierFallback()支持mwImportQuery查询参数透传(Vite HMR 场景通过环境变量MIDWAY_HMR_IMPORT_QUERY注入,见 vite.ts),保证热更新场景下模块可被正确失效与重建。
createApp 如何接入 source loader
packages/mock/src/creator.ts 是@midwayjs/mock的入口中枢(create/createApp/createLightApp/createFunctionApp/createBootstrap)。接入点有两处关键逻辑:
探测模块类型:
if (!options.moduleLoadType) { const pkgJSON = await loadModule(join(appDir, 'package.json'), { safeLoad: true, enableCache: false, }); options.moduleLoadType = pkgJSON?.type === 'module' ? 'esm' : 'commonjs'; }即依据package.json的"type": "module"决定走 ESM 还是 CommonJS 加载。
按条件注入 dev source loader:
if ( !options.moduleLoader && options.moduleLoadType === 'esm' && isTypeScriptEnvironment() ) { options.moduleLoader = createSourceModuleLoader(); }只有同时满足"ESM 项目 + TypeScript 环境(MIDWAY_TS_MODE/ts-node等标识)"时才启用 dev source loader;其余场景(CommonJS、纯 JS、或用户显式传入moduleLoader)仍走标准loadModule。这正是"开发期源码加载逻辑只在 mock 的开发链路内生效"的体现——options.moduleLoader随后会被透传给initializeGlobalApplicationContext()(creator.ts),从而影响整个应用上下文对src/server源码的加载。
createFunctionApp中也有完全相同的注入逻辑(creator.ts),说明 serverless 函数链路同样受益。
Vite 与 Rspack dev 插件:只做桥接与 reload,不承载源码兼容
重构前 Vite/Rspack 插件承担(或间接依赖)了源码兼容细节;重构后两个插件被收敛为纯粹的"请求桥接 + watch reload"职责,源码加载统一经由mock.createApp()内部的 source loader。
Vite 侧(packages/mock/src/vite.ts)
- 核心是
devPlugin(options),options必须提供appDir(否则抛[midway:mock] devPlugin requires "appDir"); baseDir默认src,basePath默认/api;- 通过 Vite middleware 拦截匹配
basePath的请求,交给 Midway app 的 request handler; - 通过
server.watcher.on('all', ...)监听文件变化,命中watchInclude且未被watchExclude排除时才触发reloadApp()(关闭旧 app、使 route manifest 模块失效并推送 full-reload); - 通过 Vite 虚拟模块
virtual:midway-route-manifest暴露MidwayWebRouterService.getRouteManifest()的路由清单,供前端侧生成路由/请求层代码。
关键的 watcher 排除规则(Vite 与 Rspack 完全一致,见 vite.ts 与 rspack.ts):
const watchExclude = options.watch?.exclude || [ /\.d\.ts$/, /\/\.midway-esm-fallback-[^/]+\/.+$/, ];第二条正则正是针对 source loader 生成的临时目录.midway-esm-fallback-*而设,直接落实了规格中"临时产物不触发重复 reload"的需求。
Rspack 侧(packages/mock/src/rspack.ts)
- 同样暴露
devPlugin(options),插件名midway-dev-runtime-rspack; - 通过
compiler.hooks.invalid监听文件失效事件,命中排除规则的不触发 reload; - 通过
compiler.options.devServer.setupMiddlewares注入请求中间件(并保留用户已有的setupMiddlewares链); compiler.hooks.shutdown时统一执行reloadApp()清理。
两插件通过ensureApp()惰性创建并复用同一个createApp()promise,且在创建期间设置MIDWAY_HMR_IMPORT_QUERY=1环境变量,保证 HMR 语义一致。
回归保障:Node 20 + ESM + TS + decorator fixture 与 watcher 测试
重构任务清单 tasks.md 明确要求为 mock 新增回归测试,并完成pnpm -C packages/core build、pnpm -C packages/mock build与 openspec 校验。仓库中的测试证据如下:
ESM 源码直跑集成测试(packages/mock/test/new.test.ts)
测试通过fork子进程以ts-node/register运行 fixturecheck.cjs,验证在createLightApp(__dirname, { moduleLoadType: 'esm' })下:
configuration.ts被成功加载(fixtureName对象被注册为'esm-functional');- 带装饰器的 Controller 注入 Service 正常(
userController.show() === 'hello world'); - 完成后
process.send('ready')通知父进程。
对应 fixture(packages/mock/test/fixtures/base-app-light-esm-functional)本身就是 Node 20 场景的缩影:
- configuration.ts:
defineConfiguration+ESModuleFileDetector(ignore: ['configuration.ts', 'index.ts', 'support.ts']),并使用.js后缀相对导入./support.js; - index.ts:
export { default } from './configuration.js'; - api/user.controller.ts:
@Provide+@Inject装饰器依赖注入; - service/user.service.ts:
@Provide服务实现。
这一组 fixture 恰好覆盖了".jsspecifier 指.ts源文件、ESM 转译、decorator metadata、相对依赖解析"四类兼容问题,并验证了 bare package import(@midwayjs/core)保持可解析。
watcher 排除回归测试(packages/mock/test/vite.test.ts 与 packages/mock/test/rspack.test.ts)
两个测试用 mock 的 server/compiler 对象断言同一个行为:向 watcher 推送.midway-esm-fallback-abc/hash.mjs这类临时产物变更时,logger.info不会被调用(不触发 reload);而推送真实业务文件src/server/user.ts变更时,日志包含reload midway app(触发 reload)。这直接验证了"临时产物不触发重复 reload"的规格场景。
迁移路径与遗留问题
设计文档 给出的迁移计划为:
- 在
packages/mock引入统一 dev source loader; - 让
mock.createApp()在源码入口探测与初始化阶段走 source loader; - 让 Vite/Rspack 插件只负责请求桥接与 reload;
- 将
core.loadModule()回退为纯加载器; - 用 Node 20 functional fixture 验证 React/Vue 一体化开发链路。
从任务清单看,1~4 与验证步骤均已标记完成(tasks.md 中 2.1~2.4、3.1~3.6 全部[x]),且openspec validate ... --strict --no-interactive已通过。
回退后的@midwayjs/core.loadModule()目前实现为标准语义(packages/core/src/util/index.ts):enableCache下 CommonJS 走require(含extraModuleRoot逐级require.resolve),ESM 走pathToFileURL+import(.json使用with: { type: 'json' });safeLoad失败时按warnOnLoadError决定是否告警并返回undefined;禁用缓存时退化为JSON.parse(readFileSync(...))。它不再包含开发期源码 fallback 与临时文件逻辑,恢复了通用加载器边界。
设计文档同时记录了两个 Open Questions(design.md):
- mock source loader 是保持"显式新函数(
createSourceModuleLoader)"方式接入,还是通过initializeGlobalApplicationContext()的可配置 loader hook 接入更合适; - decorator-heavy 业务在 mock source loader 中是否继续保留局部 transpile 策略,还是后续单独评估运行时替换方案。
从当前实现看,选择了显式工厂函数 +moduleLoader选项注入的方案,且MockBootstrapOptions.moduleLoader类型被显式保留(packages/mock/src/interface.ts),说明该注入点是面向用户与内部共享的稳定接口。
小结:边界即架构
这次重构的核心价值在于明确"开发期"与"运行时"的职责边界:
@midwayjs/core保持标准模块加载语义,任何开发期兼容细节都不再污染基础层,降低回归风险;@midwayjs/mock成为 functional 一体化开发期源码加载的唯一归属方,Vite 与 Rspack 通过createApp()共享同一套createSourceModuleLoader(),避免双实现漂移;- watcher 排除、HMR importQuery、临时产物清理等开发体验细节在同一层闭环;
- Node 20 + ESM + TypeScript + decorator 的兼容能力由专门的 fixture 与 watcher 测试锁定,可长期回归。
对于正在维护 functional 一体化项目的开发者而言,理解这条边界意味着:当遇到src/server源码加载类问题(TS 转译、.js→.tsspecifier、装饰器元数据丢失、开发期重复 reload),排查与修复都应聚焦在@midwayjs/mock的 sourceLoader.ts、creator.ts、vite.ts、rspack.ts 这条开发链路上,而不是去动 core 的通用加载器——这既是本次提案的结论,也是 Midway 该功能可持续演进的基础。
- 后端
- 微服务
- 云原生
【免费下载链接】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. 🌈
相关推荐
NemoClaw `src/lib` 分层架构地图:从 commands 到 core 的职责边界与迁移指南
NemoClaw src/lib 分层架构地图:从 commands 到 core 的职责边界与迁移指南 src/lib/README.md 是 NemoCla
终极终端聊天工具imsg:告别手机,高效沟通新方式
终极终端聊天工具imsg:告别手机,高效沟通新方式 还在为回复消息频繁切换终端和手机而烦恼吗?imsg让你无需离开终端界面,直接在命令行中通过iMessage与
即时通讯Three20代码重构实例:从臃肿类到单一职责原则
Three20代码重构实例:从臃肿类到单一职责原则 你是否曾面对过一个超过2000行的巨型类文件,其中混杂着网络请求、UI渲染和数据解析逻辑?Three20作为
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考