模块与 npm
这一课你将能做到
- 分别用 CommonJS 和 ESM 编写、导入模块,并说清两者的核心差异
- 用 npm 完成初始化、安装依赖、配置脚本,理解 package.json 与 lockfile 的分工
- 读懂语义化版本号
^1.2.3,预判升级风险
为什么要学
代码一多就必须拆文件,拆完就要解决「文件之间怎么互相引用」——这就是模块系统。而 npm 是 Node 世界的「应用商店」:生态里 200 多万个包,装一个包就能少写几百行代码。
实际开发里最常见的三个场景全和这课有关:接手项目先看 package.json(知道用了什么、怎么跑起来);装依赖(npm install);把通用逻辑抽成模块复用。CommonJS 和 ESM 并存的现状会持续很多年,两种都得会读会写——看老项目和看新项目用的可能不是同一套。
先建立图像
一个 Node 项目的依赖世界:
my-app/
├── package.json ← 项目身份证:名字、依赖清单、脚本
├── package-lock.json ← 依赖的精确版本锁(提交到 git)
├── node_modules/ ← 依赖的实际代码(不提交 git)
│ ├── dayjs/
│ └── lodash/
└── src/
├── index.js ── require ──► ./utils/format.js
└── utils/
└── format.js
| CommonJS(CJS) | ESM |
|---|
| 导入 | require() | import |
| 导出 | module.exports | export / export default |
| 载入时机 | 运行时,同步 | 编译期静态分析 |
| 文件后缀 | .js(默认) | .mjs 或 package.json 声明 "type": "module" |
| 取目录 | __dirname | import.meta.url |
| 江湖地位 | 老项目、大量存量代码 | 官方标准、新项目主流 |
跟着做
步骤 1:第一个 CommonJS 模块
新建 cjs-demo 目录,npm init -y,新建 utils.js:
// utils.js
function formatDate(date) {
const d = new Date(date);
const pad = (n) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
}
function slugify(text) {
return text.trim().toLowerCase().replace(/\s+/g, '-');
}
module.exports = { formatDate, slugify }; // 对外暴露一个对象
新建 index.js 使用它:
// index.js
const { formatDate, slugify } = require('./utils'); // 相对路径必须以 ./ 开头
console.log(formatDate('2026-10-06')); // 2026-10-06
console.log(slugify('Hello Node World')); // hello-node-world
要点:module.exports 导出什么,require 就拿到什么;require 的参数写相对/绝对路径时必须以 ./ 或 ../ 开头,只写名字则去 node_modules 找。
步骤 2:export 的两种姿势
// 导出多个:用对象打包
module.exports = { formatDate, slugify };
// 导出单个:直接给值
module.exports = class Config { /* ... */ };
新手常见错法是混用 exports.xxx = ... 和 module.exports = ...:exports 只是 module.exports 的引用,一旦给 module.exports 整体赋值,之前挂在 exports 上的东西全丢。建议只用 module.exports,少踩一个坑。
步骤 3:第一个 ESM 模块
新建 esm-demo 目录,npm init -y 后,编辑 package.json 加一行:
新建 utils.mjs(或直接叫 utils.js,因为声明了 type:module,.js 也会按 ESM 处理):
// utils.mjs
export function formatDate(date) {
const d = new Date(date);
const pad = (n) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
}
export const APP_NAME = 'esm-demo';
export default function banner() {
return `== ${APP_NAME} ==`;
}
// index.js
import banner, { formatDate, APP_NAME } from './utils.mjs';
// ↑ 默认导出 ↑ 命名导出(名字必须对得上)
console.log(banner());
console.log(formatDate('2026-10-06'), APP_NAME);
node index.js
# == esm-demo ==
# 2026-10-06 esm-demo
规则:命名导出用 { 花括号 } 且名字必须与导出方一致(可用 as 改名);默认导出每个文件最多一个,导入时不用花括号、名字随便起。
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
// Node 20.11+ 也可以直接用 import.meta.dirname
步骤 4:npm install 一个依赖
在 esm-demo 里装 dayjs(日期处理库):
命令完成后观察三件事:package.json 多了 "dependencies": { "dayjs": "^1.11.x" };生成了 package-lock.json;多了 node_modules 目录。然后使用:
// index.js 顶部加上
import dayjs from 'dayjs';
console.log(dayjs().format('YYYY-MM-DD HH:mm:ss'));
node index.js
# 2026-10-06 14:30:25(当前时间)
对比第 1 步手写的 formatDate——这就是用包的价值:边界情况(时区、闰年)别人都替你踩过了。
步骤 5:理解 npm 的常用操作
npm init -y # 生成 package.json(-y 全部默认值)
npm install dayjs # 装"运行时依赖"(生产也要用)
npm install -D nodemon # 装"开发依赖"(只在开发时用),进 devDependencies
npm uninstall dayjs # 卸载并从 package.json 移除
npm run <脚本名> # 执行 package.json 里的 scripts
npm ls --depth=0 # 查看直接依赖
package.json 里的 scripts 是项目「命令别名」:
{
"scripts": {
"start": "node index.js",
"dev": "node --watch index.js"
}
}
npm run dev
# node --watch 是 Node 20 内置的文件监听重启,改完代码自动重启
约定:npm start、npm test 可以省略 run,其他自定义脚本必须 npm run xxx。
步骤 6:读懂语义化版本
package-lock.json 里锁的是精确版本,package.json 里写的是范围:
1.11.0
│ │ └── 补丁位:修 bug,装了绝对不炸(升级无风险)
│ └──── 次位 :加功能,向后兼容(升级小风险)
└────── 主位 :破坏性变更(升级可能要改代码)
^1.11.0 → 允许 1.x.x 内升级(最常见,npm 默认)
~1.11.0 → 只允许 1.11.x 补丁升级(更保守)
1.11.0 → 锁死精确版本(最严格)
所以 npm install 时 npm 按 ^ 范围装最新兼容版,而 lockfile 保证你和同事装到完全相同的版本——这就是 lockfile 必须提交 git 的原因:删掉 node_modules 重装,版本也不会漂移。
讲清楚要点
- CommonJS 是 Node 的历史方案,ESM 是语言标准:新项目优先 ESM(静态分析可做 tree-shaking、支持顶层 await);但 CJS 是存量现实,老项目和很多工具仍是 CJS。
- require 路径规则:
./ ../ 开头找文件;纯名字先找内置模块、再找 node_modules。写错开头的 require('utils') 会报 Cannot find module。
"type": "module" 决定 .js 的解读方式:不写就是 CJS,写了就是 ESM。混用会报 ERR_REQUIRE_ESM 或 Unexpected token 'export'——看到这两个报错先查 type 声明和后缀。
- dependencies 与 devDependencies:运行时要用的进前者(express),只在开发构建用的进后者(nodemon、eslint)。装的时候
-D 一键归位。
- lockfile 是团队协作的锚:package.json 写「范围」,lockfile 锁「精确值」。两者都提交 git;装依赖一律
npm install(或 npm ci 在 CI 中严格按 lockfile 装),别手改 lockfile。
你可能会懵的地方
-
同一个项目里 CJS 和 ESM 能混用吗?
能有限混用:ESM 里 import 一个 CJS 包没问题(default 拿到 module.exports);CJS 里不能直接 require 一个真 ESM 文件,要用动态 await import()。所以库作者常同时发布两种格式。
-
node_modules 为什么那么大?能删吗?
依赖的依赖会递归安装,几十 MB 到几百 MB 都正常。可以随时删,npm install 会按 lockfile 完整重建。所以 git 里只提交 package.json + lockfile,node_modules 进 .gitignore。
-
npm install 和 npm ci 的区别?
install 按 package.json 的版本范围装并更新 lockfile;ci 严格按 lockfile 装且先删掉 node_modules,不更新任何文件。CI 流水线和「完全还原环境」用 ci。
-
为什么我 import 的包要用 import dayjs from 'dayjs' 而不是花括号?
dayjs 是 CJS 包,module.exports 是一个函数,ESM 把它映射成默认导出。拿到对象再解构也可以:import dayjs from 'dayjs' 之后 dayjs().format(...)。每个包的导出形态看它的文档。
-
什么时候把代码抽成模块?
同一段逻辑出现在第二个文件时就该抽了。抽的标准:一个文件只干一类事,导出面尽量小(不用的别导出)。
错法对照
| 错法 | 问题 | 正解 |
|---|
require('./utils') 但文件叫 util.js | 拼写不符,Cannot find module | 核对文件名(大小写、复数) |
require('utils') 找本地文件 | 没带 ./,会被当作包名去 node_modules 找 | require('./utils') |
CJS 项目里写 export | Unexpected token 'export' | 用 module.exports,或改造成 ESM |
ESM 项目里用 __dirname | ReferenceError | path.dirname(fileURLToPath(import.meta.url)) |
| 把 node_modules 提交 git | 仓库爆炸、跨平台二进制冲突 | 提交 package.json + package-lock.json |
exports.xxx = a; module.exports = b | 前者全部丢失 | 统一只用 module.exports |
| 手动改 lockfile 或只提交 package.json | 团队成员装出版本漂移 | lockfile 与 package.json 一起提交 |
卡点急救
Cannot find module 'xxx':三查——是不是本地文件却没加 ./;文件名拼写/大小写;是不是第三方包没 npm install。
ERR_REQUIRE_ESM:你在 CJS 文件里 require 了一个 ESM。改用动态导入 const pkg = await import('pkg')(注意拿到的是 .default),或把整个项目声明为 "type": "module"。
SyntaxError: Unexpected token 'export':你在按 CJS 解析的文件里写了 ESM 语法。给 package.json 加 "type": "module",或文件后缀改 .mjs。
- 装包很慢或超时:网络问题,配置国内镜像
npm config set registry https://registry.npmmirror.com,用完官方源再 npm config delete registry 还原。
- 依赖版本冲突、node_modules 状态诡异:删掉 node_modules 和 lockfile 重新
npm install(最后手段,平时别乱删 lockfile)。
面试常问
-
CommonJS 和 ESM 的区别?
CJS 是 Node 的运行时方案:require/module.exports,同步加载,值是导出时的拷贝(基本类型);ESM 是语言标准:import/export,编译期静态分析(利于 tree-shaking),导出的是「活绑定」(引用),支持顶层 await。Node 通过 "type": "module" 或 .mjs 后缀启用 ESM。
-
package-lock.json 的作用?为什么必须提交?
锁定依赖树的精确版本和下载地址,保证任何人在任何机器 npm install 得到完全一致的依赖。不提交会导致「范围版本」随时间漂移,出现本地正常、线上报错。
-
什么是语义化版本?^ 和 ~ 的区别?
主.次.补丁:主位破坏兼容、次位加功能、补丁位修 bug。^1.2.3 允许升到 <2.0.0,~1.2.3 只允许 <1.3.0。
-
循环依赖会怎样?
Node 能处理但结果可能出乎意料:CJS 下先完成的模块先返回「半成品」导出,后加载者拿到不完整的对象;ESM 通过活绑定通常能拿到最终值。正确做法是重构出公共的下层模块,消除循环。
自测三问
- ESM 里如何获得与 CJS
__dirname 等价的值?
npm install -D 和 npm install 生成的依赖分别写进 package.json 的哪个字段?各举一例。
^2.3.0 允许安装的最高版本是多少?2.9.9 可以吗?3.0.0 呢?
小结一句话
CJS 用 require/module.exports、ESM 用 import/export,两种都要会;package.json 声明范围、lockfile 锁定精确版本,node_modules 永远由 npm 重建而非手维护。
延伸