ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

Node.js模块化实战:CommonJS与ES Modules在Express工程化中的应用

Node.js模块化实战:CommonJS与ES Modules在Express工程化中的应用 这次我们继续“5分钟学编程 · Express.js”专题的第 13 篇。前面几篇我们陆续写了路由、中间件、请求参数和响应处理但大多数示例代码都放在同一个文件里。真实项目里 app.js 会越写越长几十个路由挤在一起一个中间件七八百行改一处功能要来回滚动屏幕找代码。今天这篇专门解决这个问题Node.js 中的模块化。Express.js 本身就是一个高度模块化的框架它把 HTTP 服务拆成“路由模块 中间件模块 应用入口”三层。学懂 Node.js 模块化相当于拿到阅读和搭建 Express 项目的主线。本文会用可运行的 Express 示例把 CommonJS 和 ES Modules 两种模块化方式分别演示一遍再从零把 app.js 拆成 routes / middleware / config / utils 的标准结构。整篇文章代码都可以直接复制运行适合刚学完 Node 基础、开始写真实项目的读者。1. 核心能力速览能力项说明知识点CommonJS 模块化、ES Modules 模块化、exports / module.exports / require / import / export配套框架Express.js 4.x 及以上运行环境Node.js 18建议使用 LTS 版本Node.js 22 也可正常使用学习成本熟悉 JavaScript 基础语法即可约 20 分钟跑通全部示例核心收益掌握 Express 路由拆分、中间件拆分、配置项拆分写出可维护的工程化代码是否涉及 API 服务涉及Express 本身提供 HTTP 接口模块化用于组织接口路由是否涉及批量任务不涉及本文聚焦代码组织方式文件数量最终示例项目约 8 个 js 文件加 1 个 package.json从表格能直接看到这不是某个需要下载模型或显卡的 AI 工具而是 Node.js 项目工程化的基础能力。学完这篇你再去看网上的 Express 开源项目基本能顺着模块引用关系把项目结构看懂。2. 为什么 Express 项目必须先懂模块化新手第一次用 Express 往往是这样写代码的require 一堆依赖定义几个路由listen 一下就完事。这种方式对 50 行以内的 demo 没问题但项目一旦包含用户模块、商品模块、订单模块、后台管理、文件上传、日志记录全部堆在 index.js 里就会失控。模块化的本质是把一个文件拆成多个文件同时通过明确的导入导出规范让它们互相协作。Node.js 里每个文件都是一个独立的模块模块内部声明的变量默认不会污染全局环境只有通过导出才能被其他文件引用。这带来几个直接好处变量名不会互相冲突。文件 A 里定义的 user 变量不会覆盖文件 B 里的 user。依赖关系清晰。入口文件引用了哪些路由、哪些中间件一眼就能看出来。复用率高。一个封装好的日志模块可以在多个路由里引用不需要复制粘贴。便于测试和排查。用户路由报错时直接打开 routes/user.js 定位不用在几百行代码里大海捞针。Express.js 尤其适合模块化因为它的 Router 机制天生支持把路由拆出去。你可以在 routes 目录下为每个业务模块创建一个文件每个文件导出独立的 Router再在入口文件里统一挂载。这也是本文后面实战部分的核心思路。3. 环境准备与前置检查开始写模块化代码之前先确认电脑上的 Node.js 环境是正常的。模块化不是某个第三方插件而是 Node.js 内置能力所以只需要 Node 环境和一个代码编辑器。3.1 检查 Node.js 版本打开终端Windows 推荐 PowerShell 或 CMDmacOS/Linux 用 Terminal执行node -v正常会输出版本号例如v18.20.4或v22.14.0。如果没有输出说明 Node.js 还没安装需要先到 Node.js 官网下载 LTS 版本完成安装。3.2 初始化项目并安装 Express建议新建一个干净的目录来跑本文示例mkdir node-module-demo cd node-module-demo npm init -y npm install expressnpm init -y会自动生成 package.json。npm install express会把 Express 安装到 node_modules 目录。安装完成后当前目录结构大致为node-module-demo/ ├── node_modules/ ├── package.json └── package-lock.json3.3 确认 npm 镜像与依赖安装如果npm install express速度很慢通常是网络原因可以检查 npm 镜像配置npm config get registry如果是默认的 https://registry.npmjs.org/可以按需切换为国内镜像例如npm config set registry https://registry.npmmirror.com切换后再执行一次npm install express。这一步不是必须的但能显著提升依赖下载速度。4. CommonJS 模块化详解require 与 module.exportsNode.js 从早期版本开始就内置了 CommonJS 模块规范。在 CommonJS 中一个文件就是一个模块使用require()导入使用module.exports或exports导出。4.1 最简单的导出与导入在项目目录下创建math.js// math.js function add(a, b) { return a b; } function multiply(a, b) { return a * b; } module.exports { add, multiply, };再创建app.js// app.js const math require(./math); console.log(math.add(2, 3)); console.log(math.multiply(2, 3));运行node app.js输出结果5 6这就是模块化的最小闭环math.js 把 add 和 multiply 两个函数放到 module.exports 上app.js 通过 require 拿到它们。4.2 module.exports 与 exports 的区别很多初学者会把下面两种写法搞混写法一module.exports { name: node };写法二exports.name node;这两种写法最终效果看起来一样但原理不同。exports本质上是指向module.exports的引用给exports.name赋值相当于往module.exports对象上添加属性。但如果直接写exports { name: node };这样是不生效的因为这会让 exports 变量指向一个新对象不再指向 module.exports外部 require 到的仍然是原来的空对象。所以更稳妥的做法是统一使用module.exports导出对象避免踩到引用指向的坑。4.3 在 Express 中使用 CommonJS 模块化现在把 CommonJS 用到 Express 里。创建一个routes/user.js// routes/user.js const express require(express); const router express.Router(); // GET /users router.get(/, (req, res) { res.json({ message: 获取用户列表 }); }); // GET /users/:id router.get(/:id, (req, res) { res.json({ message: 获取用户详情, id: req.params.id }); }); // POST /users router.post(/, (req, res) { res.json({ message: 创建用户 }); }); module.exports router;再创建routes/product.js// routes/product.js const express require(express); const router express.Router(); router.get(/, (req, res) { res.json({ message: 获取商品列表 }); }); router.get(/:id, (req, res) { res.json({ message: 获取商品详情, id: req.params.id }); }); module.exports router;最后修改app.js把两个路由模块挂载到应用上// app.js const express require(express); const userRouter require(./routes/user); const productRouter require(./routes/product); const app express(); const port 3000; app.use(express.json()); // 挂载路由模块 app.use(/users, userRouter); app.use(/products, productRouter); app.listen(port, () { console.log(Server is running at http://localhost:${port}); });启动服务node app.js然后访问http://localhost:3000/usershttp://localhost:3000/users/123http://localhost:3000/products每个 URL 都能得到对应的 JSON 返回。此时 app.js 只有十几行路由逻辑则分散在独立文件里这就是模块化的直接效果。5. ES Modules 模块化详解import 与 export除了 CommonJS现代 JavaScript 还支持 ES Modules 规范也就是前端里常见的import/export写法。Node.js 从 12 版本开始逐步支持 ESM到 18 已经非常稳定。5.1 启用 ES Modules在 package.json 中加上{ type: module }加上之后项目里的 .js 文件默认按 ES Modules 解析可以直接使用import和export语法。如果不加这个字段Node.js 默认按 CommonJS 解析。把math.js改写成 ESM// math.jsESM 版本 export function add(a, b) { return a b; } export function multiply(a, b) { return a * b; }把app.js改写成// app.jsESM 版本 import { add, multiply } from ./math.js; console.log(add(2, 3)); console.log(multiply(2, 3));注意 ESM 中的相对路径导入必须带完整文件名也就是./math.js不能省略.js后缀。这是和 CommonJS 差异较大的地方。运行node app.js输出同样为 5 和 6。5.2 默认导出与命名导出ESM 中有两种导出方式默认导出export default和命名导出export。默认导出通常用于一个模块只导出一个主要对象的场景比如导出 Express 的 app 实例// app.js默认导出示例 import express from express; const app express(); export default app;命名导出用于一个模块有多个工具函数或子模块的场景// utils/format.js export function formatTime(date) { return date.toISOString(); } export function formatPrice(price) { return ¥${price.toFixed(2)}; }引入时使用解构import { formatTime, formatPrice } from ./utils/format.js;5.3 Express 项目使用 ESM 模块化把前面的用户路由改写成 ESM// routes/user.jsESM 版本 import express from express; const router express.Router(); router.get(/, (req, res) { res.json({ message: 获取用户列表 }); }); router.get(/:id, (req, res) { res.json({ message: 获取用户详情, id: req.params.id }); }); export default router;入口文件// app.jsESM 版本 import express from express; import userRouter from ./routes/user.js; const app express(); const port 3000; app.use(express.json()); app.use(/users, userRouter); app.listen(port, () { console.log(Server is running at http://localhost:${port}); });这里import express from express能正常工作是因为 Express 包同时支持 CommonJS 和 ESM 引入方式Node.js 在解析时可以正确兼容。6. Express 项目模块化实战拆出完整的工程结构现在把前面学的内容整合成一个更完整的 Express 项目。目标是把一个本来所有代码都堆在 app.js 里的项目拆成 config、middleware、routes、utils 四个目录。这种结构也是中小型 Express 项目最常见的基础形态。6.1 目录结构规划node-module-demo/ ├── config/ │ └── index.js ├── middleware/ │ └── auth.js ├── routes/ │ ├── user.js │ └── product.js ├── utils/ │ └── response.js ├── app.js └── package.json每个目录职责如下config存放端口、密钥、数据库连接串等配置。middleware存放自定义中间件比如登录校验、日志记录。routes按业务模块拆分的路由文件。utils通用工具函数比如统一响应格式。6.2 config 模块创建config/index.js// config/index.js const config { port: process.env.PORT || 3000, appName: node-module-demo, }; module.exports config;把端口放到配置文件里启动时可以通过环境变量覆盖而不用改代码。6.3 utils 响应工具模块创建utils/response.js// utils/response.js function success(res, data, message success) { res.json({ code: 0, message, data, }); } function fail(res, httpStatus, message) { res.status(httpStatus).json({ code: httpStatus, message, }); } module.exports { success, fail, };这个模块统一了接口返回格式后续所有路由都使用 success 和 fail 两个方法比每个路由手动写 res.json 更整齐。6.4 middleware 鉴权中间件创建middleware/auth.js// middleware/auth.js function authMiddleware(req, res, next) { const token req.headers.authorization; if (!token) { return res.status(401).json({ message: 未提供 token }); } if (token ! Bearer demo-token) { return res.status(401).json({ message: token 无效 }); } next(); } module.exports authMiddleware;这里用简单的 token 比对演示中间件模块的写法。真实项目可以替换为 JWT 校验逻辑但模块结构是一样的。6.5 routes 用户路由改造routes/user.js引入 utils 和 middleware// routes/user.js const express require(express); const authMiddleware require(../middleware/auth); const { success, fail } require(../utils/response); const router express.Router(); // 公开接口获取用户列表 router.get(/, (req, res) { const users [ { id: 1, name: Alice }, { id: 2, name: Bob }, ]; success(res, users); }); // 受保护接口获取当前用户信息 router.get(/profile, authMiddleware, (req, res) { success(res, { id: 1, name: Alice }, 获取用户信息成功); }); // 其他接口 router.get(/:id, (req, res) { if (req.params.id ! 1) { return fail(res, 404, 用户不存在); } success(res, { id: 1, name: Alice }); }); module.exports router;6.6 routes 商品路由创建routes/product.js// routes/product.js const express require(express); const { success } require(../utils/response); const router express.Router(); router.get(/, (req, res) { const products [ { id: 1, name: 笔记本 }, { id: 2, name: 手机 }, ]; success(res, products); }); router.get(/:id, (req, res) { success(res, { id: req.params.id, name: 商品占位 }); }); module.exports router;6.7 入口文件 app.js// app.js const express require(express); const config require(./config); const userRouter require(./routes/user); const productRouter require(./routes/product); const app express(); app.use(express.json()); // 路由挂载 app.use(/users, userRouter); app.use(/products, productRouter); // 404 兜底 app.use((req, res) { res.status(404).json({ code: 404, message: 接口不存在 }); }); app.listen(config.port, () { console.log(${config.appName} is running at http://localhost:${config.port}); });启动node app.js如果一切正常终端会输出node-module-demo is running at http://localhost:3000然后用 curl 验证接口curl http://localhost:3000/users返回{code:0,message:success,data:[{id:1,name:Alice},{id:2,name:Bob}]}再验证鉴权中间件curl http://localhost:3000/users/profile返回 401{message:未提供 token}带上正确的 Authorization 头curl -H Authorization: Bearer demo-token http://localhost:3000/users/profile返回用户信息。到这里一个包含配置模块、工具模块、中间件模块、路由模块、入口文件的 Express 项目就跑通了。7. 接口 API 路由的模块化组织方式虽然本文不涉及批量任务但模块化对 API 接口工程的意义值得单独说明。当你的项目要提供多个 API 分组时可以按资源类型划分路由模块每个模块对应一组 RESTful 接口。按照 RESTful 风格常见接口分组如下模块文件挂载路径典型接口routes/user.js/usersGET /users、GET /users/:id、POST /usersroutes/product.js/productsGET /products、POST /productsroutes/order.js/ordersGET /orders、POST /orders、PATCH /orders/:idroutes/admin.js/adminGET /admin/stats、GET /admin/users入口文件只需要统一app.use(/xxx, xxxRouter)挂载URL 前缀和路由文件一一对应团队协作时不同人负责不同模块文件冲突概率会小很多。接口模块还可以继续按版本划分。例如在 routes 下加一层 v1 / v2routes/ ├── v1/ │ ├── user.js │ └── product.js ├── v2/ │ ├── user.js │ └── product.js入口挂载时指定版本前缀app.use(/api/v1/users, v1UserRouter); app.use(/api/v2/users, v2UserRouter);这样同一个接口的不同版本可以并存老版本在迁移期不会立刻下线。8. 资源占用与性能观察Node.js 模块化会带来一定的启动加载开销主要体现在文件数量增加后模块解析和加载时间变长。但在实际 Express 项目中几十个文件对启动速度的影响通常只有几十毫秒基本可以忽略。真正需要关注的是三个问题require 缓存、循环引用、文件路径解析。8.1 require 缓存Node.js 对每个加载过的模块都会缓存。第一次 require 某个文件时模块代码会完整执行一遍之后再次 require 会直接返回缓存中的 module.exports不会重复执行。这个特性有实际影响。比如 config/index.js 里保存了运行时才能确定的配置// config/index.js const config { port: 3000 }; function updatePort(newPort) { config.port newPort; } module.exports { config, updatePort };如果你在另一个模块里先通过require(./config)拿到 config 对象再在别处调用updatePort(4000)后者拿到的还是同一个 config 对象能看到更新后的端口。因为 require 缓存保证所有引用指向同一个对象这不是 bug而是可以利用的特性。8.2 循环引用模块 A 引用了模块 B模块 B 又引用了模块 A就会形成循环引用。在 CommonJS 中Node.js 会返回当前已导出部分的副本此时另一个模块可能拿到不完整的结果。比如// a.js const b require(./b); module.exports { name: a, b }; // b.js const a require(./a); module.exports { name: b, a };这种代码容易导致 undefined 或部分导出。在 Express 项目中最常见的情况是路由模块互相 require或者 utils 工具互相依赖。解决办法是避免模块之间双向引用把公共依赖下沉到更底层的模块或者把延迟引用放进调用函数内部。8.3 路径解析顺序require(express)会先在当前目录的 node_modules 里找找不到就逐级向上找父目录的 node_modules。require(./config)则按相对路径解析先找 config.js再找 config.json再找 config 目录下的 index.js。如果文件路径写错Node.js 会抛出Cannot find module。排查时先确认相对路径的层级是否正确。这也是模块化项目最常见的启动失败原因之一。9. 常见问题与排查方法问题现象可能原因排查方式解决方案Error: Cannot find module ./routes/user相对路径写错或文件不存在检查目录层级和文件名确认文件路径注意是否缺少./前缀ReferenceError: require is not definedpackage.json 设置了type: module文件按 ESM 解析查看 package.json 的 type 字段统一使用 import/export或删除 type 字段改用 CommonJS接口返回 404路由挂载路径与请求路径不一致检查 app.use 的路径和 router 内部路径确保app.use(/users, userRouter)与router.get(/)组合后为/users修改代码后不生效服务未重启观察终端是否有新日志使用node --watch app.js或 nodemon 自动重启ERR_REQUIRE_ESM用 require 引入了纯 ESM 模块查看错误堆栈里的文件改用 import或使用动态import()端口 3000 被占用其他服务占用端口执行netstat -ano查看端口占用修改 config.port或结束占用进程中间件不执行没有通过 app.use 挂载检查中间件是否在路由之前注册将app.use(express.json())放到路由挂载之前在实际开发中Cannot find module和require is not defined是新手最容易遇到的错误。前者本质是路径问题后者本质是模块规范混用问题项目已经启用 ESM但代码还在写 require。解决思路很明确要么全部统一为 CommonJS要么全部统一为 ESM不要在同一个项目里混用两种风格。10. 最佳实践与使用建议看完示例代码最后总结几组可以直接用在真实项目里的模块化建议。10.1 先定目录结构再写代码动手写 Express 项目前先创建 config / routes / middleware / utils / services 这些目录。哪怕第一版代码不多结构清楚之后新增功能时不需要重构。推荐的扩展目录结构├── config/ # 配置文件 ├── routes/ # 路由层 ├── middlewares/ # 中间件层 ├── controllers/ # 控制器层 ├── services/ # 业务逻辑层 ├── models/ # 数据模型层 ├── utils/ # 工具函数 └── app.js路由层只负责接收请求、调用控制器、返回响应业务逻辑放到 services 或 controllers 中。不要把所有代码都写在路由回调里。10.2 一个文件只干一件事如果某个文件里既有数据库查询、又有参数校验、还有登录鉴权这个文件的职责就过重了。模块化的核心不是文件数量多而是每个文件有清晰的单一职责。判断标准很简单写文件的时候能不能用一句话说清它做了什么。10.3 导出方式保持统一项目里宁可全用 CommonJS也不要一半文件写module.exports、另一半写export default。混用会让团队协作时产生大量困惑。Node.js 的 CommonJS 兼容性最成熟老项目大多使用这种风格新项目可以考虑统一使用 ESM。重点是一致。10.4 入口文件尽量只做组装app.js 应该只负责创建 Express 实例、注册全局中间件、挂载路由、监听端口。不要把业务逻辑写进去。判断标准是任何人打开 app.js都能在 30 秒内看懂这个项目加载了哪些模块。10.5 环境变量与配置分离端口、密钥、数据库地址这类值不要硬编码在路由文件里。统一放到 config 模块中并支持从process.env读取。部署到不同环境时只需要修改环境变量不需要改动代码。11. 总结与下一步这次的内容看起来是讲语法实际上解决的是 Express 项目越来越长之后“代码怎么放”的问题。建议你亲手做两件事先把第 4 节和第 5 节的示例代码跑一遍感受 CommonJS 与 ESM 的区别再把第 6 节的项目结构完整敲一遍把用户路由换成你自己想写的业务模块比如文章、评论、分类。敲完这套流程你再看网上任意一个 Express 开源项目基本都能顺着 require 或 import 的引用关系把它读明白。下一节可以把这些模块化内容组合起来做一个更完整的 RESTful API 示例包括增删改查、统一错误处理和 JWT 登录校验。到时候你会发现前面拆好的 config / middleware / routes / utils 结构刚好能直接复用。先把这节的模块化基础打牢后面写什么都顺手。
返回列表