ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Modern.js 服务端编译核心 @modern-js/server-utils:TypeScript 编译、别名解析与原生 ESM 支持的源码级解析

Modern.js 服务端编译核心 @modern-js/server-utils:TypeScript 编译、别名解析与原生 ESM 支持的源码级解析
  • 前端
  • 后端
  • Web框架

【免费下载链接】modern.js

A progressive web framework based on React and Rsbuild.

项目地址:https://gitcode.com/gh_mirrors/mo/modern.js
点击查看免费下载

本文围绕 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>;

关键参数说明:

参数类型说明
appDirectorystring应用根目录,用于定位 tsconfig、计算rootDir与相对路径
modernConfig.aliasSourceNormalizedConfig['alias']来自modern.config.ts中source.alias的别名配置
modernConfig.server.compiler'typescript'服务端编译器开关,对应 server 配置类型
sourceDirsstring[]待编译源目录,如server/、api/、shared/
distDirstring产物目录(如dist/)
tsconfigPathstring服务端 tsconfig 路径
moduleType'module' \| 'commonjs'决定产物是原生 ESM 还是 CJS
throwErrorInsteadOfExitboolean为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.0b3a0709透传moduleType、支持.tsx/.jsx入口解析、编译 JSX,使自定义 Server 产物在原生 ESM 下可运行
3.1.00fc8c26deploy命令支持生成 ESM 产物
3.8.28edf91a修复 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.1bc59ee1fix尊重 ts-node 配置指定的 TypeScript 编译器
3.8.28edf91afix发布后的 crossProject BFF 客户端声明在消费方可解析
3.8.0b3a0709fix原生 ESM 下自定义 Server 产物可运行:透传 moduleType、解析.tsx/.jsx入口、编译 JSX
3.1.00fc8c26featdeploy命令支持生成 ESM 产物
2.60.475ff77ffix拷贝非代码文件时应包含除 tsconfig.json 以外的文件
2.60.13a973a2fix编译产物不应包含 tsconfig.json
2.26.01fb5804fix按需从直接依赖中加载 typescript
2.22.14be1da5chore合并 babel-preset-lib 到 server-utils,移除无用代码
2.20.06b9d90achore移除@babel/runtime,改用@swc/helpers并开启 externalHelpers
2.14.08a3c693chore使用 babel 编译时不再替换 globalVars
2.12.005493a7feat支持 ts 编译器的noEmitOnError选项
2.0.0-beta.614b712da84fix各包统一 alias 类型定义与默认值
2.0.0-beta.6c2bb0f1745chore使用预打包的 tsconfig-paths 依赖
1.21.08f3674afix减少 dev-server 编译所需参数
1.18.1fb02c81fix编译时别名优先级高于包名
1.18.02b7406dfeat服务端改用 typescript 作为 TypeScript 编译器(替代 babel)
1.18.00a4d622fix动态导入路径替换为正确的别名
1.18.060a2e3afeat为 server 添加编译器选项
1.16.01100dd58cminor支持 React 18
1.2.11b7a1cea52feat支持 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.

项目地址:https://gitcode.com/gh_mirrors/mo/modern.js
点击查看免费下载

相关推荐

上一篇:MaxViT Nano图像分类模型:15.5M参数的轻量级视觉Transformer完全指南
下一篇:Talebook私人模式配置指南:打造专属小圈子分享平台

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进