// Loads a module at the given file path. Returns that module's `exports` property.
Module.prototype.require = function (id) {
validateString(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--
}
}
学习不能停,都给我卷起来...
一、前世今生
在 ES6 之前,JavaScript 一直没有官方的模块(Module)体系,对于开发大型、复杂的项目形成了巨大的障碍。幸好社区上有一些模块加载方案,最主要的有 CommonJS(CommonJS Modules)和 AMD(Asynchronous Module Definition)两种模块规范,前者用于服务器,后者用于浏览器。
随着 ES6 的正式发布,全新的模块将逐步取代 CommonJS 和 AMD 规范,成为浏览器和服务器通用的模块解决方案。
ES6 模块的设计思想尽量的静态化,是的编译时就能确定模块的依赖关系,以及输入和输出的变量。
而 CommonJS 和 AMD 模块都只能在运行时确定这些东西。比如,CommonJS 模块就是对象,输入时必须查找对象属性。
以上示例,实际上是整体加载了
fs
模块(即加载fs
的所有方法),生成了一个对象_fs
,然后再从这个对象上读取了 3 个方法。这种方式称为“运行时加载”,原因是只有运行时才能得到这个对象,导致完全没有办法在编译时做“静态优化”。ES6 模块不是对象,而是通过
export
命令显式指定输出的代码,再通过import
命令输入。以上示例,实际上是从
fs
模块中加载了 3 个方法,其他方法不加载。这种方式称为“编译时加载”或“静态加载”,即 ES6 模块可以在编译时就完成模块加载,效率要高于 CommonJS 模块的加载方式。这也导致了没法引用 ES6 模块本身,因为它不是对象。由于 ES6 模块是编译时加载,使得静态分析成为可能。有了它,就能进一步拓宽 JavaScript 的语法,比如引入宏(macro)和类型检验(type system)这些只能靠静态分析实现的功能。
除了静态加载带来的各种好处,ES6 模块还有以下好处:
二、为什么需要模块化?
举个 🌰
我们可以轻而易举就知道
module-b.js
里将会打印出Frankie
,原因很简单,它们都是处于全局作用域下,因此module-b.js
中的person.name
就能读取到在module-a.js
中定义的 person 变量。如果将
module-a.js
和module-b.js
在 HTML 中的顺序换过来,就会抛出错误。原因是<script>
是按块加载的,包括下载、(预)编译和执行。唯有当前块执行完毕,或者抛出错误,才会接着加载下一个<script>
。那问题就来了,这很容易造成全局污染,对于大型、复杂的项目来说会非常棘手。
假设没有诸如 CommonJS 等模块化解决方案可用,要怎样解决这种问题呢?
1. 对象字面量(Object Literal)
缺点:
作为一个单一的、有时很长的句法结构,它对其内容施加了限制。内容必须在
{}
之间,并且属性或方法之间必须添加逗号。当模块内容复杂起来之后,维护成本高,移动内容变得更加困难。在多个文件中使用相同的命名空间:可以将模块定义分散到多个文件中,并按如下方式创建命名空间变量,则可忽视加载文件的顺序。
使用多个模块,可以通过创建单个全局命名空间并向其添加子模块来避免全局名称的扩散。不建议进一步嵌套,如果名称冲突是一个问题,您可以使用更长的名称。这种方式称为:嵌套命名空间。
尽管使用命名空间可以在一定程度上解决了命名冲突的问题,但是存在一个问题:在
moduleB
中可以修改moduleA
的内容,而且moduleA
可能还蒙在鼓里,不知情。Yahoo 公司的 YUI 2 就是采用了这种方案。
2. 立即执行函数表达式(Immediately-Invoked Function Expression,简称 IIFE)
在模块模式中,我们使用 IIFE 将环境附加到模块数据。可以从模块访问该环境内的绑定,但不能从外部访问。另一个优点是 IIFE 为我们提供了执行初始化的地方。
这样的话,我们就不用担心,在外部直接修改
namespace
内部的成员或者方法了。因此,结合前面的内容,就可以这样去处理:
到现在,有了命名空间解决了命名冲突问题,同时使用 IIFE 来维护各模块的私有成员和方法,导出对外的开放接口即可。这似乎有了模块化该有的样子。
但是,还有一个问题。前面提到过
<script>
是按书写顺序加载的(即使下载顺序可能并行的),主要包括:假设我们的脚本如下:
那么我们的
modueA
在(首次)解析的时候,就没办法调用moduleB
的内容,因为它压根还没解析执行。一旦项目复杂度、模块数量上来之后,模块之间的依赖关系就很难维护了。三、社区模块化方案
在 ES2015 之前,社区上已经有了很多模块化方案,流行的主要有以下几个::
其中 CommonJS 规范在 Node.js 环境下取得了很不错的实践,它只能应用于服务器端,而不支持浏览器环境。CommonJS 规范的模块是同步加载的,由于服务器的模块文件存在于本地硬盘,只有磁盘 I/O 的,因此同步加载机制没什么问题。
但在浏览器环境,一是会产生开销更大的网络 I/O,二是天然异步,就会产生时序上的错误。后来社区上推出了异步加载、可在浏览器环境运行的 RequireJS 模块加载器,不久之后,起草并发布了 AMD 模块化标准规范。
由于 AMD 会提前加载,很多开发者担心有性能问题。假设一个模块依赖了另外 5 个模块,不管这些模块是否马上被用到,都会执行一遍,这些性能消耗是不容忽视的。为了避免这个问题,有部分人试图保留 CommonJS 书写方式和延迟加载、就近声明(就近依赖)等特性,并引入异步加载机制,以适配浏览器特性。比如,已经凉凉的 BravoJS、FlyScript 等方案。
在 2011 年,国内的前端大佬玉伯提出了 SeaJS,它借鉴了 CommonJS、AMD,并提出了 CMD 模块化标准规范。但并没有大范围的推广和使用。
在 2014 年,美籍华裔 Homa Wong 提出了 UMD 方案:将 CommonJS 和 AMD 相结合。本质上这不算是一种模块化方案。
到了 2015 年 6 月,随着 ECMAScript 2015 的正式发布,JavaScript 终于原生支持模块化,被称为 ES Module。同时支持服务器端和浏览器端。
尽管到了 2022 年,现状仍然是多种模块化方案共存,但未来肯定是 ES Module 一统江湖...
四、CommonJS
Node.js 的模块系统是基于 CommonJS 规范的实现的。除此之外,像 CouchDB 等也是 CommonJS 的一种实现。而且它们有一些是没有完全按照 CommonJS 规范去实现的,甚至额外添加了特有的功能。
由于我们接触到的 CommonJS 通常指 Node.js 中的模块化解决方法,因此,接下来提到的 CommonJS 均指 Node.js 的模块系统。
先瞅一下,一个 CommonJS 模块里面都包括一些什么信息:
如果有一些看不懂或不了解其用处的,先不急,下面娓娓道来。
CommonJS 的模块特点:
4.1 Module 对象
前面打印的
module
就是Module
的实例对象。每个模块内部,都有一个module
对象,表示当前模块。它有以下属性:源码 👉 node/lib/internal/modules/cjs/loader.js(Node.js v17.x)
注意点
module.exports
必须立即完成,不能在任何回调中完成(应在同步任务中完成)。 比如,在setTimeout
回调中对module.exports
进行赋值是“不起作用”的,原因是 CommonJS 模块化是同步加载的。请看示例:
再看个示例:
module.exports
属性被新对象完全替换时,通常也会“自动”重新分配exports
(自动是指不显式分配新对象给exports
变量的前提下)。但是,如果使用exports
变量导出新对象,则必须“手动”关联module.exprots
和exports
,否则无法按预期输出模块值。请看示例:
require()
方法引用的是module.exports
对象,而不是exports
变量。module.parent
返回null
或数值的特性,可以判断当前模块是否为入口脚本。另外,也可以通过require.main
来获取入口脚本的实例对象。module.exports 与 exports 的注意点
此前已写过一篇文章去介绍它俩的区别了。
我们可以这样对模块进行输出:
但请注意,若模块只对外输出一个接口,使用不当,可能会无法按预期工作。比如:
原因很简单,在默认情况下
module.exports
属性和exports
变量都是同一个空对象{}
(默认值)的引用(reference),即module.exports === exports
。当对
exports
变量重新赋予一个基本值或引用值的时候,module.exports
和exports
之间的联系被切断了,此时module.exports !== exports
,在当前模块下module.exports
的值仍为{}
,而exports
变量的值变为函数。而require()
方法的返回值是所引用模块的module.exports
的浅拷贝结果。正确姿势应该是:
使用类似处理,使得
module.exports
与exports
重新建立关联关系。这里并不存在任何难点,仅仅是 JavaScript 基本数据类型和引用数据类型的特性罢了。如果你还是分不清楚的话,建议只使用
module.exports
进行导出,这样的话,就不会有问题了。4.2 require 查找算法
require()
参数很简单,那么require()
内部是如何查找模块的呢?简单可以分为几类:
加载 Node 内置模块 形式如:
require('fs')
、require('http')
等。相对路径、绝对路径加载模块 形式如:
require('./file')
、require('../file')
、require('/file')
。加载第三方模块(即非内置模块) 形式如:
require('react')
、require('lodash/debounce')
、require('some-library')
、require('#some-library')
等。其中,绝对路径形式在实际项目中几乎不会使用(反正我是没用过)、而
require('#some-library')
形式目前仍在试验阶段...以下基于 Node.js 官网 相关内容翻译并整理的版本(存档)
如果不是开发 NPM 包,在实际使用中的话,并没有以上那么多复杂的步骤,很容易理解。但深入了解之后有助于平常遇到问题更快排查出原因并处理掉。如果你是发包的话,可以利用
exports
等做条件导出模块。想了解 Node.js package.json 的两个字段的意义,请看:
4.3 require 源码
源码 👉 node/lib/internal/modules/cjs/loader.js(Node.js v17.x)
源码 👉 node/lib/internal/modules/cjs/loader.js(Node.js v17.x)
4.4 require 中几个常见的问题
Q: Node.js 是如何实现同步加载机制的? A:
未完待续...
References