目录
- 前言
- Node 对 CJS 和 ESM 的支持
-
- 我们可以通过后缀来解决
-
- 通过 type 字段解决
-
- --input-type 标志
- 疑问
-
- module 字段的牛掰
- main 字段的缺点
- 王者 exports
-
- 作用域包
-
- 子路径的模式
-
- 支持条件导出
-
- 一式两份
- 总结
- 参考
前言
Node 有一个非常核心的知识点——模块,在前端模块化还未真正到来的时代,Node 给出的解决方案是 CommonJS 简称 CJS。
后来 ECMAScript 通过了 JS 的模块化系统,由此开辟了 CJS 和 ESM 同存的局面,在如今模块化流行的今天,你有没有想过大多数 package 为什么既能通过 CJS 使用也能通过 ESM 使用。
我们来研究下这个原理。
ESM 和 CJS:
- ESM - ECMAScript 模块
- CJS - CommonJS
Node 对 CJS 和 ESM 的支持
Node 默认支持 CJS,这我们都知道,后来支持了 ESM 所以 Node 做了怎么调整呢。
1. 我们可以通过后缀来解决
- ESM 以
.mjs结尾。 - CJS 以
.cjs结尾。
2. 通过 type 字段解决
- CJS 以
.js结尾的文件,且最近的父package.json中顶层字段"type"值为"commonjs"。 - ESM 以
.js结尾的文件,且最近的父package.json的顶层"type"值为"module"。
3. --input-type 标志
-
将标志
--input-type=commonjs作为--eval或--print的参数,或通过STDIN传递到node。 -
将标志为
--input-type=module的字串,作为--eval的参数传入或通过STDIN传入node
疑问
但是无论如何,正常情况下,一个 package 只能支持一种模块它要么是 ESM 要么是 CJS。
但你发现,大多数 package 都能通过 require 和 import 来使用,这是怎么回事呢?
module 字段的牛掰
原来我们借助 Node 原生支持 CJS 去支持 require 语法,借助 Webpack 等打包工具去识别 package.json 的 module 字段,从而支持 ESM,相对 require 还顺便做到了 tree-shaking。
main 字段的缺点
- main 字段首要的缺点就是不同时支持双格式。
- package 内部的文件无法隔离起来,可以随意引用,比如 我引用 chalk 的
package.json文件可以 import 这个相对路径node_modules/chalk/package.json。
新增的 exports 和打包工具支持的 module 有异曲同工之妙,但是 exports 获得了 Node 的原生支持而且还更强大。
王者 exports
exports 最重要的有三个作用:
- 作用域包。
- 子路径模式
- 支持条件导出
exports 还有其他功能,但不是我们今天文章的重点,所以略过了。
1. 作用域包
exports 和 main 字段两者是相互排斥的,如果你同时定义了 "exports" 和 "main",在支持"exports" 的 Node(版本大于等于 v12.7.0) 中 "exports" 会覆盖 "main",否则 "main" 生效。
所以我们只需要简单的复制 main 字段,改成 exports 即可使用 exports 功能,就像这样:
{
"main": "./index.js",
"exports": "./index.js"
}
注意非常要注意,如果 exports 字段生效,package 中未导出的文件,你是不能引用的,这一点不像 main 字段,这就是作用域包。
我们通过之前的文章 热乎乎的 workspaces 替代 npm link 调试的新方式 里面讲解的 workspaces 字段,创建的 calculator 计算器 demo 来讲解下。
在 加法 minus 文件夹下面,新增一个测试随意导出文件 subpath.js,内容为: export default (str) => str;,现在的文件夹目录
.
├── packages
│ ├── divide
│ │ ├── index.js
│ │ └── package.json
│ ├── minus
│ │ ├── subpath.js
│ │ ├── index.ts
│ │ └── package.json
│ ├── plus
│ │ ├── index.js
│ │ └── package.json
│ └── times
│ ├── index.js
│ └── package.json
minus 的 package.json 文件夹现在长成这样:
{
"main": "index.js"
}
我们使用的时候,可以随意引用包里面的文件,现在在根目录 index.js 文件 引入 subpath.js :
import subpath from "minus/subpath.js";
console.log(subpath("Hi JavaScript"));
但是,我们使用 exports 导出就不行了:
{
"main": "index.js",
"exports": "./index.js"
}
当定义了 "exports" 字段,所有子路径都会被封闭,调试抛出错误 ERR_PACKAGE_PATH_NOT_EXPORTED。
顺便多 YY 一句,npm 默认安装 package 的时候,真应该像 pnpm 学习下,做下包封闭功能。
2. 子路径的模式
好了,子路径封闭模式固然不错,但是有时候我们只想要一个包的某个功能,比如 Lodash 提供我们按需导入的能力。
这个就需要子路径模式了,其实就是做个路径映射。
还以上面的例子为例,我们如下在 exports 模式下做路径映射来。
"exports": {
".": "./index.js",
"./subpath.js": "./subpath.js"
},
这样在调试代码,ERR_PACKAGE_PATH_NOT_EXPORTED 错误就没了。
3. 支持条件导出
重点来了,条件导出,非常简单,. 表示当前目录。
"exports": {
".": {
"import": "./index.mjs",
"require": "./index.cjs"
}
},
当你使用这个 package 的时候 Node 将根据用户或下游包环境解析对应的模块规范。现在我可以在支持 import 环境的项目 import 它,也可以在支持 require 的项目 require 它。
一式两份
既然 package 需要支持两个模块化,那么问题来了,我们写代码不可能一份代码两份实现的,那必须的借助打包工具,Webapck 和 Rollup 都行,但它们的配置都太复杂了,等你搞完环境,写代码的灵感和心情估计都没了,今天我们来介绍一个比较小而美的工具——tsup。
tsup 还有一点完美的就是零配置结合 Typescript 使用,用法如下:
$ tsup src/index.ts
然后在你的项目根目录下就有 dist/index.js 文件供您发布。
当然,我们的重点是双格式的 module,所以支持双格式,只需一个标志:
$ tsup src/index.ts --format cjs,esm
两个文件dist/index.js,dist/index.mjs 一起生成,非常的 Nice。
另外我还强烈建议尝试一下速度惊人的 esbuild 打包。
发布 ESM 和 CJS 并存的 package
exports 是在 Node.js 12+ 中受支持,所以以前的发包模板基本是下面这样:
{
"name": "calculator",
"main": "./dist/index.js",
"module": "./dist/index.mjs",
"types": "./dist/index.d.ts"
}
types 字段可以省略,TS 默认以 main 指定的文件夹路径为准,然后把文件后缀由
.js改成.d.ts,在上面的案例如果省略 types 字段,TS 默认寻找声明文件路径是./dist/index.d.ts。
未来流行的模板肯定是下面这份 package.json 首选模板:
{
"name": "calculator",
"main": "./dist/index.js",
"module": "./dist/index.mjs",
"types": "./dist/index.d.ts",
"exports": {
".": {
"require": "./dist/index.js",
"import": "./dist/index.mjs",
"types": "./dist/index.d.ts"
}
},
"scripts": {
"build": "tsup src/index.ts --format cjs,esm --dts --clean",
"watch": "npm run build -- --watch src",
"prepublishOnly": "npm run build"
}
}
Note: exports 的 types 字段不是 node.js 原生支持的,是官方给第三方社区(TS 只是其中一个社区)指定的,参见:community-conditions-definitions。
exports 条件导出是可以嵌套的叫——nested-conditions,所以上面的模板可以改写如下:
{
"name":"calculator",
"main":"./dist/index.js",
"module":"./dist/index.mjs",
"types":"./dist/index.d.ts",
"exports":{
".":{
"require":{
"types":"./dist/index.d.ts",
"default":"./dist/index.js"
},
"import":{
"types":"./dist/index.d.ts",
"default":"./dist/index.mjs"
}
}
},
"scripts":{
"build":"tsup src/index.ts --format cjs,esm --dts --clean",
"watch":"npm run build -- --watch src",
"prepublishOnly":"npm run build"
}
}
这个不好,因为 types 字段定义了两遍,推荐第一种。
⚠️ 重大修正
注意这一节是重大更新,更新时间为 2023.08.30。
上面代码在 exports 中演示的包含两种模块格式导出并配合 types 字段在 TypeScript 中是错误的,因为 types 字段指定的路径不对,根据官网这个章节的解释: packagejson-exports-imports-and-self-referencing,我们以下面代码演示为例:
{
"name": "calculator",
"main": "./dist/index.js",
"module": "./dist/index.mjs",
"types": "./dist/index.d.ts",
"exports": {
".": {
"require": "./dist/index.js",
"import": "./dist/index.mjs"
}
}
}
exports 中同时使用了 import 和 require,且没有指定 types 字段,在 TypeScript 中就会这样加载文件:
- 使用 import 语法,加载位于
./dist/index.d.mts声明文件。 - 使用 require 语法,加载位于
./dist/index.d.ts声明文件,假如指定 require 的时候你使用了.cjs那么对应的声明文件就是.d.cts。
重点是 require 和 import 都需要独自的声明文件(两个声明文件的内容是一模一样的),但是必须有两份,你可以自己复制,也可以让打包工具生成,但是必须是两份。
上面是隐式加载声明文件,我们也可以显式指定,根据 default 指定的后缀名的不同,需要自己调整 types 字段的后缀名。
{
"name": "calculator",
"main": "./dist/index.js",
"module": "./dist/index.mjs",
"types": "./dist/index.d.ts",
"exports": {
".": {
"require": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"import": {
"types": "./dist/index.d.mts",
"default": "./dist/index.mjs"
}
}
}
}
目前就清楚了,如何需要同时支持 CJS 和 ESM 并正确指定类型,一份漂亮的模板应该是下面这样:
{
"name":"calculator",
"main":"./dist/index.js",
"module":"./dist/index.mjs",
"types":"./dist/index.d.ts",
"exports":{
".":{
"require":{
"types":"./dist/index.d.ts",
"default":"./dist/index.js"
},
"import":{
"types":"./dist/index.d.mts",
"default":"./dist/index.mjs"
}
}
},
"scripts":{
"build":"tsup src/index.ts --format cjs,esm --dts --clean",
"watch":"npm run build -- --watch src",
"prepublishOnly":"npm run build"
}
}
我在这里用了 另一种写法 作为最佳实践,你可以点击链接查看。
这里提供两个在线检查工具可以确定你是否正确指定了 types: arethetypeswrong.github.io 和 publint。
本次更新参考:
- packagejson-exports-imports-and-self-referencing
- fix(build): ensure correct typing for node esm
- FalseESM
- Blogged Answers: My Experience Modernizing Packages to ESM
TS 类型子包问题
下面这个 package.json 我添加一个子包 calculator/plus。
{
"name":"calculator",
"main":"./dist/index.js",
"module":"./dist/index.mjs",
"types":"./dist/index.d.ts",
"exports":{
".":{
"require":"./dist/index.js",
"import":"./dist/index.mjs",
"types":"./dist/index.d.ts"
},
"./plus":{
"require":"./dist/plus/index.js",
"import":"./dist/plus/index.mjs",
"types":"./dist/plus/index.d.ts"
}
},
"scripts":{
"build":"tsup src/index.ts --format cjs,esm --dts --clean",
"watch":"npm run build -- --watch src",
"prepublishOnly":"npm run build"
}
}
如果你同时满足下面两个条件,TS 就会报引用类型错误:
- Node 版本小于 12,不支持 exports 字段
- 子包引用,
import plus from "calculator/plus"
TS 报错:
Cannot find module 'calculator/plus' or its corresponding type declarations.
这是因为,exports 没生效,同时 TS 使用 types 字段,当你引用 calculator/plus,TS 实际解析的类型声明路径为:calculator/plus.d.ts 或 calculator/plus/index.d.ts,而真正的声明文件在 calculator/dist/plus/index.d.ts。
通过 version-selection-with-typesversions 可知,我们可以通过如下办法解决。
{
"typesVersions": {
"*": {
"*": [
"./dist/index.d.ts",
"./dist/plus/index.d.ts",
"./dist/*"
]
}
}
}
如果没有子包导出,就不要添加 typesVersions 字段。
总结
今天,回顾了模块化的发展,认识了如今 CJS 和 ESM 共存的局面,Node 也与时俱进跟进了双包的支持,为了弥补 package 未导出可能被滥用了的情况,Node 顺道完善了自身的功能。
目前还未看到有开源项目使用这个功能,但是我相信不就得未来你就能在各大开源项目看到它。