cjs, umd, esm or iife

2020 年了,nodejs 和浏览器基本都支持了原生 esm,那么现在 js 库该怎么写?本文先解释他们分别是什么,再结合最新环境支持给出建议和实践。

他们分别是什么?

你可以用 rollup 的在线  repl 来查看各种模块写法

它们是在 JS 里用来实现“模块”的不同规则。

CJS

CommonJS,只能在 NodeJS 上运行,使用 require("module") 读取并加载模块。

缺点:不支持浏览器,执行后才能拿到依赖信息,由于用户可以动态 require(例如 react 根据开发和生产环境导出不同代码 的写法),无法做到提前分析依赖以及 Tree-Shaking 。

AMD

Asynchronous Module Definition,可以看作 CJS 的异步版本,制定了一套规则使模块可以被异步 require 进来并在回调函数里继续使用,然后 require.js 等前端库也可以利用这个规则加载代码了,目前已经是时代的眼泪了。

UMD

Universal Module Definition,同时兼容 CJS 和 AMD,并且支持直接在前端用 <script src="lib.umd.js"></script> 的方式加载。现在还在广泛使用,不过可以想象 ESM 和 IIFE 逐渐代替它。

IIFE

Immediately Invoked Function Expression,只是一种写法,可以隐藏一些局部变量,前端人要是不懂这个可能学的是假前端。可以用来代替 UMD 作为纯粹给前端使用的写法。

ESM

ECMAScript Module,现在使用的模块方案,使用 import export 来管理依赖。由于它们只能写在所有表达式外面,所以打包器可以轻易做到分析依赖以及 Tree-Shaking。当然他也支持动态加载(import())。

浏览器直接通过 <script type="module"> 即可使用该写法。NodeJS 可以通过使用 mjs 后缀或者在 package.json 添加 "type": "module" 来使用,注意他还有一些 实验性的功能 没有正式开启。考虑到大量 cjs 库没有支持,如果要发布 esm 版的库还是通过 rollup 打包一下比较好(同时相关依赖可以放到 devDependencies 里)。

其他

SystemJS

库该怎么写?

其实很简单,要看你需要支持哪些平台:

只支持 NodeJS 的 require 写法

package.json:"main": "index.js"

其中 index.js 使用 cjs 写法(module.exports = xxx;

只支持 NodeJS 的 import 写法

package.json:"main": "index.mjs" 或 "type": "module", "main": "index.js"

其中 index.mjs 或 index.js 使用 esm 写法(export default xxx

同时支持 NodeJS 的 require 和 import 写法

利用 条件 export,直接看文档里面有例子。

支持浏览器直接通过 <script> 引入的写法

package.json:"browser": "index.global.js",然后 jsDelivr 等 cdn 会自动使用这个文件,具体到 cdn 上还有 "jsdelivr": "index.jsdelivr.js" 等配置写法,权重更高。

这里可以试试 esbuild 输出 iife 格式的包,比 webpack/babel 更快,除了对 cjs 的库不太友好(可以配合下面 rollup/commonjs 插件使用)。

浏览器直接支持 type="module" 引入 esm 写法的文件,但是这对于 cdn 来说并不友好:cdn 看到 import "xxx" 并不知道如何找到 xxx 模块,所以这种写法建议只在本地使用。另外也可以通过 vite,让它使用 rollup 和 esbuild 帮你引入这些外部模块。

支持现代打包器 rollup 和 webpack2+ 通过 import 引入的写法

package.json:"module": "index.esm.js"

如果某些库没有写这个选项,那么可以借助 rollup 的 commonjs 插件转译到可用,具体做法可以参考 rollup 文档。

如何实践?

完全使用 ESM,如果有不同环境的需求,通过 rollup 打包成支持目标环境的其他格式(esm 给其他打包器使用, iife 给浏览器, cjs 给 nodejs 玩家),可以参考 vue3


各类库的参考 package.json(2021 年 10 月更新)

这里提取几个正交的场景,比如给浏览器用、给 Node.js 用、给 Deno 用、要不要同时支持 node <12、同时支持 cjs 和 esm 时如何保证状态唯一等等,给出我个人的建议。

首先对 Node.js >= 14 科普一个小常识:esm 和 cjs 可以互相调用,在 esm 里可以直接import pkg from "some-cjs-lib"来取到 module.exports 对象,在 cjs 里可以import("some-esm-lib")来动态加载一段 esm 代码。

其次,给浏览器的包建议只导出一份 esm,因为现代打包工具对 esm 代码有更好的优化和分析能力。除非你非要使用 webpack4- 并且不用 babel。

最简单的场景:环境无关的纯函数包、或者只考虑给浏览器用

类似isEven()这种只使用了原生 JS,不需要依赖浏览器 / Node.js 特定 API,而且没有副作用(比如包体执行两遍之后产生的结果不一样)的,建议使用纯 esm,即只有一份按 esm 格式书写的代码。这里有两种选择:

  1. 在 package.json 里声明type: module,然后所有 .js 文件都会被视为 esm。
"name": "pkg",
"type": "module",
"exports": "./index.js" // 推荐用 exports 代替 main,这意味着这个包内的其他文件无法通过
                        // import "pkg/other_file.js"
                        // 的方式被引入,做到了完全隔离。
                        // 并且这个字段还可以精细控制具体要导出哪些文件。

此时如果还想要引入一些 cjs 代码比如用了require(),可以用 .cjs 结尾的文件。

2. 使用 .mjs 结尾的文件都会被视为 esm。

是纯函数包,但是也想要支持 cjs (如 node.js < 12)

这种情况可以存在两份不同格式的代码,使用 Conditional Exports 导出,假设我们导出的两个文件分别为 index.js 和 index.mjs。

"main": "./index.js",         // 兼容不支持 exports 的 node
"exports": {
  "node": {
    "import": "./index.mjs",  // 让 node 可以用 import { } 引入你的包
    "export": "./index.js"    // 让 node 可以用 require() 引入你的包
  },
  "default": "./index.mjs"    // 在 node 以外的环境始终使用 esm,
                              // 保证打包器不会一不小心打一个 cjs 进去。
}

不是纯的,但是是浏览器专用

类似 React 之类的库,在实现 hooks 时要么需要在模块层面复用一个顶层的数组,要么把它注册到全局 (window.xx = …),这种情况下引入两遍库就很有可能造成 bug(比如用不同版本的 react 来创建和渲染组件),这就是所谓“不是纯的”。

这种情况我们要小心引入两遍库的问题,不光是在代码层面,而且在 package.json 里需要保证只导出一份代码,使用唯一的main或者exports即可。

浏览器通常还会有另一个需求就是 iife/umd 这种可以通过<script>在全局引入的用法,这里使用unpkg/jsdelivr字段即可。

"exports": "./index.mjs",
"unpkg": "./index.iife.js",
"jsdelivr": "./index.iife.js"

如果包本身没有依赖,可以用<script type="module">引入并且希望所有人都这么用的话,也可以不打 iife 包,都指向 esm 文件。

不是纯的,Node.js 要用,而且要同时支持 esm 和 cjs

这种情况最简单的解法是只用 cjs,不过也有复杂的办法来实现导出两份包。

让我们回看一下上面的一个例子:

"main": "./index.js",         // 兼容不支持 exports 的 node
"exports": {
  "node": {
    "import": "./index.mjs",  // 让 node 可以用 import { } 引入你的包
    "export": "./index.js"    // 让 node 可以用 require() 引入你的包
  }
}

这个写法已经可以保证两种写法对应两个文件了,接下来是如何让同一个副作用不执行两次:只写一遍就好了。举个例子,我们需要复用一个顶层对象、或者执行一些一次性的代码:

const globalState = { count: 0 }
console.log("我必须最多被执行一次!")

我们可以把它写在一个单独的 cjs 文件里,然后导出:

// share.js
module.exports = { globalState }

接着,在 cjs/esm 入口文件内分别引入这个文件:

// index.mjs
import share from "./share.js"
const { globalState } = share
// index.js
const { globalState } = require("./share")

这样就可以保证 share.js 这个文件只执行一次,并且不管是 cjs 和 esm 还是混用的情况都会得到同一份 globalState。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值