将一个 6 万行 TypeScript (NodeJS) 代码库迁移到 ESM 后,测试速度提高了 4 倍 (1/2)。
引言
经过三次尝试,我们成功地将所有现有的 Node.js 代码从 CJS 迁移到原生 ESM,单元测试的 CI 时间显著减少。
拉取请求 重构:使用 ESM 测试:使用原生 ESM
在开始之前,我想先展示一下现状,以便更好地理解。您在代码仓库设置或工具链方面可能会有不同的选择,但核心步骤和概念应该是相同的:
- 一个包含前端和后端项目/包的 TypeScript 单体仓库。
- TypeScript 代码总量约 6 万行(包括前端)。
import在 TypeScript 中使用。- 使用PNPM进行工作区管理。
- 用于
tsc编译 Node.js,以及使用 Parcel 打包前端项目。 - 使用 Jest
ts-jest进行单元测试。 - 使用包
module-alias进行内部路径别名设置。
顺便一提,我们的项目Logto是一个开源的身份验证解决方案。
为什么选择ESM?
当我们注意到越来越多的 NPM 包是“仅限 ESM 格式”时,我们因此关闭了大量的 PR(Parcel/Jest 的除外):
抛开ESM与CJS之间的争论不谈,我们发现ESM确实具有以下几个优势:
没有类似其他语言的代码转译
尤其对于 TypeScript 而言:与编译后的版本相比,ESM 更容易阅读、编辑和调试。
给定一个简单的 TypeScript 代码片段:
import path from 'path';
const replaceFile = (filePath: string, filename: string) =>
path.resolve(path.dirname(filePath), filename);
结果如下tsc:
// CJS
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
const path_1 = __importDefault(require("path"));
const replaceFile = (filePath, filename) => path_1.default.resolve(path_1.default.dirname(filePath), filename);
// ESM
import path from 'path';
const replaceFile = (filePath, filename) => path.resolve(path.dirname(filePath), filename);
顶级等待
这是我们最喜欢的功能之一。终于无需将顶层异步表达式封装到函数中并void执行它了。
// CJS
(async () => {
await doSomething();
})();
// ESM
await doSomething();
兼容性
- 虽然 ESM 可以轻松加载 CJS 模块,但 CJS 加载 ESM 却比较困难。主要原因是 CJS
require()是同步的,而 ESM是异步的。在 CJS 中,如果没有顶层 await,import加载ESM 将会非常麻烦。await import() - 此外,CJS 仅支持 Node.js,这意味着通用软件包需要为浏览器用户编译另一个版本。(我们知道有转译器,但是,唉)
坚持使用标准配置
ESM 是JavaScript 的模块标准。TypeScript 默认也使用 ESM 语法。
不可更改的
这更像是一把双刃剑。ESM凭借其不可变的设计可以显著提高模块安全性(参见这篇文章),但它也给测试模拟带来了一些不便。
测试时间大幅缩短
是的,我们说的就是 Jest。虽然 Jest 仍然坚持使用 CJS,并且只对 ESM 提供实验性支持,ts-jest但我们已经用了一段时间了。不过很明显,即使对于 M1 Pro MacBook 来说,运行 Jest 也是一项挑战。运行单元测试时,风扇会发出明显的噪音,核心温度也会持续升高。
将所有单元测试迁移到 ESM 后,我的 MacBook 又恢复了静音和专业性能!以下是 CI 时间对比(使用 GitHub Actions 中的默认机器配置):
GitHub Actions 的执行时间不太稳定,平均而言速度快 3 到 4 倍。
代码迁移
对于官方文档,您可能会发现Modules: ECMAScript modules和ECMAScript Modules in Node.js很有帮助。
基本配置
我们先从tsconfig.json两点开始:
- 设置
compilerOptions.moduleResolution为 truenodenext以告知 TSC 使用“尖端”Node.js 模块解析策略。 - 设置
compilerOptions.module为esnext确保输出也保留 ESM。
对于 Node.js,请将其添加"type": "module"到您的package.json配置中,使其将该包视为 ESM。
路径别名
我们尝试使用别名进行映射@/,./src/但这module-alias在 ESM 中不起作用。好消息是,Node.js 提供了一种名为子路径导入imports的原生支持,只需在以下位置定义字段即可package.json:
{
"imports": {
"#src/*": "./build/*" // Point to the build directory, not source
}
}
备注imports只能以“.”开头#,并且必须有名称。因此,我们用它#src/来替换原有的备注@/。同时,也tsconfig.json请相应地更新您的备注:
{
"compilerOptions": {
"paths": {
"#src/*": ["src/*"]
}
}
}
我们还需要将源代码中@/的所有 `<input type="input">` 替换掉。#src/
文件扩展名
此时,TSC 和 Node.js 都开始在 ESM 模式下工作,但很可能会出现一些“找不到模块……”的错误。因为现有的导入约定简洁而优雅:
import x from './foo';
// It can be
import x from './foo.js';
// Or
import x from './foo/index.js';
扩展名.js可以替换为.jsx,,.ts和.tsx,等等。
然而,这在原生 ESM 中是不可接受的。您必须显式地写出带有扩展名的完整路径,例如import x from './foo/index.js';。
那么在 TypeScript 中应该怎么做呢?我们的第一个想法是将文件扩展名更改为 .dist .ts,这样路径就变成了 .dist './foo/index.ts',因为这是我们在源目录中可以找到的文件,对吧?
遗憾的是,TypeScript 团队秉持着“TS 是 JS 的超集”和“TS 不会重写路径”之类的原则。(参见自 2017 年以来的#13422 、#16577 和 #42151)所以.ts在这里行不通,最终导致了这样的结果:使用.js. :-)
它的确有效,而且我认为我没有资格评判这个解决方案。所以,让我们来看看我们为了添加扩展程序而采取的操作:
由于大多数软件包
node_modules不受此影响(至少对于主入口而言),我们可以在此过程中省略它们。
- 将所有内容
from '\.'(正则表达式)替换为from './index'. - 将所有内容
from '\./(.*)'(正则表达式)替换为from './$1.js'. - 如果您有路径别名,请使用步骤 2 中的类似技术为其添加扩展名。
- 尝试编译项目。对于省略了路径的路径,可能会出现一些错误
/index,例如,./foo实际指向的路径。这些路径会像步骤 2 中./foo/index.js那样被更新为错误的路径。./foo.js - 请再次尝试编译,这次应该不会再出现错误了。
杂项
看到输出文件几乎与输入文件相同固然令人兴奋,但运行项目时,Node.js 可能会对某些特殊变量报错,例如:
ReferenceError: __dirname is not defined in ES module scope
别放弃,我们快成功了!阅读Node.js官方文档“ES模块和CommonJS的区别”来了解相关知识,然后你就可以继续了。
概要
我们已成功将 Node.js 包从 CJS 迁移到 ESM。现在入口点文件可以在执行后运行tsc。
然而,单元测试中仍然存在一些问题:
- 截至今日(2022年12月26日),Jest 仅对 ESM 提供实验性支持。
- ESM 是不可变的,因此
jest.mock()无法正常工作,jest.spyOn()也无法用于一级变量(export const ...)。这同样适用于其他测试库,例如 Sinon。 - 你可能会找到一些用于模拟 ESM 的库,但几乎所有这些库都会创建原始模块的“新副本”,这意味着如果你想导入依赖于模块 B 的模块 A,你必须在模拟 B 之后导入 A 才能使其正常工作。
解决方案是提升 CI 时间的关键。由于本文已经涵盖了很多内容,我们将在下一章中进行讲解:
将一个 6 万行 TypeScript (NodeJS) 代码库迁移到 ESM 后,测试速度提高了 4 倍 (2/2)
您可以在这里找到ts-with-node-esm本系列关键结果的代码仓库:
https://github.com/logto-io/ts-with-node-esm
感谢阅读,如有任何疑问,欢迎留言!
本系列文章基于我们使用Logto(一款开源身份验证解决方案)的经验。
文章来源:https://dev.to/logto/migrate-a-60k-loc-typescript-nodejs-repo-to-esm-and-testing-become-4x-faster-12-5f82

