3. Node.js 模块系统:CommonJS 与 ES Module、核心模块、自定义模块、npm 包发布
聊到 Node.js,模块系统是绕不开的核心话题。我刚开始用 Node.js 那会儿,CommonJS 还是绝对的主流。现在呢?ES Module 已经成了官方标准。但现实是——你大概率两个都得会。
为什么?因为生态里还有大量老项目在用 CommonJS,而新项目基本都切到 ES Module 了。说白了,这就是个「过渡期」的常态。你想想看,一个团队里可能同时维护着两种模块风格的项目,不懂哪个都尴尬。
CommonJS:老当益壮
CommonJS 是 Node.js 最初的模块规范。它的核心就两个东西:require 和 module.exports。
// math.js
const add = (a, b) => a + b;
const subtract = (a, b) => a - b;
module.exports = { add, subtract };
// app.js
const math = require('./math.js');
console.log(math.add(2, 3)); // 5
这里有个细节——require 是同步的。为什么?因为 Node.js 刚诞生时,服务端加载模块就是同步读取文件,简单直接。我在项目中遇到过一个问题:用 require 加载一个很大的 JSON 文件,结果启动时间变长了。嗯,这时候就要考虑按需加载或者用流来处理。
require 会缓存模块。同一个模块多次 require,只会执行一次。如果你需要每次都重新加载,可以用 delete require.cache[模块路径] 清除缓存。不过生产环境慎用。
ES Module:新王登基
ES Module(简称 ESM)是 JavaScript 官方的模块标准。Node.js 从 12 版本开始逐步支持,现在已经是稳定特性了。
// math.mjs
export const add = (a, b) => a + b;
export const subtract = (a, b) => a - b;
// app.mjs
import { add, subtract } from './math.mjs';
console.log(add(2, 3)); // 5
注意文件后缀是 .mjs,或者在 package.json 里设置 "type": "module"。我个人习惯在新项目里直接用 ESM,代码更干净,而且静态分析友好——编辑器能给你更好的智能提示。
ESM 和 CommonJS 最大的区别是什么?异步加载。ESM 的 import 是静态的,在编译阶段就确定了依赖关系。这带来了一个好处:可以做 tree-shaking,把没用到的代码摇掉。我在优化一个前端项目时,就靠这个把打包体积砍掉了 30%。
import CommonJS 模块是可以的,但反过来不行——CommonJS 的 require 不能加载 ESM 模块。我曾经在这个坑里爬了半天,最后用动态 import() 才解决。
核心模块:Node.js 自带的宝藏
Node.js 内置了一批核心模块,不需要安装就能用。常用的有这些:
| 模块名 | 用途 | 我常用的场景 |
|---|---|---|
fs |
文件系统操作 | 读写配置文件、日志文件 |
path |
路径处理 | 拼接路径、获取扩展名 |
http |
创建 HTTP 服务 | 写简单的 API 服务器 |
events |
事件驱动 | 自定义事件、消息通知 |
stream |
流式数据处理 | 大文件上传、数据管道 |
举个例子,用 path 模块处理路径,比手动拼接字符串靠谱多了:
const path = require('path');
const fullPath = path.join(__dirname, 'data', 'config.json');
console.log(fullPath);
// 输出:/Users/yourname/project/data/config.json
为什么不用字符串拼接?因为不同操作系统的路径分隔符不一样——Windows 用反斜杠,Linux 用正斜杠。path.join 会自动处理这个差异。我早期写代码时吃过这个亏,代码在 Mac 上跑得好好的,部署到 Windows 服务器上就炸了。
自定义模块:组织代码的艺术
写项目时,你不会把所有代码塞到一个文件里。自定义模块就是用来拆分代码的。怎么拆?我一般遵循几个原则:
- 单一职责:一个模块只做一件事。比如数据库操作单独一个模块,路由处理单独一个模块。
- 接口清晰:暴露的函数或类要有明确的输入输出。别搞一堆全局变量。
- 避免循环依赖:A 依赖 B,B 又依赖 A,这种写法会让你怀疑人生。
来看一个实际例子。假设我们要写一个用户模块:
// userService.js
const users = [];
function createUser(name, email) {
const user = { id: users.length + 1, name, email };
users.push(user);
return user;
}
function getUser(id) {
return users.find(u => u.id === id);
}
module.exports = { createUser, getUser };
// app.js
const userService = require('./userService');
const newUser = userService.createUser('张三', 'zhangsan@example.com');
console.log(userService.getUser(1));
这样写的好处是:userService 内部怎么存储数据,外部调用者不需要关心。哪天你想换成数据库存储,改这一个模块就够了。
npm 包发布:把你的代码分享出去
写好了模块,怎么分享给全世界?通过 npm 发布。流程其实不复杂:
- 注册 npm 账号:去
npmjs.com注册,或者在终端用npm adduser。 - 初始化 package.json:
npm init,填好包名、版本、描述等信息。 - 编写代码:把模块代码写好,确保入口文件(
main字段指向的文件)正确导出。 - 登录并发布:
npm login然后npm publish。
这里有几个坑,我踩过不止一次:
- 包名冲突:npm 上的包名是全局唯一的。发布前先搜一下,别跟别人重名了。
- 版本号规范:遵循语义化版本(SemVer)。比如
1.0.0是初始版本,1.0.1是补丁,1.1.0是新增功能,2.0.0是不兼容的改动。 - 忽略敏感文件:用
.npmignore或.gitignore排除掉测试文件、配置文件等。别把密码发布出去了。
举个例子,一个简单的工具包发布流程:
// package.json
{
"name": "my-awesome-utils",
"version": "1.0.0",
"description": "一些常用的工具函数",
"main": "index.js",
"license": "MIT"
}
// index.js
function capitalize(str) {
return str.charAt(0).toUpperCase() + str.slice(1);
}
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
module.exports = { capitalize, sleep };
发布后,别人就能用 npm install my-awesome-utils 安装,然后 const { capitalize } = require('my-awesome-utils') 使用了。
npm link 把包链接到全局,然后在另一个项目里 npm link my-awesome-utils 来测试。这样不用每次修改都重新发布。
总结一下
模块系统是 Node.js 的基石。CommonJS 和 ES Module 你都得会,因为现实项目里两种都可能遇到。核心模块是 Node.js 自带的武器库,多用用能省不少事。自定义模块是组织代码的基本功,写好了项目结构就清晰。npm 包发布则是把你的成果分享出去的方式。
我个人觉得,理解模块系统的本质比记住语法更重要。说白了,模块就是「隔离」和「复用」——把代码隔离成独立单元,然后在需要的地方复用。这个思想,比你用哪种模块语法都重要。