模块系统与包管理: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) {
  // 你的代码在这里
});

这就是为什么你在文件中可以直接使用 requiremodule,它们根本不是全局变量,而是注入进来的参数。

require 与 exports:导入导出的正确姿势

先说说导出。很多新手会混淆 exportsmodule.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。它的查找规则其实很清晰:

  1. 核心模块:比如 require('fs'),直接返回内置模块
  2. 相对路径require('./utils'),查找当前目录下的文件
  3. 包名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 的常用命令,日常开发基本就够用了。剩下的,遇到问题再查文档,慢慢积累就好。