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

2021/3/30

# 写在前面

本篇是前端工程化打怪升级的第 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 双格式产物
  • 对于日常开发,强烈推荐使用 ESMPure ESM 具备传染性ESM 使用基数的提升,可能引发多米诺骨牌效应,加速 Commonjs 的淘汰。

# ESM & Commonjs 的互通

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

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

why esm

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 (opens new window)

# 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 文档 (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 ESMDual Package

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

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

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

# 后语

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

如果喜欢小包,可以在  github (opens new window)  关注我,同样也可以关注我的小小公众号——小包学前端 (opens new window)

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