模块系统与 npm——万物皆模块

**摘要:**Node.js 的生态建立在模块之上。本文将彻底解释 CommonJS 和 ES Modules 的区别与用法,教你使用 npm 管理第三方包、理解 package.json、版本号语义,以及如何创建并发布属于自己的 npm 包。读完这一篇,你将拥有参与开源生态的能力。


一、为什么需要模块化?

在 Node.js 诞生之前,浏览器里的 JavaScript 根本没有正式的模块机制。所有的脚本通过 <script> 标签引入,共享全局作用域,极易冲突。Node.js 把 JavaScript 带到服务器端后,必然要解决代码组织和复用的问题。它的答案是 CommonJS 规范。

二、CommonJS 模块系统

CommonJS 是 Node.js 默认的模块系统。每个文件就是一个模块,有自己的作用域。模块通过 require 导入,通过 module.exportsexports 导出。

新建 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

模块加载过程

解析路径,找到文件。

将文件内容包裹在一个函数中,注入 exportsrequiremodule__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 使用严格模式,thisundefined

  • 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.jsondependencies 中添加记录。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.jsonscripts 字段可以定义自定义命令:

复制代码
{
  "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 模块化的两种方案,彻底理解了 requireimport,学会了用 npm 管理依赖、使用现成的包,甚至能发布自己的包,以及更先进的 pnpm。


如果这篇文章帮你解决了实操上的困惑,别忘记点击点赞、分享 ,也可以留言告诉我你遇到的其它问题,我会尽快回复。动手练习是掌握编程最快的方法,请务必亲手敲一遍本文的所有示例代码,并截图保存你的成果。你的关注是我坚持原创和细节共享的力量来源,谢谢大家。

相关推荐
Hoey1 小时前
虚拟 DOM 和 DIFF 算法
前端·vue.js
bkspiderx1 小时前
HTTP协议:Web通信的“通用语言”解析
前端·网络协议·http
ZC跨境爬虫1 小时前
跟着 MDN 学CSS day_47:(移动优先实战——从手机到宽屏的响应式进化)
前端·css·html·tensorflow·媒体
小新1101 小时前
vue实战项目 计算器
前端·javascript·vue.js
秋田君1 小时前
2026 前端新出路:掌握 C++ 核心语法,无缝衔接 QT 桌面开发
前端·c++·qt
老毛肚2 小时前
jeecgboot vue 路由 拆分01
前端·javascript·typescript
ZC跨境爬虫2 小时前
跟着 MDN 学CSS day_46:(响应式实战——用媒体查询打造双列布局)
前端·css·ui·html·tensorflow·媒体
狗凯之家源码网2 小时前
多语言企鹅养殖投资返利系统 自定义产品配置 一键部署源码
前端·架构·php
每天吃饭的羊2 小时前
LeetCode 链表
前端