# CommonJS 和 ES6 Module 究竟有什么区别?

文章:CommonJS 和 ES6 Module 究竟有什么区别?

作者:FESKY

链接:https://juejin.cn/post/6844904080955932680

来源:掘金

作为前端开发者,你是否也曾有过疑惑,为什么可以代码中可以直接使用 require 方法加载模块,为什么加载第三方包的时候 Node 会知道选择哪个文件作为入口,以及常被问到的,为什么 ES6 Module export 基础数据类型的时候会有【引用类型】的效果?

带着这些疑问和好奇,希望阅读这篇文章能解答你的疑惑。

# CommonJS 规范

在 ES6 之前,ECMAScript 并没有提供代码组织的方式,那时候通常是基于 IIFE 来实现“模块化”,随着 JavaScript 在前端大规模的应用,以及服务端 Javascript 的推动,原先浏览器端的模块规范不利于大规模应用。于是早期便有了 CommonJS 规范,其目标是为了定义模块,提供通用的模块组织方式。

# 模块定义和使用

在 Commonjs 中,一个文件就是一个模块。定义一个模块导出通过 exports 或者 module.exports 挂载即可。

exports.count = 1;
复制代码
1
2

导入一个模块也很简单,通过 require 对应模块拿到 exports 对象。

const counter = require('./counter');
console.log(counter.count);
1
2

CommonJS 的模块主要由原生模块 module 来实现,这个类上的一些属性对我们理解模块机制有很大帮助。

Module {
  id: '.', // 如果是 mainModule id 固定为 '.',如果不是则为模块绝对路径
  exports: {}, // 模块最终 exports
  filename: '/absolute/path/to/entry.js', // 当前模块的绝对路径
  loaded: false, // 模块是否已加载完毕
  children: [], // 被该模块引用的模块
  parent: '', // 第一个引用该模块的模块
  paths: [ // 模块的搜索路径
   '/absolute/path/to/node_modules',
   '/absolute/path/node_modules',
   '/absolute/node_modules',
   '/node_modules'
  ]
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14

# require 从哪里来?

在编写 CommonJS 模块的时候,我们会使用 require 来加载模块,使用 exports 来做模块输出,还有 module__filename, __dirname 这些变量,为什么它们不需要引入就能使用?

原因是 Node 在解析 JS 模块时,会先按文本读取内容,然后将模块内容进行包裹,在外层裹了一个 function,传入变量。再通过 vm.runInThisContext 将字符串转成 Function形成作用域,避免全局污染。

let wrap = function(script) {
  return Module.wrapper[0] + script + Module.wrapper[1];
};

const wrapper = [
  '(function (exports, require, module, __filename, __dirname) { ',
  '\n});'
];
1
2
3
4
5
6
7
8

于是在 CommmonJS 的模块中可以不需要 require,直接访问到这些方法,变量。

参数中的 module 是当前模块的的 module 实例(尽管这个时候模块代码还没编译执行),exportsmodule.exports 的别名,最终被 require 的时候是输出 module.exports 的值。require 最终调用的也是 Module._load 方法。__filename__dirname 则分别是当前模块在系统中的绝对路径和当前文件夹路径。

# 模块的查找过程

开发者在使用 require 时非常简单,但实际上为了兼顾各种写法,不同类型的模块,node_modules packages 等模块的查找过程稍微有点麻烦。

首先,在创建模块对象时,会有 paths 属性,其值是由当前文件路径计算得到的,从当前目录一直到系统根目录的 node_modules。可以在模块中打印 module.paths 看看。

[ 
  '/Users/evan/Desktop/demo/node_modules',
  '/Users/evan/Desktop/node_modules',
  '/Users/evan/node_modules',
  '/Users/node_modules',
  '/node_modules'
]
1
2
3
4
5
6
7

除此之外,还会查找全局路径(如果存在的话)

[
  execPath/../../lib/node_modules, // 当前 node 执行文件相对路径下的 lib/node_modules
  NODE_PATH, // 全局变量 NODE_PATH
  HOME/.node_modules, // HOME 目录下的 .node_module
  HOME/.node_libraries' // HOME 目录下的 .node-libraries
]
1
2
3
4
5
6

按照官方文档给出的查找过程已经足够详细,这里只给出大概流程。

Y 路径运行 require(X)

1. 如果 X 是内置模块(比如 require('http')
  a. 返回该模块。
  b. 不再继续执行。

2. 如果 X 是以 '/' 开头、
   a. 设置 Y'/'

3. 如果 X 是以 './''/''../' 开头
   a. 依次尝试加载文件,如果找到则不再执行
      - (Y + X)
      - (Y + X).js
      - (Y + X).json
      - (Y + X).node
   b. 依次尝试加载目录,如果找到则不再执行
      - (Y + X + package.json 中的 main 字段).js
      - (Y + X + package.json 中的 main 字段).json
      - (Y + X + package.json 中的 main 字段).node
  c. 抛出 "not found"
4. 遍历 module paths 查找,如果找到则不再执行
5. 抛出 "not found"
复制代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23

模块查找过程会将软链替换为系统中的真实路径,例如 lib/foo/node_moduels/bar 软链到 lib/barbar 包中又 require('quux'),最终运行 foo module 时,require('quux') 的查找路径是 lib/bar/node_moduels/quux 而不是 lib/foo/node_moduels/quux

# 模块加载相关

# MainModule

当运行 node index.js 时,Node 调用 Module 类上的静态方法 _load(process.argv[1]) 加载这个模块,并标记为主模块,赋值给 process.mainModulerequire.main,可以通过这两个字段判断当前模块是主模块还是被 require 进来的。

CommonJS 规范是在代码运行时同步阻塞性地加载模块,在执行代码过程中遇到 require(X) 时会停下来等待,直到新的模块加载完成之后再继续执行接下去的代码。

虽说是同步阻塞性,但这一步实际上非常快,和浏览器上阻塞性下载、解析、执行 js 文件不是一个级别,硬盘上读文件比网络请求快得多。

# 缓存和循环引用

文件模块查找挺耗时的,如果每次 require 都需要重新遍历文件夹查找,性能会比较差;还有在实际开发中,模块可能包含副作用代码,例如在模块顶层执行 addEventListener ,如果 require 过程中被重复执行多次可能会出现问题。

CommonJS 中的缓存可以解决重复查找和重复执行的问题。模块加载过程中会以模块绝对路径为 key, module 对象为 value 写入 cache。在读取模块的时候会优先判断是否已在缓存中,如果在,直接返回 module.exports;如果不在,则会进入模块查找的流程,找到模块之后再写入 cache

// a.js
module.exports = {
    foo: 1,
};

// main.js
const a1 = require('./a.js');
a1.foo = 2;

const a2 = require('./a.js');

console.log(a2.foo); // 2
console.log(a1 === a2); // true
1
2
3
4
5
6
7
8
9
10
11
12
13

以上例子中,require a.js 并修改其中的 foo 属性,接着再次 require a.js 可以看到两次 require 结果是一样的。

模块缓存可以打印 require.cache 进行查看。

{ 
    '/Users/evan/Desktop/demo/main.js': 
       Module {
         id: '.',
         exports: {},
         parent: null,
         filename: '/Users/evan/Desktop/demo/main.js',
         loaded: false,
         children: [ [Object] ],
         paths: 
          [ '/Users/evan/Desktop/demo/node_modules',
            '/Users/evan/Desktop/node_modules',
            '/Users/evan/node_modules',
            '/Users/node_modules',
            '/node_modules'
          ]
       },
  '/Users/evan/Desktop/demo/a.js': 
       Module {
         id: '/Users/evan/Desktop/demo/a.js',
         exports: { foo: 1 },
         parent: 
          Module {
            id: '.',
            exports: {},
            parent: null,
            filename: '/Users/evan/Desktop/demo/main.js',
            loaded: false,
            children: [Array],
            paths: [Array] },
         filename: '/Users/evan/Desktop/demo/a.js',
         loaded: true,
         children: [],
         paths: 
          [ '/Users/evan/Desktop/demo/node_modules',
            '/Users/evan/Desktop/node_modules',
            '/Users/evan/node_modules',
            '/Users/node_modules',
            '/node_modules' ] } }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39

缓存还解决了循环引用的问题。举个例子,现在有模块 a require 模块 b;而模块 b 又 require 了模块 a。

// main.js
const a = require('./a');
console.log('in main, a.a1 = %j, a.a2 = %j', a.a1, a.a2);

// a.js
exports.a1 = true;
const b = require('./b.js');
console.log('in a, b.done = %j', b.done);
exports.a2 = true;

// b.js
const a = require('./a.js');
console.log('in b, a.a1 = %j, a.a2 = %j', a.a1, a.a2);
1
2
3
4
5
6
7
8
9
10
11
12
13

程序执行结果如下:

in b, a.a1 = true, a.a2 = undefined
in main, a.a1 = true, a.a2 = true
1
2

实际上在模块 a 代码执行之前就已经创建了 Module 实例写入了缓存,此时代码还没执行,exports 是个空对象

'/Users/evan/Desktop/module/a.js': 
   Module {
     exports: {},
     //...
  }
}
1
2
3
4
5
6

代码 exports.a1 = true; 修改了 module.exports 上的 a1true, 这时候 a2 代码还没执行。

'/Users/evan/Desktop/module/a.js': 
   Module {
     exports: {
      a1: true
    }
     //...
  }
}
1
2
3
4
5
6
7
8

进入 b 模块,require a.js 时发现缓存上已经存在了,获取 a 模块上的 exports 。打印 a1, a2 分别是 true,和 undefined

运行完 b 模块,继续执行 a 模块剩余的代码,exports.a2 = true; 又往 exports 对象上增加了 a2 属性,此时 module aexport 对象 a1, a2 均为 true

exports: { 
  a1: true,
  a2: true
}
1
2
3
4

再回到 main 模块,由于 require('./a.js') 得到的是 module a export 对象的引用,这时候打印 a1, a2 就都为 true

小结:

CommonJS 模块加载过程是同步阻塞性地加载,在模块代码被运行前就已经写入了 cache,同一个模块被多次 require 时只会执行一次,重复的 require 得到的是相同的 exports 引用。

值得留意: cache key 使用的是模块在系统中的绝对位置,由于模块调用位置的不同,相同的 require('foo') 代码并不能保证返回的是统一个对象引用。我之前恰巧就遇到过,两次 require('egg-core') 但是他们并不相等

# ES6 模块

ES6 模块是前端开发同学更为熟悉的方式,使用 import, export 关键字来进行模块输入输出。ES6 不再是使用闭包和函数封装的方式进行模块化,而是从语法层面提供了模块化的功能。

ES6 模块中不存在 require, module.exports, __filename 等变量,CommonJS 中也不能使用 import。两种规范是不兼容的,一般来说平日里写的 ES6 模块代码最终都会经由 Babel, Typescript 等工具处理成 CommonJS 代码。

使用 Node 原生 ES6 模块需要将 js 文件后缀改成 mjs,或者 package.json "type" 字段改为 "module",通过这种形式告知 Node 使用 ES Module 的形式加载模块。

# ES6 模块 加载过程

ES6 模块的加载过程分为三步:

# 1. 查找,下载,解析,构建所有模块实例。

ES6 模块会在程序开始前先根据模块关系查找到所有模块,生成一个无环关系图,并将所有模块实例都创建好,这种方式天然地避免了循环引用的问题,当然也有模块加载缓存,重复 import 同一个模块,只会执行一次代码。

# 2. 在内存中腾出空间给即将 export 的内容(此时尚未写入 export value)。然后使 import 和 export 指向内存中的这些空间,这个过程也叫连接。

这一步完成的工作是 living binding import export,借助下面的例子来帮助理解。

// counter.js
let count = 1;

function increment () {
  count++;
}

module.exports = {
  count,
  increment
}

// main.js
const counter = require('counter.cjs');

counter.increment();
console.log(counter.count); // 1
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

上面 CommonJS 的例子执行结果很好理解,修改 count++ 修改的是模块内的基础数据类型变量,不会改变 exports.count,所以打印结果认为 1

// counter.mjs
export let count = 1;

export function increment () {
  count++;
}

// main.mjs
import { increment, count } from './counter.mjs'

increment();
console.log(count); // 2
1
2
3
4
5
6
7
8
9
10
11
12

从结果上看使用 ES6 模块的写法,当 export 的变量被修改时,会影响 import 的结果。这个功能的实现就是 living binding,具体规范底层如何实现可以暂时不管,但是知道 living binding 比网上文章描述为 "ES6 模块输出的是值的引用" 更好理解。

更接近 ES6 模块的 CommonJS 代码可以是下面这样:

exports.counter = 1;

exports.increment = function () {
    exports.counter++;
}
1
2
3
4
5

# 3. 运行模块代码将变量的实际值填写在第二步生成的空间中。

到第三步,会基于第一步生成的无环图进行深度优先后遍历填值,如果这个过程中访问了尚未初始化完成的空间,会抛出异常。

// a.mjs
export const a1 = true;
import * as b from './b.mjs';
export const a2 = true;

// b.mjs
import { a1, a2 } from './a.mjs'
console.log(a1, a2);
1
2
3
4
5
6
7
8

上面的例子会在运行时抛出异常 ReferenceError: Cannot access 'a1' before initialization。如果改成 import * as a from 'a.mjs' 可以看到 a 模块中 export 的对象已经占好坑了。

// b.mjs
import * as a from './a.mjs'
console.log(a);
1
2
3

将输出 { a1: <uninitialized>, a2: <uninitialized> } 可以看出,ES6 模块为 export 的变量预留了空间,不过尚未赋值。这里和 CommonJS 不一样,CommonJS 到这里是知道 a1true, a2undefined

除此之外,我们还能推导出一些 ES6 模块和 CommonJS 的差异点:

  • CommonJS 可以在运行时使用变量进行 require, 例如 require(path.join('xxxx', 'xxx.js')),而静态 import 语法(还有动态 import,返回 Promise)不行,因为 ES6 模块会先解析所有模块再执行代码。

  • require 会将完整的 exports 对象引入,import 可以只 import 部分必要的内容,这也是为什么使用 Tree Shaking 时必须使用 ES6 模块 的写法。
  • import 另一个模块没有 export 的变量,在代码执行前就会报错,而 CommonJS 是在模块运行时才报错。

# 为什么平时开发可以混写?

前面提到 ES6 模块和 CommonJS 模块有很大差异,不能直接混着写。这和开发中表现是不一样的,原因是开发中写的 ES6 模块最终都会被打包工具处理成 CommonJS 模块,以便兼容更多环境,同时也能和当前社区普通的 CommonJS 模块融合。

在转换的过程中会产生一些困惑,比如说:

# __esModule 是什么?干嘛用的?

使用转换工具处理 ES6 模块的时候,常看到打包之后出现 __esModule 属性,字面意思就是将其标记为 ES6 Module。这个变量存在的作用是为了方便在引用模块的时候加以处理。

例如 ES6 模块中的 export default 在转化成 CommonJS 时会被挂载到 exports['default'] 上,当运行 require('./a.js') 时 是不能直接读取到 default 上的值的,为了和 ES6 中 import a from './a.js' 的行为一致,会基于 __esModule 判断处理。

// a.js
export default 1;

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

console.log(a);
1
2
3
4
5
6
7

转化后

// 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);
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

a 模块 export defualt 会被转换成 exports.default = 1;,这也是平时前端项目开发中使用 require 为什么还常常需要 .default 才能取到目标值的原因。

接着当运行 import a from './a.js' 时,es module 预期的是返回 export 的内容。工具会将代码转换为 _interopRequireDefault 包裹,在里面判断是否为 esModule,是的话直接返回,如果是 commonjs 模块的话则包裹一层 {default: obj},最后获取 a 的值时,也会被装换成 _a1.default

# 相关链接