Construction | 张小伦的网络日志

劝架CommonJS和ESModule

Posted on:2021-02-10 09:29
    JavaScrtipt
    Node.js

今天在开发过程中遇到一个报错

Error [ERR_REQUIRE_ESM]: Must use import to load ES Module: /root/projects/build-tool/node_modules/ora/index.js
require() of ES modules is not supported.
require() of /root/projects/build-tool/node_modules/ora/index.js from /root/projects/build-tool/init.js is an ES module file as it is a .js file whose nearest parent package.json contains "type": "module" which defines all .js files in that package scope as ES modules.
Instead rename index.js to end in .cjs, change the requiring code to use import(), or remove "type": "module" from /root/projects/build-tool/node_modules/ora/package.json.

糟糕,遇到CommonJS和ESModule两兄弟打架了😢

错误信息简单翻译一下就是: 我项目中使用require()加载了一个ES Module,后者不支持require()。要么我把自己的 JavaScript 文件后缀改成 .cjs ,然后用 import() 方法加载Ora。要么我把Ora的package.json 中的 type: module 去掉

按照第一种方式修改之后,代码立马就能跑起来。但是这种方法还是有些问题:

  1. 项目中其他文件也有可能会引入到ESModule模式的依赖,我需要改更多文件的后缀
  2. 文件后缀修改之后,在其被导入的地方需要同步修改
  3. 目录中.js.cjs的文件混在一起,都是JavaScript却总是给开发者一种混乱的感觉
  4. 在文件内部 require()import() 并存,也带来了混乱

那么有没有更加优雅的“劝架”方法呢?在回答这个问题前,我们先来认识认识这两兄弟。

CommonJS

在ESModule诞生之前,CommonJS作为NodeJS的模块系统标准。CommonJS是NodeJS社区孵化出的模块标准,实现了JavaScript在服务端的模块化。在CommonJS中,每一个JavaScript文件都是一个模块,可以使用module.exports 或者 exports导出变量,函数和对象。使用require()加载模块。默认使用同步的方式加载模块。

// foo.js
module.exports = function() { 
  return 'Hello foo!';
}

// index.js
var foo = require('./foo');
console.log(foo()); // Hello foo!

具体的标准可以参考 NodeJS Modules

CommonJS是面向服务端的模块系统,浏览器端无法使用。早期使用AMD模块规范在浏览器端实现JavaScript的模块化开发和加载。Webpack等构建工具,按照NodeJS中的CommonJS标准,实现了一套解析逻辑,在打包阶段注入对CommonJS模块的解析逻辑,使得能够像NodeJS一样加载和导出CommonJS。这使得在前端开发阶段就能用上相对比较“标准化”的模块化开发。

ESModule

在 ES6 提出了 ESModule 作为语言标准以后,JavaScript这门语言终于有了自己的模块化方案了,具体的标准可参考ECMAScript Modules

自 Node v8.5以来,开发人员已经能够使用参数--experimental-modules 开启对ES模块规范的支持。从Node v12.4开始,模块可以在.mjs文件(或在某些情况下的.js文件)中定义,或者在Package.json中增加type: modules来表示启用ESModule。而自Node v15.3开始,可以不用 experimental flag 使用ESModule。

与CommonJS相比对最为直观的差别是,ESModule 使用 import 导出模块,使用 import 加载模块。比如说:

// foo.mjs
export function foo() { 
  return 'Hello foo!'; 
}

// index.mjs
import { foo } from './foo.mjs';
console.log(foo()); // Hello foo!

当然二者的差别不止如此。而二者之间的差异也间接引发了文章开篇所提到的那个问题。

简单谈谈二者的差异

在CommonJS中,require() 是同步的。它不会返回Promise,也支持Callback。加载到模块之后会立刻执行,返回模块返回的对象。

在ESM中,模块的查找模块加载器。查找阶段,解析JavaScript文件找到import和export的调用,但不会执行import的文件。在解析阶段,ESM加载器可以立即检测到命名导入中的错误,并抛出一个异常,而不需要实际运行依赖的代码。解析通过之后,加载器开始异步下载和解析你导入的JavaScript,构造出依赖的module graph,最后代码开始执行。ES模块加载器保证了,在ES Module 构建的依赖图中,所有的脚本都是并行下载,但是顺序执行。

可以看到,二者在模块加载和解析的过程已经有很大的差别了。这也使得两种方案下的模块无法很好的互相导入和导出。

CommonJS不能 require() ESModule的最简单的原因是,后者允许顶层的await ,但是前者不行。

在ESModule中,你可以这么写:

export const foo = await fetch('./data.json');

但是在CommonJS中你得这么写:

(async () => {
	const { foo } = await fetch('./foo.mjs');
});

CommonJS可以导入 ESMoudle,但是不太好用。必须使用异步的动态导入

(async () => {
    const {foo} = await import('./foo.mjs');
})();

虽然能用,但是在导出的时候你得导出Promise,这可能会让使用的人感到困惑。

ESModule 不能在导入阶段直接导入CommonJS具名导出的对象,你可以这么写:

import _ form './lodahs.cjs';

但是不能在导入的同时进行解构,因为解构出来的对象是具名导出的对象,下面的操作会报错

import { shuffle } from './lodash.cjs'
// SyntaxError: The requested module './lodash.cjs' does not provide an export named 'shuffle'

因为CommonJS只有在执行的时候才能计算出具名导出的对象,而ESModule的模块计算发生在解析阶段。所以你只能在导入之后再解构。

import _ from './lodash.cjs';
const {shuffle} = _;

对导出对象的处理上也有差异,在CommonJS中,导出的对象所指向的值是可以修改的

// b.js
exports.name = 'b';

// a.js
let { name } = require('b');
name = 123;

而使用ESModule规范的导入被当成常量处理,不允许修改:

// a.js
export let num = 1
export const addNumber = ()=>{
    num++
}

// b.js
import {  num , addNumber } from './a'
num = 2

// 报错
num = 2
    ^
TypeError: Assignment to constant variable.

二者有如此多的差异,最好还是不要混用。但是在ESModule出现之前,所有的NPM包都是CommonJS规范标准下开发的。在实际开发过程中难免会遇到二者混合出现的场景,也就是文章开头遇到的错误。

但是再想想,在日常的前端开发中,似乎经常遇到两种写法混用的情况,而且还没有遇到任何上述提到的问题。为什么呢?

那是因为现在前端开发基本使用bundler工具打包过,其中使用了babel,将ES6的语法进行了转换,bundler工具最后以CommonJS的模式输出。在产出物中可以找到__esModule属性,将导入的模块标记为ESModule,方便后续的处理。

// a.js
export default 1;

// main.js
import a from './a';

console.log(a);

转换之后

// a.js
Object.defineProperty(exports, "__esModule", {
  value: true
});
exports.default = 1;

// main.js
'use strict';

var _a = require('./a');

var _a2 = _interopRequireDefault(_a);

function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }

console.log(_a2.default);

所以回到最初的问题,有没有更优雅一些当时来解决我的问题呢?有,加上babel配置进行一次编译。

加上一次编译

@babel/preset-env 是babel的一个核心插件,用来转换代码,让开发者能用上新的特性。其中的modules属性允许我们将ES Module 转换成另一个模块类型。默认是auto, 设置为目标模块类型为CommonJS即可。

参考:

  1. Node Modules at War: Why CommonJS and ES Modules Can’t Get Along

CommonJS的执行过程

在CommonJS中,使用require()加载模块,使用module.exports 导出模块。咱们先来看看require(),考虑一下代码:

// main.js
const fs = require('fs');
const a = require('./a';
const b = require('../b/index.js');

module.exports = {
	a,
  b
}

在main.js 真正执行之前,Node会用一个Module Wrapper 将代码包裹

(function (exports, require, module, __filename, __dirname) {
	// main.js
	const fs = require('fs');
	const a = require('./a';
	const b = require('../b/index.js');
	
	module.exports = {
		a,
	  b
	}
});

通过这种方式实现了:

  1. 将模块顶部的变量作用域(比如: var, const或者let)限制在模块中,而不是全局对象global
  2. 提供了一些指向当前模块的,看起来像是全局的变量
    1. module 和 exports ,可以导出模块中的值
    2. __filename是模块的绝对路径,__dirname是模块文件所在的文件夹路径

解决了模块作用域的问题之后,再来看看require()的事情。使用require可以导入NodeJS模块,JSON和本地文件。假设我们在目录Y中使用require(x)导入X,具体的执行过程如下:

1. 如果 X 是核心模块(比如: fs, path, http)
   a. 返回核心模块
   b. 停止
2. 如果 X 以 '/' 开头
   a. 将 Y 设置为系统根目录
3. 如果 X 以 './' or '/' 或者 '../'开头,按照下面的步骤顺序执行
   a. 执行 LOAD_AS_FILE(Y + X)
   b. 执行 LOAD_AS_DIRECTORY(Y + X)
   c. 抛错 "not found"
4. 如果 X 以 '#' 开头
   a. 执行 LOAD_PACKAGE_IMPORTS(X, dirname(Y))
5. 执行 LOAD_PACKAGE_SELF(X, dirname(Y))
6. 执行 LOAD_NODE_MODULES(X, dirname(Y))
7. 抛错 "not found"

LOAD_AS_FILE(X)
1. 如果 X 是一个文件, 以该文件的格式加载。停止
2. 如果 X.js 是一个文件, 以 JavaScript 文本的形式加载该文件。停止
3. 如果 X.json 是一个文件, 将它解析为一个 JavaScript 对象。 停止
4. 如果 X.node 是一个文件, 以二进制插件的形式加载该文件。 停止

LOAD_INDEX(X)
1. 如果 X/index.js 是一个文件, 以 JavaScript 文本的形式加载该文件。 停止
2. 如果 X/index.json 是一个文件, 将它解析为一个 JavaScript 对象。停止
3. 如果 X/index.node 是一个文件, 以二进制插件的形式加载该文件。停止

LOAD_AS_DIRECTORY(X)
1. 如果 X/package.json 是一个文件,
   a. 解析这个文件,查找 main 字段
   b. 如果 main 是可为 false 的值,前往第二步
   c. 否则 创建一个新的路径 M = X + (json main field)
   d. LOAD_AS_FILE(M)
   e. LOAD_INDEX(M)
   f. 抛错 "not found"
2. LOAD_INDEX(X)

LOAD_NODE_MODULES(X, START)
1. 从当前目录开始,向上遍历找到所有的“node_modules” let DIRS = NODE_MODULES_PATHS(START)
2. 遍历每一个目录,依次执行下面的步骤,直到最后抛出 "not found"
   a. LOAD_PACKAGE_EXPORTS(X, DIR)
   b. LOAD_AS_FILE(DIR/X)
   c. LOAD_AS_DIRECTORY(DIR/X)

NODE_MODULES_PATHS(START)
1. let PARTS = path split(START)
2. let I = count of PARTS - 1
3. let DIRS = [GLOBAL_FOLDERS]
4. while I >= 0,
   a. if PARTS[I] = "node_modules" CONTINUE
   b. DIR = path join(PARTS[0 .. I] + "node_modules")
   c. DIRS = DIRS + DIR
   d. let I = I - 1
5. return DIRS

LOAD_PACKAGE_IMPORTS(X, DIR)
1. 找到离DIR最近的作用域SCOPE
2. 如果没有找到SCOPE,返回
3. 如果 SCOPE/package.json "imports" 是 null 或者 undefined, 返回
4. 4. 如果 SCOPE/package.json 的 "imports" 不为空,则检查 X 和 SCOPE 对应的文件的路径 let MATCH = PACKAGE_IMPORTS_RESOLVE(X, pathToFileURL(SCOPE), ["node", "require"]) 。
5. RESOLVE_ESM_MATCH(MATCH).

LOAD_PACKAGE_EXPORTS(X, DIR)
1. Try to interpret X as a combination of NAME and SUBPATH where the name
   may have a @scope/ prefix and the subpath begins with a slash (`/`).
2. 如果 X does not match this pattern or DIR/NAME/package.json is not a file,
   return.
3. Parse DIR/NAME/package.json, and look for "exports" field.
4. If "exports" is null or undefined, return.
5. let MATCH = PACKAGE_EXPORTS_RESOLVE(pathToFileURL(DIR/NAME), "." + SUBPATH,
   `package.json` "exports", ["node", "require"])defined in the ESM resolver.
6. RESOLVE_ESM_MATCH(MATCH)

LOAD_PACKAGE_SELF(X, DIR)
1. Find the closest package scope SCOPE to DIR.
2. If no scope was found, return.
3. If the SCOPE/package.json "exports" is null or undefined, return.
4. If the SCOPE/package.json "name" is not the first segment of X, return.
5. let MATCH = PACKAGE_EXPORTS_RESOLVE(pathToFileURL(SCOPE),
   "." + X.slice("name".length), `package.json` "exports", ["node", "require"])
defined in the ESM resolver.
6. RESOLVE_ESM_MATCH(MATCH)

RESOLVE_ESM_MATCH(MATCH)
1. let { RESOLVED, EXACT } = MATCH
2. let RESOLVED_PATH = fileURLToPath(RESOLVED)
3. If EXACT is true,
   a. If the file at RESOLVED_PATH exists, load RESOLVED_PATH as its extension
      format. 停止
4. Otherwise, if EXACT is false,
   a. LOAD_AS_FILE(RESOLVED_PATH)
   b. LOAD_AS_DIRECTORY(RESOLVED_PATH)
5. THROW "not found"

PACKAGE_IMPORTS_RESOLVE 的定义在 esm_resolver_algorithm,里面还执行了其他的算法逻辑,感兴趣的同学可以自行阅读。在模块查找的过程中,如果找到了的package.json中有import字段,便会执行 PACKAGE_IMPORTS_RESOLVE ,返回的是一个对象:

```javascript
resolvedMatch {
	resolved: String; // 模块对应的路径
	exact: Boolean; // 精确查找 or 模糊查找
}
```