banner
innei

innei

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

2024 年、どのようにして包括的に互換性のある NPM ライブラリを書くか

最近、rc-modal をライブラリにしました。コードを書くのは難しくありませんが、元のコードをラップして抽象化するだけです。しかし、パッケージを配布することや、ユーザーがこのパッケージをどのように使用するかが難しい点でした。

まず、現在の npm パッケージは ESM Pure を行わない場合、CJS/ESM、古いバージョンと新しいバージョンの Node(package.json の exports フィールドを使用するかどうか)、および TypeScript がこれらの状況に対して bundler を有効にしたプロジェクトと無効にしたプロジェクトを考慮する必要があります。したがって、使用者がどのバージョンの Node を使用してもモジュール解決に問題がないこと、プロジェクトが type: module を有効にしているかどうかに関係なく問題がないこと、TypeScript が "moduleResolution": "Bundler" を有効にしているかどうかに関係なく問題がないことを保証する必要があります。

さらに、パッケージから生成された成果物には、ソースコードに存在する "use client" ディレクティブが失われないようにする必要があります。

したがって、以下の例では、このようなライブラリを作成し、上記のすべてのシナリオに完全に対応する必要があります。

初期化#

ライブラリを書くために、まずパッケージツールを選択します。現在一般的なものには、rollup、esbuild、swc があります。

rollup は互換性が最も優れており、エコシステムも比較的充実しています。ここでは、直接 rollup を使用する代わりに vite をパッケージツールとして選択します。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 フォルダ内に、エクスポートするための 2 つのコンポーネントを簡単に作成します。ここでは、1 つを Client Component、もう 1 つを 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" ディレクティブの保持#

上記のコードでパッケージを作成すると、成果物には "use client" ディレクティブが存在しません。また、すべてのモジュールが 1 つのファイルにパッケージ化されます。

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({})], 
    },
  },
})

現在、ビルド後の 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" ディレクティブが保持されました。

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"
}

これでビルドして、パッケージを作成し、arethetypeswrong で検証します。

npm run build
npm pack

taz をアップロードした後、次のような結果が得られました。

環境rc-librc-lib/clientrc-lib/shared
node10💀 解決に失敗しました💀 解決に失敗しました
node16 (CJS から)✅ (CJS)✅ (CJS)
node16 (ESM から)🥴 内部解決エラー (2)✅ (ESM)✅ (ESM)
bundler

node10 では、exports フィールドをサポートしていないため、後の非 index エクスポートは解決できないことがわかります。

次に、node16 では、tsconfig で moduleResolution: "node16" を有効にし、プロジェクトが type: "module" の場合、index が正しく型推論されないという問題があります。

まず、後者の問題を解決します。

上記の問題が発生したデモを作成します。

{
  "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 はこのモジュールの解決に問題がないことがわかります。しかし、問題は TypeScript の型推論にあります。

image

型に入ると、次のようなエラーが発生します。

image

問題が見つかりました。エクスポート項目にサフィックスが追加されていません。

現在、パッケージの index.ts を修正します。

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

再度パッケージを作成し、ウェブサイトで検証します。

環境rc-librc-lib/clientrc-lib/shared
node10💀 解決に失敗しました💀 解決に失敗しました
node16 (CJS から)✅ (CJS)✅ (CJS)✅ (CJS)
node16 (ESM から)✅ (ESM)✅ (ESM)✅ (ESM)
bundler

これで、node10exports をサポートしていないことを除いて、すべての問題が解決されました。

古いバージョンの Node の非 index エクスポート問題の解決#

この問題を解決するためには、typesVersions を知っておく必要があります。

typesVersions は TypeScript 4.1 で導入されたフィールドで、異なるバージョンの TypeScript が異なるバージョンの Node に対して型推論を行う問題を解決するために使用されます。

Node10 では、このフィールドを使用してエクスポート項目の推論問題を解決できます。

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

成果物のエクスポート項目は distdist/components に存在するため、2 層のディレクトリを定義する必要があり、最後のルートディレクトリもテストの結果、必須です。

これでパッケージを作成し、再度互換性を検証します。

環境rc-librc-lib/clientrc-lib/shared
node10
node16 (CJS から)✅ (CJS)✅ (CJS)✅ (CJS)
node16 (ESM から)✅ (ESM)✅ (ESM)✅ (ESM)
bundler

これで、すべての環境に完全に対応できました。大成功です。

上記のコードの完全なテンプレートは次の場所にあります:

Github Repo not found

The embedded github repo could not be found…

この記事は Mix Space によって xLog に同期更新されました。元のリンクは https://innei.in/posts/tech/write-a-universally-compatible-js-library-with-type-support

読み込み中...
文章は、創作者によって署名され、ブロックチェーンに安全に保存されています。