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保存相同的模块。
你要请我喝一杯奶茶?
版权声明:自由转载-非商用-保持署名和原文链接。
本站文章均为本人原创,参考文章我都会在文中进行声明,也请您转载时附上署名。