- 前端
- 后端
- Web框架
【免费下载链接】modern.js
A progressive web framework based on React and Rsbuild.
本文围绕 packages/server/utils/CHANGELOG.md 记录的能力演进,结合 packages/server/utils/src 的源码实现与
app-tools、plugin-bff两个消费方,系统讲解 Modern.js 中负责服务端(server / BFF / shared 目录)TypeScript 编译的工具包@modern-js/server-utils。读完本文,你将掌握它的核心 API、TypeScript 编译管线、别名与 tsconfig paths 重写机制、原生 ESM 产物支持,以及它在项目构建流程中的实际接入方式。
一、包定位:Modern.js 服务端代码的编译入口
@modern-js/server-utils是 Modern.js monorepo 中位于 packages/server/utils 的服务端编译工具包。它的职责非常聚焦:在应用构建过程中,将server/、api/(BFF)以及shared/目录下的 TypeScript 源码编译为可在 Node 端直接运行的产物,并写入应用输出目录(如dist/)。
从 package.json 可以看到,该包当前版本为3.9.3,对外暴露的入口非常精简:
{ "name": "@modern-js/server-utils", "version": "3.9.3", "main": "./dist/cjs/index.js", "exports": { ".": { "types": "./dist/types/index.d.ts", "modern:source": "./src/index.ts", "node": { "import": "./dist/esm-node/index.mjs", "require": "./dist/cjs/index.js" }, "default": "./dist/cjs/index.js" } }, "sideEffects": false, "dependencies": { "@modern-js/utils": "workspace:*", "@swc/helpers": "^0.5.17" } }值得注意的细节:包同时输出 CJS(dist/cjs)与原生 ESM(dist/esm-node)两种产物,并声明sideEffects: false;而 CHANGELOG 中2.20.0的变更6b9d90a(移除@babel/runtime、引入@swc/helpers并开启externalHelpers配置)正是为了支撑这种多产物输出、减少运行时依赖注入。
包的全部对外能力只有一个函数——编译。见 src/index.ts:
export { compile } from './common';它就像一把“服务端代码编译器”的钥匙:整个 Modern.js 框架的服务端编译能力都经由这一个入口驱动。
二、核心 API:compile函数的签名与校验逻辑
compile定义在 src/common/index.ts,其类型签名如下:
export interface Pattern { from: string; to: string; tsconfigPath?: string; } export interface IConfig { alias?: SourceNormalizedConfig['alias']; server?: { compiler?: 'typescript'; }; } export interface CompileOptions { sourceDirs: string[]; // 需要编译的源目录,必须为绝对路径 distDir: string; // 产物输出目录,必须为绝对路径 tsconfigPath?: string; // tsconfig 文件路径 moduleType?: 'module' | 'commonjs'; // 产物模块格式 throwErrorInsteadOfExit?: boolean; // 编译失败时抛错而非 process.exit } export type CompileFunc = ( appDirectory: string, // 应用根目录 modernConfig: IConfig, // 归一化后的 modern.config 中 server/source.alias 相关配置 compileOptions: CompileOptions, ) => Promise<void>;关键参数说明:
| 参数 | 类型 | 说明 |
|---|---|---|
appDirectory | string | 应用根目录,用于定位 tsconfig、计算rootDir与相对路径 |
modernConfig.alias | SourceNormalizedConfig['alias'] | 来自modern.config.ts中source.alias的别名配置 |
modernConfig.server.compiler | 'typescript' | 服务端编译器开关,对应 server 配置类型 |
sourceDirs | string[] | 待编译源目录,如server/、api/、shared/ |
distDir | string | 产物目录(如dist/) |
tsconfigPath | string | 服务端 tsconfig 路径 |
moduleType | 'module' \| 'commonjs' | 决定产物是原生 ESM 还是 CJS |
throwErrorInsteadOfExit | boolean | 为true时编译失败抛异常(适合 BFF 开发场景),否则process.exit(1) |
compile在进入真正的编译逻辑前,会先做绝对路径校验:
const validateAbsolutePaths = (filenames, messageFunc) => { filenames.forEach(filename => validateAbsolutePath(filename, messageFunc(filename)), ); }; export const compile: CompileFunc = async (appDirectory, modernConfig, compileOptions) => { const { sourceDirs, distDir, tsconfigPath } = compileOptions; validateAbsolutePaths(sourceDirs, dir => `source dir ${dir} is not an absolute path.`); validateAbsolutePath(distDir, `dist dir ${distDir} is not an absolute path.`); const { compileByTs } = await import('../compilers/typescript'); await compileByTs(appDirectory, modernConfig, compileOptions); };这里采用了动态import延迟加载compileByTs,且源目录与输出目录必须是绝对路径——这保证了在不同工作目录下调用时的行为一致性。值得注意的是,server.compiler目前只接受'typescript',这与 CHANGELOG1.18.0的变更60a2e3a(feat: add compiler option for server)一脉相承:服务端编译从历史上基于 Babel 的方案(旧版依赖@modern-js/babel-preset-lib、@modern-js/babel-compiler,见2.22.1的4be1da5变更)演进为以 TypeScript 编译器为唯一引擎。
三、TypeScript 编译引擎compileByTs:完整的编译管线
compileByTs是真正的编译实现,位于 src/compilers/typescript/index.ts。整个管线可以拆成六个步骤:
1. 加载 TypeScript:尊重 ts-node 配置的编译器
const tsConfig = readRawTsConfigByFile(tsconfigPath); const ts = new TypescriptLoader({ appDirectory, compiler: tsConfig['ts-node']?.compiler, }).load();TypescriptLoader(typescriptLoader.ts)会优先从应用目录解析tsconfig中ts-node.compiler字段指定的 TypeScript 编译器;若未配置则回退到typescript包,找不到时抛出明确错误。这正是 CHANGELOG3.9.1变更bc59ee1记录的能力:
fix: respect the TypeScript compiler configured by ts-node / 使用 ts-node 配置中指定的 TypeScript 编译器
也就是说,如果你的项目通过ts-node配置了自定义 TypeScript 编译器(例如 swc 风格的 transpiler),服务端编译会遵循同一份配置,保证开发与构建行为一致。此外,2.26.0的1fb5804(按需从直接依赖加载 typescript)也印证了 loader 这种“从 appDirectory 就近解析、失败报错”的按需加载设计。
2. 合并别名:source.alias + tsconfig paths
const aliasOption = getAliasConfig(alias, { appDirectory, tsconfigPath }); const { paths = {}, absoluteBaseUrl = './' } = aliasOption;getAliasConfig来自 @modern-js/utils 的 alias 模块:若项目存在 tsconfig,则读取其compilerOptions.baseUrl作为absoluteBaseUrl,并将source.alias与 tsconfig 的paths合并;2.0.0-beta.6变更14b712da84(在各个包中使用一致的 alias 类型定义和默认值)正对应这里与消费方之间的类型统一。
3. 构建增量编译 Program
const createProgram = ts.createIncrementalProgram || ts.createProgram; const rootNames = fileNames.filter(fileName => fileName.endsWith('.d.ts') || sourcePosixPaths.some(sourceDir => fileName.includes(sourceDir)), ); const program = createProgram.call(ts, { rootNames, projectReferences, options: { ...options, rootDir: appDirectory, outDir: distDir, jsx: options.jsx === undefined || options.jsx === ts.JsxEmit.Preserve ? ts.JsxEmit.ReactJSX : options.jsx, }, });rootNames只包含声明文件与落在sourceDirs内的源文件,rootDir固定为应用根目录,outDir为distDir。尤其关键的是 JSX 处理:当用户未配置或配置为preserve时,编译器会强制改写为ReactJSX。代码注释给出了原因——jsx: preserve会产出 Node 无法直接执行的.jsx文件,而服务端产物必须直接在 Node 中运行。这正是 CHANGELOG3.8.0变更b3a0709修复“自定义 Server 在原生 ESM 下不可运行(编译 JSX)”所引入的行为。
4. 别名 / tsconfig paths 重写:before 与 afterDeclarations 双钩子
编译时通过 TypeScript 自定义 transformer 重写模块标识符:
const tsconfigPathsPlugin = tsconfigPathsBeforeHookFactory(ts, absoluteBaseUrl, paths, compileOptions.moduleType); const tsconfigPathsDeclarationPlugin = tsconfigPathsAfterDeclarationsHookFactory(ts, absoluteBaseUrl, paths, compileOptions.moduleType); program.emit(undefined, undefined, undefined, undefined, { before: tsconfigPathsPlugin ? [tsconfigPathsPlugin] : [], afterDeclarations: tsconfigPathsDeclarationPlugin ? [tsconfigPathsDeclarationPlugin] : [], });两个钩子都实现在 tsconfigPathsPlugin.ts:
tsconfigPathsBeforeHookFactory在 JS 输出阶段重写import/export声明与动态import()的模块标识符(isDynamicImport分支,对应 CHANGELOG1.18.0变更0a4d622:动态导入路径替换为正确的别名);tsconfigPathsAfterDeclarationsHookFactory在.d.ts声明输出阶段再次重写。注释明确指出:TypeScript 自身不会在声明输出中解析 tsconfigpaths(microsoft/TypeScript#30952),且node16/nodenext消费者要求.d.ts中也有显式扩展名,因此必须对声明 AST 中的ImportTypeNode、ImportDeclaration、ImportEqualsDeclaration等节点做同样的重写,否则别名会泄漏给下游消费者。这正是 CHANGELOG3.8.2变更8edf91a的修复对象:
fix: make published crossProject BFF client declarations resolvable in consumers / 修复发布后的 crossProject BFF 客户端声明在消费方项目中无法解析类型的问题
此外,CHANGELOG2.0.0-beta.6的c2bb0f1745(使用预打包的 tsconfig-paths 依赖)对应代码中import { createMatchPath } from '@modern-js/utils/tsconfig-paths'——即复用@modern-js/utils预打包的tsconfig-paths实现,而非直接依赖上游包。
5. 诊断收集与错误处理
const allDiagnostics = ts .getPreEmitDiagnostics(program) .concat(emitResult.diagnostics); const { noEmitOnError } = options; if (allDiagnostics.length > 0) { logger.error(ts.formatDiagnosticsWithColorAndContext([...new Set(allDiagnostics)], formatHost)); if (typeof noEmitOnError === 'undefined' || noEmitOnError === true) { if (compileOptions.throwErrorInsteadOfExit) { logger.error('TypeScript compilation failed'); } else { process.exit(1); } } }编译器同时收集预编译期与 emit 阶段的诊断,并遵循 tsconfig 的noEmitOnError选项(默认开启)——对应 CHANGELOG2.12.0变更05493a7(支持 ts 编译器的noEmitOnError选项)。失败时的行为由throwErrorInsteadOfExit决定:BFF 开发场景(plugin-bff)传入true以便上层捕获并优雅处理;构建场景(app-tools)则直接process.exit(1)。
6. 拷贝非代码资产
const copyFiles = async (from, to, appDirectory) => { if (await fs.pathExists(from)) { const relativePath = path.relative(appDirectory, from); const targetDir = path.join(to, relativePath); await fs.copy(from, targetDir, { filter: src => !['.ts', '.tsx', '.js', '.jsx'].includes(path.extname(src)) && !src.endsWith('tsconfig.json'), }); } }; for (const source of sourceDirs) { await copyFiles(source, distDir, appDirectory); }编译完成后,源目录中的非代码文件(.json、.mjs、.ejs、图片、样式等)会按相对路径原样拷贝到产物目录,但tsconfig.json 被显式排除——CHANGELOG2.60.1的3a973a2(不应该包含 tsconfig.json)正是这一过滤;而2.60.4的75ff77f(应该包含除了 tsconfig.json 以外的文件)则修正了过滤条件,确保其它非代码文件不被误伤。
四、原生 ESM 产物支持:moduleType 与说明符重写
CHANGELOG 中与 ESM 相关的演进是一条清晰的主线:
| 版本 | 变更 | 内容 |
|---|---|---|
3.8.0 | b3a0709 | 透传moduleType、支持.tsx/.jsx入口解析、编译 JSX,使自定义 Server 产物在原生 ESM 下可运行 |
3.1.0 | 0fc8c26 | deploy命令支持生成 ESM 产物 |
3.8.2 | 8edf91a | 修复 ESM 场景下.d.ts中别名声明在消费方不可解析 |
对应到源码,moduleType === 'module'时:
toEsmOutputPath会把.ts/.tsx/.js/.jsx扩展名统一映射为.js(.json/.mjs/.cjs等保持原样),使产物中的相对说明符指向 Node 实际加载的文件;resolveRelativeEsmSpecifier负责把./x、../x这类未命中的相对导入解析为源码路径后再转换为.js说明符;- 前文提到的 JSX 强制
ReactJSX改写,也是为了保证 ESM 产物能被 Node 直接执行; .d.ts中同样改写为.js后缀,避免 JS 产物与声明文件说明符不一致而触发 TS2835 错误。
从 modulePath.ts 的findSourceEntry/findMatchedSourcePath可以看到配套的“.js说明符指向.ts源码”的解析规则:目录导入按.ts → .tsx → .js → .jsx顺序探测,并回退到index.*。运行时 loader 与编译期 transformer 遵循同一套匹配规则,保证 alias 行为一致。
另外,getNotAliasedPath中还有一处体现“包优先”的取舍(对应 CHANGELOG1.18.1的fb02c81:编译时别名优先级高于包名):若别名解析出的结果是裸包名且能被require.resolve解析为已安装的包,则保留包导入而不是强转为相对路径,从而让 node_modules 中的包优先于同名根目录文件(注释引用了 nest-cli 的同类问题处理经验)。
五、消费方集成:app-tools 与 plugin-bff 的实战接入
该包不面向终端用户直接调用,而是被 Modern.js 的构建插件内部消费。仓库中能找到两处真实调用。
1. app-tools:构建完成后编译 server/shared 目录
packages/solutions/app-tools/src/plugins/serverBuild.ts 在onAfterBuild钩子中触发编译:
api.onAfterBuild(async () => { const { appDirectory, distDirectory, metaName, moduleType } = api.getAppContext(); // ...检查 cache 与 config const distDir = path.resolve(distDirectory); const serverDir = path.resolve(appDirectory, SERVER_DIR); const sharedDir = path.resolve(appDirectory, SHARED_DIR); const tsconfigPath = resolveServerTsconfig(appDirectory, modernConfig?.server?.tsconfigPath); const sourceDirs = []; if (fs.existsSync(serverDir)) { sourceDirs.push(serverDir); // 仅当 server 目录存在时才编译 shared 目录 if (fs.existsSync(sharedDir)) sourceDirs.push(sharedDir); } if (sourceDirs.length > 0) { await compile(appDirectory, { server, alias }, { sourceDirs, distDir, tsconfigPath, moduleType }); } });这里可以看到:server/与shared/是编译对象;server.compiler: 'typescript'与source.alias从归一化配置中取出传入compile;moduleType来自 app context,决定产物格式。
2. plugin-bff:开发期编译 api/shared 目录
packages/cli/plugin-bff/src/cli.ts 的compileApi在 BFF 开发/构建流程中调用:
const combinedAlias = ([] as unknown[]) .concat(alias ?? []) .concat(resolveAlias ?? []) as ConfigChain<Alias>; await compile(appDirectory, { server, alias: combinedAlias }, { sourceDirs, // apiDir + sharedDir distDir, tsconfigPath, moduleType, throwErrorInsteadOfExit: true, // BFF 场景:失败抛错而非退出进程 });与 app-tools 相比,BFF 场景有两个差异:一是把source.alias与resolve.alias合并后一起传入(对应 CHANGELOG2.0.0-beta.6变更14b712da84的“各包一致 alias 类型”);二是throwErrorInsteadOfExit: true,便于开发期把编译错误转为可捕获的异常。这也呼应了 CHANGELOG1.21.0的8f3674a(减少 dev-server 编译所需参数)——BFF 编译的参数集合是经过刻意精简的。
六、版本演进速览:CHANGELOG 中的关键变更清单
@modern-js/server-utils从1.0.0-rc.3一路演进到3.9.3(中间经历过 v1、v2、v3 三个大版本,2.0.0起以Major Changes标记 v2)。除大量依赖同步更新外,以下是有实质技术含义的变更汇总(均可在本文前述源码中对应到实现):
| 版本 | Commit | 类型 | 技术要点 |
|---|---|---|---|
3.9.1 | bc59ee1 | fix | 尊重 ts-node 配置指定的 TypeScript 编译器 |
3.8.2 | 8edf91a | fix | 发布后的 crossProject BFF 客户端声明在消费方可解析 |
3.8.0 | b3a0709 | fix | 原生 ESM 下自定义 Server 产物可运行:透传 moduleType、解析.tsx/.jsx入口、编译 JSX |
3.1.0 | 0fc8c26 | feat | deploy命令支持生成 ESM 产物 |
2.60.4 | 75ff77f | fix | 拷贝非代码文件时应包含除 tsconfig.json 以外的文件 |
2.60.1 | 3a973a2 | fix | 编译产物不应包含 tsconfig.json |
2.26.0 | 1fb5804 | fix | 按需从直接依赖中加载 typescript |
2.22.1 | 4be1da5 | chore | 合并 babel-preset-lib 到 server-utils,移除无用代码 |
2.20.0 | 6b9d90a | chore | 移除@babel/runtime,改用@swc/helpers并开启 externalHelpers |
2.14.0 | 8a3c693 | chore | 使用 babel 编译时不再替换 globalVars |
2.12.0 | 05493a7 | feat | 支持 ts 编译器的noEmitOnError选项 |
2.0.0-beta.6 | 14b712da84 | fix | 各包统一 alias 类型定义与默认值 |
2.0.0-beta.6 | c2bb0f1745 | chore | 使用预打包的 tsconfig-paths 依赖 |
1.21.0 | 8f3674a | fix | 减少 dev-server 编译所需参数 |
1.18.1 | fb02c81 | fix | 编译时别名优先级高于包名 |
1.18.0 | 2b7406d | feat | 服务端改用 typescript 作为 TypeScript 编译器(替代 babel) |
1.18.0 | 0a4d622 | fix | 动态导入路径替换为正确的别名 |
1.18.0 | 60a2e3a | feat | 为 server 添加编译器选项 |
1.16.0 | 1100dd58c | minor | 支持 React 18 |
1.2.11 | b7a1cea52 | feat | 支持 tools.babel 中的 utils |
从这张表可以清晰看到三条演进主线:编译器从 Babel 切换为 TypeScript(1.18.0 → 2.22.1 合并 babel-preset-lib → 2.20.0 换 swc helpers)、原生 ESM 产物能力逐步补齐(3.1.0 → 3.8.0 → 3.8.2)、别名与声明解析的健壮性持续加固(1.18.1 → 2.0.0-beta.6 → 3.8.2)。依赖关系方面,@modern-js/utils自始至终是唯一的核心运行时依赖(部分旧版本还声明了@modern-js/babel-preset-lib、@modern-js/babel-compiler、@modern-js/plugin,随 Babel 方案退场而移除)。
七、小结
@modern-js/server-utils用极简的 API 封装了服务端 TypeScript 编译的完整工程细节:从 tsconfig 驱动的增量编译、JSX 改写,到 tsconfig paths / alias 在 JS 与.d.ts双输出上的统一重写,再到原生 ESM 说明符转换与资产过滤拷贝。它在app-tools(构建期编译 server/shared)与plugin-bff(BFF 开发/构建期编译 api/shared)两处被深度集成,是 Modern.js SSR、BFF 与部署能力背后的公共编译底座。若想深入理解它的实现细节,可继续阅读 src/compilers/typescript/index.ts、src/compilers/typescript/tsconfigPathsPlugin.ts 以及配套的 @modern-js/utils 别名模块 与 modulePath 解析工具。
- 前端
- 后端
- Web框架
【免费下载链接】modern.js
A progressive web framework based on React and Rsbuild.
相关推荐
Modern.js 服务端演进史:从 `@modern-js/server` 3.x 到 1.0 的 dev-server、Mock、热更新与 BFF 能力全景解析
Modern.js 服务端演进史:从 @modern js/server 3.x 到 1.0 的 dev server、Mock、热更新与 BFF 能力全景解析
前端后端Web框架radare2 在 Haiku 上的编译与原生内核调试支持
radare2 在 Haiku 上的编译与原生内核调试支持 导读 Haiku 是一个类 UNIX 的开源操作系统,其 32 位 gcc2 hybrid 镜像与
逆向工程网络安全开源AI数字人Duix.Avatar:从10秒克隆到出片的完整教程
开源AI数字人Duix.Avatar:从10秒克隆到出片的完整教程 想给新品录一条30秒口播视频,却要排队约主播、订棚?Duix.Avatar是一款全离线运行的
人工智能AI 应用数字人媒体生成桌面应用
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考