彻底掌握 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
彻底掌握 Commonjs 和 Es Module的更多相关文章
- UMD、CommonJS、ES Module、AMD、CMD模块的写法
AMD异步模块规范 RequireJS就是AMD的一个典型的实现. 以下是一个只依赖与jQuery的模块代码: // foo.js define(['jquery'], function($){ // ...
- JS JavaScript模块化(ES Module/CommonJS/AMD/CMD)
前言 前端开发中,起初只要在script标签中嵌入几十上百行代码就能实现一些基本的交互效果,后来js得到重视,应用也广泛起来了, jQuery,Ajax,Node.Js,MVC,MVVM等的助力也使得 ...
- 深入 CommonJs 与 ES6 Module
目前主流的模块规范 UMD CommonJs es6 module umd 模块(通用模块) (function (global, factory) { typeof exports === 'obj ...
- 前端模块化之ES Module
一.概述 之前提到的几种模块化规范:CommonJS.AMD.CMD都是社区提出的.ES 2015在语言层面上实现了模块功能,且实现简单,可以替代CommonJS和AMD规范,成为在服务器和浏览器通用 ...
- 使用 ES Module 的正确姿势
前面我们在深入理解 ES Module 中详细介绍过 ES Module 的工作原理.目前,ES Module 已经在逐步得到各大浏览器厂商以及 NodeJS 的原生支持.像 vite 等新一代的构建 ...
- JS 模块化- 05 ES Module & 4 大规范总结
1 ES Module 规范 ES Module 是目前使用较多的模块化规范,在 Vue.React 中大量使用,大家应该非常熟悉.TypeScript 中的模块化与 ES 类似. 1.1 导出模块 ...
- CommonJS与ES6 Module的使用与区别
CommonJS与ES6 Module的使用与区别 1. CommonJS 1.1 导出 1.2 导入 2. ES6 Module 2.1 导出 2.2 导入 3. CommonJS 与 ES6 Mo ...
- 探讨ES6的import export default 和CommonJS的require module.exports
今天来扒一扒在node和ES6中的module,主要是为了区分node和ES6中的不同意义,避免概念上的混淆,同时也分享一下,自己在这个坑里获得的心得. 在ES6之前 模块的概念是在ES6发布之前就出 ...
- 再次梳理AMD、CMD、CommonJS、ES6 Module的区别
AMD AMD一开始是CommonJS规范中的一个草案,全称是Asynchronous Module Definition,即异步模块加载机制.后来由该草案的作者以RequireJS实现了AMD规范, ...
随机推荐
- 深入理解Python切片
Python序列的切片很基础同时也很重要,最近看到一个[::-1]的表达,不明所以,查了一些资料并实际操作,对Python切片有了更深刻的认识,以下结合例子详细说明.先看下切片的基本语法,一般认为切片 ...
- P4389-付公主的背包【生成函数,多项式exp】
正题 题目链接:https://www.luogu.com.cn/problem/P4389 题目大意 \(n\)种物品,第\(i\)种大小为\(v_i\),数量无限.对于每个\(s\in[1,m]\ ...
- Redis之品鉴之旅(六)
持久化 快照的方式(RDB) 文件追加方式(AOF) 快照形式: save和bgsave能快速的备份数据.但是.........., Save命令:将内存数据镜像保存为rdb文件,由于redis是单线 ...
- Pytorch学习2020春-1-线性回归
线性回归 主要内容包括: 线性回归的基本要素 线性回归模型从零开始的实现 线性回归模型使用pytorch的简洁实现 线性回归的基本要素 模型 为了简单起见,这里我们假设价格只取决于房屋状况的两个因素, ...
- Java基础之(一):JDK的安装以及Notepad++的下载
从今天开始就开始我的Java的学习了,学习Java前需要做一些前期的准备工作.好了,现在我们先一起来安装JDK. JDK的安装 JDK下载链接:JDK 下载电脑对应的版本,同意协议 双击安装JDK 将 ...
- jenkins容器内安装python3
前言 很多小伙伴可能在考虑 jenkins 拉取了 github 上的代码后,发现还越少 python3 环境,那能怎么办呢? 咨询了一位运维朋友给我的答案是,将 python3 挂载到容器工作目录上 ...
- CentOS 文本编辑器
目录 1.Nano 1.1.基础命令 1.2.快捷操作 1.3.配置文件 2.Vim 2.1.四大模式 2.2.基础命令 2.3.标准操作 2.4.高级操作 2.5.配置文件 Linux 终端的文本编 ...
- 【UE4 C++】资源烘焙与UE4Editor.exe启动
资源烘焙 虚幻引擎以内部使用的特定格式存储内容资源,将内容从内部格式转换为特定于平台的格式的过程 称为 烘焙((Cooking) 从编辑器烘焙资源 FIle → Cook Content for Wi ...
- 痞子衡嵌入式:超级下载算法RT-UFL v1.0在Keil MDK下的使用
痞子衡主导的"学术"项目 <RT-UFL - 一个适用全平台i.MXRT的超级下载算法设计> v1.0 版发布近 4 个月了,部分客户已经在实际项目开发调试中用上了这个 ...
- Java:动态代理小记
Java:动态代理小记 对 Java 中的 动态代理,做一个微不足道的小小小小记 概述 动态代理:当想要给实现了某个接口的类中的方法,加一些额外的处理.比如说加日志,加事务等.可以给这个类创建一个代理 ...