banner
innei

innei

写代码是因为爱,写到世界充满爱!
github
telegram
twitter

2024 年,該如何寫一個全面兼容的 NPM 庫

最近,把 rc-modal 做成了一個庫。寫代碼倒是不難,無非就是把原本的代碼封裝抽象一下。倒是發包,和作為使用者如何使用這個包難住了。

首先,我們知道現在一個 npm 包如果不做 ESM Pure 的話,你需要考慮兼容 CJS/ESM,node 老版本和新版本(吃不吃 package.json 的 exports 字段),和 TypeScript 針對這些情況推出 bundler 開啟了特徵和沒有開啟 bundler 的項目。那么,我們需要保證使用方不管是哪个版本 node 在 module resolution 沒有問題,不管你的項目有沒有開啟 type: module 都沒有問題,不管你的 TypeScript 有沒有開啟 "moduleResolution": "Bundler" 也都沒有問題。

另外,還需要保證,打包出來的產物,不能丟失源代碼中存在的 "use client" directives。

所以,下面的例子我們來寫一個這樣的庫,需要全面兼容上面的所有場景。

初始化#

寫庫首先選擇打包工具,目前常見的有:rollup, esbuild, swc。

rollup 在兼容上最好,同時生態也比較完善。這裡我們選擇 vite 作為打包工具,相比直接使用 rollup,vite 配置會更加簡單一點。

首先初始化一個項目。

npm create vite@latest
# 選擇 react-ts

然後我們調整一下 vite 的配置。

import { readFileSync } from 'fs'

import react from '@vitejs/plugin-react'
import { defineConfig } from 'vite'

const packageJson = JSON.parse(
  readFileSync('./package.json', { encoding: 'utf-8' }),
)

const globals = {
  ...(packageJson?.dependencies || {}),
}
export default defineConfig({
  plugins: [react()],

  build: {
    lib: {
      entry: 'src/index.ts',
      formats: ['cjs', 'es'],
    },
    rollupOptions: {
      external: [
        'react',
        'react-dom',
        'lodash',
        'lodash-es',
        'react/jsx-runtime',
        ...Object.keys(globals),
      ],
    },
  },
})

然後在 src 中,簡單的寫兩個組件進行導出。這裡我們定義一個為 Client Component 另一个為 Shared Component。如果你不知道這是什麼也沒有關係,你只需要知道 "use client" 是什麼就行。

```ts export * from './components/client' export * from './components/shared' ``` ```tsx 'use client'
export const Client = () => null
```
```tsx export const Shared = () => null ```

OK,那么這個簡單庫的代碼就寫好了。接下來我們需要考慮如何兼容上面提到的所有場景。

保留 "use client" directive#

按照上面的代碼進行打包之後,產物中並不存在 "use client" directive。而且所有的模塊都被打包成一個文件。

image

使用 rollup 插件 rollup-plugin-preserve-directives 來解決這個問題。

npm i rollup-plugin-preserve-directives -D

修改一下 vite 配置。

import { preserveDirectives } from 'rollup-plugin-preserve-directives'

export default defineConfig({
  // ...
  build: {
    lib: {
      entry: 'src/index.ts', 
      entry: ['src/index.ts'], 
      formats: ['cjs', 'es'],
    },
    rollupOptions: {
      external: [
        'react',
        'react-dom',
        'lodash',
        'lodash-es',
        'react/jsx-runtime',
        ...Object.keys(globals),
      ],
      output: {
        preserveModules: true, 
      },
      plugins: [preserveDirectives({})], 
    },
  },
})

現在 build 之後,dist 目錄就這樣了。

.
├── components
│   ├── client.cjs
│   ├── client.js
│   ├── shared.cjs
│   └── shared.js
├── index.cjs
└── index.js

並且,可以看到 client.js 的產物是:

'use client'
const l = () => null
export { l as Client }

保留了 "use client" directive。

生成 d.ts#

這一步是非常重要的,也是為後續兼容 node 老版本的基礎。

現在我們的產物是完全沒有類型的,我們需要生成 d.ts 文件。這裡可以使用 vite-plugin-dts 插件。

npm i -D vite-plugin-dts

修改下配置:

import dts from 'vite-plugin-dts'

export default defineConfig({
  plugins: [react(), dts({})], 
})

現在我們就有了 d.ts 文件。每一個產物都有對應了一個 d.ts 文件。

那麼,現在我們要如何定義 package.json 的產物導出字段。

在支持 exports 字段的 node 版本中,我們應該這樣定義。假設我們現在的 type: "module"

{
  "exports": {
    ".": {
      "import": {
        "types": "./dist/index.d.ts",
        "default": "./dist/index.js"
      }
      "require": {
        "types": "./dist/index.d.ts",
        "default": "./dist/index.cjs"
      }
    },
    "./client": {
      "import": {
        "types": "./dist/components/client.d.ts",
        "default": "./dist/components/client.js"
      },
      "require":  {
        "types": "./dist/components/client.d.ts",
        "default": "./dist/components/client.cjs"
      }
    },
    "./shared": {
      "import": {
        "types": "./dist/components/shared.d.ts",
        "default": "./dist/components/shared.js"
      },
      "require":  {
        "types": "./dist/components/shared.d.ts",
        "default": "./dist/components/shared.cjs"
      }

    }
  }
}

寫起來是相當的麻煩。每一個導出項都要寫一個 importrequire 字段,在內部還需要再寫一個 types 字段,types 字段還必須在第一位,不小心寫錯了寫漏了就完了。

那麼這裡為什麼不能寫成這樣的形式。

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

那是因為,這個 types 只對 ESM 有用,對 CJS 是沒有用的。既然 CJS/ESM 都分離了,那麼 types 也需要分離才行。

修改 vite.config.ts

export default defineConfig({
  plugins: [
    react(),
    dts({
      beforeWriteFile: (filePath, content) => {
        writeFileSync(filePath.replace('.d.ts', '.d.cts'), content) 
        return { filePath, content }
      },
    }),
  ],
})

現在我們每個產物都有對應的一個 d.ctsd.ts 文件了。

.
├── components
   ├── client.cjs
   ├── client.d.cts
   ├── client.d.ts
   ├── client.js
   ├── shared.cjs
   ├── shared.d.cts
   ├── shared.d.ts
   └── shared.js
├── index.cjs
├── index.d.cts
├── index.d.ts
└── index.js

那麼現在 exports 字段只需要修改為:

{
  "exports": {
    ".": {
      "import": "./dist/index.js",
      "require": "./dist/index.cjs"
    },
    "./client": {
      "import": "./components/client.js",
      "require": "./dist/components/client.cjs"
    },
    "./shared": {
      "import": "./dist/components/shared.js",
      "require": "./dist/components/shared.cjs"
    }
  }
}

嗯,甚至我們不再需要 types 字段了。

我們添加下常規的定義。

{
  "main": "dist/index.cjs",
  "module": "dist/index.js",
  "types": "dist/index.d.ts"
}

現在我們 build 一下,然後 pack 之後去 arethetypeswrong 驗證。

npm run build
npm pack

上傳 taz 之後,得到了這樣的結果。

Environmentrc-librc-lib/clientrc-lib/shared
node10💀 Resolution failed💀 Resolution failed
node16 (from CJS)✅ (CJS)✅ (CJS)
node16 (from ESM)🥴 Internal resolution error (2)✅ (ESM)✅ (ESM)
bundler

可以看到,在 node10 也就是不支持 exports 字段環境中,後面的非 index 導出都是無法被 resolve 到的。

其次,在 node16 中,tsconfig 開啟了 moduleResolution: "node16" 並且項目是 type: "module" 時候,index 反而是無法正確類型推導的。

我們先來解決後者的問題。

起一個上面出現問題的 demo。

{
  "type": "module",
  "dependencies": {
    "rc-lib": "workspace:*" // 這裡使用 pnpm workspace 鏈接這個包。
  }
}
{
  "compilerOptions": {
    "moduleResolution": "node16"
  }
}

創建 index.ts

import { Client } from 'rc-lib'
import { Share } from 'rc-lib/shared'

console.log(!!Client, !!Share)

此時使用 tsx index.ts,程序順利運行,並輸出 true true。可見 node 對於這個 module 的 resolve 是沒有問題的。但是問題出在了 TypeScript 的類型推導。

image

進入類型後,發現這樣的報錯。

image

問題找到了,導出項沒有添加後綴。

現在我們修改包的 index.ts

export * from './components/client'
export * from './components/shared'
export * from './components/client.js'
export * from './components/shared.js'

再次打包,在網站上驗證。

Environmentrc-librc-lib/clientrc-lib/shared
node10💀 Resolution failed💀 Resolution failed
node16 (from CJS)✅ (CJS)✅ (CJS)✅ (CJS)
node16 (from ESM)✅ (ESM)✅ (ESM)✅ (ESM)
bundler

那麼現在除了 node10 不支持 exports 之外,我們已經全部解決了。

解決舊版本 node 的非 index 導出問題#

要解決這個問題,我們首先需要知道 typesVersions

typesVersions 是 TypeScript 4.1 引入的一個字段,用於解決不同版本的 TypeScript 對於不同版本的 node 的類型推導問題。

在 node10 中,我們可以通過這個字段來解決 exports 導出項的推導問題。

{
  "typesVersions": {
    "*": {
      "*": ["./dist/*", "./dist/components/*", "./*"]
    }
  }
}

因為產物導出項存在於 distdist/components 中,所以我們需要定義兩層目錄,最後一個根目錄經過測試也是必須的。

現在 pack 之後重新驗證兼容性。

Environmentrc-librc-lib/clientrc-lib/shared
node10
node16 (from CJS)✅ (CJS)✅ (CJS)✅ (CJS)
node16 (from ESM)✅ (ESM)✅ (ESM)✅ (ESM)
bundler

現在我們已經完全兼容了所有的環境,大功告成。

上面代碼的完整模板位於:

此文由 Mix Space 同步更新至 xLog 原始鏈接為 https://innei.in/posts/tech/write-a-universally-compatible-js-library-with-type-support

載入中......
此文章數據所有權由區塊鏈加密技術和智能合約保障僅歸創作者所有。