**摘要:**Node.js 的生态建立在模块之上。本文将彻底解释 CommonJS 和 ES Modules 的区别与用法,教你使用 npm 管理第三方包、理解 package.json、版本号语义,以及如何创建并发布属于自己的 npm 包。读完这一篇,你将拥有参与开源生态的能力。
一、为什么需要模块化?
在 Node.js 诞生之前,浏览器里的 JavaScript 根本没有正式的模块机制。所有的脚本通过 <script> 标签引入,共享全局作用域,极易冲突。Node.js 把 JavaScript 带到服务器端后,必然要解决代码组织和复用的问题。它的答案是 CommonJS 规范。
二、CommonJS 模块系统
CommonJS 是 Node.js 默认的模块系统。每个文件就是一个模块,有自己的作用域。模块通过 require 导入,通过 module.exports 或 exports 导出。
新建 math.js:
// math.js
const add = (a, b) => a + b;
const subtract = (a, b) => a - b;
module.exports = {
add,
subtract,
};
在 main.js 中使用:
const math = require('./math');
console.log(math.add(5, 3)); // 8

模块加载过程:
解析路径,找到文件。
将文件内容包裹在一个函数中,注入 exports、require、module、__filename、__dirname 五个参数。
执行这个函数,模块内部的变量无法污染全局。
返回 module.exports 对象。
所以本质上,上面的 math.js 运行时等同于:
(function(exports, require, module, __filename, __dirname) {
const add = (a, b) => a + b;
const subtract = (a, b) => a - b;
module.exports = { add, subtract };
});
require 的查找规则:
-
以
./或../开头:按相对路径找文件。若没写后缀,Node 会依次尝试.js、.json、.node,如果找到的是目录,则会查找该目录下的package.json里的main字段,或直接找index.js。 -
不以路径开头:则视为内置模块或
node_modules中的第三方模块。
三、ES Modules(ESM)
从 ES2015 开始,JavaScript 有了官方的模块语法。Node.js 从 v12 开始以实验性支持 ESM,v14 后基本稳定。使用 ESM,文件后缀改为 .mjs,或在 package.json 中设置 "type": "module"。
math.mjs:
export const add = (a, b) => a + b;
export const subtract = (a, b) => a - b;
main.mjs:
import { add } from './math.mjs';
console.log(add(5, 3));
ESM 与 CommonJS 的主要区别:
-
ESM 是静态导入(
import必须写在顶层,不能放在条件中),而require是动态的,可以写在任何地方。 -
ESM 使用严格模式,
this为undefined。 -
ESM 是异步加载的,支持顶级
await。 -
ESM 导出的是绑定(引用),CommonJS 导出的是值的拷贝。
现在 Node.js 项目中,两种方式并存。新项目建议使用 ESM,但在大量老项目中仍是 CommonJS。两个系统尽量不要混用。
四、走进 npm 世界
npm 是 Node Package Manager 的缩写,是目前世界上最大的开源包注册中心。安装 Node.js 时会自动安装 npm。你的项目需要什么功能,大多数都可以在 npm 找到现成方案,无需重复造轮子。
4.1 初始化项目
创建一个新项目文件夹,运行:
npm init
会交互式询问项目名称、版本、入口文件等,一路回车可快速生成 package.json。如果使用默认值,可以:
npm init -y
package.json 是一个项目的"身份证",记录了依赖、脚本、元信息等,示例:
{
"name": "my-project",
"version": "1.0.0",
"description": "一个学习项目",
"main": "index.js",
"scripts": {
"start": "node index.js",
"test": "echo \"Error: no test specified\" && exit 1"
},
"dependencies": {},
"devDependencies": {}
}
4.2 安装包
以 lodash 这个工具库为例:
npm install lodash
这会将包下载到 node_modules 文件夹,并在 package.json 的 dependencies 中添加记录。node_modules 通常很大,不要提交到版本控制系统,在 .gitignore 中加上它。
使用安装好的包:
const _ = require('lodash');
console.log(_.capitalize('hello world')); // Hello world
如果要把包作为开发依赖(如测试框架、打包工具),在安装时加 --save-dev 或简写 -D:
npm install jest --save-dev
它会出现在 devDependencies 中。
4.3 全局安装
有些工具需要在命令行直接使用,如 nodemon(自动重启服务器),可以全局安装:
npm install -g nodemon
之后可直接在终端运行 nodemon server.js。
4.4 版本号语义
npm 包的版本号遵循"主版本.次版本.修订号"格式,如 2.4.1:
-
主版本:不兼容的 API 修改。
-
次版本:向下兼容的功能新增。
-
修订号:向下兼容的问题修正。
package.json 中版本号前的符号含义:
-
^2.4.1:锁定主版本,允许次版本和修订号更新(>=2.4.1 <3.0.0)。 -
~2.4.1:锁定主版本和次版本,允许修订号更新(>=2.4.1 <2.5.0)。 -
2.4.1:精确版本。 -
*或latest:最新版。
建议使用 ^ 以获取安全更新。
五、开发自己的 npm 包
我们来写一个小工具包并发布。
5.1 创建包结构
my-utils/
├── package.json
├── index.js
└── lib/
└── string.js
package.json 注意 name 要唯一(发布前先去 npm 网站搜索是否占用),main 指向入口文件。
{
"name": "my-utils-ysyx",
"version": "1.0.0",
"main": "index.js",
"license": "MIT"
}
lib/string.js:
function capitalize(str) {
if (typeof str !== 'string') return '';
return str.charAt(0).toUpperCase() + str.slice(1);
}
module.exports = { capitalize };
index.js:
const string = require('./lib/string');
module.exports = {
capitalize: string.capitalize,
};

5.2 本地测试
在另一个项目中用相对路径安装:
npm install /path/to/my-utils
然后引入测试功能是否正常。
5.3 发布到 npm
-
先去 npmjs.com 注册账号。
-
终端运行
npm login登录。 -
在包目录下执行
npm publish。
注意 :如果包名被占用,会发布失败。每次更新版本需修改 version 字段后再次 publish。
六、npx 与包运行器
有时我们希望运行一个仅在当前项目中安装的命令,而不全局污染。npx 就是解决办法。比如安装 create-react-app 脚手架后,原本需要 ./node_modules/.bin/create-react-app my-app,有了 npx 后直接:
npx create-react-app my-app
npx 会先在本地 node_modules/.bin 中寻找命令,找不到则临时下载执行,非常方便。
七、包管理器新选择:认识 pnpm(与 npm 有何不同)
7.1 从 npm 的"黑洞"说起
通过前面几节,你已经熟练使用 npm install 来安装依赖。但你可能注意到两个现象:
磁盘空间被大量重复占用 。你电脑上有 10 个 Node 项目,每个项目的 node_modules 都躺着一份一模一样的 lodash,即使版本完全相同。这会占用大量磁盘空间,安装速度也受影响。
"幽灵依赖"问题 。npm 会把所有包的依赖都扁平化提升到顶层 node_modules,这导致你的代码可以 require 一个你从未在 package.json 中声明过的包(比如你只装了 express,却可以直接用 express 内部依赖的 debug 包)。这埋下了隐患:一旦 express 不再依赖 debug,你的项目就会突然报错。
为了解决这些问题,出现了 pnpm(performant npm)。它和 npm、yarn 一样都是包管理器,但内部设计截然不同。
7.2 pnpm 的神奇设计:硬链接 + 内容寻址
pnpm 的核心思想是:所有相同版本的包,在你的电脑上只保存一份。
-
全局仓库 :pnpm 会将所有下载过的包存储在系统的一个全局目录中(
~/.pnpm-store),类似一个"公共仓库"。 -
硬链接 :当你在项目中安装依赖时,pnpm 不会复制文件,而是创建硬链接(Hard Link),直接指向全局仓库中的文件。硬链接几乎不占额外空间,且操作速度极快。
-
非扁平化的 node_modules :pnpm 构建的
node_modules结构严格遵循你在package.json中声明的依赖关系。你只能使用自己明确安装的包,彻底杜绝"幽灵依赖"。
我们来一个简单比喻:npm 是每家每户都买一本《词典》,哪怕内容一模一样,浪费书架空间。pnpm 则是社区图书馆,每家只放一张索书卡(链接),要看时直接去图书馆取,所有家庭共享同一本书。
7.3 pnpm 与 npm、yarn 的直观对比
| 特性 | npm | yarn (classic) | pnpm |
|---|---|---|---|
| 安装速度 | 较慢 | 较快(并行下载) | 非常快(硬链接 + 缓存) |
| 磁盘占用 | 高(每项目独立副本) | 较高 | 极低(全局存储,项目仅链接) |
| node_modules 结构 | 扁平化,有幽灵依赖 | 扁平化,有幽灵依赖 | 非扁平,严格隔离 |
| monorepo 支持 | 较弱(需 workspaces) | 内置 workspaces | 一流支持,原生过滤、并行执行 |
| CLI 命令 | npm install/add |
yarn add |
pnpm add(与 npm 高度兼容) |
| lock 文件 | package-lock.json |
yarn.lock |
pnpm-lock.yaml |
关键优势一句话总结:pnpm 在几乎不改变你使用习惯的前提下,大幅节省磁盘空间,加快安装速度,并从根本上避免幽灵依赖。
7.4 pnpm 安装与基本命令
安装 pnpm(推荐使用 npm 全局安装,或者使用独立脚本):
npm install -g pnpm
# 或使用官方脚本(Windows PowerShell 中也可)
# iwr https://get.pnpm.io/install.ps1 -useb | iex
之后你可以完全像使用 npm 一样使用 pnpm,命令几乎一致:
# 初始化项目(生成 package.json)
pnpm init
# 安装依赖
pnpm install
pnpm add express
pnpm add -D nodemon # 开发依赖
# 移除依赖
pnpm remove lodash
# 运行脚本
pnpm run dev
pnpm test
# 全局安装
pnpm add -g pnpm # 更新自己
与 npm 命令对比 :只需把 npm 换成 pnpm,大部分场景都能无缝替换。甚至 npx 也有对应命令 pnpm exec 或者直接 pnpm dlx(类似 npx,用于临时下载执行包)。
7.5 一个真实体验:安装速度与磁盘空间
假设你有一个中等规模的 Vite + React 项目。我们分别用 npm 和 pnpm 安装依赖,并查看 node_modules 大小(实际测试数据可能因版本不同有浮动,但比例大致如下):
| 安装方式 | 耗时(冷安装) | node_modules 大小 |
|---|---|---|
| npm install | 约 45 秒 | 约 220 MB |
| pnpm install | 约 18 秒 | 约 120 MB(大部分为链接) |
这还只是一个项目。当你拥有 5 个相似项目时,pnpm 能为你节省 数百 MB 到 GB 级 的磁盘空间,因为相同依赖的物理文件只存一份。
7.6 pnpm 的局限性
为了让小白全面了解,也要说清楚适用场景。pnpm 的缺点或注意事项包括:
-
对部分老旧或非标准的包的兼容性问题。极少数包在代码中使用了非绝对路径的文件读取,可能因 pnpm 的严格链接结构而找不到文件。不过这类情况已非常罕见,且 pnpm 社区有处理方案。
-
学习成本几乎为零 ,但如果你需要在团队中统一包管理器,需要沟通协调,确保每个人都使用
pnpm而不是npm install,否则 lock 文件可能冲突。 -
如果你的项目是边缘物联网设备或极简环境,不希望引入任何全局存储概念,可能会倾向 npm 的"项目内自包含"方式。但绝大多数现代开发场景,pnpm 都是更优选择。
建议:新项目可以直接使用 pnpm;在学习阶段,既然你已经懂了 npm,不妨立刻体验 pnpm,你会发现它更快、更干净,而且完全兼容 npm 生态。
八、模块缓存与循环依赖
Node.js 在第一次 require 一个模块时会缓存其结果。后续 require 同一模块,直接返回缓存,模块代码不会再次执行。这有助于性能,但也要小心循环依赖(A 引用 B,B 又引用 A)。遇到循环依赖时,可能得到一个未完全初始化的对象,应尽量避免。
九、脚本与自动化(npm scripts)
在 package.json 的 scripts 字段可以定义自定义命令:
{
"scripts": {
"start": "node app.js",
"dev": "nodemon app.js",
"test": "jest",
"lint": "eslint ."
}
}
运行 npm run dev 即可启动开发模式,npm test 运行测试。这类脚本可以串联复杂流程,比如 "build": "npm run lint && npm run test && node build.js"。
十、总结
这一篇你掌握了 Node.js 模块化的两种方案,彻底理解了 require 和 import,学会了用 npm 管理依赖、使用现成的包,甚至能发布自己的包,以及更先进的 pnpm。
如果这篇文章帮你解决了实操上的困惑,别忘记点击点赞、分享 ,也可以留言告诉我你遇到的其它问题,我会尽快回复。动手练习是掌握编程最快的方法,请务必亲手敲一遍本文的所有示例代码,并截图保存你的成果。你的关注是我坚持原创和细节共享的力量来源,谢谢大家。