不懂模块化就别谈前端工程化
大家好 👋,我是 Moment,目前正在使用 Next.js、NestJS、LangChain 开发 DocFlow。这是一个面向 AI 场景的协同文档平台,集成了基于
Tiptap的富文本编辑、NestJS后端服务、实时协作与智能化工作流等核心模块。在这个项目的持续打磨过程中,我积累了不少实战经验,不只是
Tiptap的深度定制、编辑器性能优化和协同方案设计,也包括前端工程化建设、React 源码理解以及复杂项目架构实践。如果你对 AI 全栈开发、文档编辑器、前端工程化或者 React 源码相关内容感兴趣,欢迎添加我的微信
yunmz777一起交流。觉得项目还不错的话,也欢迎给 DocFlow 点个 star ⭐
![]()
前端工程化最基本的一步就是先学会模块化。简单来说,模块化就是把一大坨代码,拆成一个个小块,每个小块只做一件事,这样写起来和维护都方便多了。而且模块化还能让代码更容易被重复使用,像写好的请求封装、表单验证啥的,以后就不用再重新写一遍。多人合作的时候,模块化能让大家各做各的,互相不踩脚。更重要的是,像 Webpack 这种打包工具,都是基于模块化才能更好工作。常见的模块化写法有 CommonJS、ES Module 这些,学会了它们,工程化就有底子了。掌握模块化,等于给前端工程化打好地基!
什么是模块化
模块化的概念并不是一开始就有的。早期的网页都靠一个个大文件堆在一起,代码混乱又难维护。后来,项目越来越大,大家发现这样不行,得把功能拆分开。于是就有了“模块化”的想法:把代码分成小模块,每个模块只干一件事。这样一来,改东西的时候不容易出错,也能更好地复用代码。模块化也让多人一起开发的时候更有条理,减少冲突。现在常见的模块化方式有 CommonJS、ES Module 这些,都是让代码更清晰、管理更方便。掌握模块化,写项目会省心多了!
模块化的发展历程
石器时代
我们把这个过程称之为石器时代,因为这是最原始阶段,也是 JavaScript 刚被发明的时候(1995 年),它最早是被用来给网页加点动态效果,并没有考虑模块化。这就导致了一个很严重的问题:
-
全局变量污染
-
难以管理依赖
-
代码组织混乱
如下代码所示:
// a.js
const moment = 1;
// b.js
const moment = 2;
在 html 文件中我们有这样的代码来导入它们:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
</head>
<body>
<script src="./a.js"></script>
<script src="./b.js"></script>
</body>
</html>
很多时候我们会直接在文件里定义变量,无论是自己写的代码、和其他开发成员合作时不同文件里的变量,还是引入的第三方库中的全局变量,都会在全局作用域中共享同一个空间,这种方式在 <script> 标签默认的全局执行环境下非常常见,也因此容易产生变量冲突或被覆盖,导致全局污染和命名冲突,正是因为这样的问题,后续才会有模块化方案来解决作用域隔离和依赖管理的痛点。
![]()
这样的问题就非常容易产生了。
IIFE
IIFE(Immediately Invoked Function Expression)的全称是立即执行函数表达式,意思是定义完毕立即执行的函数。它是 JavaScript 中的一种非常常见的语法结构,用来创建一个立即执行的函数作用域,避免污染全局变量。
它的基本语法如下所示:
(function () {
// 这里是局部作用域
var a = 1;
console.log(a);
})(); // 立即执行
// 或者
(function () {
var b = 2;
console.log(b);
})();
这是借助了函数作用域,创建了一个私有空间(闭包)。在函数里定义的变量、函数,只在这个作用域可见,外部无法访问。
(function () {
// 这里是局部作用域
var a = 1;
console.log(a);
})(); // 立即执行
// 或者
(function () {
var b = 2;
console.log(b);
})();
console.log(typeof a);
最终输出结果如下图所示:
![]()
通过这种方式,IIFE 可以避免全局污染,并且能把内部变量封装起来,外部无法访问;不过,它不如模块化方案直观易读,在模块化需求较多时,代码结构容易变得混乱。
CommonJs
为了解决 JavaScript 缺少模块化体系的问题,CommonJS 标准被提出了。它主要就是给 JavaScript 提供了一个模块化的规范,让我们可以像在其他语言里那样按需引入、按需导出,把大项目拆成小块再拼装起来。
Node.js 正是借助 CommonJS 的模块体系,才让模块化管理变得井井有条。比如:
// a.js
const moment = require("moment"); // 引入模块
module.exports = { sayHi: () => console.log("hi") }; // 导出模块
这样做,变量和功能都被封装在自己的模块里,不会再跑到全局作用域里去乱七八糟。
AMD
2011 年前后,浏览器端模块化火了,出现了 AMD(代表:RequireJS),它的出现最主要的一个原因就是浏览器端加载文件是异步的,不能再用 CommonJs 的同步方式了。
AMD 是 "Asynchronous Module Definition" 的缩写,意思就是"异步模块定义"。它采用异步方式加载模块,模块的加载不影响它后面语句的运行。所有依赖这个模块的语句,都定义在一个回调函数中,等到加载完成之后,这个回调函数才会运行。
AMD 也采用 require()语句加载模块,但是不同于 CommonJS,它要求两个参数:
require([module], callback);
第一个参数 [module],是一个数组,里面的成员就是要加载的模块;第二个参数 callback,则是加载成功之后的回调函数。如果将前面的代码改写成 AMD 形式,就是下面这样:
require(["math"], function (math) {
math.add(2, 3);
});
math.add()与 math 模块加载不是同步的,浏览器不会发生假死。所以很显然,AMD 比较适合浏览器环境。
接下来编写一个完整的 AMD 来实现这个完整的示例,如下代码:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
<script
data-main="main"
src="https://cdnjs.cloudflare.com/ajax/libs/require.js/2.3.6/require.min.js"
></script>
</head>
<body>
<h1>AMD 示例页面</h1>
</body>
</html>
在这里的代码中使用的是 RequireJS CDN,它的关键点是 data-main="main",它告诉 RequireJS:页面加载完后去找 main.js 作为入口。
// math.js
define([], function () {
// 这是一个模块
return {
add: function (a, b) {
return a + b;
},
multiply: function (a, b) {
return a * b;
},
};
});
这里用到了 define(),定义了一个模块,暴露 add 和 multiply 方法。
// main.js
require(["math"], function (math) {
// 这里 math 就是 math.js 返回的模块对象
var sum = math.add(3, 4);
var product = math.multiply(3, 4);
console.log("3 + 4 =", sum);
console.log("3 * 4 =", product);
// 也可以在页面显示
var resultDiv = document.createElement("div");
resultDiv.textContent = `3 + 4 = ${sum}, 3 * 4 = ${product}`;
document.body.appendChild(resultDiv);
});
console.log(111222);
通过使用 require(['math'], callback),浏览器遇到后会异步加载 math.js,加载完毕后再执行回调,在回调里就能拿到 math 模块的内容,进行使用。
最终输出结果如下图所示:
![]()
UMD
CommonJS 和 AMD 在各自的领域(服务器端和浏览器端)都很好地解决了模块化问题,但它们之间存在兼容性问题。CommonJS 是同步加载模块的,适合服务器端,因为文件都在本地,加载速度快;而 AMD 是异步加载模块的,适合浏览器端,因为网络请求是异步的。这就导致了一个问题:如何编写一份代码,既能在 Node.js 环境下运行,又能在浏览器环境下运行,同时还能兼容 RequireJS 等 AMD 加载器?
为了解决这个问题,UMD(Universal Module Definition)应运而生。它是一种通用的模块定义规范,旨在创建一个能够兼容 CommonJS、AMD 和全局变量这三种模块化方案的代码模式。它的核心思想是,通过一套条件判断逻辑,检测当前运行环境支持哪种模块化方案,然后以对应的方式来定义和导出模块。这样,开发者就可以编写一份代码,无需修改就能在多种环境下使用。
那什么情况下是需要 UMD 呢?
-
跨环境兼容性: 如果你想编写一个 JavaScript 库,既希望它能在 Node.js 项目中使用(通过 CommonJS 模块),也希望它能在浏览器中直接作为
<script>标签引入(暴露全局变量),同时还能被RequireJS等 AMD 加载器识别,那么 UMD 是一个非常理想的选择。 -
解决 CommonJS 和 AMD 的冲突: CommonJS 是同步加载的,而 AMD 是异步加载的。直接使用其中一种方案会导致在另一种环境中无法正常工作。UMD 通过判断环境来选择最合适的加载方式。
-
简化开发流程: 避免为不同的环境编写多份模块代码,提高代码复用性。
接下来我们将借助 Rollup 来帮我们来实现一个这种 UMD 格式的模块,首先安装所需要的模块:
pnpm add rollup @rollup/plugin-node-resolve @rollup/plugin-commonjs -D
接下来我们再 src 目录下分别创建一个 index.js 文件和 utils.js 文件,并编写如下代码:
// utils.js
export function add(a, b) {
return a + b;
}
// index.js
import { add } from "./utils";
export function greet(name) {
return `Hello, ${name}! The sum is ${add(2, 3)}.`;
}
export function farewell(name) {
return `Goodbye, ${name}!`;
}
代码编写完成之后我们要在根目录下创建一个 Rollup 配置文件:
// rollup.config.js
import resolve from "@rollup/plugin-node-resolve";
import commonjs from "@rollup/plugin-commonjs";
export default {
input: "src/index.js",
output: {
file: "dist/moment.umd.js",
format: "umd",
name: "Moment",
globals: {
// 如果你的库有外部依赖但不想打包进去,可以在这里配置
// 'dayjs': 'dayjs' // 例如,如果依赖 dayjs,并且希望从全局变量获取
},
},
plugins: [resolve(), commonjs()],
};
这个时候我们需要在 package.json 中添加一个大包脚本:
"scripts": {
"build": "rollup -c"
},
这个时候我们就可以使用 pnpm build 来执行这些打包了,最终会输出一个 dist 目录:
![]()
最终输出的产物如下代码所示:
(function (global, factory) {
typeof exports === "object" && typeof module !== "undefined"
? factory(exports)
: typeof define === "function" && define.amd
? define(["exports"], factory)
: ((global =
typeof globalThis !== "undefined" ? globalThis : global || self),
factory((global.Moment = {})));
})(this, function (exports) {
"use strict";
function add(a, b) {
return a + b;
}
function greet(name) {
return `Hello, ${name}! The sum is ${add(2, 3)}.`;
}
function farewell(name) {
return `Goodbye, ${name}!`;
}
exports.farewell = farewell;
exports.greet = greet;
});
上面这个代码片段就是是一个经典的 UMD(Universal Module Definition) 模式构建产物。
它能够检测当前运行环境,并以最合适的方式导出模块:
-
CommonJS 环境 (如 Node.js):通过 module.exports 导出 farewell 和 greet 函数。
-
AMD 环境 (如 RequireJS):通过 define(["exports"], factory) 异步定义并导出模块。
-
浏览器全局环境 (无模块加载器):将模块内容挂载到全局对象 global.Moment 上。
简而言之,这份代码让我们的 JavaScript 库能够无缝地在 Node.js、支持 AMD 的浏览器以及普通浏览器环境中使用,极大地提高了兼容性。
当我们在 HTML 文件中直接通过 <script src="./dist/moment.umd.js"></script> 引入这份 UMD 文件时,它会检测到当前是浏览器环境,并将模块内容挂载到全局对象 global.Moment 上。你就可以像使用任何全局变量一样使用它:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
</head>
<body>
<h1>umd 示例页面</h1>
<script src="./dist/moment.umd.js"></script>
<script>
console.log(Moment);
</script>
</body>
</html>
最终输出结果如下图所示:
![]()
尽管 ES Module 已经成为现代 JavaScript 模块化的主流,并在现代浏览器和 Node.js 中得到了原生支持,但 UMD 在向后兼容和跨环境发布库的场景中仍然占有一席之地。理解 UMD 有助于我们更好地理解 JavaScript 模块化的发展历程以及不同模块化方案之间的兼容性问题。
ESM
ES Module,也称为 ECMAScript 模块,是 JavaScript 语言本身在 ES2015 (ES6) 标准中正式引入的官方模块化方案。它旨在成为 JavaScript 模块化的标准,在浏览器和 Node.js 环境中都能原生支持。
与 CommonJS 和 AMD 这种由社区提出的规范不同,ESM 是语言层面的原生支持,这使得它在语法、语义和性能上都具有独特的优势。
深入理解 CommonJS
在 CommonJS 中,每一个被 require 的文件,在 Node.js 内部都会被封装成一个 Module 类的实例。这个 Module 实例携带了该模块的唯一标识(ID)、文件路径、父模块信息、子模块依赖、是否已加载等元数据。
最重要的,它提供了一个 exports 对象,你的模块代码就是通过操作这个对象来决定要向外部暴露什么内容的。当你 require 这个模块时,你得到的就是这个 Module 实例的 exports 属性。
// 此类继承的是 WeakMap
const moduleParentCache = new SafeWeakMap();
function Module(id = "", parent) {
this.id = id; // 模块的识别符,通常是带有绝对路径的模块文件名
this.path = path.dirname(id); // 文件当前的路径
/
* 相当于给构造函数 Module 上添加了一个 exports 为空对象
* 等同于这样的写法 Module.exports = {};
*/
setOwnProperty(this, "exports", {});
// 返回一个弱引用对象,表示调用该模块的模块
moduleParentCache.set(this, parent);
updateChildren(parent, this, false);
this.filename = null; // 模块的文件名,带有绝对路径
this.loaded = false; // 是否已经被加载过,用作缓存
this.children = []; // 返回一个数组,表示该模块要用到的其他模块
}
我们编写如下代码:
const foo = 1;
module.exports = { foo };
console.log(module);
当我们通过直接打印 module,终端上会有如下输出:
![]()
你看到的这个 module 对象,是 Node.js 在运行你的 index.js 文件时,专门为这个文件创建的一个“档案袋”或者说“容器”。这个档案袋里装着关于你这个文件(模块)的所有重要信息:
-
id: '.': 这就好像你的文件在这个程序里的“身份证号码”。当你是直接运行node index.js时,这个index.js就是主入口,它的id会被标记为.,表示它是整个程序的“根”。 -
path: '/Users/macmini/Desktop/前端工程化': 这就是你的文件所在的文件夹路径。Node.js 在寻找你require的其他模块时,会用到这个路径来确定从哪里开始查找。 -
exports: { foo: 1 }: 这是最重要的!它是一个空盒子。你在这个index.js文件里写的所有module.exports = ...或者exports.xxx = ...的代码,都是在往这个盒子里装东西。当其他文件require你的index.js时,它们拿到的就是这个exports盒子里的内容。 -
filename: '/Users/macmini/Desktop/前端工程化/index.js': 这是你的文件的完整名字和路径,就像你的文件在这个电脑里的完整地址一样。 -
loaded: false: 这个告诉我们你的文件是否已经执行完毕。因为console.log(module)这行代码是在文件执行过程中打印的,所以此时模块还没有“加载完成”,还在运行,因此显示false。等整个文件代码都运行完了,它才会变成true。 -
children: []: 如果你的index.js里有require('其他文件')的话,那些“其他文件”的module对象就会出现在这个数组里,表明你的文件依赖了哪些模块。现在它是空的,说明你的index.js没有直接require其他文件。 -
paths: [...]: 这是 Node.js 在你require('第三方库名')(比如require('lodash')) 时,会去依次查找这些目录来找到node_modules文件夹。它从你文件所在的目录开始,逐级向上查找。 -
Symbol(...)开头的属性: 这些是 Node.js 内部使用的一些特殊标记。例如,kIsMainModule: true再次强调你的文件是程序的主入口;kIsExecuting: true则表示你的文件代码正在运行中。这些通常对开发者来说是内部实现细节,但也能帮助我们理解模块的生命周期。
简而言之,这个 module 对象就是 Node.js 对你的文件在模块系统中的“档案”,包含了它的身份信息、当前状态、以及如何与外部世界交互(通过 exports)的关键数据。
之所有会有这样的输出,主要是在 NodeJs 源码 中有这样的实现:
function Module(id = "", parent) {}
/** @type {Record<string, Module>} */
Module._cache = { __proto__: null };
/** @type {Record<string, string>} */
Module._pathCache = { __proto__: null };
/** @type {Record<string, (module: Module, filename: string) => void>} */
Module._extensions = { __proto__: null };
/** @type {string[]} */
let modulePaths = [];
/** @type {string[]} */
Module.globalPaths = [];
let patched = false;
let wrap = function (script) {
return Module.wrapper[0] + script + Module.wrapper[1];
};
const wrapper = [
"(function (exports, require, module, __filename, __dirname) { ",
"\n});",
];
let wrapperProxy = new Proxy(wrapper, {
__proto__: null,
set(target, property, value, receiver) {
patched = true;
return ReflectSet(target, property, value, receiver);
},
defineProperty(target, property, descriptor) {
patched = true;
return ObjectDefineProperty(target, property, descriptor);
},
});
在上面的代码中, Module._cache 是一个缓存区,存储所有已经加载并执行过的模块实例。当你 require 一个模块时,Node.js 会先检查这个缓存,如果模块已经存在,就直接返回缓存中的实例,避免重复加载和执行,确保模块是单例的。 它存储在 Node.js 进程的全局 JavaScript 堆内存中,作为 Module 这个构造函数(或类)的一个静态属性(Module._cache),这意味着它不属于任何特定的模块实例,而是所有模块共享的一个全局数据结构。
wrap 函数和 wrapper 数组是 CommonJS 模块机制的核心,wrapper 数组包含了两个字符串 (function (exports, require, module, filename, dirname) { 是函数体的开始部分,'\n});' 是函数体的结束部分。
这个封装后的函数就是每个 CommonJS 模块被执行时所处的环境。它为你的模块提供了私有的作用域,并且注入了 exports、require、module、__filename 和 __dirname 这些局部变量,这样你在模块里才能直接使用它们,而不会污染全局作用域。
module.exports 和 exports 的关系
我们继续来到这里的代码,这相当于给构造函数 Module 上添加了一个 exports 为空对象,等同于这样的写法 Module.exports = {},我们再来到这个文件代码的后面。
![]()
在 _compile 原型方法上定义了一个 exports 用来保存 Module.exports ,所以这也就是为什么 module.exports === exports 的原因了,实际上是它们共享同一块内存空间。
![]()
虽然他们共享的是同一块内存空间,但是最终被导出的是 module.exports 而不是 exports。值得注意的是 CommonJs 导出的是对象的引用,通过 require 之后 可以对其进行修改。
如下代码所示:
// utils.js
const object = {
moment: "Moment",
};
setTimeout(() => {
object.moment = "靓仔";
}, 2000);
module.exports = {
object,
};
// main.js
const bar = require("./utils");
console.log("main.js", bar.object.moment); // main.js Moment
setTimeout(() => {
console.log("2秒之后输出 ", bar.object.moment); // 2秒之后输出 靓仔
}, 2000);
最终的输出结果如下图所示:
![]()
验证了我们前面的说法。
CommonJs 读取的模块的缓存
在 Node.js 中,CommonJS 模块首次被 require() 后,其 module.exports 对象就会被缓存到内存中。这意味着,之后无论程序中何处再次 require() 同一个模块,Node.js 都不会重新加载和执行该模块的代码,而是直接返回缓存中的同一个实例。这种机制确保了模块只加载一次,并作为单例存在于整个应用生命周期中,从而优化了性能并避免了状态混乱。
如下代码所示:
// share.js
console.log("---- share.js 模块正在被加载和执行 ----");
let internalCounter = 0;
function increment() {
internalCounter++;
}
function getCounter() {
return internalCounter;
}
// 导出一些内容,包括一个时间戳,用于验证是否是同一个实例
module.exports = {
increment,
getCounter,
loadTimestamp: new Date().toISOString(), // 记录模块被加载的时间
};
console.log("---- share.js 模块执行完毕 ----");
创建第一个使用共享模块的模块 (moduleA.js):
// moduleA.js
console.log("*** moduleA.js 开始执行 ***");
const shared = require("./share"); // 第一次 require share
shared.increment(); // 调用共享模块的方法
shared.increment(); // 再次调用,计数器应该增加到 2
console.log("moduleA.js 访问 share 计数器:", shared.getCounter());
console.log("moduleA.js 访问 share 加载时间:", shared.loadTimestamp);
console.log("*** moduleA.js 执行结束 ***");
// 导出 shared 模块的引用,方便 main.js 进一步验证
module.exports = { sharedModuleRef: shared };
创建第二个使用共享模块的模块 (moduleB.js):
// moduleB.js
console.log("*** moduleB.js 开始执行 ***");
const shared = require("./share"); // 第二次 require share (预期从缓存读取)
shared.increment(); // 再次调用共享模块的方法,计数器应该增加到 3
console.log("moduleB.js 访问 share 计数器:", shared.getCounter());
console.log("moduleB.js 访问 share 加载时间:", shared.loadTimestamp);
console.log("*** moduleB.js 执行结束 ***");
// 导出 shared 模块的引用
module.exports = { sharedModuleRef: shared };
接下来我们创建一个主入口文件 index.js:
// index.js
console.log("--- index.js 开始执行 ---");
const moduleAExports = require("./moduleA");
const moduleBExports = require("./moduleB");
console.log("\n--- 验证共享模块的实例 ---");
// 验证 moduleA 和 moduleB 得到的 share 引用是否相同
console.log(
"moduleA.js 和 moduleB.js 获得的 share 是同一个引用:",
moduleAExports.sharedModuleRef === moduleBExports.sharedModuleRef
);
// 验证最终的计数器值
console.log(
"最终的共享模块计数器值:",
moduleAExports.sharedModuleRef.getCounter()
); // 或者 moduleBExports.sharedModuleRef.getCounter()
console.log("--- index.js 执行结束 ---");
![]()
在上面的输出结果中 share.js 被多次 require() 但最终只执行了一次,说明的代码 share.js 只在 moduleA.js 第一次 require 它时被执行了,之后无论是 moduleB.js 再次 require 它,还是你后续再进行任何 require 操作,Node.js 都直接从缓存中拿取其导出的结果,不再重复执行模块文件。
还有一个最直接、最明确的证据。=== 运算符用于比较两个变量是否指向内存中的同一个对象。输出为 true 毫不含糊地表明 moduleA 中 require 到的 share 引用和 moduleB 中 require 到的 share 引用,它们指向的是内存中的同一个 JavaScript 对象。
require 查找细节
当 require(X) 中的 X 指向一个核心模块时,Node.js 会直接返回对应的内置模块,并立即停止后续查找。这些核心模块,如 http、fs、url、path 和 Events,是用 C/C++ 编写的,因此在性能上表现优异。它们在 Node.js 编译时就被集成到二进制文件中,并在 Node 进程启动时直接加载到内存,无需额外的定位或编译过程,从而实现了极致的加载效率。
![]()
当 X 是一个路径(以 ./、../ 或 / 开头)时,Node.js 会尝试解析它:
-
如果
X指向一个文件夹,Node.js 会依次查找该文件夹下的index.js、index.json,最后是index.node文件。 -
如果
X指向一个文件但没有后缀名,Node.js 则会尝试追加.js、.json或.node后缀来查找对应文件。
而当 X 既不是路径也不是核心模块(即一个裸模块名,如 lodash)时,Node.js 会从当前目录的 node_modules 文件夹开始,逐级向上查找父目录中的 node_modules,直到文件系统根目录。如果遍历所有这些路径后仍未找到该模块,系统将报错提示。
如下代码所示:
console.log(module.paths);
![]()
它会一层一层网上查找,如果没有查到,会报没有找到的错误:
![]()
有了路径之后,下面就是 Module.findPath() 的源码,用来确定哪个是正确的路径,其中以下代码有省略的:
Module._findPath = function (request, paths, isMain) {
// 如果是绝对路径,则不在搜索,返回空
const absoluteRequest = path.isAbsolute(request);
if (absoluteRequest) {
paths = [""];
} else if (!paths || paths.length === 0) {
return false;
}
// 第一步:如果当前路径已在缓存中,就直接返回缓存
const cacheKey = request + "\x00" + ArrayPrototypeJoin(paths, "\x00");
const entry = Module._pathCache[cacheKey];
if (entry) return entry;
let exts;
// 是否有后缀的目录斜杠
const trailingSlash = "..."; //省略了很多代码
// 是否相对路径
const isRelative = "..."; // 省略了很多代码
let insidePath = true;
if (isRelative) {
const normalizedRequest = path.normalize(request);
if (StringPrototypeStartsWith(normalizedRequest, "..")) {
insidePath = false;
}
}
// 遍历所有路径
for (let i = 0; i < paths.length; i++) {
const curPath = paths[i];
if (insidePath && curPath && _stat(curPath) < 1) continue;
if (!absoluteRequest) {
const exportsResolved = resolveExports(curPath, request);
if (exportsResolved) return exportsResolved;
}
const basePath = path.resolve(curPath, request);
let filename;
const rc = _stat(basePath);
if (!trailingSlash) {
if (rc === 0) {
// File.
if (!isMain) {
if (preserveSymlinks) {
filename = path.resolve(basePath);
} else {
filename = toRealPath(basePath);
}
} else if (preserveSymlinksMain) {
filename = path.resolve(basePath);
} else {
filename = toRealPath(basePath);
}
}
if (!filename) {
if (exts === undefined) exts = ObjectKeys(Module._extensions);
// 该模块文件加上后缀名,是否存在
filename = tryExtensions(basePath, exts, isMain);
}
}
if (!filename && rc === 1) {
if (exts === undefined) exts = ObjectKeys(Module._extensions);
// 目录中是否存在 package.json
filename = tryPackage(basePath, exts, isMain, request);
}
if (filename) {
// 将找到的文件路径存入返回缓存,然后返回
Module._pathCache[cacheKey] = filename;
return filename;
}
}
// 如果没有找打返回 false
return false;
};
我们已经了解了核心模块因 C/C++ 实现而拥有极高的加载速度。然而,为了让这些底层用 C/C++ 编写的内建模块能够无缝地融入 JavaScript 的 CommonJS 模块体系并被 require 函数调用,其内部引入流程却相当复杂。它需要经历多个层面的封装和定义,包括 C/C++ 层的内建模块定义、JavaScript 核心模块的适配和封装,最终才能在 (JavaScript) 文件模块层面被正常引入和使用,以此确保了兼容性和性能的最佳平衡。
![]()
整个流程是:用户在 JavaScript 中 require 一个核心模块 -> Node.js 的 JavaScript 层 NativeModule 识别并处理 -> NativeModule 调用 process.binding 进入 C++ 层 -> C++ 层查找并加载对应的预编译模块 -> C++ 模块将其功能以 JavaScript 对象的形式导出,最终返回给用户。这个复杂的分层设计,既保证了核心模块的极致性能,又使其能够无缝融入 Node.js 的 CommonJS 模块加载体系。
一旦 Node.js 确定了模块的准确路径,就可以着手加载它了。你可能会好奇:require 函数究竟从何而来,为何在每个模块中都能“凭空”使用?它背后又执行了哪些操作?
实际上,require 并非一个全局变量。它是 Node.js 在执行每个 CommonJS 模块之前,通过模块封装函数(就是我们之前提到的那个 (function (exports, require, module, __filename, __dirname) { ... });)作为局部参数,注入到该模块的作用域中的。
而这个注入的 require 函数,其核心功能正是来源于 Module 构造函数原型上的 require 方法,它负责执行模块的查找、加载、缓存以及最终返回导出内容的完整流程。
Module.prototype.require = function (id) {
// 进行简单的 id 变量的判断,需要传入的 id 是一个 string 类型。
validateString(id, "id");
if (id === "") {
throw new ERR_INVALID_ARG_VALUE("id", id, "must be a non-empty string");
}
// 默认为0,表示还没有使用过这个模块,每使用一次便自增一次
requireDepth++;
try {
// 用于检查是否有缓存,有则从缓存里查找
return Module._load(id, this, /* isMain */ false);
} finally {
// 每次结束后递减一个,用于判断递归的层次
requireDepth--;
}
};
看完了 require 的了,我们再看看构造函数的静态方法 _load:
Module._load = function (request, parent, isMain) {
let relResolveCacheIdentifier;
if (parent) {
debug('Module._load REQUEST %s parent: %s', request, parent.id);
relResolveCacheIdentifier = `${parent.path}\x00${request}`;
// 以文件的绝对地址当成缓存 key
const filename = relativeResolveCache[relResolveCacheIdentifier];
reportModuleToWatchMode(filename);
if (filename !== undefined) {
// 先通过 key 从缓存中获取模块
const cachedModule = Module._cache[filename];
if (cachedModule !== undefined) {
updateChildren(parent, cachedModule, true);
if (!cachedModule.loaded)
// 如果要加载的模块缓存已经存在,但是并没有完全加载好,这是解决循环引用的关键
return getExportsForCircularRequire(cachedModule);
// 已经加载好的模块,直接从缓存中读取返回
return cachedModule.exports;
}
// 判断缓存是否存在父模块中,存在则删除
delete relativeResolveCache[relResolveCacheIdentifier];
}
}
// 判断是否为 node: 前缀的,也就是判断是否为原生模块
if (StringPrototypeStartsWith(request, 'node:')) {
// Slice 'node:' prefix
const id = StringPrototypeSlice(request, 5);
const module = loadBuiltinModule(id, request);
if (!module?.canBeRequiredByUsers) {
throw new ERR_UNKNOWN_BUILTIN_MODULE(request);
}
return module.exports;
}
这个函数的核心逻辑是:它会首先检查请求的模块是否已经存在于内部缓存中——如果已缓存,则直接返回其 exports 对象。如果模块带有 node: 前缀(表明是显式引入的内置模块),则会调用专门的 loadBuiltinModule() 方法处理并返回结果。除此之外,对于所有其他尚未加载过的模块,它会创建一个新的模块实例,执行其代码,并将最终导出的结果保存到缓存中,以供后续快速访问。
CommonJS 通过在检测到循环引用时,立即从缓存中返回模块当前已有的 exports 对象来解决。这意味着,如果一个模块(A)在被 require 时发现它自己又 require 了另一个模块(B)而 B 又 require 了 A,它会立刻提供 A 当前已经导出的部分内容。尽管这个 exports 对象可能是不完整的(缺少尚未执行的代码所导出的属性),但这种机制避免了死锁,并允许模块执行继续进行。
小结
require 的流程图正如下图所示:
![]()
Node.js 的 require 模块加载流程包含五个主要阶段。首先是解析(Resolution),确定模块的精确路径;接着是加载(Loading),读取文件内容。然后是包装(Wrapping),将代码放入 CommonJS 函数封装中;随后进行执行(Evaluation),运行模块代码并生成导出内容。最后,模块的导出结果会被缓存(Caching)起来,以确保后续对同一模块的 require 调用能高效地直接获取缓存实例。
CommonJS 模块的加载是同步的,意味着它会阻塞后续代码执行,这在服务器端因文件本地加载速度快而高效,但在浏览器中可能引发阻塞问题。它通过 module.exports 以对象形式导出内容,并且对每个加载的模块都存在缓存,确保无论何时何地 require 同一个模块,都只会得到并操作同一个模块实例。这种缓存机制不仅提升了性能,也有效地处理了模块间的循环引用,避免了死锁。
深入理解 ES Modules
默认情况下,普通的 JavaScript 脚本(包括那些用于旧浏览器兼容的 nomodule 脚本)会阻塞 HTML 解析和页面渲染。为了避免这种阻塞行为,你可以为这些脚本添加 defer 属性。带有 defer 属性的脚本会在 HTML 文档完全解析完毕后才开始执行,并且会按照它们在文档中出现的顺序执行,有效避免了阻塞页面内容呈现。
![]()
defer 和 async 是脚本标签的互斥可选属性,用于控制脚本的加载与执行时机。
对于常规脚本(包括 <script nomodule> 脚本),defer 属性确保脚本在 HTML 解析完成后才按顺序执行,避免阻塞页面渲染;而 async 属性则允许脚本与 HTML 并行解析和下载,并在可用时立即执行,不保证其执行顺序。
至于模块脚本 (<script type="module">),它们的默认行为就类似于 defer,即异步获取并在 HTML 解析后执行。但如果为模块脚本明确指定 async 属性,它及其所有依赖项都将与 HTML 解析并行获取,并一旦可用便立即执行,此时模块的执行顺序不再得到保证。
当我们用 ES Module(import / export)来写前端代码时,JavaScript 引擎在背后会做很多“幕后工作”来帮我们管理这些模块。比如:模块要有自己的作用域(不能全都放到全局变量去乱七八糟),还要能让模块之间互相导入导出,保证变量不会乱改。
这些幕后工作就靠了模块记录(Module Record)和模块环境记录(Module Environment Record)这样的底层概念,它们属于 JavaScript 引擎内部的数据结构,帮我们管理和组织模块。
Module Record
模块记录(Module Record)用来封装一个模块的导入和导出等结构化信息。这些信息在模块链接时非常关键,用来把一个个模块的输入输出都串联起来。一个模块记录里通常包含四个字段:
-
Realm:用来创建当前模块的作用域。 -
Environment:模块顶层的绑定环境记录,在模块被链接时设置。 -
Namespace:模块的命名空间对象,能让外部通过运行时属性访问模块的导出。这个对象本身是“外来对象”,并且没有构造函数。 -
HostDefined:这个字段是留给宿主环境(host environments)用的,方便在模块中附加额外信息。
Module Environment Record
模块环境记录是 ECMAScript 中的一种特殊的声明性环境记录,用来表示模块的外部作用域。
和普通的作用域环境记录不太一样,它在支持普通变量绑定的同时,还特别提供了不可变的 import 绑定。这些 import 绑定让模块内部能间接访问另一个模块里的变量,但又保证了这些变量不能被修改。
换句话说,不可变绑定就是指模块引入别的模块时,虽然能使用这些导入的变量,但不能在当前模块中直接更改它们,这也是模块化语法的一大特色。
Es Module 的解析流程
在开始之前,我们先大概了解一下整个流程大概是怎么样的,先有一个大概的了解:
-
构建(Construction):浏览器根据模块的地址找到对应的 JS 文件,通过网络下载,并把代码解析成一个内部的模块记录(Module Record),为后续步骤做准备。
-
实例化(Instantiation):对模块进行实例化,分配内存空间,分析并处理模块里的 import 和 export 语句,让这些变量在内存中有了位置和映射关系。
-
执行(Evaluation):真正运行模块里的代码,计算值,并把值写入内存,模块就正式被执行起来了。
Construction 构建阶段
在这个阶段,loader(加载器)负责模块的寻址和下载。它首先从入口文件开始加载,通常在 HTML 中使用 <script type="module"></script> 标签来声明这是一个模块文件。加载器会根据这个入口,去查找并下载模块代码,准备后续的实例化和执行。
![]()
模块继续通过 import 语句来声明需要的依赖。在 import 声明中,有一个模块声明标识符(ModuleSpecifier),它告诉 loader 如何去查找下一个模块的地址。
![]()
每一个模块标识符都对应着一个模块记录(Module Record),而每个模块记录中包含了:
-
JavaScript 代码本身
-
执行上下文
-
以及四种重要的表项:
ImportEntries、LocalExportEntries、IndirectExportEntries、StarExportEntries
其中,ImportEntries 是一个 ImportEntry Records 类型的结构,记录了模块里所有的 import 语句信息;
而 LocalExportEntries、IndirectExportEntries 和 StarExportEntries 都是 ExportEntry Records 类型的结构,记录了模块的各种导出方式。
ImportEntry Records
一个 ImportEntry Record 记录了当前模块中 import 语句的具体信息,它包含三个字段:
-
ModuleRequest:模块标识符(ModuleSpecifier),告诉系统从哪里去找这个模块。
-
ImportName:要从 ModuleRequest 指定的模块中导入的具体名称。值 namespace-object 表示这次导入的是目标模块的命名空间对象。
-
LocalName:当前模块内部用来引用导入值的变量名,也就是在你自己模块里写的名字。
详情可参考下图:
![]()
下面这张表记录了使用 import 导入的 ImportEntry Records 字段的实例:
| 导入声明 (Import Statement From) | 模块标识符 (ModuleRequest) | 导入名 (ImportName) | 本地名 (LocalName) |
|---|---|---|---|
| import React from "react"; | "react" | "default" | "React" |
import * as Moment from "react"; |
"react" | namespace-obj | "Moment" |
| import {useEffect} from "react"; | "react" | "useEffect" | "useEffect" |
| import {useEffect as effect } from "react"; | "react" | "useEffect" | "effect" |
ExportEntry Records
一个 ExportEntry Record 记录了当前模块中的导出信息,它包含四个字段:
-
ExportName:导出的名称,也就是别的模块在import时用到的名字。 -
ModuleRequest:模块标识符(ModuleSpecifier),如果是间接导出(export { a } from 'x')时,指定从哪里引入。 -
ImportName:当是间接导出时,要从ModuleRequest指定的模块中导出的具体名称。 -
LocalName:当前模块里要导出的变量名。
和 ImportEntry Records 不同的是,ExportEntry Records 多了一个 ExportName,专门用来描述这个导出的名字。
下面这张表记录了使用 export 导出的 ExportEntry Records 字段的实例:
| 导出声明 | 导出名 | 模块标识符 | 导入名 | 本地名 |
|---|---|---|---|---|
| export var v; | "v" | null | null | "v" |
| export default function f() {} | "default" | null | null | "f" |
| export default function () {} | "default" | null | null | "default" |
| export default 42; | "default" | null | null | "default" |
| export {x}; | "x" | null | null | "x" |
| export {v as x}; | "x" | null | null | "v" |
| export {x} from "mod"; | "x" | "mod" | "x" | null |
| export {v as x} from "mod"; | "x" | "mod" | "v" | null |
export * from "mod"; |
null | "mod" | all-but-default | null |
export * as ns from "mod"; |
"ns | "mod" | all | null |
回到主题,只有当解析完当前的 Module Record 之后,才能知道当前模块依赖的是那些子模块,然后你需要 resolve 子模块,获取子模块,再解析子模块,不断的循环这个流程 resolving -> fetching -> parsing,结果如下图所示:
![]()
这个过程也被称为静态分析,它只会识别 export 和 import 关键字,不会真正执行 JavaScript 代码。也正因为这样,import 语句只能出现在全局作用域中,动态导入(import())除外。
那如果多个文件同时依赖同一个模块,会不会引起死循环呢?答案是:不会。
这是因为 loader 使用了一个叫做 Module Map 的东西,来追踪和缓存全局范围内所有的 Module Record。这确保了每个模块只会被 fetch 一次,避免了重复加载或死循环的问题。并且,每个全局作用域都有自己的独立 Module Map。
Module Map 是一个
key/value结构的映射对象,key 是一个 URL(模块的请求地址),value 是模块类型的字符串(比如 “javascript”)。 模块映射的值可以是模块脚本、null(表示获取失败),或者一个占位符fetching(表示正在获取中)。
如下图所示:
![]()
linking 链接阶段
在所有 Module Record 解析完成后,接下来 JavaScript 引擎会对这些模块进行链接。引擎会从入口文件的 Module Record 开始,按照深度优先的顺序,递归地把依赖的模块链接起来。
在这个过程中,引擎会为每个 Module Record 创建一个 Module Environment Record,用来管理当前模块中声明的变量。
![]()
Module Environment Record 中有一个叫做 Binding 的东西,用来存放 Module Record 里导出的变量。比如在模块 main.js 中导出了一个名为 count 的变量,那么在 Module Environment Record 中的 Binding 就会包含一个 count,为这个变量分配内存空间,但初始值是 undefined 或 null。
这个过程类似于 V8 在编译阶段时,先创建一个模块实例对象,并为其中的变量和方法分配内存空间。
当子模块 count.js 中通过 import 关键字导入 main.js 时,count.js 的 import 变量和 main.js 的 export 变量指向的内存位置是相同的,这样就把父子模块之间的关系联系在一起了。
如下图所示:
![]()
需要注意的是,我们称 export 导出的为父模块,import 引入的为子模块,父模块可以对变量进行修改,具有读写权限,而子模块只有读权限。
Evaluation 求值阶段
在所有模块完成链接后,JavaScript 引擎会进入求值阶段。这时,它会按照模块的依赖顺序,执行各个模块文件中的顶层作用域代码。 执行过程中,引擎会将之前在链接阶段中分配好内存空间的变量,赋予实际的运行时值。
这样,模块中声明的变量和导出的内容就真正填充到内存中,整个模块的功能也随之生效。求值阶段也是模块真正开始“工作”的时候,确保模块之间的导入导出关系和依赖都能正确执行。
ES Module 是如何解决循环引用的
在 ES Module 中,模块加载和执行过程通过五种状态来管理,分别是:unlinked、linking、linked、evaluating 和 evaluated。
模块的状态存储在 循环模块记录(Cyclic Module Records)的 Status 字段中。通过这个状态,JavaScript 引擎可以判断一个模块是否已经被执行过,从而确保每个模块只会被执行一次。
这也是为什么引擎会使用 Module Map 来缓存全局的 Module Record,保证只在第一次加载时 fetch 并执行一次。
如果检测到一个模块的状态已经是 evaluated,下次再遇到它就会跳过执行,避免了死循环的发生。ES Module 会使用深度优先的方式遍历整个模块图,逐个执行模块的顶层代码,并且只会执行一次,从根本上避免了重复加载和死循环的问题。
深度优先搜索(Depth-First-Search,DFS)是一种常用的图遍历算法,它会尽可能深地搜索一个分支的节点,直到该分支的所有节点都被访问过,再回退到上一层继续探索其他分支。通过这种方式,ES Module 确保了每个模块都能被访问到一次,并且不会重复执行。
![]()
来看下面这个循环引用的例子,三个模块之间互相引用,但都只会执行一次:
// main.js
import { bar } from "./bar.js";
export const main = "main";
console.log("main");
// foo.js
import { main } from "./main.js";
export const foo = "foo";
console.log("foo");
// bar.js
import { foo } from "./foo.js";
export const bar = "bar";
console.log("bar");
在 Node.js 中运行 main.js,会得到下面的结果:
![]()
可以看到,每个模块只会输出一次,即使循环依赖也不会导致死循环。
总结
前端模块化是将大型代码拆分成独立小块的开发方式,每个模块专注单一功能,提高了代码的可维护性和复用性。模块化经历了从石器时代的全局变量污染,到 IIFE 函数作用域隔离,再到 CommonJS、AMD、UMD 等规范的发展历程。CommonJS 采用同步加载适合服务器端,通过 require/module.exports 实现模块导入导出并具有缓存机制;而 ES Module 是 JavaScript 官方标准,采用异步加载和静态分析,通过 import/export 语法提供更好的性能和树摇优化。掌握模块化是前端工程化的基础,为后续使用 Webpack 等构建工具奠定了重要基础。