之前出过一篇对于 CommonjsEs Module 的一个简单对比 —— CommonJS与ES6 Module的使用与区别,今天我们来深度分析一下 CommonjsEs Module,希望通过本文的学习,能够让大家彻底明白 CommonjsEs Module 原理。

Commonjs

commonjs 中每一个 js 文件都是一个单独的模块,我们可以称之为 module。在该模块中,保护一些核心变量如 exportsmodule.exportsrequire等,其中 exportsmodule.exports 可以负责对模块中的内容进行导出。具体怎么使用这里就不作介绍,想了解请参考 —— CommonJS与ES6 Module的使用与区别。

这里主要探究一下几个问题:

  • 如何解决变量污染?
  • module.exportsexportsrequire 三者是如何工作的?又有什么关系?

commonjs 实现原理

从上述得知每个模块文件上存在 moduleexportsrequire三个变量,然而这三个变量是没有被定义的,但是我们可以在 Commonjs 规范下每一个 js 模块上直接使用它们。在 nodejs 中还存在 __filename__dirname 变量。

实际上在一个模块的代码被执行之前,Node.js会用一个函数包装器来包装它,如下所示:

(function(exports, require, module, __filename, __dirname) {
// Module code actually lives in here
});

我们写的代码将作为包装函数的执行上下文,使用的 requireexportsmodule 本质上是通过形参的方式传递到包装函数中的。

而包装函数本质上是这样的:

function wrapper (script) {
return '(function (exports, require, module, __filename, __dirname) {' +
script +
'\n})'
}

如上模拟了一个包装函数功能, script 为我们在 js 模块中写的内容,最后返回的就是如上包装之后的函数。当然这个函数暂且是一个字符串。

在模块加载的时候,会通过 runInThisContext (可以理解成 eval ) 执行 modulefunction ,传入requireexportsmodule 等参数。最终我们写的 nodejs 文件就这么执行了。

通过这样做,Node.js实现了一些事情:

  • 它保持顶级变量(用varconstlet定义)作用域为模块而不是全局对象。
  • 它有助于提供一些实际特定于模块的全局变量,例如:
    • moduleexports 对象,实现者可以使用这些对象从模块导出值。
    • 方便变量 __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.jsonmain 属性指向的文件,如果没有 package.json ,在 node 环境下会以此查找 index.jsindex.jsonindex.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.jsa.js 模块都引用了 b.js 模块,但是 b.js 模块只执行了一次。
  • a.js 模块 和 b.js 模块互相引用,但是没有造成循环引用的情况。
  • 执行顺序是父 -> 子 -> 父;

那么 Common.js 规范是如何实现上述效果的呢?

require 加载原理

首先为了弄清楚上述两个问题。我们要明白两个感念,那就是 moduleModule

module :在 Node 中每一个 js 文件都是一个 modulemodule 上保存了 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 赋值一样, 基本类型导出的是值, 引用类型导出的是引用地址。

  • exportsmodule.exports 持有相同引用,因为最后导出的是 module.exports, 所以对 exports 进行赋值会导致 exports 操作的不再是 module.exports 的引用。

require 避免重复加载

从上面我们可以直接得出,require 如何避免重复加载的。对应 demo 片段中,首先 main.js 引用了 a.jsa.jsrequire b.js 此时 b.jsmodule 放入缓存 Module 中,接下来 main.js 再次引用 b.js ,那么直接走的缓存逻辑。所以 b.js 只会执行一次,也就是在 a.js 引入的时候。

require 避免循环引用

缓存还解决了循环引用的问题。举个例子,现在有模块 a require 模块 b;而模块 brequire 了模块 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 上的 a1true, 这时候 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 aexport对象 a1, a2 均为 true

exports: {
a1: true,
a2: true
}

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

exports 和 module.exports

exportsmodule.exports 的用法这里就不再介绍了,通过上述讲解都知道 exportsmodulerequire 作为形参的方式传入到 js 模块中。我们直接 exports = {} 修改 exports ,等于重新赋值了形参,那么会重新赋值一份,但是不会在引用原来的形参。而且在 require 原理实现中,我们知道了 exportsmodule.exports 持有相同引用,因为最后导出的是 module.exports 。那么这就说明在一个文件中,我们最好选择 exportsmodule.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 moduleCommon.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 对象, 返回的 Promisethen 成功回调中,可以获取模块的加载成功信息。我们来简单看一下 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规范的区别

最后我们再总结一下 CommonJSES6 Modules规范的区别:

  • CommonJS 模块是运行时加载,ES6 Modules是编译时输出接口
  • CommonJS 输出是值的拷贝;ES6 Modules 输出的是值的引用,被输出模块的内部的改变会影响引用的改变
  • CommonJs 导入的模块路径可以是一个表达式,因为它使用的是require()方法;而ES6 Modules只能是字符串
  • ES6 Modules中没有这些顶层变量:argumentsrequiremoduleexports__filename__dirname

彻底掌握 Commonjs 和 Es Module的更多相关文章

  1. UMD、CommonJS、ES Module、AMD、CMD模块的写法

    AMD异步模块规范 RequireJS就是AMD的一个典型的实现. 以下是一个只依赖与jQuery的模块代码: // foo.js define(['jquery'], function($){ // ...

  2. JS JavaScript模块化(ES Module/CommonJS/AMD/CMD)

    前言 前端开发中,起初只要在script标签中嵌入几十上百行代码就能实现一些基本的交互效果,后来js得到重视,应用也广泛起来了, jQuery,Ajax,Node.Js,MVC,MVVM等的助力也使得 ...

  3. 深入 CommonJs 与 ES6 Module

    目前主流的模块规范 UMD CommonJs es6 module umd 模块(通用模块) (function (global, factory) { typeof exports === 'obj ...

  4. 前端模块化之ES Module

    一.概述 之前提到的几种模块化规范:CommonJS.AMD.CMD都是社区提出的.ES 2015在语言层面上实现了模块功能,且实现简单,可以替代CommonJS和AMD规范,成为在服务器和浏览器通用 ...

  5. 使用 ES Module 的正确姿势

    前面我们在深入理解 ES Module 中详细介绍过 ES Module 的工作原理.目前,ES Module 已经在逐步得到各大浏览器厂商以及 NodeJS 的原生支持.像 vite 等新一代的构建 ...

  6. JS 模块化- 05 ES Module & 4 大规范总结

    1 ES Module 规范 ES Module 是目前使用较多的模块化规范,在 Vue.React 中大量使用,大家应该非常熟悉.TypeScript 中的模块化与 ES 类似. 1.1 导出模块 ...

  7. CommonJS与ES6 Module的使用与区别

    CommonJS与ES6 Module的使用与区别 1. CommonJS 1.1 导出 1.2 导入 2. ES6 Module 2.1 导出 2.2 导入 3. CommonJS 与 ES6 Mo ...

  8. 探讨ES6的import export default 和CommonJS的require module.exports

    今天来扒一扒在node和ES6中的module,主要是为了区分node和ES6中的不同意义,避免概念上的混淆,同时也分享一下,自己在这个坑里获得的心得. 在ES6之前 模块的概念是在ES6发布之前就出 ...

  9. 再次梳理AMD、CMD、CommonJS、ES6 Module的区别

    AMD AMD一开始是CommonJS规范中的一个草案,全称是Asynchronous Module Definition,即异步模块加载机制.后来由该草案的作者以RequireJS实现了AMD规范, ...

随机推荐

  1. 深入理解Python切片

    Python序列的切片很基础同时也很重要,最近看到一个[::-1]的表达,不明所以,查了一些资料并实际操作,对Python切片有了更深刻的认识,以下结合例子详细说明.先看下切片的基本语法,一般认为切片 ...

  2. P4389-付公主的背包【生成函数,多项式exp】

    正题 题目链接:https://www.luogu.com.cn/problem/P4389 题目大意 \(n\)种物品,第\(i\)种大小为\(v_i\),数量无限.对于每个\(s\in[1,m]\ ...

  3. Redis之品鉴之旅(六)

    持久化 快照的方式(RDB) 文件追加方式(AOF) 快照形式: save和bgsave能快速的备份数据.但是.........., Save命令:将内存数据镜像保存为rdb文件,由于redis是单线 ...

  4. Pytorch学习2020春-1-线性回归

    线性回归 主要内容包括: 线性回归的基本要素 线性回归模型从零开始的实现 线性回归模型使用pytorch的简洁实现 线性回归的基本要素 模型 为了简单起见,这里我们假设价格只取决于房屋状况的两个因素, ...

  5. Java基础之(一):JDK的安装以及Notepad++的下载

    从今天开始就开始我的Java的学习了,学习Java前需要做一些前期的准备工作.好了,现在我们先一起来安装JDK. JDK的安装 JDK下载链接:JDK 下载电脑对应的版本,同意协议 双击安装JDK 将 ...

  6. jenkins容器内安装python3

    前言 很多小伙伴可能在考虑 jenkins 拉取了 github 上的代码后,发现还越少 python3 环境,那能怎么办呢? 咨询了一位运维朋友给我的答案是,将 python3 挂载到容器工作目录上 ...

  7. CentOS 文本编辑器

    目录 1.Nano 1.1.基础命令 1.2.快捷操作 1.3.配置文件 2.Vim 2.1.四大模式 2.2.基础命令 2.3.标准操作 2.4.高级操作 2.5.配置文件 Linux 终端的文本编 ...

  8. 【UE4 C++】资源烘焙与UE4Editor.exe启动

    资源烘焙 虚幻引擎以内部使用的特定格式存储内容资源,将内容从内部格式转换为特定于平台的格式的过程 称为 烘焙((Cooking) 从编辑器烘焙资源 FIle → Cook Content for Wi ...

  9. 痞子衡嵌入式:超级下载算法RT-UFL v1.0在Keil MDK下的使用

    痞子衡主导的"学术"项目 <RT-UFL - 一个适用全平台i.MXRT的超级下载算法设计> v1.0 版发布近 4 个月了,部分客户已经在实际项目开发调试中用上了这个 ...

  10. Java:动态代理小记

    Java:动态代理小记 对 Java 中的 动态代理,做一个微不足道的小小小小记 概述 动态代理:当想要给实现了某个接口的类中的方法,加一些额外的处理.比如说加日志,加事务等.可以给这个类创建一个代理 ...