模块化热点 Pure ESM 与 Dual Package,你怎么看?
# 写在前面
本篇是前端工程化打怪升级的第 3 篇, 小册传送门 (opens new window) | 案例代码 (opens new window)
Pure ESM 是目前模块化中比较有意思的一个话题,最早由sindresorhus (opens new window) 在 Github 上的一个帖子中提出,它的意思不难理解,"纯净的 ESM",对此暂且有两种解读:一种是比较狭义的理解,即 npm 包仅保留 ESM 格式产物,抛弃其他格式产物;广义的理解包容性更强,即所有的 npm 包都提供 ESM 格式产物。
Pure ESM 这个概念天生带有一份激进性和排他性,在社区里面引发了一系列讨论,众说纷纭,赞成的有,不赞成亦有。ESM 作为面向未来的前端模块化标准,已经足以应对于现代模块化开发的大部分场景。社区中越来越多的包也开始拥抱 ESM 大一统趋势,开始提供 ESM 格式产物,部分包更加激进,直接采用 Pure ESM 模式,例如 chalk 包,v5.0 以后将不再支持 Commonjs 产物。
面对来势汹汹的 Pure ESM,我们应该持有什么样的态度那?首先谈一下我的看法:
从长远趋势来看,Pure ESM 是革命性的正确,推动 ESM 大一统能有效推进前端开发的规范性,提高开发效率。但从现状来看,目前尚存有大量 Commonjs Only 的包,一昧的推行 Pure ESM 有些过于武断。
因此,我们应该结合实际,客观的对待 Pure ESM:
- 对于没有上层封装的大型框架,例如
Vite、Next、Umi等,鼓励使用ESM,推动社区向ESM迁移 - 对于底层基础库,推荐使用 Dual Package (opens new window),即提供 ESM 和 Commonjs 双格式产物
- 对于日常开发,强烈推荐使用
ESM,Pure ESM具备传染性,ESM使用基数的提升,可能引发多米诺骨牌效应,加速Commonjs的淘汰。
# ESM & Commonjs 的互通
JavaScript 现在有两种模块,一种是 Commonjs 模块,另一种是 ESM。Commonjs 采取同步加载方案,主要应用于 Nodejs 中;ESM 则采用异步加载方案,两者互相并不兼容。
在群雄逐鹿,前端模块化的未来在何方 (opens new window)中讲过,Node v12 版本后,开始提供对 ESM 的原生支持;ES2020 提案中,ESM 引入 import() 函数,来实现动态加载模块。也就是说,ESM 其实已经实现了对 Commonjs 的全面覆盖,还额外附带自己的优势,ESM 比 Commonjs 更具潜力。
Nodejs v12 以后,可以支持 ESM 和 Commonjs 模块协同操作,但两者并不能互相加载,ESM 可以加载 Commonjs,Commonjs 缺无法通过 require 加载 ESM。
# Commonjs 下使用 import
Commonjs 模块是无法使用 import 语法,import 只允许用于 ESM 模块。例如下面的栗子:
// commonjs-import
// index.js
import { name } from "./esm.mjs";
console.log(name);
// esm.mjs
export const name = "test";
此时,如果 package.json 不指定 index.js 为 ESM,会抛出以下错误:
(node:58816) 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)
d:\workspace\blogs\Front-end-engineering\code\3.pure-esm\commonjs-import\index.js:1
import { name } from "./esm.mjs";
^^^^^^
SyntaxError: Cannot use import statement outside a module
at wrapSafe (internal/modules/cjs/loader.js:979:16)
at Module._compile (internal/modules/cjs/loader.js:1027:27)
at Object.Module._extensions..js (internal/modules/cjs/loader.js:1092:10)
at Module.load (internal/modules/cjs/loader.js:928:32)
at Function.Module._load (internal/modules/cjs/loader.js:769:14)
at Function.executeUserEntryPoint [as runMain] (internal/modules/run_main.js:72:12)
at internal/main/run_main_module.js:17:47
# ESM 下使用 require
Commonjs 模块规范中,每一个文件为一个模块,模块的本质为一个函数,require、exports 等为函数上下文中的参数,ESM 中不存在该上下文,因此 require 是无法使用的。
# ESM 通过 import 导入 Commonjs 模块
在 ESM 中,可以使用 import 直接导入 Commonjs 模块,但注意由于 Commonjs 导出 module.exports 为对象,ESM 存有静态分析过程,因此只能使用整体加载模式。
// esm-import-commonjs/commonjs.cjs
module.exports.onlyCommonjs = true;
// esm-import-commonjs/esm.js
import commonjs from "./commonjs-conly.cjs";
console.log(commonjs.onlyCommonjs); // true
// error
import { onlyCommonjs } from "./commonjs-conly.cjs";
console.log(commonjs.onlyCommonjs); // true
# Commonjs 加载 ESM
ESM 中可以通过 import 加载 Commonjs 模块,而 Commonjs 中则无法通过 require 加载 ESM 模块,具体看下面案例:
// commonjs-import-esm/esmOnly.mjs
export const onlyESM = true;
// commonjs-import-esm/common.js
const { onlyESM } = require("./esm-only.mjs");
console.log(onlyESM);
Error [ERR_REQUIRE_ESM]: Must use import to load ES Module.
这根本原因在于 require 是同步加载,而 ESM 则是异步加载,无法在同步上下文中导入异步模块。
那是不是意味着 Commonjs 中无法导入 ESM 模块呐?不是的,ES2020 提供的 import() 函数是一个例外,借助它可以实现在 Commonjs 中导入 ESM 模块。
// commonjs-import-esm/common.js
(async () => {
const { onlyESM } = await import("./esm-only.mjs");
console.log(onlyESM); // true
})();
借助 dynamic import 成功实现了 Commonjs 导入 ESM 包,但同时也带来了一个非常明显的负面效应——同步执行环境异步化,这意味着整体的执行顺序都需要被异步,这并不是一种上佳的解决方案。
# 小结
通过上面四种情况的分析,我们可以发现 Commonjs 与 ESM 两者虽然一定程度上可以互通,但存在诸多问题。
Commonjs中无法使用import语法,同样ESM也无法使用require语法Commonjs无法通过require导入ESM模块,但可以借助dynamic import动态加载实现ESM可以通过import导入Commonjs模块
ESM 可以兼容 Commonjs,Commonjs 导入 ESM 则存在诸多限制,这也就意味着目前社区中存在的大量 Commonjs Only 基础包无法与现代 ESM 包完美适配,迁移到 ESM 又需要大量的人力物力消耗,诸如此类,都严重阻挡了 ESM 大一统趋势的发展。
针对这种情况,社区中提出一种折中方案——Dual Package (opens new window)。
# Dual Package
Dual Package 实现的关键在于 package.json 中提供的新字段 exports。exports 属性类似于 main,都是为 package.json 提供入口信息描述,此外 exports 优先级高于 main。
// es-module-package
// ./node_modules/es-module-package/package.json
{
"type": "module",
"main": "./src/index.js"
}
通过 main 指定 es-module-package 包的入口文件,格式为 ES6 模块,此时便可以使用 import 进行加载。
// ./my-app.mjs
import { something } from "es-module-package";
// 实际加载的是 ./node_modules/es-module-package/src/index.js
脚本运行后,Nodejs 便会去 node_modules 下寻找 es-module-package 包,然后根据 package.json 下的 main 属性执行入口文件。
main 属性比较好理解,下面主要讲一下 exports 的多种导出方式:默认导出、子路径导出、条件导出
首先建立一个如下格式的项目:
// exports
├── index.js
├── package.json
├── node_modules
│ ├── testmodule
│ | ├── index.cjs
│ | ├── index.mjs
│ | ├── package.json
│ │ └── lib
│ | └── childmodule.js
# 子路径导出
package.json 文件的 exports 字段可以指定脚本或子目录的别名。
// ./node_modules/testmodule/package.json
{
"exports": {
"./childmodule": "./lib/childmodule.js"
}
}
看上面的栗子,定义 lib/childmodule.js 别名为 childmodule,此时便可以通过 childmodule 进行加载。
import childmodule from "testmodule/childmodule";
// 加载 ./node_modules/testmodule/lib/childmodule.js
# 默认导出
exports 属性如果使用 . 指定别名,则代表模块的主入口,其优先级高于 main 字段。
{
"exports": {
".": "./main.js"
}
}
// 等同于
{
"exports": "./main.js"
}
高版本 Nodejs 才支持 exports 字段,通常我们要考虑低版本的兼容问题。
{
"main": "./main-legacy.cjs", // 低版本入口文件
"exports": {
".": "./main-modern.cjs" // 高版本入口文件
}
}
# 条件导出
Dual Package 便是借助条件导出而实现的,通过条件导出可以指定多个条目以有条件地提供 Commonjs 及 ESM 格式产物。
条件导出有三大核心属性
require: 指定require方式导入情形,例如require('testmodule')import: 指定import方式导入情形default: 默认情形,兜底方案
"exports": {
".": {
"require": "./index.cjs",
"import": "./index.mjs"
},
"./childmodule": "./lib/childmodule.js"
},
. 路径别名定义项目入口,require 及 import 分别定义 Commonjs 和 ESM 格式产物位置,使用 require 加载时,入口文件为 index.cjs,使用 import 加载时,入口文件为 index.mjs。通过这种方式实现了 Commonjs 和 ESM 双格式产物的导出,也就是 Dual Package。
# 注意事项
Tips1: 意外的双包加载风险
当使用 Dual Package 模式开发包时,同时提供 Commonjs 和 ESM 两种版本产物,这两种产物在可以在同一运行时环境中被加载,这可能造成一些未知的错误。虽然应用程序或者包并不会有意加载两种版本,但可能存在一些意外情况,例如我们的应用代码使用 import ESM 版本,而项目其它依赖项 require 了 Commonjs 版本,这就造成包的两个版本同时被加载到内存中,造成某些难以解决的错误。
千里大堤,溃于蚁穴,双包模式虽然非常强大,但一定要尽可能避免双包风险,具体方案请参考文档Nodejs 文档 (opens new window)
Tips2: type:module 强制 ESM 格式
在 Nodejs 中,一般有两种方式来支持原生 ESM:
- 文件名以
.mjs结尾 package.json中配置type: module
配置 type: module 后,文件中 .js 扩展名都将被视为 ESM 模块,此时下列配置便会发生错误。
// ./node_modules/testmodule/package.json
{
"type": "module",
"exports": {
"import": "./index.mjs",
"require": "./index.js"
}
}
index.js 默认被视为 ESM 模块,因此 require 导出指令会失效。因此双包模式在编写时,如果配置 type: module,需要显式指定扩展名,来引导 Nodejs 识别模块类型。
// ./node_modules/testmodule/package.json
{
"type": "module",
"exports": {
"import": "./index.mjs",
"require": "./index.cjs"
}
}
# 总结
本文讲解了当前模块化方案中比较热点的话题——Pure ESM 与 Dual Package。
ESM 与 Commonjs 互通的不兼容是造成当今模块化冲突的根源所在,此外社区中尚存有大量 Commonjs Only 的基础包,整体向 ESM 迁移需要消耗巨量成本,目前来看,Pure ESM 有几分武断,但从长远来看,ESM 必将是模块化未来的规范,提倡使用 ESM 规范,共同推动社区向 ESM 进发。
Dual Package 借助 Nodejs 新增加的条件导出功能,实现了一种双包格式导出的折中模块化处理方案,由于各类开发者所用模块化规范不一致,可能会造成双包风险,从而引起一系列未知错误。Dual Package 方案还意味着要开发两种格式产物,开发成本相对较高。
从模块化历史进步的长河来看,Dual Packages 只能是模块化完善之路的一步台阶,暂时的救急之法,无法根深蒂固的影响整个模块化体系,积极拥抱 ESM,才是前端模块化规范完善的必经之路。
# 后语
我是 战场小包 ,一个快速成长中的小前端,希望可以和大家一起进步。
如果喜欢小包,可以在 github (opens new window) 关注我,同样也可以关注我的小小公众号——小包学前端 (opens new window)。
一路加油,冲向未来!!!