聊聊Node两种模块规范:CJS 与 ESM,有什么不同点?(CommonJS模块和ES6模块的区别

Written by

in

文章目录

本篇文章给大家带大家了解一下Node的两种模块规范(难以相容的 CJS 与 ESM),介绍一下CJS 和 ESM 的不同点,怎么实现 CJS、ESM 混写,希望对大家有所帮助!

13.2.0 版本开始,Nodejs 在保留了 CommonJS(CJS)语法的前提下,新增了对 ES Modules(ESM)语法的支持。

天下苦 CJS 久已,Node 逐渐拥抱新标准的规划当然值得称赞,我们也会展望未来 Node 不再需要借助工具,就能打破两种模块化语法的壁垒……

但实际上,一切没有想象中的那么美好。

1.1 在 Node 中使用 ESM

Node 默认只支持 CJS 语法,这意味着你书写了一个 ESM 语法的 js 文件,将无法被执行。

如果想在 Node 中使用 ESM 语法,有两种可行方式:

  • ⑴ 在 package.json 中新增 "type": "module" 配置项。
  • ⑵ 将希望使用 ESM 的文件改为 .mjs 后缀。

对于第一种方式,Node 会将和 package.json 文件同路径下的模块,全部当作 ESM 来解析。

第二种方式不需要修改 package.json,Node 会自动地把全部 xxx.mjs 文件都作为 ESM 来解析。

我们可以通过上述修改 package.json 的方式,来让全部模块都以 ESM 形式执行,然后项目上的模块都统一使用 ESM 语法来书写。

如果存在较多陈旧的 CJS 模块懒得修改,也没关系,把它们全部挪到一个文件夹,在该文件夹路径下新增一个内容为 {"type": "commonjs"}package.json 即可。

Node 在解析某个被引用的模块时(无论它是被 import 还是被 require),会根据被引用模块的后缀名,或对应的 package.json 配置去解析该模块。

1.2 ESM 引用 CJS 模块的问题

ESM 基本可以顺利地 import CJS 模块,但对于具名的 exports(Named exports,即被整体赋值的 module.exports),只能以 default export 的形式引入:

/** @file cjs/a.js **/
// named exports
module.exports = {
    foo: () => {
        console.log("It's a foo function...")
    }
}


/** @file index_err.js **/
import { foo } from './cjs/a.js';  
// SyntaxError: Named export 'foo' not found. The requested module './cjs/a.js' is a CommonJS module, which may not support all module.exports as named exports.
foo();


/** @file index_err.js **/
import pkg from './cjs/a.js';  // 以 default export 的形式引入
pkg.foo();  // 正常执行

登录后复制

具体原因我们会在后续提及。

1.3 CJS 引用 ESM 模块的问题

假设你在开发一个供别人使用的开源项目,且使用 ESM 的形式导出模块,那么问题来了 —— 目前 CJS 的 require 函数无法直接引入 ESM 包,会报错:

let { foo } = require('./esm/b.js');
              ^

Error [ERR_REQUIRE_ESM]: require() of ES Module BlogDemo3\220220\test2\esm\b.js from BlogDemo3\220220\test2\require.js not supported.
Instead change the require of b.js in BlogDemo3\220220\test2\require.js to a dynamic import() which is available in all CommonJS modules.
    at Object.<anonymous> (BlogDemo3\220220\test2\require.js:4:15) {
  code: 'ERR_REQUIRE_ESM'
}

登录后复制

按照上述错误陈述,我们不能并使用 require 引入 ES 模块(原因会在后续提及),应当改为使用 CJS 模块内置的动态 import 方法:

import('./esm/b.js').then(({ foo }) => {
    foo();
});

// or

(async () => { 
    const { foo } = await import('./esm/b.js'); 
})();

登录后复制

开源项目当然不能强制要求用户改用这种形式来引入,所以又得借助 rollup 之类的工具将项目编译为 CJS 模块……


由上可见目前 Node.js 对 ESM 语法的支持是有限制的,如果不借助工具处理,这些限制可能会很糟心。

对于想入门前端的新手来说,这些麻烦的规则和限制也会让人困惑。

截至我落笔书写本文时, Node.js LTS 版本为 16.14.0,距离开始支持 ESM 的 13.2.0 版本已过去了两年多的时间。

那么为何 Node.js 到现在还无法打通 CJS 和 ESM?

答案并非 Node.js 敌视 ESM 标准从而迟迟不做优化,而是因为 —— CJS 和 ESM,二者真是太不一样了。

2.1 不同的加载逻辑

在 CJS 模块中,require() 是一个同步接口,它会直接从磁盘(或网络)读取依赖模块并立即执行对应的脚本。

ESM 标准的模块加载器则完全不同,它读取到脚本后不会直接执行,而是会先进入编译阶段进行模块解析,检查模块上调用了 importexport 的地方,并顺腾摸瓜把依赖模块一个个异步、并行地下载下来。

在此阶段 ESM 加载器不会执行任何依赖模块代码,只会进行语法检错、确定模块的依赖关系、确定模块输入和输出的变量。

最后 ESM 会进入执行阶段,按顺序执行各模块脚本。

在上方 1.2 小节,我们曾提及到 ESM 中无法通过指定依赖模块属性的形式引入 CJS named exports:

/** @file cjs/a.js **/
// named exports
module.exports = {
    foo: () => {
        console.log("It's a foo function...")
    }
}

/** @file index_err.js **/
import { foo } from './cjs/a.js';  
// SyntaxError: Named export 'foo' not found. The requested module './cjs/a.js' is a CommonJS module, which may not support all module.exports as named exports.
foo();

登录后复制

这是因为 ESM 获取所指定的依赖模块属性(花括号内部的属性),是需要在编译阶段进行静态分析的,而 CJS 的脚本要在执行阶段才能计算出它们的 named exports 的值,会导致 ESM 在编译阶段无法进行分析。

2.2 不同的模式

ESM 默认使用了严格模式(use strict),因此在 ES 模块中的 this 不再指向全局对象(而是 undefined),且变量在声明前无法使用。

这也是为何在浏览器中,<script> 标签如要启用原生引入 ES 模块能力,必须加上 type="module" 告知浏览器应当把它和常规 JS 区分开来处理。

2.3 ESM 支持“顶级 await”,但 CJS 不行。

ESM 支持顶级 await(top-level await),即 ES 模块中,无须在 async 函数内部就能直接使用 await

// index.mjs
const { foo } = await import('./c.js');
foo();

登录后复制

在 CSJ 模块中是没有这种能力的(即使使用了动态的 import 接口),这也是为何 require 无法加载 ESM 的原因之一。

试想一下,一个 CJS 模块里的 require 加载器同步地加载了一个 ES 模块,该 ES 模块里异步地 import 了一个 CJS 模块,该 CJS 模块里又同步地去加载一个 ES 模块…… 这种复杂的嵌套逻辑处理起来会变得十分棘手。

2.4 ESM 缺乏 __filename 和 __dirname

在 CJS 中,模块的执行需要用函数包起来,并指定一些常用的值:

  NativeModule.wrapper = [
    '(function (exports, require, module, __filename, __dirname) { ',
    '\n});'
  ];

登录后复制

所以我们才可以在 CJS 模块里直接用 __filename__dirname

而 ESM 的标准中不包含这方面的实现,即无法在 Node 的 ESM 里使用 __filename__dirname


从上方几点可以看出,在 Node.js 中,如果要把默认的 CJS 切换到 ESM,会存在巨大的兼容性问题。

这也是 Node.js 目前,甚至未来很长一段时间,都难以解决的一场模块规范持久战。

如果你希望不借助工具和规则,也能放宽心地使用 ESM,可以尝试使用 Deno 替代 Node,它默认采用了 ESM 作为模块规范(当然生态没有 Node 这么完善)。

借助构建工具可以实现 CJS 模块、ES 模块的混用,甚至可以在同一个模块同时混写两种规范的 API,让开发不再需要关心 Node.js 上面的限制。另外构建工具还能利用 ESM 在编译阶段静态解析的特性,实现 Tree-shaking 效果,减少冗余代码的输出。

这里我们以 rollup 为例,先做全局安装:

pnpm i -g rollup

登录后复制

接着再安装 rollup-plugin-commonjs 插件,该插件可以让 rollup 支持引入 CJS 模块(rollup 本身是不支持引入 CJS 模块的):

pnpm i --save-dev @rollup/plugin-commonjs

登录后复制

我们在项目根目录新建 rollup 配置文件 rollup.config.js

import commonjs from 'rollup-plugin-commonjs';

export default {
  input: 'index.js',  // 入口文件
  output: {
    file: 'bundle.js',  // 目标文件
    format: 'iife'
  },
  plugins: [
    commonjs({
      transformMixedEsModules: true,
      sourceMap: false,
    })
  ]
};

登录后复制

接着执行 rollup --config 指令,就能按照 rollup.config.js 进行编译和打包了。

示例

/** @file a.js **/
export let func = () => {
    console.log("It's an a-func...");
}

export let deadCode = () => {
    console.log("[a.js deadCode] Never been called here");
}


/** @file b.js **/
// named exports
module.exports = {
    func() {
        console.log("It's a b-func...")
    },
    deadCode() {
        console.log("[b.js deadCode] Never been called here");
    }
}


/** @file c.js **/
module.exports.func = () => {
    console.log("It's a c-func...")
};

module.exports.deadCode = () => {
    console.log("[c.js deadCode] Never been called here");
}


/** @file index.js **/
let a = require('./a');
import { func as bFunc } from './b.js';
import { func as cFunc } from './c.js';

a.func();
bFunc();
cFunc();

登录后复制

打包后的 bundle.js 文件如下:

(function () {
	'use strict';

	function getAugmentedNamespace(n) {
		if (n.__esModule) return n;
		var a = Object.defineProperty({}, '__esModule', {value: true});
		Object.keys(n).forEach(function (k) {
			var d = Object.getOwnPropertyDescriptor(n, k);
			Object.defineProperty(a, k, d.get ? d : {
				enumerable: true,
				get: function () {
					return n[k];
				}
			});
		});
		return a;
	}

	let func$1 = () => {
	    console.log("It's an a-func...");
	};

	let deadCode = () => {
	    console.log("[a.js deadCode] Never been called here");
	};

	var a$1 = /*#__PURE__*/Object.freeze({
		__proto__: null,
		func: func$1,
		deadCode: deadCode
	});

	var require$$0 = /*@__PURE__*/getAugmentedNamespace(a$1);

	var b = {
	    func() {
	        console.log("It's a b-func...");
	    },
	    deadCode() {
	        console.log("[b.js deadCode] Never been called here");
	    }
	};

	var func = () => {
	    console.log("It's a c-func...");
	};

	let a = require$$0;

	a.func();
	b.func();
	func();

})();

登录后复制

可以看到,rollup 通过 Tree-shaking 移除掉了从未被调用过的 c 模块的 deadCode 方法,但 a、b 两模块中的 deadCode 代码段未被移除,这是因为我们在引用 a.js 时使用了 require,在 b.js 中使用了 CJS named exports,这些都导致了 rollup 无法利用 ESM 的特性去做静态解析。

常规在开发项目时,还是建议尽量使用 ESM 的语法来书写全部模块,这样可以最大化地利用构建工具来减少最终构建文件的体积。


希望本文能为你提供帮助,共勉~

更多node相关知识,请访问:nodejs 教程!

以上就是聊聊Node两种模块规范:CJS 与 ESM,有什么不同点?的详细内容,更多请关注风君子博客其它相关文章!

评论

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注

站内搜索