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 里)。
其他
库该怎么写?
其实很简单,要看你需要支持哪些平台:
只支持 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 格式书写的代码。这里有两种选择:
- 在 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。