lmk123 / blog

个人技术博客,博文写在 Issues 里。
https://github.com/lmk123/blog/issues
623 stars 35 forks source link

将 NPM 包改为直接使用 tsc 的踩坑记录 #124

Open lmk123 opened 1 year ago

lmk123 commented 1 year ago

更新:TypeScript 5.0 给 moduleResolution 添加了一个新的选项 bundler,可以解决文中提到的需要给输出的文件加上后缀之类的问题。

我个人还是倾向于严格遵守 ES 标准,但这个新选项可以用于快速绕过 ES 标准的要求,很适合那些想要暂时专注于开发业务代码、不想花时间在兼容 ES 标准上的情况。

相关说明:https://devblogs.microsoft.com/typescript/announcing-typescript-5-0/#moduleresolution-bundler


我准备将我写的一些 NPM 包改为直接用 tsc 来输出,原因见 开发 NPM 包时可能不需要将代码打包进一个文件里

对 build 命令进行改造

先介绍一下我要改造的第一个 NPM 包,它的 package.json 在改造前是这样的:

{
"name": "my-npm-module",
"exports": {
    "types": "./dist/types/index.d.ts",
    "import": "./dist/bundle.mjs",
    "require": "./dist/bundle.cjs"
  },
  "scripts": {
    "test": "jest",
    "build": "rollup -c"
  }
}

为了让 typescript 支持 package.json 里的 exports,所以我在 tsconfig.json 里配置了 "moduleResolution": "NodeNext"(原本是 Node);再加上我在 tsconfig.json 里配置了 "module": "ESNext",所以 typescript 输出的文件都会是 es module,所以我提前在 package.json 里配置了 "type": "module"

我先是改造了 build 命令,改为了 "build": "tsc",试着运行了下,报了第一个错,是关于文件后缀的。

我在代码中使用了下面的 import:

import utils from './utils' // <- 没有 .js 后缀

前面提到,我为了让 typescript 支持 package.json 的 exports,所以设置了 "moduleResolution": "NodeNext",而这就要求导入模块时带上文件后缀。

加上 '.js' 后缀(只能是 '.js',其它后缀比如 '.ts' 都会报错)之后,build 命令就能成功运行了。运行之后,代码输出到了 dist 文件夹,且都是单个的文件,没有打包成一个文件。

然后,package.json 就成了这样:

{
  "name": "my-npm-module",
  "type": "module",
  "exports": "./dist/index.js",
  "scripts": {
    "test": "jest",
    "build": "tsc"
  }
}

几点变化:

遇到的第一个问题:给 import 带上 .js 后缀后,Jest 报 Can't find module 错误

目前为止看起来不错,但运行 jest 时出了问题:

Can't find module './utils.js' in 'src/index.ts'

我还特意试了下,去掉 .js 后缀就能成功运行,加上就不行了。

出错的原因在于,虽然代码里写的是 utils.js,但文件实际上是 utils.ts

第一个解决方案:用 jest 的 moduleNameMapper

谷歌了一下,发现给 jest config 加上 moduleNameMap 可以解决这个问题(解决方案来自这里):

moduleNameMapper: {
    '\\./utils\\.js': './utils', // 不能带 .ts 后缀,我也不知道什么原因
}

但如果有很多个文件,总不能一个个加,所以我尝试用正则来匹配:

moduleNameMapper: {
    '(.+)\\.js': '$1'
}

但这样做又报错了,因为影响到了 node_modules 里的文件,但是我试了很多次也没法做到只匹配到我自己的文件。

第二个解决方案:先 build,再运行 jest

先运行 build 把 index.ts 和 index.test.ts 都输出成 js 文件,然后再运行 jest 跑 index.test.js 就没问题了。

另外还要给 jest config 添加 testMatch: ['<rootDir>/dist/**/*.test.js']

感觉有点繁琐。

第三个解决方案:"moduleResolution": "NodeNext" 改为 "moduleResolution": "Node"

这样写代码时就不用带后缀了,但这样的话,输出的代码里也是没带后缀的,如果直接在 Node.js 里 import 会报错,因为 import 是要求带后缀的。在 Webpack 里也是会报错的,除非设置 fullySpecified: false

但还是应该按照标准来。

第四个解决方案:安装一个 npm 包

换了一下谷歌搜索关键词为 jest typescript with extension,找到了一个相关的 issue:https://github.com/kulshekhar/ts-jest/issues/1057

但其实这个问题跟 ts-jest 没关系,这是 jest 的 jest-resolve 的问题,然后 issue 里有人开发了一个 npm 包来解决这个问题,见 https://github.com/VitorLuizC/ts-jest-resolver

第五个解决方案:还是 moduleNameMapper

还是在刚才的 issue 里找到的解决方案。如果你不想单独安装一个 npm 包,可以添加 moduleNameMapper

moduleNameMapper: {
  '^(\\.{1,2}/.*)\\.js$': '$1',
}

来源 https://github.com/swc-project/jest/issues/64#issuecomment-1029753225

但是我个人比较喜欢 npm 包,因为这应该是 resolver 负责解决的问题。moduleNameMapper 是被设计用来把一些对 css、image 之类的文件的 import 转为 js 模块的。

小结

搜索关键词很重要 :joy:

遇到的第二个问题:tsc 把 *.test.ts 也输出了

写在 src 目录下的 index.test.ts 也被输出到了 dist/index.test.js,这会导致 jest 同时运行了 dist/index.test.js 和 src/index.test.ts。就算不是因为这个,输出文件夹内也不应该包含测试文件。

这个问题只能单独创建一个 build 用的 tsconfig 把测试文件排除掉了:

// tsconfig.build.json
{
  extends: './tsconfig.json',
  exclude: ['src/**/*.test.ts']
}

然后把 build 命令改为 tsc -p tsconfig.build.json

对消费端(即使用我们的 NPM 包的项目)的改造

前面都是对 NPM 包本身进行改造的,但是我们的 NPM 包最终会用在别的项目(下文简称“消费端”)里。在这么改造之后,消费端也需要做一些调整。

以划词翻译为例。

划词翻译的项目使用了 Webpack + babel-loader 来编译,tsconfig.json 里也设置了 "moduleResolution": "NodeNext"。当 build 的时候,typescript 会报第一个错误:

src/path/to/file.ts:1:31 - error TS1479: The current file is a CommonJS module whose imports will produce 'require' calls; however, the referenced file is an ECMAScript module and cannot be imported with 'require'. Consider writing a dynamic 'import("my-npm-module")' call instead.
  To convert this file to an ECMAScript module, change its file extension to '.mts', or add the field `"type": "module"` to 'package.json'.

我没有在划词翻译的 package.json 里设置 "type": "module",所以项目里的 .ts 文件默认会被当作 CommonJS,而 CommonJS 里的 require() 方法是不能用来导入 es module 的。

但是,作为一个 Webpack 项目,我不需要让 typescript 认为我会输出成 es module,因为它在我的 Webpack 项目中只负责进行类型检查,真正负责文件输出的是 Webpack,负责处理 es module 的也是 Webpack,即使我在 ts 文件里直接 import es module,Webpack 也是能处理的。

所以第一步我首先尝试将 tsconfig.json 里的 moduleResolution 从 'NodeNext' 改为了 'Node'。

注意:这么改之后之所以不会再有 TS1479 错误了,是因为 moduleResolution

这么改了之后就不会报 TS1479 错误了,因为 moduleResolution: Node 会忽略(或者用“不支持”更准确一些) package.json 里的 typeexports 字段,所以 npm 报默认就会被当作是 CommonJS,但也正因为它忽略了 exports,就有了第二个报错:

src/path/to/file.ts:1:31 - error TS2307: Cannot find module 'my-npm-module' or its corresponding type declarations.

前面我提到,我改造完成后的 npm 包 package.json 大概长这样:

{
  "name": "my-npm-module",
  "type": "module",
  "exports": "./dist/index.js"
}

但是,只有当 tsconfig.json 里的 moduleResolution 为 'NodeNext' 时,typescript 才会识别 exports 字段。而当 moduleResolution 是 Node 时,只会识别传统的 maintypes 字段,所以这里要给 NPM 包的 package.json 单独加上 "types": "./dist/index.d.ts"

虽然解决了问题,但我又有了一个疑惑:如果我的 NPM 包有 submodule 会如何?

比如如果我的 npm 包的 package.json 是下面这样:

{
  "name": "my-npm-module",
  "type": "module",
  "exports": {
    ".": "./dist/index.js",
    "./submodule": "./dist/submodule.js"
  },
  "types": "./dist/index.d.ts"
}

types 只能给 index.js 声明类型,然后我试了一下 import 'my-npm-module/submodule',果然 typescript 就报错了:

Cannot find module 'my-npm-module/submodule' or its corresponding type declarations.

在 package.json 里没有 exports 之前,这类 submodule 是根据路径来的,然后我试了下在 my-npm-module 的 package.json 旁边创建了一个 submodule.d.ts 文件,果然 typescript 就没有报错了。

那么现在,我面临一个选择。

如果我想要兼容 "moduleResolution": "Node" 或者不支持 package.json exports 的 Node.js(比如 v12 以下的版本)

Node.js v12.22.12 的文档就已经支持 package.json 的 exports 了,而 Node.js 官网没有更旧的版本的文档了,所以我不确定 Node.js 具体是在哪个版本开始支持的。

那么我就需要做一些额外的配置,比如将 submodule 输出到根目录下,就像这样:

{
 "name": "my-npm-module",
  "type": "module",
  "exports": {
    ".": "./index.js",
    "./submodule": "./submodule.js"
  },
  "main": "./index.js",
  "types": "./index.d.ts"
}

这种就是同时兼容旧版本和新版本的,但这样的话就得把 tsconfig.json 的 outDir: 'dist' 改为 outDir: '.',这样一来文件都被直接输出在根目录下,看上去就很混乱。

又或者保持目录结构不变,但引用路径里多一层 dist 目录,就像这样:

{
 "name": "my-npm-module",
  "type": "module",
  "exports": {
    ".": "./dist/index.js",
    // 用户得写成 import 'my-npm-module/dist/submodule'
    "./dist/submodule": "./dist/submodule.js"
  },
  "main": "./dist/index.js",
  "types": "./dist/index.d.ts"
}

又或者单独为 entry 在根目录下创建转发 export 的文件,比如这样:

// index.js 和 index.d.ts 的内容
export * from './dist/index.js'

// submodule.js 和 submodule.d.ts 的内容
export * from './dist/submodule.js'

这样的话 package.json 就可以保持原样、import 路径里也不用加入 dist 目录了。

但是无论哪种都需要额外的步骤,比较繁琐。

如果我不想做兼容

那就很快乐了:

我的选择

我选择在消费端做改造。现在的 Node.js、Webpack、TypeScript 都已经支持 package.json 的 exports 了,没必要再单独对旧版本做兼容。

但是要注意:对消费端的改造看起来是似乎是由于 "moduleResolution": "NodeNext" 造成的,但实际上不是的。这个选项仅仅只是让 typescirpt 支持识别 package.json 的新字段如 type 和 exports,真正导致消费端需要做改造的原因是因为,前面例子里的 my-npm-module 是一个纯 es module。

换句话说,如果 my-npm-module 是一个纯 commonjs 模块,那么即使消费端是 "moduleResolution": "NodeNext" 也没有任何问题。

或者,也可以在提供 es module 的同时也提供 commonjs,就像这样:

{
 "name": "my-npm-module",
  // "type": "commonjs", // 不要声明 type 字段或者声明成 commonjs,不然即使提供了 "require" 也会被 typescript 识别成一个 es module
  "exports": {
    ".": {
      // 使用这种写法需要显式声明 types
      "types": "./dist/index.d.ts",
      // 由于不能声明 "type": "module",所以 .js 文件默认会被当成 commonjs,所以我们需要给 es module 显式使用 .mjs 后缀
      "import": "./dist/index.mjs",
      "require": "./dist/index.js" // <- 提供 commonjs 输出
    },
    "./submodule": {
      "types": "./dist/submodule.d.ts",
      "import": "./dist/submodule.mjs",
      "require": "./dist/submodule.cjs" // <- 提供 commonjs 输出
    }
  },
}

关于如何使用 typescript 同时提供两种格式的输出文件,可以参考 https://styfle.dev/blog/es6-modules-today-with-typescript

但实际操作时我发现了一个问题,那就是虽然文件改成了 .mjs,但是代码里的 import 语句没有带上完整的文件扩展名,比如 import './utils' 应该是 import './utils.mjs' 才对,如果不加的话,在 Webpack 里倒是没关系,但在 Node.js 里使用就会报错。

新的 .mts 可能可以满足同时提供 mjs / cjs 的场景,从网上搜到的一篇文章 Publish ESM and CJS in a single package 也提到了两个工具可以用一份 ts 同时生成 mjs 和 cjs,不过我还是以后再踩坑吧。

最后我自己写了个模块来处理 js -> mjs 的转换 :joy:

https://www.npmjs.com/package/@hcfy/js-to-mjs

开始实施

所以总结下来,消费端的改造有以下两种方法,二选一:

  1. 在 tsconfig.json 里配置 "moduleResolution": "Node",然后确保所有 npm 包都是使用 maintypes 字段来提供输出文件的路径的,如果有 submodule 那么引用方式要匹配文件路径(即 import 'my-module/submodule' 要确保 my-module 目录下有 submodule.js 文件存在)
  2. 在 tsconfig.json 里配置 "moduleResolution": "NodeNext"。但这样一来如果 npm 包里有 es module(即 "type": "module")那么 typescript 就会报错。如果你确认你的代码不是用在 Node.js 的 CommonJS 环境的(比如前端项目里使用 Webpack 打包代码),那么这个错就仅仅是 typescript 的错,不会影响到代码打包,要解决的话,以下方法三选一:
    • 给所有引入了 es module 的代码上方加 @ts-ignore
    • 把纯 es module 改为 commonjs module 或者同时提供两种格式的形式,但是有一些不是自己开发的纯 es module 那就只能用 @ts-ignore 忽略
    • 把消费端整个改为 es module,参考前面的对 NPM 的消费端改造

再次强调,使用 @ts-ignore 的方式只能在你确认你的代码不是运行在 Node.js 的 CommonJS 环境的,因为 CommonJS 环境是不能 require 一个 es module require('my-module') 的,要么使用 import('my-module'),要么把自己的代码也改成 es module 的。

把消费端整个改为 "type": "module" 的坑

我一开始选择的是把消费端整个改为 es module 的,但有不少问题。

第一个问题,当我把 '.js' 后缀加上后,webpack 会报 Can't resolve module 的错误,这感觉就跟 jest 一样,显式指定了 .js 所以 webpack 就真的去读取 .js 了。

既然是跟 module resolve 有关的报错,我就翻了一下 webpack resolve 的配置项,果然就让我找到了一个 resolve.extensionAlias,加上下面的配置就不会报错了(但我感觉加了这个之后 build 变慢了,不知道是不是错觉):

module.exports = {
  resolve: {
    extensionAlias: {
      '.js': ['.ts', '.js']
    }
  }
}

然后又遇到了第二个问题,typescript 解析模块的类型时出问题了,比如这样的代码:

import EventEmitter form 'eventemitter3'

class MyEE extends EventEmitter {}

没给 package.json 加 "type": "module" 之前是没问题的,但加了之后在 extends EventEmitter 那里出现问题了:

TS2507: Type 'typeof import("/node_modules/eventemitter3/index")' is not a constructor function type.

虽然这个改成 import { EventEmitter } form 'eventemitter3' 就没问题了,但另一个模块怎么改都不行:

import userEvent from '@testing-library/user-event'
// 这里也会报同样的错误
// TS2339: Property 'click' does not exist on type 'typeof import("/node_modules/@testing-library/user-event/dist/index")'.
userEvent.click()

我检查过它们的 d.ts,它们都有使用 export default 导出类型,我感觉当声明了 "type": "module" 之后,typescript 对 export default 的导出有问题。

在遇到这两个问题之后,我决定不折腾了。all in esmodule 并没有为我带来好处,反而有很多坑,而我一开始的初衷只是不想把 npm 包打包成一个文件而已,所以我决定把我自己的 npm 包全输出成 commonjs 形式,这样消费端也不用改了,jest 也不用单独安装一个 resolver 了、也不用强制带上 .js 后缀了。

所以我踩了一天的坑,而这些坑都是因为我使用了 "type": "module" 导致的,如果一开始就选择 CommonJS,那么啥事都没有 :joy:

把 npm 包全改为 commonjs 输出后的坑

然后,我把我自己的所有 npm 包都输出成了 commonjs 的形式,但是在消费端的 webpack 打包的时候出现了一些异常:

这个问题没什么头绪,然后我就发现浏览器控制台也报了错:exports is not defined,报错的代码为 exports.getToken = void 0

这个错也很奇怪,看了一下 webpack 打包出来的代码,包裹这段代码的形式是这样的:

(__unused_webpack_module, __webpack_exports__, __webpack_require__) => {

 // ... 上面一堆 corejs 引入的代码 ...

  exports.getToken = void 0
})

奇怪的地方就在于 __webpack_exports__,它应该就是 exports 才对。

网上查了一下,有人提到是 @babel/transform-runtime 导致的,然后我联想到我给 babel env 用了 useBuiltIns: 'usage',然后我注释掉之后又试了下,上面两个问题就都没有了,但同时,corejs 引入的代码也没有了。

看起来这个问题出现的整个流程是这样的:

  1. 由于我写 npm 包的时候都用的是最新的语法,所以我是希望消费端的 babel 来做低版本浏览器兼容的,也为此我在消费端设置了 babel-loader 要处理我写的 npm 包
{
          test: /\.(tsx?|jsx?|mjs|cjs|js)$/,
          exclude: {
            and: [/node_modules/],
            not: [
              // 所有 @hcfy 域下的 node_modules 都要经过 babel 处理
              /@hcfy[\\/]/,
            ],
          },
          use: {
            loader: 'babel-loader',
            options: {
              cacheDirectory: true,
            },
          },
        }
  1. 然后,我在消费端又用了useBuiltIns: 'usage',这就导致我的 npm 包被插入了一堆 corejs 引入的代码
  2. 而插入的这些代码估计是用 import 的方式插入进来的,这就导致 webpack 将我的 npm 包识别成了 es module,所以把 exports 变成了 __webpack_exports__

所以解决这个问题有两个方案:

按照链接里的说明将 babel config 的 sourceType 改成 'unambiguous' 果然就没问题了,顺便看了下 babel 关于 sourceType 的说明,确实就提到了对 node_modules 下的文件进行处理时会导致这个问题。

在改造前之所以没有这个问题,是因为改造前我提供了 es module 的输出文件,所以用 import 语句插入 corejs 是没有问题的;但现在我是纯 commonjs 了,还用 import 语句就会出现这个问题。

关于纯 es module

自从 Node.js 原生支持 es module 之后,越来越多的 npm 包开始切换为纯 es module,即只提供 es module 的代码,然后由消费端来做处理,比如: