banner
innei

innei

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

2022年にライブラリを書くのはどれほど難しいか

更新
Nuxt 3 のドキュメントにも類似の内容が言及されていますので、参考にご覧ください:https://nuxt.com/docs/guide/concepts/esm#what-kinds-of-problems-can-there-be

最近頭を悩ませているのは、CJS と ESM pure の両方に対応したライブラリを書く方法です。これは単に tsc を使用したり、バンドルツールで cjs と esm 形式の js ファイルを直接生成するだけではありません。NodeJS が package.json の新しいフィールド規格 exports を読み込むことをサポートし、正式に ESM をサポートし始めてから、type: "module" を有効にしたプロジェクトでのサポートを書くことがこんなにも複雑になるとは思いませんでした。そのため、ESM をサポートするために CJS のサポートを放棄することがあり、例えば Google の zx(v5 から CJS のサポートを放棄)や、他の nanoid や chalk などのライブラリも CJS のサポートを放棄し始めています。これにより、CJS のみをサポートするプロジェクトでは、更新された依存ライブラリを使用し続けることができなくなります。そして現在、大部分の Node バックエンドプロジェクトは純粋な ESM 環境に対応できません。

まず、プロジェクト内には、すでにメンテナンスされていない、またはライブラリの作者が追従していないライブラリが大量に存在する可能性があります。このようなライブラリは CJS のみをサポートしているため、ESM 環境では createRequire () を使用して回避する必要があるか、デフォルトエクスポートのみを使用するしかなく、名前付きエクスポートを使用できません。createRequire を使用するか、すべてをデフォルトエクスポートにしてから分解するのは良い方法ではありません。第二の理由は、ほとんどのプロジェクトが TypeScript を使用していることです。この点については後で詳しく説明します。

// my-module/lib/index.js
module.exports = {
  name: 'my-module-cjs',
}

// import-cjs.js
import { createRequire } from 'module'
import path from 'path'

const require = createRequire(path.resolve(import.meta.url, './node_modules'))
const { name } = require('my-module/lib/index')
console.log(name)

import { name } from 'my-module' // error, info below
// import { name } from 'my-module'
//          ^^^^
// SyntaxError: Named export 'name' not found. The requested module 'my-module' is a CommonJS module, which may not support all module.exports as named exports.
// CommonJS modules can always be imported via the default export, for example using:
// 
// import pkg from 'my-module';
// const { name } = pkg;

Vanilla NodeJS 環境で ESM を有効にした場合、2 種類のライブラリを混在させることはできますか?#

TypeScript プロジェクトのことは置いておいて、答えは「はい」です。前の部分で、ESM で CJS モジュールを使用する方法を示しました。純粋な JS ではコンパイルされないため、import 文は実行時に依然として import であり、require は依然として require です。TypeScript や他のコンパイラが import -> require または await import() -> Promise.resolve().then(() => require()) に翻訳することはありません。

package.json に "type": "module" を追加すると、プロジェクトはデフォルトで ESM が有効になり、すべての .js 拡張子は Node によって ES モジュールとして認識され、.cjs 拡張子のみが CommonJS と見なされます。したがって、.js ファイル内で import を使用してさまざまな ESM ライブラリをインポートしたり、createRequire () を使用して CommonJS ライブラリをインポートしたりできます。一方、.cjs では従来の方法と同様に、require () と await import() を使用できます。

// test.js
export const obj = {
  a: 1,
}
// test.cjs
import('./test.js').then(({ obj }) => {
  console.log(obj)
})

CJS 環境では ESM Pure のライブラリを使用できないのですか?#

答えは「はい」、ただし非同期インポートのみ使用できます:

async function main() {
  const { $ } = await import('zx')
  await $`ls`
}

main()

では、なぜ多くのライブラリが CJS のサポートを放棄し始めたのでしょうか。いくつかの理由があると思います:

  • CJS は本当に互換性が悪い
  • ESM は大勢の流れ
  • TypeScript のコンパイル産物の理由(TSC 4.7 で多少緩和された)

ESM で CJS のみのライブラリを使用する際の落とし穴#

次に、プロジェクトで ESM を有効にし、axios をインポートする場合を見てみましょう。axios は v1 以前は CommonJS のみのサポートでした。以下のコードを例にします:

import { AxiosError } from 'axios'
console.log(AxiosError)

エラーが発生します。axios は cjs モジュールであり、require を使用する必要があり、直接 import すると名前付きエクスポートがサポートされません。次のように変更する必要があります:

import axios from 'axios'
console.log(axios.AxiosError)

しかし、この場合、tsserver は axios の型ヒントを直接失います。

image

名前付きインポートを使用すると、型ヒントが正常に表示されます。もちろん、axios の typing は手書きであり、tsc によって生成されたものではないという理由もあります。また、次のように書くことでも型ヒントが得られます。これは非常に面倒です。

image

ESM import サードパーティライブラリの package.json 読み取り#

次に、別の状況を見てみましょう。これは私のライブラリで、現在多くのライブラリが同時に cjs/esm を出力するように模倣した package.json です。

image

my-module
├── esm
│   └── index.js
├── lib
│   └── index.js
└── package.json

esm ディレクトリには esm の成果物があり、lib には cjs があります。package.json で "module": "esm/index.js" を定義し、esm をサポートする場合はこれを優先的にインポートファイルとして選択することを示しています(exports フィールドは使用しません)。実際には、index.js でインポートすると、デフォルトでは lib/index.js がエントリとして選択されます。

// my-module/lib/index.js
export default {
  name: 'my-module-esm',
}
// .../lib/...
module.exports = {
  name: 'my-module-cjs',
}

// index.js
import module from 'my-module'

console.log(module) // { name: 'my-module-cjs' }

これが何に影響するかというと、最近使用している SSR フレームワーク Rakkajs の例を挙げると、サーバーの成果物は純粋な ESM であり、プロダクション環境では Vite のプリビルドを廃止し、多くのライブラリがプロダクション環境で直接クラッシュしました。すべてインポート時に問題が発生しました。例えば、前述の axios や react-use などのライブラリです。

では、esm/index.js を直接インポートするとどうなるでしょうか?

// esm/index.js
export default {
  name: 'my-module-esm',
}

// index.js
import module from 'my-module/esm/index.js'

console.log(module)

答えは直接エラーが発生します。なぜなら、プロジェクトは ESM が有効になっており、デフォルトでは .js はすべて ESM ですが、インポートしているライブラリは ESM ではないからです。さらに、package.json でも "type": "module" が有効になっていないため、このライブラリの .js はすべて CommonJS であるべきですが、このファイルは ESM の export default 構文を使用しているため、必然的に不正です。

at 16:12:03 ❯ node index.js
(node:18949) Warning: To load an ES module, set "type": "module" in the package.json or use the .mjs extension.
(Use `node --trace-warnings ...` to show where the warning was created)
/private/tmp/test/node_modules/my-module/esm/index.js:1
export default {
^^^^^^

SyntaxError: Unexpected token 'export'

しかし、解決策は非常に面倒です。すべてのライブラリを変更する必要があります。

  • ライブラリを直接 type: module に変更すると、CJS は廃止されます。
  • 各 esm ファイルの拡張子を .mjs に変更します。

第二の方法は簡単に見えますが、実際に変更した後は動作します。

// my-module/esm/index.mjs
export default {
  name: 'my-module-esm',
}
// index.js
import module from 'my-module/esm/index.mjs'

console.log(module) // { name: 'my-module-esm' }

しかし、あなたは間違っています。仮に私のライブラリの esm が次のようなものであった場合。

// my-module/esm/index.mjs
import { add } from './utils'

export default {
  name: 'my-module-esm',
}

utils のインポートは一見正常に見えますが、ランタイムで直接エラーが発生します。なぜなら、utils.js が見つからないからです。なぜ見つからないのかというと、utils.mjs はファイル拡張子を省略できないからです。

at 16:17:34 ❯ node index.js
node:internal/errors:477
    ErrorCaptureStackTrace(err);
    ^

Error [ERR_MODULE_NOT_FOUND]: Cannot find module '/private/tmp/test/node_modules/my-module/esm/utils' imported from /private/tmp/test/node_modules/my-module/esm/index.mjs

そのため、次のように変更する必要があります。

+ import { add } from './utils.mjs'
- import { add } from './utils'

これで問題が解決しましたが、心の平穏が崩れました。手動で変更するのは基本的に不可能で、単にファイル拡張子を変更するだけではなく、インポートの置き換えを行うために AST スクリプトを書く必要があります。

TypeScript プロジェクトにおける巨大な落とし穴#

これで終わりだと思いますか?いいえ、TypeScript では直接的に落とし穴です。まず、tsconfig.json で "moduleResolution": "NodeNext""module": "NodeNext" を有効にしない場合、一般的には "module": "CommonJS" に設定されます。同時にプロジェクトで ESM を無効にします。

前述のように、CJS で ESM を使用する方法は、.mjs 拡張子を使用して import () を使用するか、サードパーティライブラリを直接 import () することです。しかし、誰も TypeScript で require を使用することはありません。すべて import で、tsc は最終的に require に翻訳するだけです。ここに問題があります。そうすると、私は ESM ライブラリをインポートできなくなります。なぜなら、import () が tsc によって require に翻訳されてしまうからです。

async function main() {
  const { $ } = await import('zx')
  await $`ls`
}

main()

翻訳されると:

async function main() {
    const { $ } = await Promise.resolve().then(() => __importStar(require('zx')));
    await $ `ls`;
}
main();

完全に失敗です。

at 16:33:29 ❯ node dist/index.js 
node:internal/process/promises:279
            triggerUncaughtException(err, true /* fromPromise */);
            ^

Error [ERR_REQUIRE_ESM]: require() of ES Module /private/tmp/test/node_modules/.pnpm/[email protected]/node_modules/zx/build/index.js from /private/tmp/test/dist/index.js not supported.
Instead change the require of /private/tmp/test/node_modules/.pnpm/[email protected]/node_modules/zx/build/index.js in /private/tmp/test/dist/index.js to a dynamic import() which is available in all CommonJS modules.
    at /private/tmp/test/dist/index.js:25:67
    at async main (/private/tmp/test/dist/index.js:25:19) {
  code: 'ERR_REQUIRE_ESM'
}

その後、TypeScript は 4.7 で "module": "NodeNext" を追加しました。それを有効にして、もう一度試してみましょう。

import axios from 'axios'
const $axios = axios.create({})
async function main() {
  const { $ } = await import('zx')
  await $`ls`
}

main()

コンパイルされると:

"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
    return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
const axios_1 = __importDefault(require("axios"));
const $axios = axios_1.default.create({});
async function main() {
    const { $ } = await import('zx');
    await $ `ls`;
}
main();

これで正しく翻訳され、import と require を正しく区別できました。しかし、問題が再び発生します。axios は CJS モジュールであり、axios の package.json 内には "exports" フィールドが定義されていません。このフィールドがある場合、TypeScript は import を使用するべきか require を使用するべきか分からず、再び失敗します。例えば、nanoid の場合、nanoid のパッケージには exports が定義されています。

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

この場合、TypeScript は import を使用するべきか require を使用するべきか分からず、再び失敗します。

import nanoid from 'nanoid'
nanoid() //  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("nanoid")' call instead.
  To convert this file to an ECMAScript module, change its file extension to '.mts', or add the field `"type": "module"` to '/private/tmp/test/package.json'.

その後、手動で require の形式に変更する必要があります。

const { nanoid } = require('nanoid')
nanoid() 

その後、型はすべて any になります。

これは、プロジェクトが ESM を有効にしていない場合の話です。もし有効にした場合、その状況はさらに複雑になります。本当に心が疲れます。

まとめ#

CJS と ESM の両方に対応するのがこれほど面倒であるなら、CJS をサポートせずに、直接 ESM Pure にする方が良いでしょう。ファイル拡張子を区別する必要もなく、exports をたくさん書く必要もなく、すべてがうまくいきます。しかし、ライブラリを使用する人々にとっては、特に SSR の状況では心が痛むことになります。

この記事は Mix Space によって xLog に同期更新されました。
元のリンクはこちら https://innei.ren/posts/programming/write-a-nodejs-library-in-2022

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