划词翻译的源码是用 monorepo 的形式组织的,目录结构类似于这样:
hcfy/
├── node_modules/
├── apps/
│ └── browser-extension/
├── packages/
│ ├── utilsA/
│ │ ├── dist/
│ │ │ ├── index.js
│ │ │ ├── index.mjs
│ │ │ └── index.d.ts
│ │ ├── src/
│ │ │ ├── index.ts
│ │ │ └── index.test.ts
│ │ ├── package.json
│ │ └── tsconfig.json
│ └── componentsB/
│ ├── dist/
│ │ ├── index.js
│ │ ├── index.mjs
│ │ └── index.d.ts
│ ├── src/
│ │ ├── index.tsx
│ │ └── index.test.tsx
│ ├── package.json
│ └── tsconfig.json
├── README.md
├── tsconfig.base.json
└── package.json
对于 packages 下的各种工具包,我一开始同时输出了 CommonJS 和 ES Module 两种格式的代码,你可以在上面的目录结构中看到,dist 文件夹是同时存在 index.js 和 index.mjs 的。
当初之所以这么做,是因为很多工具或 node_modules 包对纯 ES Module 的支持并不是很好,但是随着时间的推移,很多包已经变成了纯 ES Module,所以当我在 CommonJS 的环境下使用这些包时,就会出现一些问题。
而且,为了同时输出两种格式的代码,package.json 看上去会很复杂,就像下面这样:
{
"name": "@hcfy/utilsA",
"scripts": {
"clean": "rm -rf dist",
"build:mjs": "tsc -p tsconfig.build.json -m ESNext --moduleResolution Bundler -d && js-to-mjs dist",
"build:cjs": "tsc -p tsconfig.build.json -m NodeNext",
"build": "npm run build:mjs && npm run build:cjs",
"dev:watch": "nodemon --watch src -e ts --exec \"npm run build\""
},
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.mjs",
"require": "./dist/index.js"
}
},
"devDependencies": {
"@hcfy/js-to-mjs": "^1.1.0",
"nodemon": "^3.1.4",
"typescript": "^5.5.4"
}
}
从上面的代码中可以看到,我在输出代码时:
- 分别输出了 CommonJS 和 ES Module 两种格式的代码
- 自行开发了
@hcfy/js-to-mjs这个工具来给 tsc 输出的导入路径补上.js后缀。 - 使用
nodemon这个工具来检测文件的变化,然后重新编译代码
总之就是很繁琐。
如果改成纯 ES Module 的话,编译流程就会变得非常简单:
{
"name": "@hcfy/utilsA",
"scripts": {
"clean": "rm -rf dist",
"build": "tsc -p tsconfig.build.json -d",
"dev": "tsc -d",
"dev:watch": "tsc -d -w"
},
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
},
"devDependencies": {
"typescript": "^5.5.4"
}
}
说干就干,我开始把所有的包都按照上面的形式改成了纯 ES Module,接下来就是验证是否成功的时刻。
第一个问题:文件后缀
尝试 build 的时候报错了,不过很容易就发现了错误的原因。
在 CommonJS 的环境下,我们可以省略文件的后缀,比如 import foo from './abc',但是在 ES Module 的环境下,我们必须要写全文件的后缀,比如 import foo from './abc.js'。
所以,我又给源码里的所有导入路径补上了 .js 后缀,然后再次 build,这次就成功了。
第二个问题:Jest 报错 Can't find module './abc.js' in 'src/index.ts'
build 虽然成功了,但是运行 Jest 时报了上面的错。
原因是,Jest 会尝试去找 ./abc.js,但是我的代码是用 Typescript 写的,所以这个文件实际上是 ./abc.ts。
第一次尝试:将代码里的导入路径后缀由 .js 改成 .ts
既然 Jest 找不到 .js,那就把后缀改成真正的 .ts,这样 Jest 就能找到了。
然而在实际修改之后,build 的时候 TypeScript 报错了,因为它只允许在 --noEmit 或者 emitDeclarationOnly 模式下使用 .ts 后缀。
第二次尝试:使用 ts-jest
问了 ChatGPT,又谷歌了一圈之后,几乎都推荐使用 ts-jest 这个工具。
我最开始也是用的 ts-jest 的,但是随着代码库越来越大,文件之间的依赖关系越来越复杂,我发现 ts-jest 越来越慢(见之前的文章 M1 Air 运行 jest 时卡住 / 内存泄漏的问题解决方案),所以后来就换成了 @babel/preset-typescript。
我决定先试试看 ts-jest 能不能解决这个问题,于是根据 TypeScript Jest imports with .js extension cause error: Cannot find module | StackOverflow,我在 jest.config.js 里先后进行了如下尝试:
- 将
preset改成ts-jest - 将
transform的配置改成ts-jest,并配置useESM: true - 添加
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'] - 添加
extensionsToTreatAsEsm: ['.ts']
然而,这些尝试都没有成功,Jest 会报别的错误导致测试不通过。
第三次尝试:使用 jest-ts-webcompat-resolver
在刚才的 StackOverflow 问题中,有人提到了一个叫 jest-ts-webcompat-resolver 的工具,我决定试试。
它的源码非常简单,原理就是如果找不到 .js 文件,就尝试找 .ts 文件。于是,我在 jest.config.js 中添加了如下配置:
module.exports = {
// ...
resolver: 'jest-ts-webcompat-resolver',
// 将 @hcfy/* 的所有包都用 babel 转成 CommonJS
transformIgnorePatterns: [
'node_modules/(?!(@hcfy)/)',
],
}
然后,我运行了 Jest,果然报错的测试数量减少了。然而,还是有一些测试报错了,所以还需要继续解决。
package.json 中的 exports 字段
在剩下的报错当中,我发现 Can't find module './abc.js' in 'src/index.ts' 这类报错已经都没有了,但是 Jest 会报另外一种错误:
Can't find module '@hcfy/utilsA' in 'src/index.ts'
Can't find module '@hcfy/utilsD' in 'src/index.ts'
Can't find module '@hcfy/utilsF' in 'src/index.ts'
换句话说,找不到对应的文件的问题已经解决了,现在剩下找不到 packages 下的模块了。
我注意到,并不是所有的包都会报错,只有一部分包会报错,而且这些包我都在 package.json 中使用了 exports 字段。
根据以往的经验,问题肯定出在 exports 字段上。以前我就遇到过跟 exports 字段有关的问题。
大概情况就是我写了一个 package,这个 package 包含一些 React 组件,其中用到了 tailwindcss,在使用这个 package 时,需要让用户自己配置 tailwindcss,包含 node_modules 下的这个包。
但是我在实际使用时发现,即使我正确配置了 tailwindcss,webpack 也还是会报错,说找不到
..../node_modules/package/taildcss?post-loader=&...(一条非常长的带有 loader 的文件路径),我百思不得其解,最后尝试把 exports 字段删掉,就一切正常了。然后我去查了一下
exports字段的文档,发现这个问题的原因就出在,声明exports字段后,只有在exports字段中声明的文件才会被导出,其他文件不会被导出,而上面那条长长的文件路径应该就是由 tailwindcss 自动生成的一个编译后的 css 文件路径,而exports字段当然没法包含这个文件,于是 webpack 报错了。
回到 Jest 这里,我猜测它应该是内部使用 Babel 把我们的 js 文件转换成了 CommonJS,然后这个转换后的文件没有(也不能,因为我不知道具体路径)被 exports 字段导出,所以报了错。有了解决思路,我就把其中一个报错的包的 exports 字段删掉,然后再次运行 Jest,果然这个包就不报错了。
但是,有一些包是必须要有 exports 字段的,因为我确实想要导出多个不同的端点,比如 @hcfy/utilsA/for-react、@hcfy/utilsA/for-vue 等。不到万不得已,我不想把这一个包拆成多个包,这样会增加维护成本。
我看了一下,目前我的 exports 字段是这样的:
{
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
}
}
我又猜测,会不会是 Jest 用 Babel 转换后的文件已经是 CommonJS 了,但我声明的 exports 字段用的是 import 而不是 require,所以导致了报错。
于是我把 import 改成了 default:
{
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
}
再次运行 Jest,果然相关的报错都没了,但还是有一行代码报错了。
最后一个错误
最后这一个错误是这样的:
Cannot find module './def.js' from './src/index.tsx'
报错信息里的 def.js 实际上是 def.tsx,我一下子就猜到,可能是 jest-ts-webcompat-resolver 尝试查找了 .ts 文件,但是没有查找 .tsx 文件。
看了源码之后,果然如此:
源码来源:https://github.com/AyogoHealth/jest-ts-webcompat-resolver/blob/v1.0.0/index.js#L44-L48
try {
return defaultResolver(request, options);
} catch (e) {
return defaultResolver(request.replace(/\.js$/, '.ts'), options);
}
所以解决办法也就有了。我把 jest-ts-webcompat-resolver 的代码复制下来,然后把上面这部分代码改成:
try {
return defaultResolver(request, options)
} catch (e) {
try {
return defaultResolver(request.replace(/\.js$/, '.ts'), options)
} catch (e) {
// 再接着查找 tsx 文件
return defaultResolver(request.replace(/\.js$/, '.tsx'), options)
}
}
然后将 jest.config.js 里的 resolver 指向我复制的这个文件,再次运行 Jest,终于,所有的测试都通过了。
使用 ts-jest-resolver 替代 jest-ts-webcompat-resolver
上面的方法虽然解决了问题,但是需要自己维护一个文件,不太方便。所以我又去找了一下,发现了 ts-jest-resolver 这个工具,它的原理跟 jest-ts-webcompat-resolver 一样,但是它考虑到了 .tsx 文件,且虽然名字里有 ts-jest,但是它并不依赖 ts-jest,所以我最终换成了这个工具。
总结
于是,把一个 CommonJS / ES Module 混合的 package 改成纯 ES Module 的所有步骤包括:
- 给所有导入路径补上
.js后缀 - 将改为 ES Module 后的 package 包含进
jest.config.js的transformIgnorePatterns中,确保即使它们在 node_modules 下,也会被 Babel 转换成 CommonJS - 删除 package.json 的
exports字段,或者将import改成default - 使用
ts-jest-resolver解决 Jest 报错“找不到xxx.js文件”的问题