侧边栏

NodeJS中CommonJS和ESModule混用的问题

发布于 | 分类于 前端/模块化

JavaScript在过去几年飞速发展,同时也带来了很多混乱的问题,比如模块系统cjs、esm混用,或者js、ts混用的项目比比皆是。

能理解将新技术落地到项目中的心情,但同时也需要具备一些知识避免这些混乱带来的影响。

指定项目模块类型

早期版本的NodeJS只支持CommonJS模块,也被称作CJS

13.2.0之后,NodeJS正式支持ES Modules模块化,也被称作ESM,当然同时还兼容了历史的CJS模块

对于一个node项目,可以通过在pacakge.jsontype字段声明当前项目采用的模块方案

  • 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模块那样调用

js
// lib.cjs
exports.greet = function(){}

// index.mjs
import lib from './lib.cjs'
lib.greet()

需要注意的是,如果在lib.cjs这个CJS模块对module.exports重新赋值,则import最终导入的模块是module.exports,前面的exports导出会被丢失。

js
// 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={}比较像,太冗余了,应该没人会用。

js
// lib.cjs
module.exports = {
  greet() {}
}
module.exports.default = module.exports

// index.mjs
import lib from './lib.cjs'
lib.default.greet()

本身在CJS中,也不推荐exports.xxxmodule.exports两种导出方式混用。

第三种方法就是:不要混用模块!

CJS调用ESM

可以通过dynamic import()在CJS中引入ESM,同样不太推荐混用

js
// lib.mjs
export function greet() {
    console.log('hello')
}

// index.cjs
import('./2.mjs').then((lib) => {
    lib.greet()
})

双重CommonJS/ES 模块包

当你准备编写一个模块并发布到npm上时,就需要目标用户使用的模块类型了。

前面提到,pacakge.jsontype字段声明当前项目采用的模块方案,而main字段会作为整个包的入口文件。对于某个只在NodeJS运行下的包,只需要显式声明了type字段,然后指定main入口文件即可。

但是一个包是采用某种模块类型开发的,并不意味着调用方也是按照这种模块类型开发的。

尽管包的开发者可以“固执己见“地强行只选择某个模块,另外一种更友好的做法是同时提供ESMCJS模块入口文件,NodeJS在加载这个包的使用,根据使用者所在的模块系统自动选择。

这种模块也被称作Dual CommonJS/ES module packages,参考:Nodejs官方文档

可以通过conditional-exports显式声明ESM和CJS的入口文件

json
{
  "type": "module",
  "exports": {
    "import": "./index.mjs",
    "require": "./index.cjs"
  }
}

module字段

在NodeJS只支持CJS的时候,只需要在package.jsonmain字段,指定整个包的CJS入口文件。

npm上也包含了很多可以通过webpack、rollup等bundlers打包并在浏览器运行的包,这些包也是支持CJS的。

在ESM出来之后,部分构建工具,如rollup可以利用ES的特性来进行打包优化,比如tree shaking

因此有些包提供了ESM模块,为了兼容历史的main字段被CJS的入口占据了(且在过渡期间还有大量的包只支持CJS模块),因此约定了package.jsonmodule字段指定包的ESM入口文件。

需要注意的是,module字段只是用于给webpack、rollup等工具使用的,NodeJS本身并不使用这个字段

更后来的时间,TypeScript也变得非常流行,为了让IDE提供更好的代码提示,包的提供者可以通过.d.ts文件,并通过package.jsontypes字段指定包的类型声明文件。

因此,在现代JavaScript的包开发过程中,大体流程上是

  • 通过typescript ESM代码编写源代码
  • 分别构建CJS、ESM等模块的类型,有些还会提供UMD和IIFE等类型的模块
  • 通过exports字段,或者使用mainmodule字段声明入口文件
  • 通过types字段指定类型声明文件

其他问题

CJS的全局变量

需要注意的是__filename, __dirname 这些都是专属于CJS的全局变量,ESM中没有定义这些变量,参考官方文档,如果在ESM中使用了这些变量,会得到如下错误提示

ReferenceError: __dirname is not defined in ES module scope

但是这两个变量在编写脚本时是很有用的,下面是在ESM中等价的__filename__dirname写法

js
import path from 'path'
import { fileURLToPath } from 'url'

const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)

在NodeJS的v21之后,也可以通过import.meta.dirnameimport.meta.filename来访问这两个变量

Node内置模块

NodeJS内置了pathfs等众多模块,现在也支持ESM了,由于历史问题,下面这两种引入方式是等同的

js
import path from 'path'

import * as path from 'path'

小结

一句话:拥抱标准,使用ESM,让前端JavaScript和NodeJS保存相同的模块。

你要请我喝一杯奶茶?

版权声明:自由转载-非商用-保持署名和原文链接。

本站文章均为本人原创,参考文章我都会在文中进行声明,也请您转载时附上署名。