模块系统与包管理:CommonJS 模块规范、require 与 exports、npm 包管理工具、package.json 详解、node_modules 与依赖管理
聊到 Node.js,绕不开的就是它的模块系统。说实话,我刚从前端转 Node.js 时,最不适应的就是这套模块机制。前端习惯了 script 标签一把梭,到了 Node.js 这边,每个文件都是一个独立的世界。今天我们就把它彻底讲透。
CommonJS 模块规范:Node.js 的基石
Node.js 在诞生之初,JavaScript 还没有官方的模块标准。于是社区搞了一套 CommonJS 规范,Node.js 直接采纳了。说白了,这套规范就干了三件事:
- 每个文件是一个模块,拥有独立作用域
- 通过
module.exports导出内容 - 通过
require()导入内容
为什么要有独立作用域?你想想看,如果没有模块隔离,全局变量满天飞,项目一大了根本没法维护。我在早期一个项目中就吃过这个亏——两个文件都定义了 utils 对象,结果后加载的覆盖了前面的,排查了整整一个下午。
核心原则:每个 Node.js 文件在执行时,都会被一个函数包裹。这个函数长这样:
(function(exports, require, module, __filename, __dirname) {
// 你的代码在这里
});
这就是为什么你在文件中可以直接使用 require 和 module,它们根本不是全局变量,而是注入进来的参数。
require 与 exports:导入导出的正确姿势
先说说导出。很多新手会混淆 exports 和 module.exports。我直接告诉你结论:永远使用 module.exports。
为什么会这样?因为 exports 只是 module.exports 的一个引用。如果你给 exports 重新赋值,这个引用就断了。我曾经见过这样的代码:
// ❌ 错误示范
exports = {
name: '工具函数',
sayHello: function() { console.log('你好'); }
};
// 这样写,外面 require 得到的是空对象
正确的做法是:
// ✅ 正确示范
module.exports = {
name: '工具函数',
sayHello: function() { console.log('你好'); }
};
// 或者逐个添加属性(这个可以用 exports)
exports.name = '工具函数';
exports.sayHello = function() { console.log('你好'); };
再说说 require。它的查找规则其实很清晰:
- 核心模块:比如
require('fs'),直接返回内置模块 - 相对路径:
require('./utils'),查找当前目录下的文件 - 包名:
require('lodash'),去node_modules里找
个人习惯:我写 require 时,总是按这个顺序排列——核心模块、第三方包、本地模块。中间空一行,看起来清爽,找依赖也快。
npm 包管理工具:生态的核心
npm 全称是 Node Package Manager。它不仅仅是装包的工具,更是 Node.js 生态的命脉。目前 npm 上已经有超过 200 万个包,你几乎能找到任何你想要的功能。
常用的 npm 命令,我列个表给你:
| 命令 | 作用 | 备注 |
|---|---|---|
npm init |
初始化项目,生成 package.json | 加 -y 跳过问答 |
npm install <包名> |
安装包到 dependencies | 简写 npm i |
npm install -D <包名> |
安装包到 devDependencies | 开发依赖 |
npm uninstall <包名> |
卸载包 | 简写 npm un |
npm update |
更新所有包 | 慎用,可能有兼容问题 |
npm ls |
查看已安装的包 | 加 --depth=0 只看顶层 |
避坑指南:我曾经在生产环境执行了 npm update,结果一个包从 1.x 跳到了 2.x,API 全变了,服务直接挂了。从那以后,我养成了一个习惯——永远锁定版本号,用 package-lock.json 来保证环境一致。
package.json 详解:项目的身份证
每个 Node.js 项目根目录下都有一个 package.json。它记录了项目的元信息、依赖、脚本等。我挑几个最重要的字段说说:
{
"name": "my-api-server", // 项目名,发布到 npm 时必须唯一
"version": "1.0.0", // 语义化版本号
"description": "一个 API 服务", // 描述
"main": "index.js", // 入口文件
"scripts": { // 可执行脚本
"start": "node index.js",
"dev": "nodemon index.js",
"test": "jest"
},
"dependencies": { // 生产依赖
"express": "^4.18.0"
},
"devDependencies": { // 开发依赖
"nodemon": "^2.0.0",
"jest": "^29.0.0"
}
}
关于版本号前面的符号,我解释一下:
^:允许安装次版本号变更,比如^4.18.0可以安装 4.x.x 的最新版~:只允许补丁版本变更,比如~4.18.0只能安装 4.18.x*:安装最新版,不推荐,太不可控了- 无符号:锁定精确版本
scripts 字段的妙用:我习惯把常用的命令都配到 scripts 里。比如 "lint": "eslint ."、"format": "prettier --write ."。这样团队里所有人都用同一套命令,不用记各种工具的调用方式。
node_modules 与依赖管理:理解那堆文件夹
第一次看到 node_modules 文件夹的人,多半会被吓到。一个简单的项目,装了几个包,结果 node_modules 里躺着几百个文件夹。为什么会这样?
因为依赖是有层次的。你装的包 A,它可能依赖包 B 和 C,而 B 又依赖 D。npm 会把所有这些依赖都下载下来,放在 node_modules 里。在 npm v3 之前,依赖是嵌套的,目录结构深得吓人。v3 之后改成了扁平结构,尽量把依赖提到顶层。
但扁平也不是万能的。如果两个包依赖了同一个包的不同版本,npm 还是会创建嵌套结构。比如:
node_modules/
├── express/ # 顶层
├── lodash/ # 顶层
└── packageA/
└── node_modules/
└── lodash/ # 嵌套,因为版本不同
我的建议:不要手动去改 node_modules 里的文件。我曾经为了修一个 bug,直接改了 node_modules 里的源码,结果下次 npm install 后全没了,白忙活一场。正确的做法是用 patch-package 这类工具来打补丁。
最后说说 package-lock.json。这个文件会自动生成,记录了每个包的确切版本和依赖关系。它的作用就是保证所有人、所有环境安装的依赖完全一致。一定要把它提交到 Git 仓库里。
嗯,模块系统和包管理这块,内容确实不少。但只要你理解了 CommonJS 的模块隔离思想,掌握了 require 和 module.exports 的用法,再熟悉一下 npm 的常用命令,日常开发基本就够用了。剩下的,遇到问题再查文档,慢慢积累就好。