尧图网络科技YAOTU DIGITAL 获取报价
获取报价
首页 / 资讯中心 / 文章详情

Node.js自定义模块实战:从能跑到能维护的模块化之路

发布时间:2026/9/26 13:01:12

资讯中心
01
ARTICLE

Node.js自定义模块实战:从能跑到能维护的模块化之路

Node.js自定义模块实战:从能跑到能维护的模块化之路
做后端开发这些年我见过太多人写 Node.js 像是在写一个巨大的单文件脚本。业务逻辑、路由、数据库操作全堆在 index.js 里几千行下来改一个字段都要在文件里翻半天。后来大家开始讲模块化编程但不少人的理解还停留在“把代码拆到不同文件”——文件是拆了问题没拆掉。其实自定义模块这件事想明白之后你的 Node.js 项目才算真正从“能跑”迈向了“能维护”。这篇内容不扯高深理论只讲我自己在项目里怎么拆模块、怎么写导出、怎么处理引用以及踩过哪些坑。适合刚接触 Node.js、正在做第一个真实项目、或者已经写完大量脚本但总觉得项目难维护的朋友。1. 模块化不是“把代码拆开”这么简单1.1 拆文件不等于模块化我见过一个典型的失败案例同事把工具函数都塞进 utils.js把所有接口都塞进 api.js最后每个文件都是上千行。表面看是分文件了实际上还是单体脚本的思路。真正的模块化编程核心是两件事高内聚、低耦合。高内聚一个模块只做一类事比如“时间格式化”就只管时间格式化不要顺带把网络请求也写了。低耦合模块对外只暴露必要的接口内部实现细节不随便被别人依赖。拿生活里的例子类比模块化像房间里的抽屉每个抽屉放一类东西要找螺丝刀不会把衣服全翻一遍。如果只是把东西从一个房间堆到另一个房间那叫搬家的箱子叠高不叫收纳。自定义模块的前提是你先想清楚“哪块逻辑该独立”而不是先纠结用什么语法导出。1.2 自定义模块的两种语法体系CommonJS 与 ES ModuleNode.js 生态里做自定义模块如今有两条路传统 CommonJSCJS和现代 ES ModuleESM。很多新手一上来就被这两套弄晕其实记住三点就行。对比项CommonJSES Module导入语法require(./module)import xxx from ./module导出语法module.exports {}export default {}/export const xx文件识别.js默认按 CJS 解析无type: module时.mjs或 package.json 里设置type: module加载方式同步加载异步加载支持静态分析动态导入require可以在任意位置执行import()是动态的import语句必须写在顶层在实际项目里我的建议是新项目优先考虑 ESM老项目继续用 CJS不要混着写。“混着写”的意思不是说你不能在一个项目里引入两者而是说同一个模块内部不要今天module.exports、明天export default。目前 npm 上大量库仍然提供 CommonJS 版本你写require大概率不会缺依赖但如果你要写工具库发布给别人用最好同时考虑两种加载方式的支持。1.3 为什么 Node.js 默认给了你 CommonJS很多人不理解JavaScript 在浏览器里为啥没有“模块”概念到了 Node.js 就有了require这要回到 Node.js 诞生的年代。当时服务端 JavaScript 还没有统一标准社区里几个人制定了一套叫 CommonJS 的规范Node.js 从中吸收了require和module.exports的核心设计。浏览器后来也在推进 ES Module 标准但 Node.js 生态早就依赖 CommonJS 长成了参天大树。所以你在网上搜node.js 安装教程、node.js 是干什么的老教程里全是require。ESM 直到 Node.js 12 才开始正式支持到 Node.js 20 左右才真正成熟。这不是说 CommonJS 十全十美而是历史惯性太大很多企业项目、老框架、内置模块默认都走 CJS。理解这一点你看到“自定义模块”相关文章大多用 CommonJS 演示就不奇怪了。2. 自定义模块的三个核心机制2.1 module.exports 与 exports一次拥抱跌宕入坑 Node.js 时几乎每个人都写过一段困惑代码// 错误示例 exports function () { return hello; }; // 结果require 这个模块得到的是一个空对象 {}原因很简单Node.js 在编译每个模块时本质上会向模块内部注入两个变量——module和exports并且exports只是module.exports的一个引用别名。你写exports.name xx相当于在module.exports对象上挂属性这没问题但如果你直接给exports重新赋值就相当于把别名指向了别的对象而 Node.js 最终返回的仍然是原来的module.exports。它当然还是空对象。正确的姿势有两种// 方式一挂属性 exports.format function () {}; exports.parse function () {}; // 方式二整体赋值 module.exports { format: function () {}, parse: function () {} };我更推荐方式二尤其是模块导出多个成员时一眼能看到模块对外提供了什么。还有一个小细节module.exports.name x和exports.name x可以混用但尽量不要混着写团队里每个人风格不同时代码 review 会很痛苦。我自己定的规范是模块里只出现module.exports不再使用exports。这样新人接手不会搞错。2.2 require 的解析规则路径、文件与目录自定义模块写好之后怎么被require正确找到很关键。Node.js 的require解析顺序大概是核心模块比如path、fs、http。这些模块即使你本地写了同名文件也不会被优先加载。以./或../开头的相对路径或绝对路径。既不是核心模块也不带路径的包名比如require(lodash)Node.js 会从当前目录的node_modules开始逐级向上查找直到根目录。第 2 步里还有个容易被忽略的细节如果你写的路径没有后缀Node.js 会先尝试.js、.json、.node文件如果都没有再把这个路径当目录处理。目录处理时先看这个目录里的package.json的main字段没有main字段就找index.js、index.json、index.node。举个例子project/ ├── app.js └── lib/ ├── package.json // main 指向 ./src/index.js ├── src/ │ └── index.js在app.js里写require(./lib)Node.js 会先找lib.js找不到再把lib当目录读取package.json里的main最终加载./lib/src/index.js。这就是很多人为什么明明没有写index.js也能跑起来——人家目录里可能有个package.json在暗中托底。2.3 模块缓存为什么第二次 require 拿到的是同一个对象CommonJS 有一个容易被忽略的特性模块加载后会被缓存。同一个模块第一次被require时模块代码会执行一遍之后再次require直接返回第一次加载时的module.exports不再重复执行代码。这个特性能带来一个好处模块天然单例。比如你写了一个配置模块// config.js module.exports { env: process.env.NODE_ENV || development, retryTimes: 3 };不管在多少地方require(./config)拿到都是同一个对象。你在 A 文件里改它的属性B 文件会同步看到变化。这个行为在某些场景非常方便比如内部缓存、全局配置。但也带来注意点热更新和测试重置时会卡住你。你改了模块代码却不重启进程模块大概率不生效。测试框架里有时要清理缓存function freshRequire(modulePath) { delete require.cache[require.resolve(modulePath)]; return require(modulePath); }这招在写单元测试、模拟模块状态时很常用一般业务代码里不怎么需要手动碰缓存。3. 手把手实现一个自定义模块3.1 先从一个小工具模块开始说的再多不如直接写。我拿一个日常项目里常见的“格式化工具”举例。新建utils/format.js// utils/format.js const pad (n) String(n).padStart(2, 0); function formatDate(date new Date()) { if (!(date instanceof Date)) { throw new TypeError(formatDate requires a Date instance); } const year date.getFullYear(); const month pad(date.getMonth() 1); const day pad(date.getDate()); const hour pad(date.getHours()); const minute pad(date.getMinutes()); return ${year}-${month}-${day} ${hour}:${minute}; } function formatMoney(value) { if (typeof value ! number || !Number.isFinite(value)) return 0.00; return value.toFixed(2).replace(/\B(?(\d{3})(?!\d))/g, ,); } module.exports { formatDate, formatMoney };使用方const { formatDate, formatMoney } require(./utils/format); console.log(formatDate(new Date())); // 2025-06-11 14:30 console.log(formatMoney(1234567.89)); // 1,234,567.89这里有一个设计细节formatDate里我故意加了类型检查和throw TypeError。很多人写工具函数觉得“传错参数就传错呗反正不崩就行”但工具型模块被多个业务引用时你希望问题尽早暴露在调用方而不是埋在一个莫名其妙返回NaN的结果里。宁可调的时候炸得明明白白不要在排查时云里雾里。3.2 封装一个可配置的日志模块工具函数只是热身真正体现自定义模块价值的是“带状态、带配置”的模块。以日志模块为例我不想在业务代码里到处写console.log因为以后如果想统一加时间戳、加级别、加日志输出方向散落的console.log根本没法收拾。于是我在logger/index.js里封装一个可配置的 logger// logger/index.js const { formatDate } require(../utils/format); const LEVELS { debug: 10, info: 20, warn: 30, error: 40 }; function createLogger(options {}) { const level options.level || info; const label options.label || app; const threshold LEVELS[level] ?? 20; function write(levelName, args) { if (LEVELS[levelName] threshold) return; const time formatDate(new Date()); const method levelName debug ? log : levelName; console[method]([${time}] [${label}] [${levelName.toUpperCase()}], ...args); } return { debug: (...args) write(debug, args), info: (...args) write(info, args), warn: (...args) write(warn, args), error: (...args) write(error, args) }; } module.exports { createLogger };业务模块中使用// service/user.js const { createLogger } require(../logger); const logger createLogger({ level: debug, label: user-service }); function getUser(id) { logger.debug(fetch user start, id${id}); // ...模拟查询 logger.info(user fetched, id${id}); } module.exports { getUser };为什么用createLogger工厂函数而不是直接导出一个 logger 对象因为不同模块可能需要不同标签、不同日志级别。一个模块内部想调试打详细日志另一个模块只想显示 warn 以上如果大家都是同一个全局实例配置会被互相污染。工厂函数每次生成独立实例模块之间互不干扰测试时也能轻松注入 mock。3.3 导出一个类、工厂函数还是普通对象自定义模块的导出形态没有绝对标准但可以按场景选纯函数集合比如utils/format.js推荐导出一个对象成员是多个函数。需要保持内部状态的单例比如数据库连接、配置对象直接module.exports connection。每个调用方需要独立状态比如日志、缓存实例用工厂函数返回新对象。面向对象风格比如业务 Service用class导出。举一个 Service 的例子// service/user-service.js const { createLogger } require(../logger); const logger createLogger({ level: info, label: user-service }); class UserService { constructor({ repository }) { this.repository repository; } async getUser(id) { logger.info(get user start, id${id}); return this.repository.find(id); } async save(user) { logger.info(save user, id${user.id}); return this.repository.save(user); } } module.exports { UserService };这里有个我比较坚持的习惯Service 类通过构造器注入 repository而不是在模块内部 require 一个 repository。原因很简单这样写测试时我可以传入一个假 repository不用真的连数据库。模块化如果只是拆文件但内部依赖全是隐式的那测试和重构依然寸步难行。3.4 给自定义模块加类型提示JSDoc 也能顶半边天如果你项目还没上 TypeScript不用觉得写自定义模块就只能“裸奔”。用 JSDoc 注释也能让编辑器有良好的自动补全。以formatDate为例/** * 将 Date 实例格式化为 YYYY-MM-DD HH:mm * param {Date} date * returns {string} */ function formatDate(date new Date()) { // ... }配合 VSCode导入这个函数时鼠标悬停就能看到参数类型和返回值。遇到对象参数时定义typedef能进一步约束结构/** * typedef {Object} LoggerOptions * property {debug|info|warn|error} [level] * property {string} [label] */自定义模块内部再复杂只要接口处的类型信息是明确的调用方就不会摸黑。等到项目规模大了再迁 TypeScript模块边界清晰的话迁移成本也低很多。别等到代码已经一团乱才想起类型这回事。4. 让自定义模块真正落地到项目4.1 package.json从“单文件”到“一个包”当自定义模块不止一个文件时最好把模块放进独立目录并配一个 package.json。比如我想做一个my-utils本地模块my-utils/ ├── package.json └── src/ ├── index.js ├── format.js └── logger.jspackage.json里最关键的是main字段或exports字段{ name: my-utils, version: 1.0.0, main: src/index.js, exports: { .: ./src/index.js, ./format: ./src/format.js, ./logger: ./src/logger.js } }在src/index.js中再统一导出几个常用模块module.exports { ...require(./format), ...require(./logger) };这样做的好处是外部调用时有两种姿势// 整包引入 const utils require(my-utils); utils.formatDate(new Date()); // 子路径引入只加载需要的部分 const { formatDate } require(my-utils/format);如果你想被其他项目直接引用package.json必须字段完整。exports字段还有个额外好处可以控制哪些子路径对外可见不想暴露的内部实现文件不写在exports里外部就require不到老版本 Node.js 不完全强制但生态已经默认支持。4.2 本地调试的两种方式npm link 与 file: 依赖自己开发的自定义模块还没发布到 npm又想在另一个项目里用最常见的两种做法方式一npm link# 在 my-utils 目录下 npm link # 在需要使用的项目目录下 npm link my-utils这会在全局 node_modules 里建一个软链接开发时对my-utils的修改立刻能反映到目标项目里。代价是全局环境会被污染多台机器、CI 环境上还要重新链接不适合自动化部署。方式二本地依赖在目标项目的package.json里直接写{ dependencies: { my-utils: file:../my-utils } }然后执行npm installnpm 会把它当成本地路径依赖安装。这种方式适合团队内部几个人协作的私有项目不需要注册 npm 账号也不污染全局环境。需要注意file:依赖在发布和团队同步时会有坑npm 会把目录复制或链接到node_modules如果my-utils目录里有.git别人拉下来大概率路径对不上。更稳妥的是把my-utils放进私有 Git 仓库用gitssh://地址安装{ dependencies: { my-utils: gitssh://gitgithub.com:your-team/my-utils.git#main } }这种形式 CI 也友好npm install直接拉最新代码。日常开发需要频繁改动时再用npm link或file:顶着。4.3 Node.js 版本选择与部署环境的现实问题网上搜node.js 下载、node.js 18.20.4 LTS 版本下载你会发现版本多到眼花。我的建议很简单生产环境优先用当前 Active LTS 或 Maintenance LTS不要盲目追最新版。Node.js 的版本号变化非常快今天我们常用 18、20、22 甚至 24但“最新”不等同于“兼容”。你项目依赖的某个旧库可能声明只支持 Node.js 16 以上在 22 里跑却报错。有的部署服务器还是 CentOS 7.9系统自带的 yum 源里没有现代 Node.js往往要用 nvm 或二进制包安装。最简单的部署安装套路拿 nvm 举例curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.1/install.sh | bash # 重开终端或 source ~/.bashrc nvm install 20 nvm use 20 node -v npm -v之后在项目目录执行npm ci node src/index.js如果你的自定义模块代码只用了很基础的语法从 14 到 22 大概率都能跑但如果你用了较新的 API比如Array.prototype.findLast、fetch就得保证目标是支持这些特性的 Node.js 版本。做模块化开发时我会在模块顶部用 JSDoc 或注释标注“兼容 Node.js 18”并且本地用一个.nvmrc文件固定开发版本比如写入20。团队里其他人进项目时nvm use自动切换版本能避免很多“我本地没问题服务器上不行”的尴尬。5. 模块化过程中的经典坑与排查方案5.1 循环依赖CommonJS 的软肋循环依赖是 CommmonJS 模块化最容易碰到的坑而且报错看起来毫无逻辑。举个例子// a.js const b require(./b); module.exports { name: a, getBName() { return b.name; } }; // b.js const a require(./a); module.exports { name: b, getAName() { return a.name; } };入口文件main.jsconst a require(./a); console.log(a.getBName());你会拿到TypeError: Cannot read properties of undefined (reading name)或者更不明显的错误。原因就是模块缓存main.js先加载a.jsa.js第一行又加载b.jsb.js第一行回头加载a.js但此刻a.js的module.exports还没赋值完缓存里的是一个不完整对象。于是b.js拿到的a是未完成的{}getAName自然取不到。解决办法首先是重构把两个模块共同依赖的逻辑抽到第三个模块。如果抽不动退而求其次用“函数内 require”// b.js module.exports { name: b, getAName() { // 延迟到调用时再加载 const a require(./a); return a.name; } };这种“懒 require”能绕开循环依赖是因为真正执行到这一行时a.js已经加载完成缓存里是完整对象了。不过它属于缓解手段不是根治方案还是尽量把公共依赖拆出去。5.2 大小写与路径问题Windows 没事Linux 上就崩“本地跑得好好的一部署到 Linux 就报 Cannot find module”是我在群里看到最多的求助之一。常见原因是文件名大小写不一致本地 Windows 或 macOS 里require(./UserService)和实际文件userService.js可能都能过但 Linux 文件系统是区分大小写的UserService.js和userService.js是两个文件。我自己定过一条规矩所有模块文件名一律小写用中划线或下划线分隔写 require 时也全部小写。比如user-service.js而不是UserService.js、userService.js。同时明显一些歧义的场景我会写全后缀require(./user-service.js)不省略.js。这样有两个好处第一避免 Node.js 先尝试.js再.json时加载到意外文件第二IDE 和命令行里跳转文件时不会翻错。还有一层坑require(../lib)这种以目录结尾的写法依赖目录里的package.json或index.js。如果哪天服务器上构建步骤把package.json过滤掉了看起来路径没问题实际也加载不出来。所以入口文件尽量写成精确路径 后缀减少“让 Node.js 猜”的场景。5.3 异步初始化模块顶层别搞“立即执行的异步活”自定义模块如果一加载就要连接数据库、读远端配置、拉外部接口但又没有正确的异步处理很多诡异问题会随之而来。例如// db.js const mysql require(mysql2/promise); const connection mysql.createConnection({ host: localhost, user: root, password: 123456, database: test }); module.exports { connection };mysql.createConnection返回值可能是个 Promise也可能是个连接对象但真正的握手还没完成。如果其他模块加载后马上执行查询大概率报错。我的处理思路很简单模块加载阶段不要产生副作用把初始化主动权交给调用方。// db.js let connection null; async function init(options) { const mysql require(mysql2/promise); connection await mysql.createConnection(options); return connection; } function getConnection() { if (!connection) { throw new Error(Database not initialized, call init first); } return connection; } module.exports { init, getConnection };业务代码入口处先await db.init(...)后面所有模块再用db.getConnection()拿连接。这样模块加载是同步、安全的异步流程统一收敛在入口处。做测试时注入一个 mock 连接也很方便。这个思路适合所有“连接型”模块数据库、消息队列、云存储客户端。5.4 模块顶层 this 指向不是 global而是 module.exports还有一个容易被人忽略的点CommonJS 模块的顶层this并不是 Node.js 的全局对象global而是module.exports。也就是说你在模块顶层写this.name hello;效果等同于module.exports.name hello;很多刚接触 Node.js 的开发者误以为this指向 global于是去this.xxx ...设置全局变量结果怎么都读不到绕了一圈才发现是module.exports被改了。这个行为在 Node.js 官档里明确写过但新手教材很少强调。我的建议是模块顶层永远不要依赖this要用全局变量就老老实实写global.xxx要导出就写module.exports.xxx。干脆利落别搞什么隐式魔法。最后再分享一点个人的心得自定义模块这事的核心不在于你多会用module.exports而在于你能不能把一个复杂系统切出清晰的边界。我自己封装模块时每写一个都要问三个问题它对谁负责、它需要什么依赖、外部最少知道多少才能用。这三句话听着像空话实际作用很大能逼你少写一堆乱七八糟的耦合。还有个小技巧模块的引用关系尽量由入口文件统一编排业务代码里少出现“require 三层外三层”的情况。入口不清晰、模块引用像蜘蛛网一样乱的项目我修过太多每次都耗到头皮发麻。如果你也正在经历“写着写着不知道改哪里会影响哪里”的痛那大概率是自定义模块的边界没立好。现在回头调整还不晚。
02
RELATED NEWS

相关资讯

更多网站建设与数字化升级内容

03
WHY YAOTU

想打造同款高转化官网?

懂行业、懂生意,从建站到增长一站式陪跑

◈

场景化定制

不做模板站,围绕你的业务场景量身设计,小众不撞款。

◐

营销型架构

以转化目标组织内容与路径,让官网真正带来询盘。

▲

全周期服务

设计、开发、运营、运维一体,上线只是开始。

免费获取你的建站方案

留下需求,专属顾问 24 小时内为你输出方案建议。