Nodejs 模块化规范遵循两套规范,一套
CommonJS
规范,另一套esm
规范
- 想要对这两套规范有所熟悉,那对他们的历史由来也需要有一定的了解,这样我们才能够理解现在为什么要这么做
两者的由来
CommonJS 的由来:
CommonJS
规范诞生于服务器端 JavaScript 的早期,尤其是在 Node.js 出现之前。在那个时候,JavaScript 主要用于浏览器,没有模块化的概念,这意味着所有JavaScript代码和库都是通过 <script>
标签引入,并且全都在同一个全局作用域中。这种做法很容易造成命名冲突,而且代码组织和依赖管理也很混乱。
随着 JavaScript 开始被用于更复杂的服务器端开发,需要一种方法来组织和封装代码,以便易于管理和重用。因此,CommonJS
规范被提出,目的是为 JavaScript 创建一个模块生态系统。CommonJS
模块允许开发者在一个文件中导出一个或多个对象、函数或变量,并在另一个文件中通过 require
函数来同步导入这些模块。
Node.js 选择 CommonJS
作为其模块系统的基础,因为它适合服务器端编程,这种环境下,模块文件通常是本地可用的,因此可以同步加载模块,而不会对性能产生太大影响。
ECMAScript Modules (ESM) 的由来:
随着前端开发的日益复杂化和模块化需求的增长,以及 JavaScript 语言标准的不断发展,社区感到需要一个内置在语言层面的模块系统,这样的系统应当同时适用于服务器和浏览器环境。
这促使了 ECMAScript 标准(JavaScript的官方标准)的制定者引入了 ESM 规范。ESM 作为 ECMAScript 2015(也称为ES6)的一部分被加入 JavaScript 语言标准,它采用 import
和 export
语句来导入和导出模块,这些操作是异步完成的,特别适合于网络加载模块。
ESM 的设计是为了支持静态分析和树摇(tree shaking),这意味着工具可以在构建时分析代码中的 import
语句,仅打包实际使用的模块代码,从而减少最终应用程序的体积。此外,它也支持循环依赖和动态导入。
CommonJS规范
- 首先需要通过
npm init -y
生成一个package.json
文件,在这个文件里面的type属性就能看到我们当前使用的是哪套规范
- 可以明显的看到,两套设置的规范可供我们选择,默认情况下为commonJS,可以选择不写type这个属性,那就会以默认值为准
五种模式引入
- 这各种模式都是为了将内容分开,而不至于所有的内容都塞在一个文件里,那就太多啦,这会导致毫无结构,很难维护,也很难阅读
-
引入自己编写的模块
jsrequire('./test.js')//能够引入我们的模块内容,运行这个当下文件相当于也会运行test文件的内容
-
引入第三方模块
- npm i md5,我们安装一个md5模块
jsconst md5 = require("md5"); console.log(md5("xiaoyu"));
-
NodeJS的内置模块
- 这是随着下载node环境之后,自带的一些模块
- 例如
http
os
fs
child_process
等nodejs内置模块
jsconst fs = require("node:fs");//这个`node:`可加可不加,主要是为了好区分且有这种写法 //建议node16版本以上的都这样写 console.log(fs.readFileSync("xiaoyu-node2.js").toString());
-
C++扩展 例如addon napi 通过node-gyp进行编译,都能转化为.node文件
-
引入json文件:
jsconst data = require('./data.json') console.log("引入json的文件内容:",data)
导出
导出模块module.exports
module.exports
是 CommonJS 规范中最常用的导出方式。你可以通过它导出一个对象、类、函数或任何其他有效的 JavaScript 值。被module.exports
导出的内容可以通过require
函数导入到另一个模块中。
js
//导入这个模块时,会得到一个包含所有导出内容的对象
module.exports = {
age:21,
hello: function() {
console.log('Hello, XiaoYu!');
}
};
- 如果不想导出对象,也可以直接导出值
js
module.exports = 666
使用 exports
对象导出
CommonJS 也提供了一个名为
exports
的全局引用,它默认等同于module.exports
。使用exports
,可以导出多个变量或函数,但不能直接将exports
重新赋值为一个新的对象,因为这样会断开exports
和module.exports
之间的引用关系。
导出多个元素
使用 exports
添加属性,这些属性可以在导入模块时使用:
js
// myModule.js
exports.sayHello = function(name) {
console.log(`Hello, ${name}!`);
};
exports.sayGoodbye = function(name) {
console.log(`Goodbye, ${name}!`);
};
然后和之前一样导入并使用这些函数:
js
// app.js
const myModule = require('./myModule');
myModule.sayHello('Alice');
myModule.sayGoodbye('Bob');
注意事项
- 不要混用
exports
和module.exports
:虽然在某些情况下它们似乎是互换的,但最好避免在同一个模块中同时使用exports
和module.exports
,以避免混淆和潜在的错误。 exports
是module.exports
的一个引用 :这意味着可以通过exports
添加或修改module.exports
的属性,但如果直接将exports
重新赋值为一个新的对象,那么它将不再引用module.exports
,而require
函数只会返回module.exports
的内容。
为什么不能将exports重新赋值对象?
-
在 CommonJS 模块系统中,
exports
是module.exports
的一个引用或"快捷方式"。默认情况下,它们指向同一个对象,这意味着当我们给exports
添加属性时,实际上也是在给module.exports
添加属性。 -
然而,如果尝试将
exports
重新赋值为一个新的对象,这种操作实际上是在改变exports
变量自身的引用,让它指向一个全新的对象,而不再是module.exports
的引用。这样一来,module.exports
仍然指向原始的对象,而exports
则指向一个完全不同的新对象。由于 Node.js 中模块的导出实际上是通过module.exports
来实现的,所以这种改变exports
引用的操作并不会影响到模块的真正导出内容,也就是说,外部通过require
引入模块时获取到的是module.exports
的内容,而不是新赋值后的exports
对象。
js
// 错误的使用方式
exports = {
sayHello: function(name) {
console.log(`Hello, ${name}!`);
}
};
// 正确的使用方式
module.exports = {
sayHello: function(name) {
console.log(`Hello, ${name}!`);
}
};
- 在错误的使用方式中,尽管
exports
被赋值为一个新的对象,包含了sayHello
函数,但这个操作并不会影响到module.exports
。因此,当其他模块require
这个模块时,它们实际上获取到的是空对象(module.exports
的初始值),而不是包含sayHello
函数的对象。 - 在正确的使用方式中,通过直接给
module.exports
赋值一个新的对象,可以确保导出的内容是期望的对象。 - 因此,当我们想要导出一个新的对象时,应该直接操作
module.exports
。如果只是想导出多个属性或函数,可以通过给exports
添加属性的方式来实现,但要避免直接给exports
赋值一个新的对象。
ESM规范
导出
- 首先需要回到package.json文件中,将type类型改为"module",才能运行ESM规范的引入导出
命名导出 (Named Exports)
- 可以导出多个值。每个值都有其名称,导入时需要使用相同的名称。
js
// file: mathFunctions.js
// 导出单个函数
export function xiaoyu1(x, y) {
return x + y;
}
// 导出另一个函数
export function xiaoyu2(x, y) {
return x * y;
}
// 导出变量
export const xiaoyu3 = 3.14159;
默认导出 (Default Exports)
- 每个模块可以有一个默认导出(且仅能有一个)。默认导出的导入方式较为简洁。
js
// file: myModule.js
// 默认导出一个函数
export default function() {
console.log('This is the default export');
}
导入 (Import)
导入命名导出
- 必须使用导出时指定的名称,也就是解构。
js
// file: main.js
import { sum, multiply, pi } from './mathFunctions.js';
console.log(sum(2, 3)); // 5
console.log(multiply(4, 5)); // 20
console.log(pi); // 3.14159
导入默认导出
- 默认导出可以使用任意名称。
js
import myDefaultFunction from './myModule.js';
myDefaultFunction(); // 输出: This is the default export
混合导入
- 可以同时导入默认导出和命名导出。
js
import myDefault, { sum, multiply } from './mathFunctions.js';
整体导入 (Namespace import)
- 但如果我不知道我要导入的有哪些内容,想看导入的到底有哪些内容,那也是可以的
- 将模块的所有导出导入为一个对象,使用
as
关键字重命名(如果导入的名字跟当前的文件内的名字重复了,就可以使用as
来解决)。
js
import * as mathFunctions from './mathFunctions.js';
console.log(mathFunctions)//查看导出的所有内容
console.log(mathFunctions.sum(2, 3)); // 5
console.log(mathFunctions.multiply(4, 5)); // 20
动态导入 (Dynamic Imports)
- ESM 也支持动态导入,这意味着可以根据需要在代码运行时导入模块。动态导入返回一个 Promise。
js
import('./myModule.js')
.then((module) => {
module.default(); // 调用默认导出的函数
});
注意事项
-
ESM 是静态的,这意味着不能在条件语句中使用
import
或export
,并且所有的import
/export
语句都应该在模块的顶层。 -
模块路径可以是相对路径(如
./module.js
)或绝对路径(如/lib/module.js
),也可以是 URL。 -
浏览器中使用 ESM 时,
<script>
标签需要有type="module"
属性或者就像前面说的package.json
的type 属性要调节为module
。 -
ESM规范
是不支持引入JSON
文件的,这点需要注意。有些地方可以import进JSON文件,是因为vite和webpack的loader去处理了,所以才能使用。正常情况下是用不了的- 在Node18版本以上,可以通过特殊的方法做到强行导入
jsimport json from './data.json' assert { type : "json"}//Node18以上可以强行引入
CommonJS vs ESM的区别
CommonJS
和 ESM
的主要差异在于:
-
加载机制 :CommonJS 模块是运行时加载,ESM 模块则是编译时输出接口。(最重要)
-
由于是运行时加载,CommonJS 允许一些动态编程技巧,比如基于条件的导入模块,或者在任意位置导入模块,甚至在函数内部。
-
与 CommonJS 不同,ESM编译时(或者说加载时)静态分析并处理模块依赖的。"编译时" 这个词可能有些误导,因为 JavaScript 是一种解释型语言,不像 C++ 或 Java 那样有一个单独的编译步骤。但在 JavaScript 引擎处理 ESM 代码之前,它会先解析
import
和export
语句,构建出模块之间的依赖关系图 -
由于 ESM 的静态性质,所有的
import
和export
语句必须位于模块的顶层作用域,不能被条件语句包围,也不能动态生成。这使得工具(比如 Webpack 和 Rollup)能在打包阶段进行摇树优化(tree-shaking),移除未被使用的代码,因为它们可以准确地知道哪些导出被导入并使用了。 -
但如果非要强行使用的话,也不是不行,那就采用上面的动态导入模式,返回的是一个Promise
-
-
语法 :CommonJS 使用
require
和module.exports
,而 ESM 使用import
和export
。 -
异步加载:CommonJS 通常不支持异步加载和动态导入,而 ESM 是原生支持的。
-
执行环境:CommonJS 主要用于 Node.js 环境,而 ESM 设计时考虑了跨环境,可以在现代浏览器和 Node.js 中使用。
-
摇树优化:Cjs不可以tree shaking,esm支持tree shaking
-
this指向:commonjs中顶层的this指向这个模块本身,而ES6中顶层this指向undefined
-
值修改:Cjs是可以修改值的,esm值并且不可修改(可读的)
nodejs部分源码解析
- 内容位于:lib=>internal=>modules=>cjs=>loader.js
.json文件如何处理
- 使用fs读取json文件读取完成之后是个字符串 然后JSON.parse变成对象返回
js
// 定义 '.json' 文件的模块扩展处理函数。当 Node.js 遇到 require('.json') 时,会调用这个函数。
Module._extensions['.json'] = function(module, filename) {
// 使用 fs.readFileSync 同步地读取 JSON 文件的内容。'utf8' 参数确保文件内容被正确地作为 UTF-8 文本读取。
const content = fs.readFileSync(filename, 'utf8');
// 如果存在安全策略(例如通过 --policy 参数传入的),则验证文件内容的完整性。
if (policy?.manifest) {
// 将文件路径转换为符合 URL 格式的字符串。
const moduleURL = pathToFileURL(filename);
// 检查文件内容是否符合预设的安全策略。
policy.manifest.assertIntegrity(moduleURL, content);
}
try {
// 尝试解析 JSON 文件内容。stripBOM 函数用于移除可能存在的字节顺序标记(BOM)。
// 如果 JSON 解析成功,将解析后的对象赋值给 module.exports,使得 require('.json') 可以返回这个对象。
setOwnProperty(module, 'exports', JSONParse(stripBOM(content)));
} catch (err) {
// 如果解析过程中发生错误(例如 JSON 格式不正确),则修改错误信息,添加文件名,然后重新抛出这个错误。
err.message = filename + ': ' + err.message;
throw err;
}
};
.node文件如何处理
- 通过process.dlopen 方法处理.node文件
js
// 定义 '.node' 文件的模块扩展处理函数。当 Node.js 遇到 require('.node') 时,会调用这个函数。
Module._extensions['.node'] = function(module, filename) {
// 如果存在安全策略(例如通过 --policy 参数传入的),则验证文件内容的完整性。
if (policy?.manifest) {
// 同步地读取 .node 文件的内容。由于 .node 文件是二进制文件,此处不指定编码。
const content = fs.readFileSync(filename);
// 将文件路径转换为符合 URL 格式的字符串。
const moduleURL = pathToFileURL(filename);
// 检查文件内容是否符合预设的安全策略。
policy.manifest.assertIntegrity(moduleURL, content);
}
// 使用 process.dlopen 方法加载 .node 文件。
// path.toNamespacedPath 函数用于在 Windows 上处理文件路径,以确保路径格式正确。
// 注意,尽管读取了文件内容用于安全检查,但实际加载模块并不使用这些内容。
return process.dlopen(module, path.toNamespacedPath(filename));
};
.js文件如何处理
js
Module._extensions['.js'] = function(module, filename) {
// If already analyzed the source, then it will be cached.
//首先尝试从cjsParseCache中获取已经解析过的模块源代码,如果已经缓存,则直接使用缓存中的源代码
const cached = cjsParseCache.get(module);
let content;
if (cached?.source) {
content = cached.source; //有缓存就直接用
cached.source = undefined;
} else {
content = fs.readFileSync(filename, 'utf8'); //否则从文件系统读取源代码
}
// 检查文件名是否以 '.js' 结尾。
if (StringPrototypeEndsWith(filename, '.js')) {
// 读取文件所在目录(或其父目录)的 package.json 文件,以确定当前模块的上下文是否为 ES 模块上下文。
const pkg = readPackageScope(filename);
// 如果确定当前上下文为 ES 模块上下文(即 package.json 中包含 "type": "module"),则对使用 require 加载 .js 文件进行限制。
if (pkg?.data?.type === 'module') {
// 获取当前模块的父模块引用,主要用于错误报告。
const parent = moduleParentCache.get(module);
const parentPath = parent?.filename;
// 构建 package.json 文件的完整路径,主要用于错误报告。
const packageJsonPath = path.resolve(pkg.path, 'package.json');
// 检查文件内容是否包含 ES 模块语法(如 import/export),该函数未在代码中给出,但可以假设它通过分析文件内容来判断。
const usesEsm = hasEsmSyntax(content);
// 如果上述条件满足,则创建一个错误对象,表示不能在声明为 ES 模块上下文的 package.json 中使用 require 加载 .js 文件。
const err = new ERR_REQUIRE_ESM(filename, usesEsm, parentPath, packageJsonPath);
// 如果父模块存在于模块缓存中,则尝试从文件系统中读取父模块的源代码。
if (Module._cache[parentPath]) {
let parentSource;
try {
parentSource = fs.readFileSync(parentPath, 'utf8');
} catch {
// 如果读取父模块源代码时发生错误,则忽略错误,继续执行。
}
// 如果成功读取了父模块的源代码,则尝试在错误报告中添加更详细的信息,比如错误发生的具体位置。
if (parentSource) {
// 从错误对象的堆栈中提取出错的行号和列号。
const errLine = StringPrototypeSplit(StringPrototypeSlice(err.stack, StringPrototypeIndexOf(err.stack, ' at ')), '\n', 1)[0];
const { 1: line, 2: col } = RegExpPrototypeExec(/(\d+):(\d+)\)/, errLine) || [];
// 如果成功获取到行号和列号,则构建一个表示错误位置的代码框架,并将其添加到错误对象中,以便在控制台输出时显示。
if (line && col) {
const srcLine = StringPrototypeSplit(parentSource, '\n')[line - 1];
const frame = `${parentPath}:${line}\n${srcLine}\n${StringPrototypeRepeat(' ', col - 1)}^\n`;
setArrowMessage(err, frame);
}
}
}
// 抛出错误,中断执行。
throw err;
}
}
// 如果当前文件不属于 ES 模块上下文或者不受上述限制,则使用 _compile 方法编译并执行模块代码。
// _compile 方法负责将 JavaScript 代码编译成可执行的函数,并执行它。
module._compile(content, filename);
- 如果缓存过这个模块就直接从缓存中读取,如果没有缓存就从fs读取文件,并且判断如果是cjs但是type为module就报错,并且从父模块读取详细的行号进行报错,如果没问题就调用 compile
js
// _compile 方法定义在 Module 的原型上,接收文件内容和文件名作为参数。
Module.prototype._compile = function(content, filename) {
let moduleURL;
let redirects;
// 检查是否定义了安全策略,并获取其 manifest 属性。
const manifest = policy?.manifest;
if (manifest) {
// 将文件名转换为符合 URL 格式的字符串,用于处理模块。
moduleURL = pathToFileURL(filename);
// 获取依赖映射,这可能用于重定向模块的查找路径。
redirects = manifest.getDependencyMapper(moduleURL);
// 断言文件内容的完整性,确保它未被篡改。
manifest.assertIntegrity(moduleURL, content);
}
// 使用 wrapSafe 函数包装模块内容,它可能包括一些安全措施,比如在执行代码之前进行语法检查。
const compiledWrapper = wrapSafe(filename, content, this);
let inspectorWrapper = null;
// 如果启用了 --inspect-brk 标志,并且不是在评估模式下,可能会设置断点。
if (getOptionValue('--inspect-brk') && process._eval == null) {
if (!resolvedArgv) {
// 如果给定了文件名参数,尝试解析它。
if (process.argv[1]) {
try {
resolvedArgv = Module._resolveFilename(process.argv[1], null, false);
} catch {
// 在预加载模块的情况下可能会达到这个代码路径,此时应该已经有 '--require' 参数了。
assert(ArrayIsArray(getOptionValue('--require')));
}
} else {
resolvedArgv = 'repl'; // 如果没有给定文件名,则进入 REPL 模式。
}
}
// 如果要在模块启动时设置断点,并且当前文件是目标文件,则使用 inspector 包的 callAndPauseOnStart 方法。
if (resolvedArgv && !hasPausedEntry && filename === resolvedArgv) {
hasPausedEntry = true;
inspectorWrapper = internalBinding('inspector').callAndPauseOnStart;
}
}
// 获取当前文件的目录名。
const dirname = path.dirname(filename);
// 创建 require 函数的实例,可能会包括重定向。
const require = makeRequireFunction(this, redirects);
let result; // 用于存储模块执行的结果。
const exports = this.exports; // 引用当前模块的 exports 对象。
const thisValue = exports; // 将 this 值设置为 exports 对象。
const module = this; // 引用当前模块实例。
// 如果是第一次调用 require,则初始化状态缓存。
if (requireDepth === 0) statCache = new SafeMap();
// 如果设置了断点,则使用 inspectorWrapper 执行编译后的包装函数。
if (inspectorWrapper) {
result = inspectorWrapper(compiledWrapper, thisValue, exports,
require, module, filename, dirname);
} else {
// 否则,正常执行编译后的包装函数,并传入模块的常用变量。
result = ReflectApply(compiledWrapper, thisValue,
[exports, require, module, filename, dirname]);
}
// 标记已经加载了用户的 CJS 模块。
hasLoadedAnyUserCJSModule = true;
// 如果这是最外层的 require 调用,清理状态缓存。
if (requireDepth === 0) statCache = null;
// 返回模块执行的结果。
return result;
};
- 首先,它检查是否存在安全策略对象
policy.manifest
。如果存在,表示有安全策略限制需要处理 将函数将模块文件名转换为URL格式,redirects是一个URL映射表,用于处理模块依赖关系,manifest则是一个安全策略对象,用于检测模块的完整性和安全性,然后调用wrapSafe
js
// wrapSafe 函数用于包装并安全执行给定的 JavaScript 代码内容。
function wrapSafe(filename, content, cjsModuleInstance) {
// 检查是否已经应用了某些补丁(可能是为了支持某些特定功能或修复)。
if (patched) {
// 使用 Module.wrap 方法将模块内容包装在一个函数中。这个函数是一个立即执行的函数表达式 (IIFE),用于隔离模块作用域。
const wrapper = Module.wrap(content);
// 创建一个新的 Script 实例,这允许我们在当前的 V8 虚拟机上下文中编译和执行 JavaScript 代码。
const script = new Script(wrapper, {
// 设置脚本文件名,这在错误堆栈追踪中很有用。
filename,
// 设置行偏移量为 0,因为这段代码从文件的第一行开始执行。
lineOffset: 0,
// 提供一个函数来动态导入 ESM 模块。这是实现动态 import() 语法的关键。
importModuleDynamically: async (specifier, _, importAssertions) => {
// 获取 ESM 模块加载器的实例。
const loader = asyncESM.esmLoader;
// 使用加载器动态导入指定的模块,并将当前文件名作为引用者 URL 进行标准化处理。
return loader.import(specifier, normalizeReferrerURL(filename),
importAssertions);
},
});
// 如果脚本包含源映射 URL,则可能将其缓存起来,以便在调试时使用。
if (script.sourceMapURL) {
maybeCacheSourceMap(filename, content, this, false, undefined, script.sourceMapURL);
}
// 在当前全局上下文中运行编译后的脚本。这意味着脚本将在全局作用域中执行,而不是在隔离的模块作用域中。
return script.runInThisContext({
// 设置 displayErrors 为 true,以确保在执行脚本时遇到错误能够被正确地显示。
displayErrors: true,
});
}
// 如果没有应用补丁,这里可能需要有其他的处理逻辑(在示例代码中未给出)。
}
- wrapSafe调用了wrap方法
- wrap方法 发现就是把我们的代码包装到一个函数里面
js
let wrap = function(script) {
return Module.wrapper[0] + script + Module.wrapper[1];
};
//中间这里就是我们的代码,进行一个包装,这样做的好处就是如果内部有什么var全局变量,进行这样的一个包装,就不会影响到其他模块。形成一个沙箱
//(function (exports, require, module, __filename, __dirname) {
//const xm = 18
//\n});
const wrapper = [
'(function (exports, require, module, __filename, __dirname) { ',
'\n})',
];
然后继续看wrapSafe函数,发现把返回的字符串也就是包装之后的代码放入nodejs虚拟机里面Script,看有没有动态import去加载,最后返回执行后的结果,然后继续看_compile,获取到wrapSafe返回的函数,通过Reflect.apply调用因为要填充五个参数[exports, require, module, filename, dirname],最后返回执行完的结果。