三:模块系统——拆开你的代码

一、回顾与本篇目标

上一篇我们写了第一个 Node.js 后端程序,7 行代码启动了一个 HTTP 服务。但那 7 行代码全部挤在一个文件里。你可以想象,如果整个后端项目——处理用户登录、操作数据库、发送邮件、上传文件——全部写在一个文件里,这个文件会有几千行甚至上万行。改一个地方要翻半天,出了 bug 找不到源头,想复用一段逻辑只能复制粘贴。

解决这个问题的方法叫模块化:把代码按照功能拆成多个文件,每个文件负责一件事。需要的时候,通过 require 把别的文件加载进来。

本篇的目标:

  1. 彻底理解 require 的用法和查找规则
  2. 学会用 module.exports 导出自己的函数和数据
  3. 认识两个最常用的内置模块:fs(文件系统)和 path(路径处理)
  4. 把上一篇的 HTTP 服务拆成多个模块

二、为什么需要模块

先看一个反面例子。假设你要写一个后端程序,功能包括:

  • 启动 HTTP 服务
  • 处理用户登录
  • 读写文件
  • 发送邮件

如果全部写在一个 app.js 里:

// app.js —— 所有功能混在一起(反面教材,不要这样写)
const http = require('http');
const fs = require('fs');

// 登录相关的函数
function checkPassword(username, password) { /* 50 行代码 */ }
function createToken(user) { /* 30 行代码 */ }

// 文件相关的函数
function readUserData() { /* 40 行代码 */ }
function writeUserData(data) { /* 40 行代码 */ }

// 邮件相关的函数
function sendWelcomeEmail(email) { /* 60 行代码 */ }
function sendResetPasswordEmail(email) { /* 60 行代码 */ }

// HTTP 服务
const server = http.createServer(function (request, response) {
  // 处理各种路由,又是几百行
});

server.listen(3000);

这个文件很快就会变成一坨灾难:

  • 想找到处理登录的那段逻辑,要在一大堆无关代码里翻。
  • 改了一个函数,不小心影响了另一个函数,因为它们共享了一些变量名。
  • 如果另一个项目也需要用邮件发送功能,你只能把这个文件里的相关代码复制过去——然后两个地方的代码逐渐变得不一样,修了这头的 bug,那头还有。

模块化的思路:把不同功能的代码拆到不同的文件里,每个文件只负责一件事。

项目文件夹/
├── app.js           ← 只负责启动 HTTP 服务
├── auth.js          ← 只负责登录验证
├── userData.js      ← 只负责读写用户数据
├── email.js         ← 只负责发送邮件

每个文件之间通过 requiremodule.exports 来互相调用。这样每个文件都不长,改什么功能就打开对应的文件,不会影响其他模块。

三、require:加载模块

require 是 Node.js 中最核心的函数。它的作用是:找到指定的模块文件,执行里面的代码,然后把模块导出的内容返回给你

三种 require 的写法

1. 加载内置模块:直接写模块名

const http = require('http');
const fs = require('fs');
const path = require('path');

Node.js 自带了几个核心模块,直接写名字就能加载。常见的除了 httpfspath,还有 os(操作系统信息)、url(URL 解析)、crypto(加密)等。

2. 加载自己写的模块:写相对路径

const auth = require('./auth.js');        // 同一文件夹下的 auth.js
const userData = require('./data/user.js'); // 子文件夹 data 里的 user.js
const config = require('../config.js');     // 上一级文件夹里的 config.js

注意:路径必须以 ./../ 开头,否则 Node.js 会以为你要加载的是内置模块或第三方模块。

3. 加载第三方模块:写包名

const express = require('express');
const mysql = require('mysql2');

第三方模块是通过 npm install 安装的,后面我们会学。现在先聚焦前两种。

require 的内部行为

当你写 const xxx = require('./someModule.js'); 时,Node.js 做了这几件事:

  1. 找到 someModule.js 这个文件。
  2. 从头到尾执行这个文件里的所有代码。
  3. 把这个文件里通过 module.exports 导出的内容返回给你。
  4. 把返回的内容存到你指定的变量(这里是 xxx)里。
  5. 如果同一个模块被 require 了多次,Node.js 会缓存它。只有第一次会真正执行文件,后续的 require 直接返回缓存的结果。这避免了重复执行的开销。

四、module.exports:导出功能

一个模块文件里定义了函数和变量,但默认情况下这些函数和变量是私有的——其他文件 require 了它也访问不到。要暴露出去给别人用,必须通过 module.exports

基础用法

第一步:创建一个工具模块

新建一个文件叫 math.js

// math.js —— 数学工具模块

function add(a, b) {
  return a + b;
}

function subtract(a, b) {
  return a - b;
}

function multiply(a, b) {
  return a * b;
}

// 把需要暴露的函数挂到 module.exports 上
module.exports.add = add;
module.exports.subtract = subtract;
module.exports.multiply = multiply;

第二步:在主文件里使用

新建一个文件叫 main.js(和 math.js 在同一文件夹):

// main.js

const math = require('./math.js');

console.log(math.add(10, 20));       // 30
console.log(math.subtract(50, 15));  // 35
console.log(math.multiply(3, 7));    // 21

在终端里执行 node main.js,输出三个计算结果。

发生了什么

  • require('./math.js') 执行了 math.js 里的代码,拿到了 module.exports 这个对象。
  • 这个对象上有三个方法:addsubtractmultiply
  • 赋值给 math 变量后,你就可以通过 math.add() 来调用这些方法。

更简洁的写法

上面的写法有点啰嗦——函数名写了两次。可以用对象简写:

// math.js —— 简洁版

function add(a, b) { return a + b; }
function subtract(a, b) { return a - b; }
function multiply(a, b) { return a * b; }

module.exports = {
  add: add,
  subtract: subtract,
  multiply: multiply
};

或者更简洁(ES6 属性简写):

module.exports = {
  add(a, b) { return a + b; },
  subtract(a, b) { return a - b; },
  multiply(a, b) { return a * b; }
};

导出单个函数

如果一个模块只导出一件事,可以直接给 module.exports 赋一个值(而不是对象):

// capitalize.js —— 只导出一个函数

module.exports = function (str) {
  return str.charAt(0).toUpperCase() + str.slice(1);
};
// main.js

const capitalize = require('./capitalize.js');
console.log(capitalize('hello'));  // 'Hello'

注意:这里 require 拿到的直接就是那个函数,不需要通过属性访问。

五、实战:把 HTTP 服务拆成两个模块

回顾上一篇的 app.js,它做了两件事:

  1. 启动 HTTP 服务(createServerlisten)。
  2. 处理请求并返回内容(回调函数里的逻辑)。

现在我们把“处理请求”的逻辑拆到一个单独的模块里。

第 1 步:创建 handler.js——专门处理请求

// handler.js —— 请求处理模块

function handleRequest(request, response) {
  // 不管请求什么路径,统一返回一段文字
  response.writeHead(200, { 'Content-Type': 'text/plain; charset=utf-8' });
  response.end('这是从 handler 模块返回的内容。');
}

module.exports = handleRequest;

这个文件导出了一个函数 handleRequest。它接收 requestresponse 两个参数,设置状态码和响应体,然后结束响应。

第 2 步:修改 app.js——只负责启动服务

// app.js —— 主入口,只负责启动服务

const http = require('http');
const handleRequest = require('./handler.js');

const server = http.createServer(handleRequest);

server.listen(3000, function () {
  console.log('服务已启动,访问 http://localhost:3000');
});

变化点:

  • 原来 createServer 的回调函数是一个完整的匿名函数,现在换成了从 handler.js 加载进来的 handleRequest 函数。
  • app.js 现在只有 5 行有效代码,职责非常清晰:加载模块、创建服务、启动监听。

运行 node app.js,效果和之前完全一样。但代码已经拆成了两个文件。以后要修改请求处理逻辑,只需要打开 handler.js 改,不会碰到 app.js

六、内置模块 fs:读写文件

fs 是 File System(文件系统)的缩写。这是 Node.js 中最常用的内置模块之一,让你能用 JavaScript 读写电脑上的文件。

读取文件:fs.readFileSync()(同步)

同步读取的意思是:读文件的时候,程序停下来等,直到文件读完才继续往下执行。

先在项目文件夹里新建一个 data.txt,写入任意几行文字。然后创建 readFileDemo.js

// readFileDemo.js

const fs = require('fs');

// 同步读取文件,返回文件内容(字符串)
let content = fs.readFileSync('./data.txt', 'utf-8');

console.log('文件内容:');
console.log(content);
console.log('读取完毕。');

执行 node readFileDemo.js,终端会按顺序输出:“文件内容:”→ 文件的实际内容 →“读取完毕。”

参数解释

  • './data.txt':要读取的文件的路径。
  • 'utf-8':字符编码。如果不写这个参数,readFileSync 返回的是 Buffer(二进制数据),人看不懂。加上 'utf-8' 后返回的就是可读的字符串。

写入文件:fs.writeFileSync()(同步)

// writeFileDemo.js

const fs = require('fs');

let data = '这是通过 Node.js 写入文件的内容。\n第二行文字。';

fs.writeFileSync('./output.txt', data, 'utf-8');

console.log('文件写入成功!检查 output.txt');

执行后,项目文件夹里会出现一个 output.txt,内容就是你写的那段字符串。

注意:如果 output.txt 已经存在,writeFileSync覆盖它的全部内容。如果文件不存在,会自动创建。

异步读写:fs.readFile()fs.writeFile()

同步方法简单直观,但有一个致命问题:读大文件或者写大量数据的时候,程序会卡住,无法处理其他请求。所以实际项目中强烈推荐使用异步方法

// asyncReadDemo.js

const fs = require('fs');

console.log('开始读文件...');

// 异步读取:不会阻塞后续代码
fs.readFile('./data.txt', 'utf-8', function (error, data) {
  if (error) {
    console.error('读文件出错:', error.message);
    return;
  }
  console.log('文件内容:');
  console.log(data);
});

console.log('这行会先打印出来,因为读文件是异步的。');

执行顺序:

开始读文件...
这行会先打印出来,因为读文件是异步的。
文件内容:
(data.txt 的实际内容)

回调函数的两个参数

  • error:如果读文件过程中出错了(比如文件不存在、没权限),这个参数会包含错误信息。如果一切正常,它是 null
  • data:读到的文件内容。

异步写文件同理:

fs.writeFile('./output.txt', '内容', 'utf-8', function (error) {
  if (error) {
    console.error('写文件出错:', error.message);
    return;
  }
  console.log('写入成功');
});

七、内置模块 path:处理文件路径

在不同操作系统上,文件路径的写法不同。Windows 用反斜杠 \,Mac 和 Linux 用正斜杠 /。如果你在代码里写死路径(比如 './data/file.txt'),换一个操作系统可能会出问题。path 模块就是来解决这个问题的。

拼接路径:path.join()

const path = require('path');

let filePath = path.join(__dirname, 'data', 'users.json');
console.log(filePath);
// Windows 上输出:C:\Users\xxx\project\data\users.json
// Mac 上输出:  /Users/xxx/project/data/users.json

__dirname:Node.js 提供的一个全局变量,它始终指向当前文件所在的文件夹的绝对路径。结合 path.join,就能拼出正确的完整路径,无论代码跑在什么操作系统上。

为什么不用字符串拼接'./data/' + 'file.txt' 在 Windows 上可能变成 ./data/file.txt,在 Mac 上也是 ./data/file.txt。但绝对路径拼接时,Windows 是 C:\project\data + \file.txt,如果你写成 base + '/' + filename,在 Windows 上会出问题。用 path.join 自动处理这些差异。

获取文件扩展名、文件名等

let filePath = '/users/docs/report.pdf';

console.log(path.basename(filePath));     // 'report.pdf' —— 文件名
console.log(path.dirname(filePath));      // '/users/docs' —— 目录路径
console.log(path.extname(filePath));      // '.pdf' —— 扩展名
console.log(path.basename(filePath, path.extname(filePath))); // 'report' —— 不带扩展名的文件名

八、综合演示:用模块 + 文件系统做一个简单的接口

现在我们把本篇学的三个知识点——模块系统、fs 文件读写、path 路径处理——整合到一个完整的 HTTP 服务里。

需求:浏览器访问 http://localhost:3000,返回一个本地的 index.html 文件的内容。

项目结构

项目文件夹/
├── app.js          ← 主入口,启动服务
├── handler.js      ← 处理请求,读取文件并返回
├── index.html      ← 被返回的网页文件

第 1 步:创建 index.html

<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <title>Node.js 返回的页面</title>
  <style>
    body { font-family: sans-serif; padding: 40px; background: #f0f4f8; }
    h1 { color: #333; }
  </style>
</head>
<body>
  <h1>你好,这是 Node.js 返回的网页!</h1>
  <p>这个页面的内容是从本地文件读取的,然后通过 HTTP 服务返回给浏览器。</p>
</body>
</html>

第 2 步:创建 handler.js

// handler.js —— 请求处理模块

const fs = require('fs');
const path = require('path');

function handleRequest(request, response) {
  // 用 path.join 拼出 index.html 的完整路径
  let filePath = path.join(__dirname, 'index.html');

  // 异步读取 index.html 文件
  fs.readFile(filePath, 'utf-8', function (error, data) {
    if (error) {
      // 如果读文件出错(比如文件不存在),返回 500
      response.writeHead(500, { 'Content-Type': 'text/plain; charset=utf-8' });
      response.end('服务器内部错误:无法读取文件。');
      return;
    }

    // 读取成功,返回文件内容
    response.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
    response.end(data);
  });
}

module.exports = handleRequest;

第 3 步:创建 app.js

// app.js —— 主入口

const http = require('http');
const handleRequest = require('./handler.js');

const server = http.createServer(handleRequest);

server.listen(3000, function () {
  console.log('服务已启动,访问 http://localhost:3000');
});

运行:在终端执行 node app.js,浏览器访问 http://localhost:3000。你会看到 index.html 的内容被渲染成网页显示出来。

这段代码的完整执行流程

  1. node app.js 启动服务,监听 3000 端口。
  2. 浏览器访问 http://localhost:3000,HTTP 请求到达服务。
  3. app.jscreateServer 的回调是 handleRequest,这个函数被调用。
  4. handleRequestpath.join 拼出 index.html 的路径,用 fs.readFile 异步读取文件。
  5. 文件读完之后,回调函数把文件内容写入响应体,设置 Content-Typetext/html,结束响应。
  6. 浏览器收到 HTML 内容,渲染成你看到的网页。

九、本篇小结

这一篇你学会了 Node.js 模块系统的完整用法:

  • require:加载模块。内置模块直接写名字,自定义模块写相对路径(./../)。同一个模块被多次加载时会走缓存。
  • module.exports:导出模块的功能。可以导出对象(多个函数)或单个值(单个函数)。其他文件通过 require 拿到这些导出的内容。
  • fs 模块:读写文件。readFileSyncwriteFileSync 是同步方法(简单但会阻塞);readFilewriteFile 是异步方法(推荐,需要回调函数处理结果)。
  • path 模块:处理文件路径。path.join 跨平台拼接路径;path.basenamepath.extname 等获取路径各部分信息。
  • __dirname:当前文件所在文件夹的绝对路径,配合 path.join 使用。
  • 模块化的实际价值:代码按功能拆分,每个文件只负责一件事,方便维护和复用。

模块系统是 Node.js 后端的组织基础。把这一篇的内容练熟,你就能把任何复杂的功能拆成清晰的模块结构。

下一篇预告

下一篇——《HTTP 协议——浏览器和服务器怎么对话》:我们上一篇和这篇已经写了两个 HTTP 服务,但还没有真正理解 HTTP 协议本身。下一篇会拆开请求报文和响应报文,看看里面到底装了什么。你会学到 GET 和 POST 的区别、常见状态码的含义、请求头和响应头的作用。这之后,我们就能写出能区分不同 URL、处理不同请求方法的路由了。

后端零基础入门,每周更新。

© 版权声明
THE END
喜欢就支持一下吧
点赞9 分享
评论 抢沙发

请登录后发表评论

    暂无评论内容