Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Jest 与 纯 ESM 包的集成 #133

Open
lmk123 opened this issue Jul 31, 2024 · 0 comments
Open

Jest 与 纯 ESM 包的集成 #133

lmk123 opened this issue Jul 31, 2024 · 0 comments

Comments

@lmk123
Copy link
Owner

lmk123 commented Jul 31, 2024

划词翻译的源码是用 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.jsindex.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 的所有步骤包括:

  1. 给所有导入路径补上 .js 后缀
  2. 将改为 ES Module 后的 package 包含进 jest.config.jstransformIgnorePatterns 中,确保即使它们在 node_modules 下,也会被 Babel 转换成 CommonJS
  3. 删除 package.json 的 exports 字段,或者将 import 改成 default
  4. 使用 ts-jest-resolver 解决 Jest 报错“找不到 xxx.js 文件”的问题
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

1 participant