NodeJS中CommonJS和ESModule混用的问题
JavaScript在过去几年飞速发展,同时也带来了很多混乱的问题,比如模块系统cjs、esm混用,或者js、ts混用的项目比比皆是。
能理解将新技术落地到项目中的心情,但同时也需要具备一些知识避免这些混乱带来的影响。
指定项目模块类型
早期版本的NodeJS只支持CommonJS模块,也被称作CJS。
在13.2.0之后,NodeJS正式支持ES Modules模块化,也被称作ESM,当然同时还兼容了历史的CJS模块
对于一个node项目,可以通过在pacakge.json的type字段声明当前项目采用的模块方案
- CJS对应的值为
commonjs,为了兼容,该值为默认值 - ESM对应的值
module
对于该项目中的所有js文件,都会根据这个字段来判断选取的模块类型。
这两种模块的在NodeJS中具有不同的语法,具体可以参考NodeJS官方文档
如果在文件中采用了非项目配置的模块引入方式,比如在一个ESM的项目中,使用了require,就会得到下面的提示
ReferenceError: require is not defined in ES module scope, you can use import instead ...
如果某个js文件需要单独指定模块类型,可以通过修改后缀名来实现,如果文件名是下面这两种特殊后缀,则会忽略package.json中的type。
.cjs对应CJS模块.mjs对应ESM模块
这个功能非常有用,比如对于一个ESM的项目,项目根目录往往还会保存一些第三方工具的配置文件,比如.eslintrc.js等,某些第三方工具的版本只支持CJS的模块(比如在配置文件中需要通过module.exports导出配置项),这个时候将该文件后缀修改为.eslintrc.cjs就可以了。
除了这种第三方依赖库不兼容导致的模块系统混用之外,对于该项目下的用户文件,最好统一一个模块标准(推荐使用ESM),而尽量不要通过修改后缀的方式混用模块。
如果项目有混用,就需要参考下面的几种情况。注意这里的所有调用都只是在Node环境下直接运行的,不包含前端通过babel等工具调用的场景
ESM调用CJS模块
可以像正常的ESM模块那样调用
// lib.cjs
exports.greet = function(){}
// index.mjs
import lib from './lib.cjs'
lib.greet()需要注意的是,如果在lib.cjs这个CJS模块对module.exports重新赋值,则import最终导入的模块是module.exports,前面的exports导出会被丢失。
// lib.cjs
exports.greet = function(){}
// 重写了module.exports
module.exprots = {
test(){ }
}
// index.mjs
import lib from './lib.cjs'
lib.greet() // error!!造成这种情况的根本原因是CommonJS模块和ES Modules之间导入/导出机制的不同
在CommonJS模块中,module.exports对象用于定义模块导出的API。只要给module.exports重新赋值,那么这个对象就会被作为模块的导出值。
但是在ES模块中,import语句是被静态分析的,编译器会在编译阶段对模块的导入导出行为进行分析。当你使用import lib from './lib.cjs'语句时,编译器会期望lib.cjs导出是一个默认导出(无需使用default关键字),并将其赋值给lib变量。
但问题在于,当lib.cjs中使用module.exports = {...}这种方式时,它实际上是导出了一个新对象,而非默认导出。ES模块编译器无法正确地解析这种情况,所以会导致上面的错误。
要解决这个办法,一种方法是像上面通过exports.xxx导出。
第二种方法是为module.exports添加一个default字段,然后再引入的使用通过lib.default.xxx调用方法,这种方法本质上跟写了一个exports.default={}比较像,太冗余了,应该没人会用。
// lib.cjs
module.exports = {
greet() {}
}
module.exports.default = module.exports
// index.mjs
import lib from './lib.cjs'
lib.default.greet()本身在CJS中,也不推荐exports.xxx和module.exports两种导出方式混用。
第三种方法就是:不要混用模块!
CJS调用ESM
可以通过dynamic import()在CJS中引入ESM,同样不太推荐混用
// lib.mjs
export function greet() {
console.log('hello')
}
// index.cjs
import('./2.mjs').then((lib) => {
lib.greet()
})双重CommonJS/ES 模块包
当你准备编写一个模块并发布到npm上时,就需要目标用户使用的模块类型了。
前面提到,pacakge.json的type字段声明当前项目采用的模块方案,而main字段会作为整个包的入口文件。对于某个只在NodeJS运行下的包,只需要显式声明了type字段,然后指定main入口文件即可。
但是一个包是采用某种模块类型开发的,并不意味着调用方也是按照这种模块类型开发的。
尽管包的开发者可以“固执己见“地强行只选择某个模块,另外一种更友好的做法是同时提供ESM和CJS模块入口文件,NodeJS在加载这个包的使用,根据使用者所在的模块系统自动选择。
这种模块也被称作Dual CommonJS/ES module packages,参考:Nodejs官方文档。
可以通过conditional-exports显式声明ESM和CJS的入口文件
{
"type": "module",
"exports": {
"import": "./index.mjs",
"require": "./index.cjs"
}
}module字段
在NodeJS只支持CJS的时候,只需要在package.json的main字段,指定整个包的CJS入口文件。
npm上也包含了很多可以通过webpack、rollup等bundlers打包并在浏览器运行的包,这些包也是支持CJS的。
在ESM出来之后,部分构建工具,如rollup可以利用ES的特性来进行打包优化,比如tree shaking。
因此有些包提供了ESM模块,为了兼容历史的main字段被CJS的入口占据了(且在过渡期间还有大量的包只支持CJS模块),因此约定了package.json的module字段指定包的ESM入口文件。
需要注意的是,module字段只是用于给webpack、rollup等工具使用的,NodeJS本身并不使用这个字段。
更后来的时间,TypeScript也变得非常流行,为了让IDE提供更好的代码提示,包的提供者可以通过.d.ts文件,并通过package.json的types字段指定包的类型声明文件。
因此,在现代JavaScript的包开发过程中,大体流程上是
- 通过typescript ESM代码编写源代码
- 分别构建CJS、ESM等模块的类型,有些还会提供UMD和IIFE等类型的模块
- 通过
exports字段,或者使用main和module字段声明入口文件 - 通过
types字段指定类型声明文件
其他问题
CJS的全局变量
需要注意的是__filename, __dirname 这些都是专属于CJS的全局变量,ESM中没有定义这些变量,参考官方文档,如果在ESM中使用了这些变量,会得到如下错误提示
ReferenceError: __dirname is not defined in ES module scope
但是这两个变量在编写脚本时是很有用的,下面是在ESM中等价的__filename和__dirname写法
import path from 'path'
import { fileURLToPath } from 'url'
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)在NodeJS的v21之后,也可以通过import.meta.dirname、import.meta.filename来访问这两个变量
Node内置模块
NodeJS内置了path、fs等众多模块,现在也支持ESM了,由于历史问题,下面这两种引入方式是等同的
import path from 'path'
import * as path from 'path'小结
一句话:拥抱标准,使用ESM,让前端JavaScript和NodeJS保存相同的模块。
你要请我喝一杯奶茶?
版权声明:自由转载-非商用-保持署名和原文链接。
本站文章均为本人原创,参考文章我都会在文中进行声明,也请您转载时附上署名。
