彻底掌握 Commonjs 和 Es Module
之前出过一篇对于 Commonjs 和 Es Module 的一个简单对比 —— CommonJS与ES6 Module的使用与区别,今天我们来深度分析一下 Commonjs 和 Es Module,希望通过本文的学习,能够让大家彻底明白 Commonjs 和 Es Module 原理。
Commonjs
在 commonjs 中每一个 js 文件都是一个单独的模块,我们可以称之为 module。在该模块中,保护一些核心变量如 exports 、 module.exports、require等,其中 exports 和 module.exports 可以负责对模块中的内容进行导出。具体怎么使用这里就不作介绍,想了解请参考 —— CommonJS与ES6 Module的使用与区别。
这里主要探究一下几个问题:
- 如何解决变量污染?
module.exports,exports,require三者是如何工作的?又有什么关系?
commonjs 实现原理
从上述得知每个模块文件上存在 module,exports,require三个变量,然而这三个变量是没有被定义的,但是我们可以在 Commonjs 规范下每一个 js 模块上直接使用它们。在 nodejs 中还存在 __filename 和 __dirname 变量。
实际上在一个模块的代码被执行之前,Node.js会用一个函数包装器来包装它,如下所示:
(function(exports, require, module, __filename, __dirname) {
// Module code actually lives in here
});
我们写的代码将作为包装函数的执行上下文,使用的 require ,exports ,module 本质上是通过形参的方式传递到包装函数中的。
而包装函数本质上是这样的:
function wrapper (script) {
return '(function (exports, require, module, __filename, __dirname) {' +
script +
'\n})'
}
如上模拟了一个包装函数功能, script 为我们在 js 模块中写的内容,最后返回的就是如上包装之后的函数。当然这个函数暂且是一个字符串。
在模块加载的时候,会通过 runInThisContext (可以理解成 eval ) 执行 modulefunction ,传入require,exports,module 等参数。最终我们写的 nodejs 文件就这么执行了。
通过这样做,Node.js实现了一些事情:
- 它保持顶级变量(用
var、const或let定义)作用域为模块而不是全局对象。 - 它有助于提供一些实际特定于模块的全局变量,例如:
module和exports对象,实现者可以使用这些对象从模块导出值。- 方便变量
__filename和__dirname,包含模块的绝对文件名和目录路径。
到此为止,完成了整个模块执行的原理。接下来我们来分析以下 require 文件加载的流程。
require 文件加载流程
模块标识符就是你在引入模块时调用require()函数的参数。
你会看到我们经常会有这样的用法:
const fs = require('fs') // 核心模块
const module1 = require('./module1.js') // 文件模块
const express = require('express') // 第三方自定义模块
这其实是因为我们引入的模块会有不同的分类:
- 像
fs这种它是Node.js就自带的模块 - module1是路径模块
- express是我们使用
npm i express下载到node_modules里的模块的第三方自定义模块。
接下来我们来介绍一下他们分别是如何被加载的
核心模块的处理:
核心模块的优先级仅次于缓存加载,在 Node 源码编译中,已被编译成二进制代码,所以加载核心模块,加载过程中速度最快。
路径形式的文件模块处理:
已 ./ ,../ 和 / 开始的标识符,会被当作文件模块处理。require() 方法会将路径转换成真实路径,并以真实路径作为索引,将编译后的结果缓存起来,第二次加载的时候会更快。至于怎么缓存的?我们稍后会讲到。
自定义模块处理:
自定义模块,一般指的是非核心的模块,它可能是一个文件或者一个包,它的查找会遵循以下原则:
在当前目录下的 node_modules 目录查找。
如果没有,在父级目录的 node_modules 查找,如果没有在父级目录的父级目录的 node_modules 中查找。
沿着路径向上递归,直到根目录下的 node_modules 目录。
在查找过程中,会找 package.json 下 main 属性指向的文件,如果没有 package.json ,在 node 环境下会以此查找 index.js ,index.json ,index.node。
以下是官方文档给的大致流程翻译而来:
从 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"
require 模块引入与处理
CommonJS 模块同步加载并执行模块文件,CommonJS 模块在执行阶段分析模块依赖,采用深度优先遍历(depth-first traversal),执行顺序是父 -> 子 -> 父;
为了搞清除 require 文件引入流程。我们接下来再举一个例子,这里注意一下细节:
// a.js
const moduleB = require('./b')
const a = '这是 a 模块'
console.log(a)
module.exports = { a }
// b.js
const moduleA = require('./a')
const b = '这是 b 模块'
console.log(b)
module.exports = { b }
// main.js
const moduleA = require('./a')
const moduleB = require('./b')
console.log('node 文件入口')
接下来终端输入 node main.js 运行 main.js,效果如下:
这是 b 模块
这是 a 模块
node 文件入口
从上面的运行结果可以发现:
main.js和a.js模块都引用了b.js模块,但是b.js模块只执行了一次。a.js模块 和b.js模块互相引用,但是没有造成循环引用的情况。- 执行顺序是父 -> 子 -> 父;
那么 Common.js 规范是如何实现上述效果的呢?
require 加载原理
首先为了弄清楚上述两个问题。我们要明白两个感念,那就是 module 和 Module。
module :在 Node 中每一个 js 文件都是一个 module , module 上保存了 exports 等信息之外,还有一个 loaded 表示该模块是否被加载。
false表示还没有加载;true表示已经加载
Module:以nodejs为例,整个系统运行之后,会用Module缓存每一个模块加载的信息。
文件模块查找挺耗时的,如果每次 require 都需要重新遍历文件夹查找,性能会比较差;还有在实际开发中,模块可能包含副作用代码,例如在模块顶层执行 addEventListener ,如果 require 过程中被重复执行多次可能会出现问题。
CommonJS 中的缓存可以解决重复查找和重复执行的问题。模块加载过程中会以模块绝对路径为 key, module 对象为 value 写入 cache 。在读取模块的时候会优先判断是否已在缓存中,如果在,直接返回 module.exports;如果不在,则会进入模块查找的流程,找到模块之后再写入 cache。
require 的源码大致长如下的样子:
// id 为路径标识符
function require(id) {
/* 查找 Module 上有没有已经加载的 js 对象*/
const cachedModule = Module._cache[id]
/* 如果已经加载了那么直接取走缓存的 exports 对象 */
if(cachedModule){
return cachedModule.exports
}
/* 创建当前模块的 module */
const module = { exports: {} ,loaded: false , ...}
/* 将 module 缓存到 Module 的缓存属性中,路径标识符作为 id */
Module._cache[id] = module
/* 加载文件 */
runInThisContext(wrapper('module.exports = "123"'))(module.exports, require, module, __filename, __dirname)
/* 加载完成 */
module.loaded = true
/* 返回值 */
return module.exports
}
从上面我们总结出一次 require 大致流程是这样的;
-
require会接收一个参数——文件标识符,然后分析定位文件,分析过程我们上述已经讲到了,接下来会从Module上查找有没有缓存,如果有缓存,那么直接返回缓存的内容。 -
如果没有缓存,会创建一个
module对象,缓存到Module上,然后执行文件,加载完文件,将loaded属性设置为true,然后返回module.exports对象。借此完成模块加载流程。 -
模块导出就是
return这个变量的其实跟a = b赋值一样, 基本类型导出的是值, 引用类型导出的是引用地址。 -
exports和module.exports持有相同引用,因为最后导出的是module.exports, 所以对exports进行赋值会导致exports操作的不再是module.exports的引用。
require 避免重复加载
从上面我们可以直接得出,require 如何避免重复加载的。对应 demo 片段中,首先 main.js 引用了 a.js ,a.js 中 require 了 b.js 此时 b.js 的 module 放入缓存 Module 中,接下来 main.js 再次引用 b.js ,那么直接走的缓存逻辑。所以 b.js 只会执行一次,也就是在 a.js 引入的时候。
require 避免循环引用
缓存还解决了循环引用的问题。举个例子,现在有模块 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');
exports.a2 = true;
// b.js
const a = require('./a.js');
console.log('in b, a.a1 = %j, a.a2 = %j', a.a1, a.a2);
运行结果如下:
in b, a.a1 = true, a.a2 = undefined
in main, a.a1 = true, a.a2 = true
实际上在模块 a 代码执行之前就已经创建了 Module 实例写入了缓存,此时代码还没执行,exports 是个空对象。
输出 require.cache 可以看到:
'c:\\Users\\ThinkPad\\Desktop\\demo\\a.js':
Module {
exports: {},
//...
}
}
代码 exports.a1 = true; 修改了 module.exports 上的 a1 为 true, 这时候 a2 代码还没执行。
'c:\\Users\\ThinkPad\\Desktop\\demo\\a.js':
Module {
exports: {
a1: true
}
//...
}
}
进入b模块,require a.js 时发现缓存上已经存在了,获取 a 模块上的 exports 。打印 a1, a2 分别是true,和 undefined。
运行完 b 模块,继续执行 a 模块剩余的代码,exports.a2 = true; 又往 exports 对象上增加了a2属性,此时 module a 的 export对象 a1, a2 均为 true。
exports: {
a1: true,
a2: true
}
再回到 main 模块,由于 require('./a') 得到的是 module a export 对象的引用,这时候打印 a1, a2 就都为 true。
exports 和 module.exports
exports 和 module.exports 的用法这里就不再介绍了,通过上述讲解都知道 exports , module 和 require 作为形参的方式传入到 js 模块中。我们直接 exports = {} 修改 exports ,等于重新赋值了形参,那么会重新赋值一份,但是不会在引用原来的形参。而且在 require 原理实现中,我们知道了 exports 和 module.exports 持有相同引用,因为最后导出的是 module.exports 。那么这就说明在一个文件中,我们最好选择 exports 和 module.exports 两者之一,如果两者同时存在,很可能会造成覆盖的情况发生。比如如下情况:
exports.name = 'module' // 此时 exports.name 是无效的
module.exports = {
name:'new module',
}
上述情况下 exports.name 无效,会被 module.exports 覆盖。
那么既然有了 exports ,为何又出了 module.exports?
如果我们不想在 commonjs 中导出对象,而是只导出一个类或者一个函数再或者其他属性的情况,那么 module.exports 就更方便了,如上我们知道 exports 会被初始化成一个对象,也就是我们只能在对象上绑定属性,但是我们可以通过 module.exports 自定义导出出对象外的其他类型元素。
module.exports = [1, 2, 3] // 导出数组
module.exports = function(){} //导出方法
然而与 exports 相比,module.exports 有一些缺陷:
module.exports 当导出一些函数等非对象属性的时候,也有一些风险,就比如循环引用的情况下。对象会保留相同的内存地址,就算一些属性是后绑定的,也能间接通过异步形式访问到。但是如果 module.exports 为一个非对象其他属性类型,在循环引用的时候,就容易造成属性丢失的情况发生了。
Es Module
从 ES6 开始, JavaScript 才真正意义上有自己的模块化规范,ES6 不再是使用闭包和函数封装的方式进行模块化,而是从语法层面提供了模块化的功能。
ES6 模块中不存在 require, module.exports, __filename 等变量,CommonJS 中也不能使用 import。两种规范是不兼容的,一般来说平日里写的 ES6 模块代码最终都会经由 Babel, Typescript 等工具处理成 CommonJS 代码。
使用 Node 原生 ES6 模块需要将 js 文件后缀改成 mjs,或者 package.json "type" 字段改为 "module",通过这种形式告知 Node 使用ES Module 的形式加载模块。
Es Module 的静态特性
ES6 module 的引入和导出是静态的,import 会自动提升到代码的顶层 。
注意:import , export 不能放在块级作用域或条件语句中,import 的导入名不能为字符串或在判断语句。
这种静态语法,在编译过程中确定了导入和导出的关系,所以更方便去查找依赖。
Es Module 的执行特性
ES6 module 和 Common.js 一样,对于相同的 js 文件,会保存静态属性。
但是与 Common.js 不同的是 ,CommonJS 模块同步加载并执行模块文件, ES6 模块提前加载并执行模块文件,ES6 模块在预处理阶段分析模块依赖,在执行阶段执行模块,两个阶段都采用深度优先遍历,执行顺序是子 -> 父。
例如一栗子:
// a.js
import b from './b'
console.log('a模块加载')
export default function say (){
console.log('我是 a 模块')
}
// b.js
console.log('b模块加载')
export default function say (){
console.log('我是 b 模块')
}
// main.js
console.log('main.js开始执行')
import say as aSay from './a'
import say as bSay from './b'
console.log('main.js执行完毕')
效果如下:
b模块加载
a模块加载
main.js开始执行
main.js执行完毕
Es Module 的导出绑定
import导入的属性是不能直接修改的:
例如:
// a.js
export let name = 'a'
export const setName = (newName) => {
name = newName
}
// main.js
import { name, setName } from './a'
name = 'main' // 直接修改,报错: Uncaught Error: "name" is read-only
通过以下方式则可以成功修改
import { name, setName } from './a'
console.log(name) // a
setName('main')
console.log(name) // main
所以使用 import 被导入的变量是只读的,可以理解默认为 const 装饰,无法被赋值,而被导入的变量是与原变量绑定/引用的,可以理解为 import 导入的变量无论是否为基本类型都是引用传递。
import 的动态导入
import() 返回一个 Promise 对象, 返回的 Promise 的 then 成功回调中,可以获取模块的加载成功信息。我们来简单看一下 import() 是如何使用的。
// a.js
export let name = 'a'
export default let defaultName = 'moduleA'
// main.js
const result = import('./a')
result.then(res => {
console.log(res)
})
// 输出结果为
// { name: "a", __esModule: true, default: "moduleA" }
// 其中:default 代表 export default 。__esModule 为 es module 的标识。
CommonJS与ES6 Modules规范的区别
最后我们再总结一下 CommonJS 与 ES6 Modules规范的区别:
CommonJS模块是运行时加载,ES6 Modules是编译时输出接口CommonJS输出是值的拷贝;ES6 Modules输出的是值的引用,被输出模块的内部的改变会影响引用的改变CommonJs导入的模块路径可以是一个表达式,因为它使用的是require()方法;而ES6 Modules只能是字符串ES6 Modules中没有这些顶层变量:arguments、require、module、exports、__filename、__dirname

浙公网安备 33010602011771号