Nodejs:模块化加载机制与循环依赖的探索

27 阅读7分钟

模块这个概念其实在前端开发中已经见过类似的东西了,比如 import 一个组件、export 一个函数。Node.js 里的模块就是把这个思想搬到了服务器端。

我们用 工具箱 来打个比方,就全明白了。


一. 什么是模块?—— 就是一个功能独立的“工具箱”

想象你要修理一个东西:

  • 没有模块:你把所有工具(扳手、螺丝刀、锤子)全堆在一个大桌子上,想用哪个都得翻半天,代码几万行全写在一个文件里,改一行代码都可能碰坏其他地方。
  • 有了模块:每个工具箱只装一类工具。
    • hammer.js(锤子工具箱):只负责砸钉子。
    • wrench.js(扳手工具箱):只负责拧螺丝。
    • screwdriver.js(螺丝刀工具箱):只负责拆装小零件。

每个工具箱就是一个模块
当你需要砸钉子时,就拿“锤子工具箱”出来用;不需要关心扳手里面是怎么写的。

对应到代码:
一个 .js 文件就是一个模块。文件里写什么功能,这个模块就负责什么功能。


1.1. 为什么需要模块?

好处通俗解释
复用你在项目A写的“日期格式化”工具,直接复制到项目B就能用,不用重写。
好维护出 bug 了,比如“用户登录失败”,你直接去 login.js 模块里找原因,不用在 1 万行代码里搜。
多人协作你写 user.js,我写 order.js,互不打架,最后组合起来就行。

1.2. Node.js 中的两种模块

① 内置模块(Node.js 自带的,像“官方工具箱”)

安装 Node.js 时,它就送你一堆常用工具箱,比如:

  • fs:操作文件(读、写、删)
  • http:创建 web 服务器
  • path:处理文件路径

怎么用? 直接用 require 拿过来就行,不用安装。

// 拿官方送的 fs 工具箱
const fs = require('fs');

// 用里面的 readFile 功能读文件
fs.readFile('a.txt', (err, data) => {
  console.log(data);
});
  1. fs (文件系统模块):用于进行文件和目录的读写操作。
const fs = require('fs');

// 示例:读取文件内容
fs.readFile('file.txt', 'utf8', (err, data) => {
  if (err) throw err;
  console.log(data);
});

// 示例:写入文件内容
fs.writeFile('file.txt', 'Hello, World!', (err) => {
  if (err) throw err;
  console.log('File written successfully');
});
  1. http (HTTP 模块):用于创建 HTTP 服务器和客户端。
const http = require('http');

// 示例:创建 HTTP 服务器
const server = http.createServer((req, res) => {
  res.statusCode = 200;
  res.setHeader('Content-Type', 'text/plain');
  res.end('Hello, World!');
});

server.listen(3000, () => {
  console.log('Server is running on port 3000');
});

// 示例:发送 HTTP 请求
const options = {
  hostname: 'www.example.com',
  port: 80,
  path: '/',
  method: 'GET',
};

const req = http.request(options, (res) => {
  console.log(`Status Code: ${res.statusCode}`);
  res.on('data', (data) => {
    console.log(data.toString());
  });
});

req.end();
  1. path (路径处理模块):用于处理文件路径。
const path = require('path');

// 示例:拼接路径
const fullPath = path.join(__dirname, 'public', 'index.html');
console.log(fullPath);

// 示例:获取文件名和文件扩展名
const fileName = path.basename('/path/to/file.txt');
const fileExtension = path.extname('/path/to/file.txt');
console.log(`File Name: ${fileName}`);
console.log(`File Extension: ${fileExtension}`);
  1. events (事件模块):用于实现事件驱动的编程模式。
const EventEmitter = require('events');

// 示例:创建自定义事件
class MyEmitter extends EventEmitter {}
const myEmitter = new MyEmitter();

myEmitter.on('event', (arg) => {
  console.log(`Event occurred with argument: ${arg}`);
});

myEmitter.emit('event', 'example');
  1. util (实用工具模块):提供一些实用函数和工具方法。
const util = require('util');

// 示例:将函数转换为基于 Promise 的函数
const setTimeoutPromise = util.promisify(setTimeout);
setTimeoutPromise(2000).then(() => {
  console.log('2 seconds have passed');
});

// 示例:格式化字符串
const formattedString = util.format('%s: %d', 'Example', 123);
console.log(formattedString);

② 自定义模块(你自己做的工具箱)

你想写一个“把用户名字变成大写”的功能,不想跟主文件混在一起,就可以新建一个文件 format.js

// format.js
function toUpperCaseName(name) {
  return name.toUpperCase();
}

// 把这个函数“交出去”,让别的文件能用
module.exports = toUpperCaseName;

然后在另一个文件 app.js 里使用它:

// app.js
const toUpperCaseName = require('./format.js');  // 注意写相对路径

console.log(toUpperCaseName('alice'));  // 输出 ALICE

1.3. 关键的两个语法(记住就行)

语法作用类比
module.exports = 东西把模块里的东西“公开”出去工具箱打开盖子,让别人能拿到里面的工具
require('模块名')从别处拿一个模块进来用伸手去拿某个工具箱

注意

  • 内置模块直接写名字:require('fs')
  • 自定义模块要写路径:require('./myModule.js')

1.4. 对比你熟悉的前端模块化

前端(ES6 模块)Node.js(CommonJS 模块)
export default fnmodule.exports = fn
import fn from './fn.js'const fn = require('./fn.js')

虽然写法不同,但思路一模一样:一个文件一个独立功能,用的时候导入,不用的时候不影响别人


1.5. 最后一句人话总结

模块就是一个独立的 JavaScript 文件,它只做一件事,并把自己的功能通过 module.exports 交出来,其他文件通过 require 拿去用。

之前写前端可能一个组件就是一个模块,现在 Node.js 里一个工具函数、一个数据库操作、一组路由都可以是一个模块。学会这个,就已经掌握了组织 Node.js 代码的核心方法。

试着写两个小文件,互相 require 一下,立刻就会了。

非常好,我们继续用“工具箱”的比喻,把模块加载和循环依赖这两个概念彻底讲清楚。


二、模块加载机制:你找工具箱的过程

想象你在一个工坊里工作,每个工具箱(模块)都放在一个货架上。当你需要一个工具时(require),你会经历以下步骤:

  1. 路径解析:你看工具单上写的是“./hammer.js”(自己的工具箱)还是“fs”(官方工具箱)。

    • 如果是./../开头,你会去当前文件夹或上级文件夹找。
    • 如果是fs这样的名字,你会去“官方工具箱专柜”拿(Node.js 内置模块)。
  2. 文件定位:找到对应的箱子。你可能只写了“hammer”,Node.js 会依次尝试 hammer.jshammer.jsonhammer.node

  3. 编译执行:第一次拿到这个工具箱,你会把它打开,把里面的工具(函数、对象)按说明摆放好(执行模块代码)。

  4. 模块缓存:记下这个工具箱已经打开过了。下次再有别人找你要同一个工具箱,你直接把之前摆好的工具给他,不会再重新打开一次

关键点require 同一个模块多次,模块里的代码只会执行一次。第二次及以后直接从缓存里取。

这就像你第一次打开一个工具箱需要把工具拿出来摆好,第二次直接拿摆好的用,省时间。

代码例子:

// counter.js
console.log('工具箱被打开了!');
module.exports = { count: 1 };
// app.js
const a = require('./counter.js'); // 打印“工具箱被打开了!”
const b = require('./counter.js'); // 不打印任何东西,直接拿到缓存里的对象
console.log(a === b); // true

2.1循环依赖:两个工具箱互相放对方里面,炸了

情景再现

你现在有两个工具箱:

  • A.js 里写着:“我需要 B 才能工作”
  • B.js 里写着:“我需要 A 才能工作”

就像两个人互相说:“你先给我东西,我再给你东西。”结果谁也没法先给。

具体代码(就是教程中的例子,简化一下):

a.js:

const b = require('./b');
exports.say = () => console.log('A 说你好');
b.say();  // 试图调用 B 的方法

b.js:

const a = require('./a');
exports.say = () => console.log('B 说你好');
a.say();  // 试图调用 A 的方法

main.js:

const a = require('./a');

当你运行 node main.js 时,Node.js 会:

  1. 加载 a.js → 发现 a 需要 b,于是暂停 a 的执行,先去加载 b.js
  2. 加载 b.js → 发现 b 需要 a,于是又回去加载 a.js
  3. 但 a 已经在加载中了(还没完成),Node.js 此时会返回一个 不完整的 a 对象 给 b。
  4. 于是 b 拿到的不完整的 a 里还没有 say 方法,调用 a.say() 就报错。

最终错误a.say is not a functionCannot access before initialization


2.2.如何避免循环依赖?(3 种简单方法)

方法1:重构,把共用的东西抽出来

如果 A 和 B 都需要某个功能 C,那就新建一个 c.js,让 A 和 B 都依赖 C,但 A 和 B 之间不再直接依赖。

原来: AB
改成: ACB

方法2:延迟引入(在函数里面 require)

不在文件顶部 require,而是在真正要用的时候才 require。

a.js:

exports.callB = function() {
  const b = require('./b');  // 等调用这个方法时才去加载 b
  b.say();
};

b.js:

exports.say = function() {
  console.log('B say hi');
};

这样就不会在加载阶段形成死循环,因为 require('./b') 是在运行时才发生的,那时 a 已经暴露出了 callB 方法。

方法3:使用中间层模块

创建 bridge.js,A 和 B 都只依赖 bridge,由 bridge 协调它们。不过对初学者而言,方法1和方法2就足够用了。


2.3.一句话总结(帮你记住)

  • 加载机制require = 找箱子 → 打开一次 → 永远缓存。
  • 循环依赖:A 要 B,B 要 A,两个同时加载时互相只拿到半成品 → 报错。
  • 解决:抽公共代码到新模块,或者在函数内部 require

其实我们已经接触过循环依赖的影子了:比如在浏览器里,两个 JS 文件互相用 import 也会出问题。Node.js 的机制类似,只是它只给你半成品而不是直接报错,导致方法调用失败。

可以自己写两个小文件故意制造一个循环依赖,观察报错信息,再用“延迟 require”改好它。试过一次,就彻底懂了。