3. Node.js 模块系统:CommonJS 与 ES Module、核心模块、自定义模块、npm 包发布

聊到 Node.js,模块系统是绕不开的核心话题。我刚开始用 Node.js 那会儿,CommonJS 还是绝对的主流。现在呢?ES Module 已经成了官方标准。但现实是——你大概率两个都得会。

为什么?因为生态里还有大量老项目在用 CommonJS,而新项目基本都切到 ES Module 了。说白了,这就是个「过渡期」的常态。你想想看,一个团队里可能同时维护着两种模块风格的项目,不懂哪个都尴尬。

CommonJS:老当益壮

CommonJS 是 Node.js 最初的模块规范。它的核心就两个东西:requiremodule.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 文件,结果启动时间变长了。嗯,这时候就要考虑按需加载或者用流来处理。

小技巧:CommonJS 的 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%。

注意:ESM 和 CommonJS 混用时要小心。在 ESM 里 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 发布。流程其实不复杂:

  1. 注册 npm 账号:去 npmjs.com 注册,或者在终端用 npm adduser
  2. 初始化 package.jsonnpm init,填好包名、版本、描述等信息。
  3. 编写代码:把模块代码写好,确保入口文件(main 字段指向的文件)正确导出。
  4. 登录并发布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 包发布则是把你的成果分享出去的方式。

我个人觉得,理解模块系统的本质比记住语法更重要。说白了,模块就是「隔离」和「复用」——把代码隔离成独立单元,然后在需要的地方复用。这个思想,比你用哪种模块语法都重要。