模块化热点 Pure ESM 与 Dual Package,你怎么看?

1,634 阅读9分钟

写在前面

本篇是前端工程化打怪升级的第 3 篇, 关注专栏 | 小册传送门 | 案例代码

Pure ESM 是目前模块化中比较有意思的一个话题,最早由sindresorhus 在 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,即提供 ESM 和 Commonjs 双格式产物
  • 对于日常开发,强烈推荐使用 ESMPure ESM 具备传染性ESM 使用基数的提升,可能引发多米诺骨牌效应,加速 Commonjs 的淘汰。

ESM & Commonjs 的互通

JavaScript 现在有两种模块,一种是 Commonjs 模块,另一种是 ESMCommonjs 采取同步加载方案,主要应用于 Nodejs 中;ESM 则采用异步加载方案,两者互相并不兼容。

群雄逐鹿,前端模块化的未来在何方中讲过,Node v12 版本后,开始提供对 ESM 的原生支持;ES2020 提案中,ESM 引入 import() 函数,来实现动态加载模块。也就是说,ESM 其实已经实现了对 Commonjs 的全面覆盖,还额外附带自己的优势,ESMCommonjs 更具潜力。

why-esm.png

Nodejs v12 以后,可以支持 ESMCommonjs 模块协同操作,但两者并不能互相加载,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.jsESM,会抛出以下错误:

(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 包,但同时也带来了一个非常明显的负面效应——同步执行环境异步化,这意味着整体的执行顺序都需要被异步,这并不是一种上佳的解决方案。

小结

通过上面四种情况的分析,我们可以发现 CommonjsESM 两者虽然一定程度上可以互通,但存在诸多问题。

  • Commonjs 中无法使用 import 语法,同样 ESM 也无法使用 require 语法
  • Commonjs 无法通过 require 导入 ESM 模块,但可以借助 dynamic import 动态加载实现
  • ESM 可以通过 import 导入 Commonjs 模块

ESM 可以兼容 CommonjsCommonjs 导入 ESM 则存在诸多限制,这也就意味着目前社区中存在的大量 Commonjs Only 基础包无法与现代 ESM 包完美适配,迁移到 ESM 又需要大量的人力物力消耗,诸如此类,都严重阻挡了 ESM 大一统趋势的发展。

针对这种情况,社区中提出一种折中方案——Dual Package

Dual Package

Dual Package 实现的关键在于 package.json 中提供的新字段 exportsexports 属性类似于 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 便是借助条件导出而实现的,通过条件导出可以指定多个条目以有条件地提供 CommonjsESM 格式产物。

条件导出有三大核心属性

  • require: 指定 require 方式导入情形,例如 require('testmodule')
  • import: 指定 import 方式导入情形
  • default: 默认情形,兜底方案
"exports": {
  ".": {
    "require": "./index.cjs",
    "import": "./index.mjs"
  },
  "./childmodule": "./lib/childmodule.js"
},

. 路径别名定义项目入口,requireimport 分别定义 CommonjsESM 格式产物位置,使用 require 加载时,入口文件为 index.cjs,使用 import 加载时,入口文件为 index.mjs。通过这种方式实现了 CommonjsESM 双格式产物的导出,也就是 Dual Package

注意事项

Tips1: 意外的双包加载风险

当使用 Dual Package 模式开发包时,同时提供 CommonjsESM 两种版本产物,这两种产物在可以在同一运行时环境中被加载,这可能造成一些未知的错误。虽然应用程序或者包并不会有意加载两种版本,但可能存在一些意外情况,例如我们的应用代码使用 import ESM 版本,而项目其它依赖项 require 了 Commonjs 版本,这就造成包的两个版本同时被加载到内存中,造成某些难以解决的错误。

千里大堤,溃于蚁穴,双包模式虽然非常强大,但一定要尽可能避免双包风险,具体方案请参考文档Nodejs 文档

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 ESMDual Package

ESMCommonjs 互通的不兼容是造成当今模块化冲突的根源所在,此外社区中尚存有大量 Commonjs Only 的基础包,整体向 ESM 迁移需要消耗巨量成本,目前来看,Pure ESM 有几分武断,但从长远来看,ESM 必将是模块化未来的规范,提倡使用 ESM 规范,共同推动社区向 ESM 进发。

Dual Package 借助 Nodejs 新增加的条件导出功能,实现了一种双包格式导出的折中模块化处理方案,由于各类开发者所用模块化规范不一致,可能会造成双包风险,从而引起一系列未知错误。Dual Package 方案还意味着要开发两种格式产物,开发成本相对较高。

从模块化历史进步的长河来看,Dual Packages 只能是模块化完善之路的一步台阶,暂时的救急之法,无法根深蒂固的影响整个模块化体系,积极拥抱 ESM,才是前端模块化规范完善的阳关大道。

后语

我是  战场小包 ,一个快速成长中的小前端,希望可以和大家一起进步。

如果喜欢小包,可以在  掘金  关注我,同样也可以关注我的小小公众号——小包学前端

一路加油,冲向未来!!!