模块化是一种将软件功能抽离成独立、可交互的软件设计技术,能促进大型应用程序和系统的构建。
  Node.js内置了两种模块系统,分别是默认的[CommonJS模块](https://nodejs.org/dist/latest-v18.x/docs/api/modules.html)和浏览器所支持的[ECMAScript模块](https://nodejs.org/dist/latest-v18.x/docs/api/esm.html)。
  其中,ECMAScript模块是在8.5.0版本中新增的,后面又经过了几轮的迭代。本文若无特别说明,那么分析的都是CommonJS模块。
  顺便说一句,本系列分析的是[Node.js](https://github.com/nodejs/node)的最新版本18.0.0,在Github上下载源码后,可以关注下面3个目录。
~~~
├── deps 第三方依赖
├── lib 对外暴露的标准库JavaScript源码,例如path、fs等
├── src 支撑Node运行的C/C++ 源码文件,例如HTTP解析、进程处理等
~~~
  本系列所有的示例源码都已上传至Github,[点击此处](https://github.com/pwstrick/node)获取。
  还有一点需要指出,Node.js的[官方说明文档](https://nodejs.org/dist/latest-v18.x/docs/api/documentation.html),是我目前为止遇到的比较符合人类阅读的文档。
## 一、基础语法
  先来分析一下CommonJS模块的基础语法,在Node.js中,可通过 module.exports 和 exports 来导出一个模块,再通过 require() 来导入一个模块。
  来看个简单的示例,先在 1.js 文件中声明 human 对象,然后使用 module.exports 导出,然后在 2.js 中导入 1.js 文件,打印输出。
~~~
// 1.js
const human = {
name: 'strick'
}
module.exports = human;
// 2.js
const human = require('./1.js');
console.log(human); // { name: 'strick' }
~~~
  exports 是 module.exports 的快捷方式,但是不能对其直接赋值,像下面这样导出的就是一个空对象。
~~~
// 3.js
exports = {
name: 'strick'
};
// 2.js
const human = require('./3.js');
console.log(human); // {}
~~~
  接下来换一种写法,为 exports 添加一个属性,这样就能正确导出。
~~~
// 3.js
exports.human = {
name: 'strick'
};
// 2.js
const human = require('./3.js');
console.log(human); // { human: { name: 'strick' } }
~~~
  module.exports 导出了它所指向的对象,而 exports 导出的是对象的属性。
## 二、CommonJS原理
  在Node.js中,可分成两大类的模块:核心模块和第三方模块。
  其中核心模块又分成 built-in 模块和 native 模块,前者由C/C++编写,存在于源码的src目录中;后者由JavaScript编写,存在于lib目录中。
  注意,在 lib/internal/modules 目录中,可以查看两种模块系统的源码。
  所有非Node.js自带的模块统称为第三方模块,也就是任意文件,大家自己写的业务代码以及依赖的第三方应用库都属于此范畴。
  Node.js会使用模块封装器(如下所示)将模块中的代码包裹,形成模块作用域,这样就能避免模块之间的作用域污染。
~~~
(function(exports, require, module, __filename, __dirname) {
// 模块代码实际存在于此处
});
~~~
  \_\_filename可以得到当前模块的绝对路径加文件名。\_\_dirname表示当前模块的目录名,也包含绝对路径,与 path.dirname() 相同。
~~~
console.log(__filename); // /Users/strick/code/web/node/01/4.js
console.log(__dirname); // /Users/strick/code/web/node/01
~~~
**1)require()**
  在[lib/internal/modules/cjs/loader.js](https://github.com/nodejs/node/blob/master/lib/internal/modules/cjs/loader.js)中声明了 require() 函数,requireDepth 记载了模块加载的深度。
~~~
Module.prototype.require = function(id) {
validateString(id, 'id'); // 判断id变量是否是字符串类型
if (id === '') {
throw new ERR_INVALID_ARG_VALUE('id', id, 'must be a non-empty string');
}
requireDepth++;
try {
return Module._load(id, this, /* isMain */ false);
} finally {
requireDepth--;
}
};
~~~
  在 \_load() 中实现了主要的加载逻辑,源码比较长,做了些删减,只列出了关键部分。
~~~
Module._load = function(request, parent, isMain) {
// 解析模块的路径和名称
const filename = Module._resolveFilename(request, parent, isMain);
// 核心模块使用 node: 前缀,会绕过 require 缓存
if (StringPrototypeStartsWith(filename, 'node:')) {
const id = StringPrototypeSlice(filename, 5); // Slice 'node:' prefix
const module = loadNativeModule(id, request);
if (!module?.canBeRequiredByUsers) {
throw new ERR_UNKNOWN_BUILTIN_MODULE(filename);
}
return module.exports;
}
// 第一种情况:如果缓存中已经存在此模块,那么返回模块的 exports 属性
const cachedModule = Module._cache[filename];
if (cachedModule !== undefined) {
updateChildren(parent, cachedModule, true);
if (!cachedModule.loaded) {
const parseCachedModule = cjsParseCache.get(cachedModule);
if (!parseCachedModule || parseCachedModule.loaded)
return getExportsForCircularRequire(cachedModule);
parseCachedModule.loaded = true;
} else {
return cachedModule.exports;
}
}
// 第二种情况:如果是核心模块,那么调用 NativeModule.prototype.compileForPublicLoader() 返回模块的 exports 属性
const mod = loadNativeModule(filename, request);
if (mod?.canBeRequiredByUsers &&
NativeModule.canBeRequiredWithoutScheme(filename)) {
return mod.exports;
}
// 第三种情况:如果是第三方文件,那么创建一个新模块并加载文件内容,再将其保存到缓存中
const module = cachedModule || new Module(filename, parent);
Module._cache[filename] = module;
return module.exports;
};
~~~
  在 \_load() 方法中,会先判断 node: 前缀(在官方文档的核心模块中有过介绍),然后列出3种加载情况:
1. 如果缓存中已经存在此模块,那么返回模块的 exports 属性。
2. 如果是核心模块,那么调用 NativeModule.prototype.compileForPublicLoader() 返回模块的 exports 属性。
3. 如果是第三方文件,那么创建一个新模块并加载文件内容,再将其保存到缓存中。
  Node.js在加载JS文件时,会先判断是否有缓存,然后读取文件内容,再调用 \_compile() 进行编译,下面的源码也做了删减。
  还有另外两种 .json 和 .node 后缀的文件加载过程在此省略。
~~~
Module._extensions['.js'] = function(module, filename) {
// 如果已经分析了源,那么它将被缓存
const cached = cjsParseCache.get(module);
let content;
if (cached?.source) {
content = cached.source;
cached.source = undefined;
} else {
content = fs.readFileSync(filename, 'utf8');
}
module._compile(content, filename);
};
~~~
  在 \_compile() 方法中会调用[vm模块](https://nodejs.org/dist/latest-v18.x/docs/api/vm.html)创建沙盒,再执行函数代码,源码比较长,在此省略。
  注意,虽然 vm 可以在V8虚拟机的上下文中编译和执行JavaScript代码,但是它比[eval()](https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/eval)更为安全,因为它运行的脚本无权访问外部作用域。
**2)加载顺序**
  经过上面的源码分析,可知加载顺序是先缓存,再核心模块,最后第三方模块,再详细一点的话就是:
1. 缓存,模块在第一次加载后被缓存,也就是说,解析相同的文件,会返回完全相同的对象,除非修改[require.cache](https://nodejs.org/dist/latest-v18.x/docs/api/modules.html#requirecache)。
2. 核心模块,部分核心模块已被编译成二进制文件,加载到了内存中。
3. 文件模块的加载过程如下:
1. 优先加载带' /'、'./' 或 '../' 路径前缀的模块。
2. 若文件没有后缀,则依次添加 .js、.json 和 .node 尝试加载。
3. 若模块没有路径来指示文件,则该模块必须是核心模块或从 node\_modules 目录加载。
4. 再找不到就抛出 MODULE\_NOT\_FOUND 错误。
4. 目录作为模块的加载过程如下:
1. 先将目录当成包来处理,查找 package.json 文件,读取 main 字段描述的入口文件。
2. 若没有 package.json,main 字段缺失或无法解析时,尝试依次加载目录中的 index.js、index.json 或 index.node 文件。
3. 如果这些尝试都失败,则抛出错误,Error: Cannot find module 'xx/xx.js'。
5. 从 node\_modules 目录加载,若不是核心模块并且没有路径前缀,那么从当前模块的目录向上查找,并添加 /node\_modules,直至根目录为止。
例如,在'/Users/strick/code/tmp.js' 中调用require('test.js'),那么将按以下顺序查找:
1. /Users/strick/code/node\_modules/test.js
2. /Users/strick/node\_modules/test.js
3. /Users/node\_modules/test.js
4. /node\_modules/test.js
6. 从全局目录加载,一种官方不推荐的加载方式。
  如果 NODE\_PATH 环境变量设置为以冒号分隔的绝对路径列表,则 Node.js 将在这些路径中搜索模块(如果它们在其他地方找不到)。
**3)循环引用**
  在Node.js中,当两个模块通过 require() 函数加载对方时,就形成了循环引用,但不会形成死循环。
  下面的示例来自于官网,对其做了些调整。
  先创建 a.js,在加载 b 模块之前,done 是 false,并且声明了一个 globalVar 变量,没有为其添加任何声明变量的关键字,在 b 模块加载完成后,done 赋值为 true。
~~~
console.log('a starting');
exports.done = false;
globalVar = '全局变量'; // 在a模块中声明的全局变量
const b = require('./b.js');
console.log('在a模块中, b.done = %j', b.done);
exports.done = true;
console.log('a done');
~~~
  再创建 b.js,在加载 a 模块之前,done 也是 false,在 a 模块加载完成之后,done 也赋值为 true。
~~~
console.log('b starting');
exports.done = false;
const a = require('./a.js');
console.log('在b模块中, a.done = %j', a.done);
console.log('globalVar: ', globalVar);
exports.done = true;
console.log('b done');
~~~
  最后创建 main.js,再加载 b 模块。
~~~
console.log('main starting');
const a = require('./a.js'); // 先导入a模块
const b = require('./b.js'); // 再导入b模块
console.log('在main模块中, a.done = %j, b.done = %j', a.done, b.done);
~~~
  最终的打印顺序如下所示,在 main.js 中,先加载 a 模块,而在 a 模块中会尝试加载 b 模块。那么在进入到 b 模块后,为了防止无限死循环,会导出 a 模块已执行完成的部分。
~~~
main starting
a starting
b starting
在b模块中, a.done = false
globalVar: 全局变量
b done
在a模块中, b.done = true
a done
在main模块中, a.done = true, b.done = true
~~~
  在上述示例中,还涉及到另一个问题,那就是在 a 模块中声明的 globalVar 变量,能在 b 模块中被成功打印。
  在上文中也曾提到过模块封装器,那么 globalVar 变量的声明和打印,相当于下面这样,如果在函数内声明变量时省略 var 关键字,那么这个变量就会变成全局变量。
~~~
// a.js
(function (exports, require, module, __filename, __dirname) {
globalVar = '全局变量';
});
// b.js
(function (exports, require, module, __filename, __dirname) {
console.log(globalVar);
});
~~~
  若要避免污染全局作用域,那么可以声明严格模式,禁止隐式的全局声明,如下所示。
~~~
'use strict';
globalVar = '全局变量';
~~~
**5)与ECMAScript模块的差异**
1. import 语句只允许在 ES 模块中使用,但可以导入两种模块;而 CommonJS 的 require() 不能导入 ES 模块。
2. ES 模块的 import 是异步执行的;而 CommonJS 模块的 require() 是同步执行的。
3. ES 模块没有 \_\_filename、\_\_dirname、require.cache、module.exports 等变量。
4. ES 模块是编译时输出,可以静态分析模块依赖;而 CommonJS 是运行时加载。
5. ES 模块输出的是值引用;而 CommonJS 模块输出的是值副本。
  需要通过一个示例来理解第五点差异,首先创建 lib.mjs 文件,.mjs 是 Node.js 为 ES 模块保留的后缀,在此类文件内可使用 export 和 import 语法。
  在 lib.mjs 文件中,声明 digit 变量和 increase() 函数,在函数中对 digit 执行递增,通过 export 将它们导出。
~~~
// lib.mjs
export let digit = 0;
export function increase() {
digit++;
}
~~~
  在 main.mjs 文件中,加载 lib.mjs,打印 digit 变量,值为 0,调用 increase() 函数,再打印,值变为 1。由此可知,外部可以修改模块内部的值。
~~~
// main.mjs
import { digit, increase } from './lib.mjs';
console.log(digit); // 0
increase();
console.log(digit); // 1
~~~
  接下来创建 lib.js 文件,同样是 digit 变量和 increase() 函数,通过 module.exports 将它们导出。
~~~
// lib.js
let digit = 0;
function increase() {
digit++;
}
module.exports.digit = digit;
module.exports.increase = increase;
~~~
  在 main.js 文件中,加载 lib.js,打印 digit 变量,值为 0,调用 increase() 函数,再打印,仍然是 0。由此可知,外部无法修改模块内部的值。
~~~
// main.js
const lib = require('./lib');
console.log(lib.digit); // 0
lib.increase();
console.log(lib.digit); // 0
~~~
6. ES 模块不管是否遇到循环引用,其 import 导入的变量都会成为一个指向被加载模块的引用,而 CommonJS 模块遇到循环引用只会导出模块已执行完成的部分。
  这其实也是两者加载机制的不同所导致的,参考第四点不同。
  CommonJS 对循环引用的处理过程在上文中已介绍,现在改造之前官网的示例,在 main.mjs 中导入 a 和 b 两个模块,并打印 a 和 b 的值。
~~~
// main.mjs
import a from './a.mjs';
import b from './b.mjs';
console.log('在main模块中, a = %j, b = %j', a, b);
~~~
  在 a.mjs 中,会导入 b.mjs,并打印 b 的值。而在 b.mjs 中,会导入 a.mjs,并打印 a 的值,如此就形成了循环引用。
~~~
// a.mjs
import b from './b.mjs';
let done = false;
export default done;
console.log('在a模块中, b = %j', b);
// b.mjs
import a from './a.mjs';
let done = false;
export default done;
console.log('在b模块中, a = %j', a);
~~~
  运行 main.mjs,马上就会报错:ReferenceError: Cannot access 'a' before initialization。
  在 main.mjs 中读取 a 的值时,会执行 a.mjs 并读取 b 的值,而在 b.mjs 中,默认会认为 a 已存在,但在访问的时候就会发现被欺骗,然后就报错了。
参考资料:
[CommonJS模块](https://nodejs.org/dist/latest-v18.x/docs/api/modules.html)
[ECMAScript模块](https://nodejs.org/dist/latest-v18.x/docs/api/esm.html)
[使用 exports 从 Node.js 文件中公开功能](http://nodejs.cn/learn/expose-functionality-from-a-nodejs-file-using-exports)
[饿了么模块题目](https://github.com/ElemeFE/node-interview/blob/master/sections/zh-cn/module.md)
[为什么 Node.js 不给每一个.js文件以独立的上下文来避免作用域被污染? ](https://www.zhihu.com/question/57375179)
[Node.js技术栈](https://www.nodejs.red/#/nodejs/module)
[深入理解Node.js:核心思想与源码分析](https://yjhjstz.gitbooks.io/deep-into-node/content/chapter2/chapter2-2.html)
[Node.js 模块系统源码探微](https://www.zoo.team/article/node-module)
[Node.js VM 不完全指北](https://zhuanlan.zhihu.com/p/128090873)
[What’s the difference between CommonJS and ES6 modules?](https://www.mo4tech.com/whats-the-difference-between-commonjs-and-es6-modules-2.html)
[ECMAScript6入门之ES6模块的循环加载](https://es6.ruanyifeng.com/?search=%E5%BE%AA%E7%8E%AF&x=0&y=0#docs/module-loader#ES6-%E6%A8%A1%E5%9D%97%E7%9A%84%E5%BE%AA%E7%8E%AF%E5%8A%A0%E8%BD%BD)
*****
> 原文出处:
[博客园-Node.js精进](https://www.cnblogs.com/strick/category/2154090.html)
[知乎专栏-前端性能精进](https://www.zhihu.com/column/c_1611672656142725120)
已建立一个微信前端交流群,如要进群,请先加微信号freedom20180706或扫描下面的二维码,请求中需注明“看云加群”,在通过请求后就会把你拉进来。还搜集整理了一套[面试资料](https://github.com/pwstrick/daily),欢迎浏览。
![](https://box.kancloud.cn/2e1f8ecf9512ecdd2fcaae8250e7d48a_430x430.jpg =200x200)
推荐一款前端监控脚本:[shin-monitor](https://github.com/pwstrick/shin-monitor),不仅能监控前端的错误、通信、打印等行为,还能计算各类性能参数,包括 FMP、LCP、FP 等。
- ES6
- 1、let和const
- 2、扩展运算符和剩余参数
- 3、解构
- 4、模板字面量
- 5、对象字面量的扩展
- 6、Symbol
- 7、代码模块化
- 8、数字
- 9、字符串
- 10、正则表达式
- 11、对象
- 12、数组
- 13、类型化数组
- 14、函数
- 15、箭头函数和尾调用优化
- 16、Set
- 17、Map
- 18、迭代器
- 19、生成器
- 20、类
- 21、类的继承
- 22、Promise
- 23、Promise的静态方法和应用
- 24、代理和反射
- HTML
- 1、SVG
- 2、WebRTC基础实践
- 3、WebRTC视频通话
- 4、Web音视频基础
- CSS进阶
- 1、CSS基础拾遗
- 2、伪类和伪元素
- 3、CSS属性拾遗
- 4、浮动形状
- 5、渐变
- 6、滤镜
- 7、合成
- 8、裁剪和遮罩
- 9、网格布局
- 10、CSS方法论
- 11、管理后台响应式改造
- React
- 1、函数式编程
- 2、JSX
- 3、组件
- 4、生命周期
- 5、React和DOM
- 6、事件
- 7、表单
- 8、样式
- 9、组件通信
- 10、高阶组件
- 11、Redux基础
- 12、Redux中间件
- 13、React Router
- 14、测试框架
- 15、React Hooks
- 16、React源码分析
- 利器
- 1、npm
- 2、Babel
- 3、webpack基础
- 4、webpack进阶
- 5、Git
- 6、Fiddler
- 7、自制脚手架
- 8、VSCode插件研发
- 9、WebView中的页面调试方法
- Vue.js
- 1、数据绑定
- 2、指令
- 3、样式和表单
- 4、组件
- 5、组件通信
- 6、内容分发
- 7、渲染函数和JSX
- 8、Vue Router
- 9、Vuex
- TypeScript
- 1、数据类型
- 2、接口
- 3、类
- 4、泛型
- 5、类型兼容性
- 6、高级类型
- 7、命名空间
- 8、装饰器
- Node.js
- 1、Buffer、流和EventEmitter
- 2、文件系统和网络
- 3、命令行工具
- 4、自建前端监控系统
- 5、定时任务的调试
- 6、自制短链系统
- 7、定时任务的进化史
- 8、通用接口
- 9、微前端实践
- 10、接口日志查询
- 11、E2E测试
- 12、BFF
- 13、MySQL归档
- 14、压力测试
- 15、活动规则引擎
- 16、活动配置化
- 17、UmiJS版本升级
- 18、半吊子的可视化搭建系统
- 19、KOA源码分析(上)
- 20、KOA源码分析(下)
- 21、花10分钟入门Node.js
- 22、Node环境升级日志
- 23、Worker threads
- 24、低代码
- 25、Web自动化测试
- 26、接口拦截和页面回放实验
- 27、接口管理
- 28、Cypress自动化测试实践
- 29、基于Electron的开播助手
- Node.js精进
- 1、模块化
- 2、异步编程
- 3、流
- 4、事件触发器
- 5、HTTP
- 6、文件
- 7、日志
- 8、错误处理
- 9、性能监控(上)
- 10、性能监控(下)
- 11、Socket.IO
- 12、ElasticSearch
- 监控系统
- 1、SDK
- 2、存储和分析
- 3、性能监控
- 4、内存泄漏
- 5、小程序
- 6、较长的白屏时间
- 7、页面奔溃
- 8、shin-monitor源码分析
- 前端性能精进
- 1、优化方法论之测量
- 2、优化方法论之分析
- 3、浏览器之图像
- 4、浏览器之呈现
- 5、浏览器之JavaScript
- 6、网络
- 7、构建
- 前端体验优化
- 1、概述
- 2、基建
- 3、后端
- 4、数据
- 5、后台
- Web优化
- 1、CSS优化
- 2、JavaScript优化
- 3、图像和网络
- 4、用户体验和工具
- 5、网站优化
- 6、优化闭环实践
- 数据结构与算法
- 1、链表
- 2、栈、队列、散列表和位运算
- 3、二叉树
- 4、二分查找
- 5、回溯算法
- 6、贪心算法
- 7、分治算法
- 8、动态规划
- 程序员之路
- 大学
- 2011年
- 2012年
- 2013年
- 2014年
- 项目反思
- 前端基础学习分享
- 2015年
- 再一次项目反思
- 然并卵
- PC网站CSS分享
- 2016年
- 制造自己的榫卯
- PrimusUI
- 2017年
- 工匠精神
- 2018年
- 2019年
- 前端学习之路分享
- 2020年
- 2021年
- 2022年
- 2023年
- 日志
- 2020