前言
学习不能停,都给我卷起来...
正文
一、前世今生
在 ES6 之前,JavaScript 一直没有官方的模块(Module)体系,对于开发大型、复杂的项目形成了巨大的障碍。幸好社区上有一些模块加载方案,最主要的有 CommonJS(CommonJS Modules)和 AMD(Asynchronous Module Definition)两种模块规范,前者用于服务器,后者用于浏览器。
随着 ES6 的正式发布,全新的模块将逐步取代 CommonJS 和 AMD 规范,成为浏览器和服务器通用的模块解决方案。
ES6 模块的设计思想尽量的静态化,是的编译时就能确定模块的依赖关系,以及输入和输出的变量。
在 rollup、 webpack 等构建工具中常见的 Tree Shaking 能力,就是依赖于 ES6 模块的静态特性实现的。
而 CommonJS 和 AMD 模块都只能在运行时确定这些东西。比如,CommonJS 模块就是对象,输入时必须查找对象属性。
// CommonJS 模块 const { stat, exists, readFile } = require('fs') // 相当于 const _fs = require('fs') const stat = _fs.stat const exists = _fs.exists const readFile = _fs.readFile
以上示例,实际上是整体加载了 fs
模块(即加载 fs
的所有方法),生成了一个对象 _fs
,然后再从这个对象上读取了 3 个方法。这种方式称为“运行时加载”,原因是只有运行时才能得到这个对象,导致完全没有办法在编译时做“静态优化”。
ES6 模块不是对象,而是通过 export
命令显式指定输出的代码,再通过 import
命令输入。
import { stat, exists, readFile } from 'fs'
以上示例,实际上是从 fs
模块中加载了 3 个方法,其他方法不加载。这种方式称为“编译时加载”或“静态加载”,即 ES6 模块可以在编译时就完成模块加载,效率要高于 CommonJS 模块的加载方式。这也导致了没法引用 ES6 模块本身,因为它不是对象。
由于 ES6 模块是编译时加载,使得静态分析成为可能。有了它,就能进一步拓宽 JavaScript 的语法,比如引入宏(macro)和类型检验(type system)这些只能靠静态分析实现的功能。
除了静态加载带来的各种好处,ES6 模块还有以下好处:
- 不再需要 UMD 模块格式了,将来服务器和浏览器都会支持 ES6 模块格式。
- 将来浏览器的新 API 就能用模块格式提供,不再必须做成全局变量或者 navigator 对象的属性。
- 不再需要对象作为命名空间(比如 Math 对象),未来这些功能可以通过模块提供。
二、为什么需要模块化?
举个 🌰
<!DOCTYPE html> <html lang="en"> <body> <script src="module-a.js"></script> <script src="module-b.js"></script> </body> </html>
// module-a.js var person = { name: 'Frankie', age: 20 }
// module-b.js console.log(person.name) // 将会打印什么呢?
我们可以轻而易举就知道 module-b.js
里将会打印出 Frankie
,原因很简单,它们都是处于全局作用域下,因此 module-b.js
中的 person.name
就能读取到在 module-a.js
中定义的 person 变量。
如果将 module-a.js
和 module-b.js
在 HTML 中的顺序换过来,就会抛出错误。原因是<script>
是按块加载的,包括下载、(预)编译和执行。唯有当前块执行完毕,或者抛出错误,才会接着加载下一个 <script>
。
注意,这里提到的按顺序加载,是指没有
defer
和async
属性的哈。它俩对外部脚本的加载方式是有影响的。但非本文话题,因此不展开讲述。
那问题就来了,这很容易造成全局污染,对于大型、复杂的项目来说会非常棘手。
假设没有诸如 CommonJS 等模块化解决方案可用,要怎样解决这种问题呢?
1. 对象字面量(Object Literal)
// 声明 var namespace = { prop: 123, method: function () {}, // ... } // 调用 namespace.prop namespace.method()
缺点:
作为一个单一的、有时很长的句法结构,它对其内容施加了限制。内容必须在 {}
之间,并且属性或方法之间必须添加逗号。当模块内容复杂起来之后,维护成本高,移动内容变得更加困难。
在多个文件中使用相同的命名空间:可以将模块定义分散到多个文件中,并按如下方式创建命名空间变量,则可忽视加载文件的顺序。
var namespace = namespace || {}
使用多个模块,可以通过创建单个全局命名空间并向其添加子模块来避免全局名称的扩散。不建议进一步嵌套,如果名称冲突是一个问题,您可以使用更长的名称。这种方式称为:嵌套命名空间。
// 全局命名空间 var globalns = globalns || {} // 添加 A 子模块 globalns.moduleA = { // module content } // 添加 B 子模块 globalns.moduleB = { // module content }
尽管使用命名空间可以在一定程度上解决了命名冲突的问题,但是存在一个问题:在 moduleB
中可以修改 moduleA
的内容,而且 moduleA
可能还蒙在鼓里,不知情。
以上命名空间内的所有成员和方法,无论是否私有,对外都是可访问的。这是一个明显的缺点,模块化不应该如此设计。
Yahoo 公司的 YUI 2 就是采用了这种方案。
2. 立即执行函数表达式(Immediately-Invoked Function Expression,简称 IIFE)
在模块模式中,我们使用 IIFE 将环境附加到模块数据。可以从模块访问该环境内的绑定,但不能从外部访问。另一个优点是 IIFE 为我们提供了执行初始化的地方。
var namespace = (function () { // private data var _prop = 123 var _method = function () {} return { // read-only get prop() { return _prop }, get method() { return _method } } })()
这样的话,我们就不用担心,在外部直接修改 namespace
内部的成员或者方法了。
// 读取 namespace.prop // 123 namespace.method() // 写入 namespace.prop = 456 // 无效 namespace.method = function foo() {} // 无效
因此,结合前面的内容,就可以这样去处理:
// 全局命名空间 var globalns = globalns || {} // 添加 A 子模块 globalns.moduleA = (function () { // ... return { // ... } })() // 添加 B 子模块 globalns.moduleB = (function () { // ... return { // ... } })()
到现在,有了命名空间解决了命名冲突问题,同时使用 IIFE 来维护各模块的私有成员和方法,导出对外的开放接口即可。这似乎有了模块化该有的样子。
但是,还有一个问题。前面提到过 <script>
是按书写顺序加载的(即使下载顺序可能并行的),主要包括:
- 脚本下载
- 脚本解析(编译和执行)
假设我们的脚本如下:
<!DOCTYPE html> <html lang="en"> <body> <script src="module-a.js"></script> <script src="module-b.js"></script> </body> </html>
那么我们的 modueA
在(首次)解析的时候,就没办法调用 moduleB
的内容,因为它压根还没解析执行。一旦项目复杂度、模块数量上来之后,模块之间的依赖关系就很难维护了。
三、社区模块化方案
在 ES2015 之前,社区上已经有了很多模块化方案,流行的主要有以下几个::
- CommonJS
- AMD(Asynchronous Module Definition)
- CMD(Common Module Definition)
- UMD(Universal Module Definition)
其中 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 一统江湖...
关于 JavaScript 模块化历史线,可以看下这篇文章。
四、CommonJS
Node.js 的模块系统是基于 CommonJS 规范的实现的。除此之外,像 CouchDB 等也是 CommonJS 的一种实现。而且它们有一些是没有完全按照 CommonJS 规范去实现的,甚至额外添加了特有的功能。
由于我们接触到的 CommonJS 通常指 Node.js 中的模块化解决方法,因此,接下来提到的 CommonJS 均指 Node.js 的模块系统。
先瞅一下,一个 CommonJS 模块里面都包括一些什么信息:
如果有一些看不懂或不了解其用处的,先不急,下面娓娓道来。
CommonJS 的模块特点:
- 每一个 JavaScript 文件就是一个独立模块,其作用域仅在模块内,不会污染全局作用域。
- 一个模块包括
require
、module
、exports
三个核心变量。- 其中
module.exports
、exports
负责模块的内容导出。后者只是前者的“别名”,若使用不当,还可能会导致无法导出预期内容。其中require
负责其他模块内容的导入,而且其导入的是其他模块的module.exports
对象。- 模块可以加载多次,但只会在第一次加载时运行一次,然后运行结果就会被缓存起来。下次再加载是直接读取缓存结果。模块缓存是可以被清除的。
- 模块的加载是同步的,而且是按编写顺序进行加载。
4.1 Module 对象
前面打印的 module
就是 Module
的实例对象。每个模块内部,都有一个 module
对象,表示当前模块。它有以下属性:
// Module 构造函数 function Module(id = '', parent) { this.id = id this.path = path.dirname(id) this.exports = {} moduleParentCache.set(this, parent) updateChildren(parent, this, false) this.filename = null this.loaded = false this.children = [] }
源码 👉 node/lib/internal/modules/cjs/loader.js(Node.js v17.x)
module.id
:返回字符串,表示模块的标识符,通常这是完全解析的文件名。
module.path
:返回字符串,表示模块的目录名称,通常与module.id
的path.dirname()
相同。
module.exports
:模块对外输出的接口,默认值为{}
。默认情况下,module.exports
与exports
是相等的。
module.filename
:返回字符串,表示模块的完全解析文件名(含绝对路径)。
module.loaded
:返回布尔值,表示模块是否已完成加载或正在加载。
module.children
:返回数组,表示当前模块引用的其他模块的实例对象。
module.parent
:返回null
或数组。若返回值为数组时,表示当前模块被其他模块引用了,而且每个数组元素表示被引用模块对应的实例对象。
module.paths
:返回数组,表示模块的搜索路径(含绝对路径)。
module.isPreloading
:返回布尔值,如果模块在 Node.js 预加载阶段运行,则为 true。
注意点
- 赋值给
module.exports
必须立即完成,不能在任何回调中完成(应在同步任务中完成)。
比如,在setTimeout
回调中对module.exports
进行赋值是“不起作用”的,原因是 CommonJS 模块化是同步加载的。
请看示例:
// module-a.js setTimeout(() => { module.exports = { welcome: 'Hello World' } }, 0) // module-b.js const a = require('./a') console.log(a.welcome) // undefined // ❌ 错误示例
再看个示例:
// module-a.js const EventEmitter = require('events') module.exports = new EventEmitter() // 同步任务中完成对 module.exports 的赋值 setTimeout(() => { module.exports.emit('ready') // ❓ 这个会生效吗? }, 1000) // module-b.js const a = require('./module-a') a.on('ready', () => { console.log('module a is ready') }) // ⚠️ 执行 `node module-b.js` 命令运行脚本,以上 ready 事件可以正常响应, // 原因 require() 会对模块输出值进行“浅拷贝”,因此 module-a.js 中的 setTimeout 是可以更新 EventEmitter 实例对象的。
- 当
module.exports
属性被新对象完全替换时,通常也会“自动”重新分配exports
(自动是指不显式分配新对象给exports
变量的前提下)。但是,如果使用exports
变量导出新对象,则必须“手动”关联module.exprots
和exports
,否则无法按预期输出模块值。
请看示例:
// 1️⃣ 以这种方式进行模块的输出,module.exports 与 exports 会自动分配,即 module.exports === exports module.exports = { // ... } // 2️⃣ 以这种方式导出的值,将会是空对象 {},而不是 { sayHi: <Function> } // 此时 module.exports !== exports exports = { sayHi: function () {} } // ❌ // 3️⃣ 解决以上问题,需要手动关联 module.exprots 和 exports,使得二者相等 module.exports = exports = { sayHi: function () {} } // ✅
- 由以上示例也可以看出,
require()
方法引用的是module.exports
对象,而不是exports
变量。 - 利用
module.parent
返回null
或数值的特性,可以判断当前模块是否为入口脚本。另外,也可以通过require.main
来获取入口脚本的实例对象。
module.exports 与 exports 的注意点
一句话总结:
exports
变量只是module.exports
属性的一个别名,仅此而已。
我们可以这样对模块进行输出:
module.exports = { name: 'Frankie', age: 20, sayHi: () => console.log('Hi~') } // 相当于 exports.name = 'Frankie' exports.age = 20 exports.sayHi = () => console.log('Hi~')
但请注意,若模块只对外输出一个接口,使用不当,可能会无法按预期工作。比如:
// ❌ 以下模块的输出是“无效”的,最终输出值仍是 {} exports = function () { console.log('Hi~') }
原因很简单,在默认情况下 module.exports
属性和 exports
变量都是同一个空对象 {}
(默认值)的引用(reference),即 module.exports === exports
。
当对 exports
变量重新赋予一个基本值或引用值的时候, module.exports
和 exports
之间的联系被切断了,此时 module.exports !== exports
,在当前模块下 module.exports
的值仍为 {}
,而 exports
变量的值变为函数。而 require()
方法的返回值是所引用模块的 module.exports
的浅拷贝结果。
正确姿势应该是:
module.exports = export = function () { console.log('Hi~') } // ✅
使用类似处理,使得 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 官网 相关内容翻译并整理的版本(存档)
场景:在 `Y.js` 文件下,`require(X)`,Node.js 内部模块查找算法: 1. 如果 `X` 为内置模块的话,立即返回该模块; 因此,往 NPM 平台上发包的话,`package.json` 中的 `name` 字段不能与 Node.js 内置模块同名。 2. 如果 `X` 是以绝对路径或相对路径形式,根据 `Y` 所在目录以及 `X` 的值以确定所要查找的模块路径(称为 `Z`)。 a. 将 `Z` 当作「文件」,按 `Z`、`Z.js`、`Z.json`、`Z.node` 顺序查找文件,若找到立即返回文件,否则继续往下查找; b. 将 `Z` 当作「目录」, 1)查找 `Z/package.json` 是否存在,若 `package.json` 存在且其 `main` 字段值不为虚值,将会按照其值确定模块位置,否则继续往下; 2)按 `Z/index.js`、`Z/index.json`、`Z/index.node` 顺序查找文件,若找到立即返回文件,否则会抛出异常 "not found"。 3. 若 `X` 是以 `#` 号开头的,将会查找最靠近 `Y` 的 `package.json` 中的 `imports` 字段中 `node`、`require` 字段的值确认模块的具体位置。 (这一类现阶段用得比较少,后面再展开介绍一下) // https://github.com/nodejs/node/pull/34117 4. 加载自身引用 `LOAD_PACKAGE_SELF(X, dirname(Y))` a. 如果当前所在目录存在 `package.json` 文件,而且 `package.json` 中存在 `exports` 字段, 其中 `name` 字段的值还要是 `X` 开头一部分, 满足前置条件下,就会匹配 subpath 对应的模块(无匹配项会抛出异常)。 (这里提到的 subpath 与 5.b.1).1.1 类似) b. 若不满足 a 中任意一个条件均不满足,步骤 4 执行完毕,继续往下查找。 5. 加载 node_modules `LOAD_NODE_MODULES(X, dirname(Y))` a. 从当前模块所在目录(即 `dirname(Y)`)开始,逐层查找是否 `node_modules/X` 是否存在, 若找到就返回,否则继续往父级目录查找 `node_modules/X` ,依次类推,直到文件系统根目录。 b. 从全局目录(指 `NODE_PATH` 环境变量相关的目录)继续查找。 若 `LOAD_NODE_MODULES` 过程查找到模块 X(可得到 X 对应的绝对路径,假定为 M),将按以下步骤查找查找: 1) 若 Node.js 版本支持 `exports` 字段(Node.js 12+), 1.1 尝试将 `M` 拆分为 name 和 subpath 形式(下称 name 为 `NAME`) 比如 `my-pkg` 拆分后,name 为 `my-pkg`,subpath 则为空(为空的话,对应 `exports` 的 "." 导出)。 比如 `my-pkg/sub-module` 拆分后,name 为 `my-pkg`,subpath 为 `sub-module`。 请注意带 Scope 的包,比如 `@myorg/my-pkg/sub-module` 拆分后 name 应为 `@myorg/my-pkg`,subpath 为 `sub-module`。 1.2 如果在 M 目录下存在 `NAME/package.json` 文件,而且 `package.json` 的 `exports` 字段是真值, 然后根据 subpath 匹配 `exports` 字段配置,找到对应的模块(若 subpath 匹配不上的将会抛出异常)。 请注意,由于 `exports` 支持条件导出,而且这里查找的是 CommonJS 模块, 因此 `exports` 的 `node`、`require`、`default` 字段都是支持的,键顺序更早定义的优先级更高。 1.3 如果以上任意一个条件不满足的话,将继续执行 2) 步骤 2) 将 X 以绝对路径的形式查找模块(即前面的步骤 2),若找不到步骤 5 执行完毕,将会跑到步骤 6。 6. 抛出异常 "not found"
如果不是开发 NPM 包,在实际使用中的话,要不并没有以上那么多复杂的步骤,很容易理解。但深入了解之后有助于平常遇到问题更快排查出原因并处理掉。如果你是发包的话,可以利用 exports
等按一定的策略导出模块。
想了解 Node.js package.json 的两个字段的意义,请看:
4.3 require 源码
源码 👉 node/lib/internal/modules/cjs/loader.js(Node.js v17.x)
// 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-- } }
源码 👉 node/lib/internal/modules/cjs/loader.js(Node.js v17.x)
/** * 检查所请求文件的缓存 * 1. 如果缓存中已存在请求的文件,返回其导出对象(module.exports) * 2. 如果请求的是原生模块,调用 `NativeModule.prototype.compileForPublicLoader()` 并返回其导出对象 * 3. 否则,为该文件创建一个新模块并将其保存到缓存中。 然后让它在返回其导出对象之前加载文件内容。 */ Module._load = function (request, parent, isMain) { let relResolveCacheIdentifier if (parent) { debug('Module._load REQUEST %s parent: %s', request, parent.id) // Fast path for (lazy loaded) modules in the same directory. The indirect // caching is required to allow cache invalidation without changing the old // cache key names. relResolveCacheIdentifier = `${parent.path}\x00${request}` const filename = relativeResolveCache[relResolveCacheIdentifier] if (filename !== undefined) { const cachedModule = Module._cache[filename] if (cachedModule !== undefined) { updateChildren(parent, cachedModule, true) if (!cachedModule.loaded) return getExportsForCircularRequire(cachedModule) return cachedModule.exports } delete relativeResolveCache[relResolveCacheIdentifier] } } // 1️⃣ 获取 require(id) 中 id 的绝对路径(filename 作为模块的标识符) const filename = Module._resolveFilename(request, parent, isMain) if (StringPrototypeStartsWith(filename, 'node:')) { // Slice 'node:' prefix const id = StringPrototypeSlice(filename, 5) const module = loadNativeModule(id, request) if (!module?.canBeRequiredByUsers) { throw new ERR_UNKNOWN_BUILTIN_MODULE(filename) } return module.exports } // 2️⃣ 缓动是否存在缓存 // 所有加载过的模块都缓存于 Module._cache 中,以模块的绝对路径作为键值(cache key) 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 { // 若该模块缓存过,则直接返回该模块的 module.exports 属性 return cachedModule.exports } } // 3️⃣ 加载 Node.js 原生模块(内置模块) const mod = loadNativeModule(filename, request) if (mod?.canBeRequiredByUsers) return mod.exports // 4️⃣ 若请求模块无缓存,调用 Module 构造函数生成模块实例 module const module = cachedModule || new Module(filename, parent) // 如果是入口脚本,将入口模块的 id 置为 "." if (isMain) { process.mainModule = module module.id = '.' } // 5️⃣ 将模块存入缓存中 // ⚠️⚠️⚠️ 在模块执行之前,提前放入缓存,以处理「循环引用」的问题 // See, http://nodejs.cn/api/modules.html#cycles Module._cache[filename] = module if (parent !== undefined) { relativeResolveCache[relResolveCacheIdentifier] = filename } let threw = true try { // 6️⃣ 执行模块 module.load(filename) threw = false } finally { if (threw) { delete Module._cache[filename] if (parent !== undefined) { delete relativeResolveCache[relResolveCacheIdentifier] const children = parent?.children if (ArrayIsArray(children)) { const index = ArrayPrototypeIndexOf(children, module) if (index !== -1) { ArrayPrototypeSplice(children, index, 1) } } } } else if ( module.exports && !isProxy(module.exports) && ObjectGetPrototypeOf(module.exports) === CircularRequirePrototypeWarningProxy ) { ObjectSetPrototypeOf(module.exports, ObjectPrototype) } } // 7️⃣ 返回模块的输出接口 return module.exports }
4.4 require 中几个常见的问题
Q: Node.js 是如何实现同步加载机制的?
A:
未完待续...
References
- ECMAScript 6 Modules
- Writing Modular JavaScript With AMD, CommonJS & ES Harmony
- Learning JavaScript Design Patterns by Addy Osmani
- Patterns for modules and namespaces in JavaScript