ESModule与CommonJS互相引用的问题

前言

最近使用Rollup打包输出CJS模块的代码,时不时会提示导入的模块是ESM规范,需要使用动态导入的方式引入这个模块,这个问题引起了我的注意,所以决定深入研究一下。

  • 简单介绍ESMCJS的原理
  • ESMCJS的互相引用问题

CJS和ESM的历史演变

JavaScript一开始诞生是在浏览器上作为脚本语言使用的,当时在浏览器上并不存在模块的概念。它就像日本动漫里的男主角,刚转生到异世界时没有任何的武器和装备(不是龙傲天),所以说一开始的JavaScript也是不完善的,需要时间的历练,打怪升级学技能。

直到Node.JS的诞生,让JavaScript能在服务端运行,同时Node.js社区的发展给JavaScript带来了模块化的概念,使用module.exports导出和require引入模块,这个规范叫做CommonJS。它的出现使得服务端的JavaScript应用程序的开发更加规范化和标准化。

由于CommonJS的引入模块原理是同步加载的方式,要是在浏览器端使用这个规范,则需要等待所有的模块加载完才能执行后面的代码。由于浏览器中的JavaScript引擎是单线程的,如果加载JavaScript模块的过程中,代码执行的时间过长,就会导致其他代码无法执行,从而影响整个页面的渲染和交互,最终影响了用户体验。因此,浏览器需要一种异步加载模块方式。

在2015年的6月,ES6标准正式推出,在这个标准里带来了JavaScript异步加载模块的ESM规范。

ESM,也叫做 ECMAScript modules或者ES modules,是来自ES6规范里的模块化概念,使用importexport关键字,来实现js的模块化导入和导出。

ESModule和CommonJS互相引用

完全不同的两个规范,互相导入的话会导致什么情况呢?

在继续之前,我们需要了解Node.js支持运行ESM规范的最低版本是:v13.2.0,在之前的版本中,想要在node中使用ESM,需要添加--experimental-module参数,在v13.2.0版本之后可以直接使用。

我将会使用Node.jsv16.18.0版本进行下面的测试,话不多说,立马进入。

CJS导入ESM模块

  1. 先新建一个ESM模块,文件名为: esm.js,代码如下:
// esm.js
export const a = 1;
export const b = 2;
export function foo () {
  return 3;
}
  1. 另外新建一个CJS模块,文件名为:cjs.js,代码如下:
// cjs.js
const { a, b, foo } = require('./esm.js')

console.log(a);
console.log(b);
console.log(foo());
  1. 运行cjs.js文件,结果提示以下的错误信息:
export const a = 1;
^^^^^^

SyntaxError: Unexpected token 'export'

通过来说,如果在CJS上通过相对路径方式引入.js结尾的ESM模块,就会遇到上面的问题。根本原因Node.js底层在加载这个esm.js文件时,把它识别成了CJS规范,最后使用cjs的loader进行处理时抛出的异常:

Pasted image 20230605165120

那么问题来了,Node.js是如何识别引入的文件是ESM还是CJS规范的?其实Node.js有一个判断的机制叫做:Determining module system。用图例简单解析这个机制的判断逻辑:

Drawing 2023-06-05 17.08.24.excalidraw

为了Determining module system能够把esm.js正确识别成ESM模块,我们有2种方案:

第一种:修改文件名后缀的方式。

步骤如下:

  1. 我们修改一下esm.js文件为esm.mjs,然后调整一下cjs.js文件的内容如下:
// cjs.js
const { a, b, foo } = require('./esm.mjs');

console.log(a);
console.log(b);
console.log(foo());
  1. 执行命令node cjs.js,又抛出异常了,不过这次的报错稍微不太一样:
node:internal/modules/cjs/loader:1031
    throw new ERR_REQUIRE_ESM(filename, true);
    ^

Error [ERR_REQUIRE_ESM]: require() of ES Module esm.mjs not supported.
Instead change the require of esm.mjs to a dynamic import() which is available in all CommonJS modules.
  1. 大致的意思是ESM模块不支持通过require()函数引入,需要替换成通过import()函数动态引入ESM模块,那我们就改一下cjs.js文件中代码,通过动态import的方式引入:
async function main() {
  const mod = await import('./esm.mjs');
  const { a, b, foo } = mod;
  console.log(a);
  console.log(b);
  console.log(foo());
}

main();
  1. 成功输出:
1
2
3

第二种:在文件同级或者父级目录加一个package.json文件,并且加上"type"字段,值为"module"。

为了让cjs.jsesm.js分别识别成正确的规范。我们改造成下图的结构:

Pasted image 20230605180856

其中lib/package.json的文件内容如下,这样esm.js就会被识别成ESM模块。

// lib/package.json
{
  "type": "module"
}

然后cjs.js文件导入esm.js我们可以改写成:

// cjs.js
async function main() {
  // 这里改成导入./lib/esm.js 注意看后缀是 .js
  const mod = await import('./lib/esm.js');
  const { a, b, foo } = mod;
  console.log(a);
  console.log(b);
  console.log(foo());
}

main();

最后运行命令:node ./cjs.js,成功!!!

1
2
3

经过以上的实验,我们了解到Node.js的底层机制已经支持CJS模块导入ESM模块,不过需要注意以下的几个点:

  • 根据Determining Module System机制,需要修改ESM模块的文件后缀名为.mjs或者通过package.json加上"type": "module"字段。
  • CJS只能通过import()函数的方式导入ESM模块。

ESM导入CJS模块

在继续之前,我们需要了解Node.js支持运行ESM规范的最低版本是:v13.2.0,在此之前,想要在node中使用ESM,需要添加--experimental-module参数。在v13.2.0版本之后可以直接使用。

首先新建一个文件夹,结构如下:

Pasted image 20230605182410

  1. 编辑根目录下的package.json文件,加上type字段,值为"module",如下所示:
// package.json
{
  ...,
  "type": "module",
  ...,
}

这样esm.js就会被当做ESM模块处理。

  1. 编辑lib/package.json,内容如下:
// lib/package.json
{
    // 或者可以加上"type": "commonjs"
}
  1. esm.jscjs.js文件内容分别为:
// ./lib/cjs.js
module.exports = {
  a: 1,
  b: 2,
  foo: function () {
    return 3
  }
}

// esm.js
import pkg from './lib/cjs.js'

const { a, b, foo} = pkg;

console.log(a);
console.log(b);
console.log(foo());
  1. 运行node esm.js,输出了以下信息:
1
2
3

ESM成功引入CJS模块!!!

在Node.js中ESM模块是支持引入CJS模块的,但是注意以下几点:

  • 为了让Node.js正确识别这是一个CJS模块,根据Determining Module System机制,需要修改ESM模块的文件后缀名为.cjs,或者通过同级或者最近父级文件夹下的package.json加上"type": "commonjs"字段。
  • 转换时,CJS模块的module.exportsESM模块的export default对应。

总结

经过以上的章节,我们了解到CJSESM模块各自的特点,也知道了Node.js的Determining module system机制时是如何识别代码属于哪种模块,再通过不同的loader去转换代码。

当你需要封装一些类库,引入不同规范依赖的时候也能够游刃有余地处理,也可以配合一些打包工具例如Rollup的使用。

原本两种模块规范是分别用在不同的端,但是由于ESM规范的异步加载优势,天生支持Tree Shaking,以及各种JavaScript打包工具的流行,例如WebpackRollup等等,越来越多的开发者在封装各类工具库时使用ESM规范。

后面我将会写一篇文章讲解使用Rollup工具的简单使用,来解决打包成CJS规范时处理ESM。不过也许在不远的将来,Node.js默认使用ESM规范执行js代码也说不定。

感谢你阅读到这里,如有发现文章中存在错误的地方也请指正。