聊聊Node两种模块规范:CJS 与 ESM,有什么不同点?

2023-11-11 10:11

本文主要是介绍聊聊Node两种模块规范:CJS 与 ESM,有什么不同点?,希望对大家解决编程问题提供一定的参考价值,需要的开发者们随着小编来一起学习吧!

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

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

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

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

一、并不完美的 ESM 支持

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 BlogDemo3220220test2esmb.js from BlogDemo3220220test2require.js not supported.
Instead change the require of b.js in BlogDemo3220220test2require.js to a dynamic import() which is available in all CommonJS modules.at Object.<anonymous> (BlogDemo3220220test2require.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,二者真是太不一样了。

二、CJS 和 ESM 的不同点

2.1 不同的加载逻辑

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

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

在此阶段 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、ESM 混写

借助构建工具可以实现 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两种模块规范:CJS 与 ESM,有什么不同点?的文章就介绍到这儿,希望我们推荐的文章对编程师们有所帮助!



http://www.chinasem.cn/article/389323

相关文章

Python中构建终端应用界面利器Blessed模块的使用

《Python中构建终端应用界面利器Blessed模块的使用》Blessed库作为一个轻量级且功能强大的解决方案,开始在开发者中赢得口碑,今天,我们就一起来探索一下它是如何让终端UI开发变得轻松而高... 目录一、安装与配置:简单、快速、无障碍二、基本功能:从彩色文本到动态交互1. 显示基本内容2. 创建链

Node.js 中 http 模块的深度剖析与实战应用小结

《Node.js中http模块的深度剖析与实战应用小结》本文详细介绍了Node.js中的http模块,从创建HTTP服务器、处理请求与响应,到获取请求参数,每个环节都通过代码示例进行解析,旨在帮... 目录Node.js 中 http 模块的深度剖析与实战应用一、引言二、创建 HTTP 服务器:基石搭建(一

Python读取TIF文件的两种方法实现

《Python读取TIF文件的两种方法实现》本文主要介绍了Python读取TIF文件的两种方法实现,包括使用tifffile库和Pillow库逐帧读取TIFF文件,具有一定的参考价值,感兴趣的可以了解... 目录方法 1:使用 tifffile 逐帧读取安装 tifffile:逐帧读取代码:方法 2:使用

python中的与时间相关的模块应用场景分析

《python中的与时间相关的模块应用场景分析》本文介绍了Python中与时间相关的几个重要模块:`time`、`datetime`、`calendar`、`timeit`、`pytz`和`dateu... 目录1. time 模块2. datetime 模块3. calendar 模块4. timeit

Python模块导入的几种方法实现

《Python模块导入的几种方法实现》本文主要介绍了Python模块导入的几种方法实现,文中通过示例代码介绍的非常详细,对大家的学习或者工作具有一定的参考学习价值,需要的朋友们下面随着小编来一起学习学... 目录一、什么是模块?二、模块导入的基本方法1. 使用import整个模块2.使用from ... i

python: 多模块(.py)中全局变量的导入

文章目录 global关键字可变类型和不可变类型数据的内存地址单模块(单个py文件)的全局变量示例总结 多模块(多个py文件)的全局变量from x import x导入全局变量示例 import x导入全局变量示例 总结 global关键字 global 的作用范围是模块(.py)级别: 当你在一个模块(文件)中使用 global 声明变量时,这个变量只在该模块的全局命名空

深入探索协同过滤:从原理到推荐模块案例

文章目录 前言一、协同过滤1. 基于用户的协同过滤(UserCF)2. 基于物品的协同过滤(ItemCF)3. 相似度计算方法 二、相似度计算方法1. 欧氏距离2. 皮尔逊相关系数3. 杰卡德相似系数4. 余弦相似度 三、推荐模块案例1.基于文章的协同过滤推荐功能2.基于用户的协同过滤推荐功能 前言     在信息过载的时代,推荐系统成为连接用户与内容的桥梁。本文聚焦于

MySQL高性能优化规范

前言:      笔者最近上班途中突然想丰富下自己的数据库优化技能。于是在查阅了多篇文章后,总结出了这篇! 数据库命令规范 所有数据库对象名称必须使用小写字母并用下划线分割 所有数据库对象名称禁止使用mysql保留关键字(如果表名中包含关键字查询时,需要将其用单引号括起来) 数据库对象的命名要能做到见名识意,并且最后不要超过32个字符 临时库表必须以tmp_为前缀并以日期为后缀,备份

Node.js学习记录(二)

目录 一、express 1、初识express 2、安装express 3、创建并启动web服务器 4、监听 GET&POST 请求、响应内容给客户端 5、获取URL中携带的查询参数 6、获取URL中动态参数 7、静态资源托管 二、工具nodemon 三、express路由 1、express中路由 2、路由的匹配 3、路由模块化 4、路由模块添加前缀 四、中间件

Node Linux相关安装

下载经编译好的文件cd /optwget https://nodejs.org/dist/v10.15.3/node-v10.15.3-linux-x64.tar.gztar -xvf node-v10.15.3-linux-x64.tar.gzln -s /opt/node-v10.15.3-linux-x64/bin/npm /usr/local/bin/ln -s /opt/nod