js - commonjs
js - commonjs
what
- 【CommonJS】是一种规范,不是具体实现。就像 Promise a+ 规范一样
- 【Node】实现了CommonJS这种规范
environment
需要在node环境中运行,浏览器环境默认不支持CommonJS
三个核心关键字
【module.exports】、【exports】、【require】
特点
- 每一个 JavaScript 文件就是一个独立模块,其作用域仅在模块内,不会污染全局作用域。
- 模块可以加载多次,但只会在第一次加载时运行一次,然后运行结果就会被缓存起来。下次再加载是直接读取缓存结果。模块缓存是可以被清除的。
- 模块的加载是同步的,而且是按编写顺序进行加载
- 一个模块包括 require、module、exports 三个核心变量。
- 其中 module.exports、exports 负责模块的内容导出。后者只是前者的“别名”,若使用不当,还可能会导致无法导出预期内容。其中 require 负责其他模块内容的导入,而且其导入的是其他模块的 module.exports 对象。
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。
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/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-- } }
/** * 检查所请求文件的缓存 * 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 }
notice
同步
赋值给 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 属性替换
当 module.exports 属性被新对象完全替换时,通常也会“自动”重新分配 exports(自动是指不显式分配新对象给 exports 变量的前提下)。但是,如果使用 exports 变量导出新对象,则必须“手动”关联 module.exprots 和 exports,否则无法按预期输出模块值。
// 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
- module.exports 是真正决定导出对象的【重要角色】
- exports 仅仅是 module.exports 的【一个引用】
// 源码 module.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 进行导出,这样的话,就不会有问题了。
多次引入,模块代码会执行多次吗?
- 【加载次数】每个模块只会加载运行一次;因为每个模块对象module内部都有一个loaded属性,用来保证仅仅加载一次。
- 【加载顺序】按照深度优先搜索(DFS,depth first search)加载顺序
example
测试代码,require module开头的日志,会输出几次?
// module.js console.log('module'); let name = 'name', age = 100; console.log('module.exports'); module.exports = { name, age, }; console.log('module.exports end'); // index.js console.log('start'); let module1 = require('./module'); let module2 = require('./module'); console.log('index'); console.log(module1); console.log(module2); console.log('index end');
- 只会输出一次
use
// index.js const why = require("./why/why"); console.log(why); // why.js var name = "whyName"; var age = 18; module.exports = { name, age, }; // 输出结果 $ node index.js { name: 'whyName', age: 18 }
原理
理解CommonJS的内部原理图解【重要,其实也是内存布局图】
info、module.exports、why 指向内存中的同一个对象
缺点(对比ES Module)
- ① 属于非官方的方案
- ② 比较适用于node,因为CommonJS加载模块是同步的
参考资料
CommonJS规范
import 和 require区别
深入JavaScript Day25 - 模块化、CommonJS、module.exports、exports、require
细读 JS | JavaScript 模块化之路
浏览器加载 CommonJS 模块的原理与实现
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步