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

加载中...
此文章数据所有权由区块链加密技术和智能合约保障仅归创作者所有。