楼层: 首页/ 软件技术/ Node.js 全栈实战/ 模块系统:代码怎么拆、怎么拼
02

模块系统:代码怎么拆、怎么拼

Module System · CommonJS & ESM

一个项目不可能全写在一个文件里——那就是个几千行的大泥球,改一行心慌半天。模块系统就是"把代码切成小文件、互相引用"的规则。Node 历史上有两套规则:祖传的 CommonJS(require)和现代的 ES Modules(import)。两套你都得认得,因为老项目全是前者,新项目越来越多用后者。

CommonJS:Node 的祖传写法

这是 Node 最早的模块规则,require 进东西、module.exports 出东西。看代码:

math.js —— 一个"被别人引用"的模块

// 这是两个内部函数,外面默认看不见 function add(a, b) { return a + b; } function mul(a, b) { return a * b; } // module.exports 就是"对外公开的窗口",想让别人用什么就挂什么 module.exports = { add, mul };

app.js —— 引用上面那个模块

// require 就是"去 math.js 里把它公开的东西拿过来" const { add, mul } = require("./math"); console.log(add(2, 3)); // 5 console.log(mul(2, 3)); // 6

ES Modules:现代写法,和浏览器、前端统一

Node 14 以后原生支持浏览器那套 import / export。怎么告诉 Node "这个项目用 ESM"?两种办法:① 把文件后缀改成 .mjs;② 在 package.json 里加一行 "type": "module"。

math.mjs —— 用 export 对外暴露

// 直接在函数前写 export,就是"对外公开" export function add(a, b) { return a + b; } export function mul(a, b) { return a * b; }

app.mjs —— 用 import 引入

// 注意:import 后面必须写完整的文件名(带 .mjs),不像 require 可以偷懒 import { add, mul } from "./math.mjs"; console.log(add(10, 20)); // 30
两套模块规则的对比,记个混眼熟,项目里遇到能认出来就行
对比点CommonJS(老)ES Modules(新)
引入const x = require('./x')import x from './x.js'
导出module.exports = {...}export const / export default
文件后缀默认 .js.mjs,或 package.json 写 "type":"module"
加载时机运行时同步加载,能 require 变量编译时静态分析,更利于打包摇树
互操作能用 import 引入 CJS 包用 import 引老包时,import 包名 会拿到它的整体

三类模块,别搞混

  • 内置模块:Node 自带、不用装,比如 fs(文件)、path(路径)、http(网络)。直接 require('fs')。
  • 第三方模块:npm 上别人写好的包,比如 Express、Prisma。装完直接 require('express'),不用写 ./。
  • 本地模块:你自己项目里的文件,引入时必须以 ./ 或 ../ 开头,否则 Node 会以为你在找第三方包。

package.json:一个项目的身份证

每个 Node 项目根目录都有一个 package.json,它记录"这是个什么项目、靠哪些包活着、有哪些命令可以跑"。npm init -y 一键生成一个最简版。

package.json 长这样(关键字段都标了注释)

{ "name": "my-blog-api", // 项目名 "version": "1.0.0", // 版本号 "type": "module", // 写 module = 用 ESM 的 import 语法 "scripts": { "dev": "node --watch app.js", // npm run dev 就等于敲这串命令 "start": "node app.js" // npm start 可以省略 run }, "engines": { "node": ">=22" // 声明要求的 Node 版本,别人装时会提醒 }, "dependencies": { // 生产环境要跑的包 "express": "^4.21.0" }, "devDependencies": { // 只在开发时用的包(测试、构建工具) "jest": "^29.7.0" } }

问package-lock.json 是干嘛的,能删吗

不能删。你 npm install express@^4.21.0 装的其实不是"精确的 4.21.0",而是"4 系列里不低于 21.0 的最新版"。今天装是 4.21.3,下周可能自动变成 4.21.5——这就可能导致你电脑能跑、同事电脑跑不起来的灵异 bug。

package-lock.json 就是把你这次实际装的每一个包、每一个版本、甚至它们的子依赖全部锁死记下来。别人 clone 你的项目跑 npm install,装出来的版本和你一模一样。所以:package.json 进 git,package-lock.json 也必须进 git。

记
本章小结

① 老项目认 require/module.exports(CommonJS),新项目用 import/export(ESM),见到别懵。

② 引自己的文件必须带 ./,引第三方包直接写名字。

③ package.json 写项目说明书,package-lock.json 锁版本,两个都要提交到 git。

ESM 里没有 __dirname?import.meta.url 补上

CommonJS 里天然有 __dirname(当前目录)、__filename(当前文件);切到 ESM(package.json 写了 "type": "module")后这俩变量直接消失,用 __dirname 会报错。这是迁移时最常踩的坑,固定三行替换:

ESM 里拿到"当前目录"的标准写法

import { fileURLToPath } from 'node:url'; import { dirname, join } from 'node:path'; // import.meta.url 是当前文件的 file:// 绝对 URL,把它转成路径再取目录 const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); // 之后照常用:拼一个相对当前文件的配置路径 const cfg = join(__dirname, 'config.json');

一句话记忆:CJS 有 __dirname 白送;ESM 要用 fileURLToPath(import.meta.url) 自己造。新项目如果全栈统一 ESM,就把这三行写成个小工具到处复用。

循环依赖:A 引 B、B 又引 A 会怎样

模块 A require('./B'),模块 B require('./A')——绕回来了。CommonJS 的处理是"先给一个没加载完的半成品 exports 出去",于是你拿到的可能是 {} 或 undefined,报 X is not a function,玄学到怀疑人生。

循环依赖的典型翻车现场

// a.js const b = require('./b'); function hello() { return 'A says ' + b.fromB(); } module.exports = { hello }; // b.js —— 加载到这里 require('./a'),a 还没执行到 exports 那行 const a = require('./a'); // 拿到的是 a 的"半成品 exports",hello 可能是 undefined! function fromB() { return 'B'; } module.exports = { fromB };

怎么避免:① 重构解依赖——把 A、B 共用的底层东西抽到第三个模块 C,让 A、B 都依赖 C,而不是互相依赖;② 把 require 挪到函数内部(用到时才加载),让顶层加载顺序避开环;③ ESM 对循环依赖处理更智能(live binding),但同样推荐从设计上消除环。记住:循环依赖不是报错,而是"拿到 undefined 还能跑",这才是最可怕的。