lmk123 / blog

个人技术博客,博文写在 Issues 里。
https://github.com/lmk123/blog/issues
623 stars 35 forks source link

npm 包导出模块的选择 #112

Open lmk123 opened 1 year ago

lmk123 commented 1 year ago

最近打算写几个 npm 包,但是在了解过现在的模块导出方式之后,我发现选择太多了,于是在经过一番调查后,将结论整理如下。

导出为 CommonJS

最受支持的模块格式当然是 CommonJS 了。只需要在 pacakge.json 里定义一个 main 字段即可导出 CommonJS:

{
  "main": "./dist/index.js"
}

但是,随着前端也开始使用 npm 作为模块发布载体,CommonJS 已经不够用了。这是因为,CommonJS 会导入一个 npm 模块中的所有代码,即使我本身可能只用到了一小部分。举个例子,如果你使用了 const _ = require('lodash'),那么这会将 lodash 里的所有工具函数代码都打包进最终代码里,即使我们可能只用到了其中几个工具函数。

也因此,前端开始使用 ESM 作为主流的模块导出方式。作为一个 npm 模块的开发者,当我们会像 lodash 那样提供多个导出项时,我们就应该提供 ESM 的导出方式,帮助我们的使用者减少最终生成的代码体积。

同时导出 CommonJS 与 ESM

于是,Webpack 和 Rollup 等打包工具开始支持 package.json 里的 module 字段,而流行的做法就是——同时提供 CommonJS 和 ESM 的代码。一个典型的 package.json 文件会是这样子:

{
  "main": "./dist/lib.common.js",
  "module": "./dist/lib.esm.js"
}

同时导出针对 Node.js 和浏览器的代码

但是这仍然不够。这是因为,部分 npm 模块既支持在浏览器里使用、也支持在 Node.js 里使用,比如 axios,它会在 Node.js 里使用 https 模块、在浏览器里使用 XMLHttpRequest 来发起网络请求,所以 axios 需要为不同的使用环境导出不同的代码,于是,package.json 里又新增了一个 browser 字段用于这种场景。

举例来说,axios v0.27.2 的 package.json 是这么写的:

{
  "main": "index.js",
  "browser": {
    "./lib/adapters/http.js": "./lib/adapters/xhr.js",
    "./lib/defaults/env/FormData.js": "./lib/helpers/null.js"
  }
}

当在 Node.js 中引用 axios 时,Node.js 会忽略 browser 字段,按照正常的 CommonJS 方式处理 index.js;而代码打包工具(Webpack 或 Rollup)在遇到引用 axios 的情况时,会进行判断:如果打包的目标是浏览器,那么它们会在解析 "./lib/adapters/http.js" 这个文件时,将它替换为 "./lib/adapters/xhr.js" 的内容打包进最终的代码里,这样一来就能在浏览器里使用 axios 了。

顺带一提,axios 是给 browser 字段提供了一个对象来替换了部分模块,不过 browser 字段也支持提供一个字符串,作为整个模块的替代。

最新的模块导出方式:exports

前面介绍了 mainmodulebrowser 这三个字段的使用,不过现在出现了一个新的字段 exports 用于替代前面这几种导出方式。

exports 的使用方式非常多,不仅支持针对引用形式(import 或者 require())导出不同的文件,还支持针对使用目标(Node.js、浏览器甚至 Electron 等)导出不同的文件。

具体的介绍太过复杂,这里提供两个链接:

这里列举一些使用 exports 替代以前的导出方式的例子,不过我没有真的使用过,不知道会不会有问题,如果有,欢迎指正:

只导出 CommonJS 模块

以前是 { "main": "index.js" },现在是 { "exports": "./index.js"}

同时导出 CommonJS 与 ESM

以前是 { "main": "./dist/lib.common.js", "module": "./dist/lib.esm.js" },现在是

{
  "type": "module",
  "exports": {
    "require": "./dist/lib.common.cjs",
    "import": "./dist/lib.esm.js"
  }
}

注意,这里出现了两个前文没介绍过的内容:.cjs 扩展名和 type: module,后面会介绍。

同时导出针对 Node.js 和浏览器的代码

以前是 { "main": "./dist/lib.common.js", "browser": "./dist/lib.borowser.js" },现在是

{
  "exports": {
    "browser": "./dist/lib.browser.js",
    "default": "./dist/lib.js"
  }
}

那么我的模块该提供哪些形式的导出?

我觉得,这要视情况而定,具体分析如下:

  1. 任何情况下,模块都应该有一个 CommonJS 的导出。很多工具对 ESM 的支持不是很好,或者需要一些额外的配置才支持 ESM,举例来说,jest 目前对 ESM 只有实验性质的支持,且需要用到 babel 转为 CommonJS。
  2. 如果模块提供多个导出项目、或者模块本身引用了另一个模块的少数项目,那么则应该提供 EMS。举例来说,如果你的模块提供了多个函数、或者你的模块导入了另一个模块的部分导出项目(import { a, b, c } from 'another-module'),那么应该提供 ESM 来减少最终打包出来的代码体积。反过来说,如果你的模块本身只导出一个项目且没有部分导入其它模块,那么我觉得只提供 CommonJS 即可。
  3. 最后,就根据你的需要来配置 exports,比如针对生产环境和开发环境提供不同的代码、针对浏览器和 Node.js 提供不同的代码等。

相关链接: