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,如谷歌的 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 之後混用兩種類型的庫#

先不說 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 Module,只有 .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 module,只能用 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 的話優先選擇這個作為 import 的文件(不使用 exports 字段)。事實並不是,我在 index.js 中 import 他,默認還是選擇的 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 為例,他的 Server 產物是純 ESM 的,在生產環境中丟棄了 Vite 的預構建,使得大量庫在生產環境直接墜機,都是 import 的時候出現了問題。比如前面提到的 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 的,但是你 import 的庫不是 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

第二種方法看上去簡單,的確改了之後他也是 work 的。

// 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 的 import,看上去是不是很正常,但是 runtime 直接跪了,為什麼因為 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'

這下好了,心態直接崩了。手動改基本不可能了,不是只是改一個文件後綴這麼簡單的事了,import 的替換都需要去寫一個 ast 腳本了。

在 TypeScript 項目中的巨巨巨坑#

你以為這樣就好了?不,在 TypeScript 那是直接坑死。先說說沒有在 tsconfig.json 開啟 "moduleResolution": "NodeNext""module": "NodeNext" 的情況,一般的只是設置成 "module": "CommonJS"。同時在項目中也關閉 ESM。

前面說過了,在 CJS 中如何使用 ESM,一是用 .mjs 後綴然後用 import (),或者直接 import () 一個三方庫。但是大家都知道在 TypeScript 誰還用 require 啊,都是 import 一把梭,反正 tsc 最後翻譯成 require 就完事了。問題就在這裡。那這樣的話,我完全 import 不了任何 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" 字段,如果一旦遇到有這個字段的,又要頭疼了。如 nanoid 為例。nanoid 的 package 中定義了 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

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