如何完美地为package.json中的exports兼容各个版本的nodejs

759 阅读2分钟

背景

npm 包的自定义导出路径的类型推断的兼容性不是太好,Typescript需要4.7+。可以用这个网站进行类型兼容性测试:Are The Types Wrong? - Tool for analyzing TypeScript types of npm packages

实现

  1. 使用 typesVersion 字段,参考 巧用 exports 和 typeVersions 提升 npm 包用户使用体验
  2. 使用 tsup + 构建后脚本。tsup 使用 esbuild 对源代码进行构建,同时,在 7.1 版本,tsup 支持了按照模块类型生成对于类型定义文件

配置tsup

在输出 esmcjs 时:

  • package.jsontypecommonjs 或者缺省,cjs 输出类型文件的后缀为 .d.tsesm 输出类型文件的后缀为 .d.mts
  • package.jsontypemodulecjs 输出类型文件的后缀为 .d.ctsesm 输出类型文件的后缀为 .d.ts

配置文件

tsup 挺开箱即用的

// tsup.config.ts
import { defineConfig } from 'tsup'

export default defineConfig({
  entry: [
    'src/index.ts',
    'src/another.ts',
  ],
  clean: true,
  format: ['cjs', 'esm'],
  shims: true,
  dts: true,
  treeshake: true,
})

配置 package.json

这部分是花的时间和问题最多的。在 package.json 中,exports 字段是用于配置导出路径的,第一层用于指定路径,第二层用于指定类型为 esm(import)/cjs(require) 时导入的文件

types 可以根据 requireimport 的路径自动读取同目录下同名的类型文件。

// package.json
{
    "exports": {
        ".": {
            "import": "./dist/index.mjs",
            "require": "./dist/index.js",
            //"types": "./dist/index.d.ts"
        },
        "./renderer": {
            "import": "./dist/another.mjs",
            "require": "./dist/another.js"
        },
    },
}

如果需要手动指定,可以使用以下代码。

{
    "exports": {
        ".": {
            "import": {
                "default": "./dist/index.mjs",
                "types": "./dist/index.d.mts"
            },
            "require": {
                "default": "./dist/index.js",
                "types": "./dist/index.d.ts"
            },
        },
    },
}

但是 typescript 在 4.7 版本后才支持读取和自动提示,而且 tsconfig.jsonmoduleResolution 必须设置为 Node16 或者 NodeNext。这个设置导致 package.jsontype 必须是 module,否则 IDE 会报错,十分膈应人。

最终,选择了自动生成真实的路径映射,用于兼容低版本 node 的路径问题,于是有了构建后脚本

配置构建后脚本

低版本 node 会按照路径在 npm 包中寻找文件,因此只需要将真实文件路径和 package.jsonexports 字段一一对应,就可以在导入时获得类型提示了

const { relative, join } = require('node:path/posix')
const { writeFileSync, mkdirSync, rmSync } = require('node:fs')
const { exports: exp } = require('./package.json')

const ROOT_PATH = __dirname
const exportMap = exp

for (const ex of Object.keys(exportMap)) {
  if (ex === '.' || !exportMap[ex].require) {
    continue
  }

  const [, ...folders] = ex.split('/')
  const fileName = folders.pop()

  const [, ...targetFolders] = exportMap[ex].require.split('/')
  const targetFileName = targetFolders.pop()
  const target = relative(
    join(ROOT_PATH, ...folders),
    join(ROOT_PATH, ...targetFolders, targetFileName),
  )

  mkdirSync(join(ROOT_PATH, ...folders), {
    recursive: true,
  })

  writeFileSync(
    join(ROOT_PATH, ...folders, `${fileName}.js`),
    `module.exports = require('./${target}')`,
  )

  writeFileSync(
    join(ROOT_PATH, ...folders, `${fileName}.d.ts`),
    `export * from './${target.split('.')[0]}'`,
  )
}

按照前面的 package.json 的配置,最终会在根目录增加 4 个文件,两个类型定义文件、两个代码文件,都是利用 tsup 生成的文件自动进行映射的。

其他配置

将生成的文件路径添加进 files 字段和 .gitignore 文件中

样例

github.com/subframe753…