第5章 5.1 模块化:ESM vs CommonJS
「上章回顾」:上一章我们折腾了「并发请求 + 限流」,学会了怎么同时抓一堆数据还不把自己服务器压垮。代码写得挺爽,但有个问题——所有代码都塞在一个文件里,自己都快看不懂了。
「本章目标」:这章我们要解决一个实际问题——代码一多就乱成一锅粥,学完你能把代码拆成独立的小块,想用哪个用哪个,再也不用「复制粘贴大法」了。
🎯 开场 3 分钟:为什么要学这个?
你有没有遇到过这种情况:
- 写了一个
handleUser.js,1000 行代码,改一个小功能要翻半天 - 想要用别人写的好代码,只能「全选复制」过来,和自己的代码搅在一起
- 两个人同时改一个文件,Git 合并冲突合并到怀疑人生
说白了:模块化就是「把大象装冰箱」的正确步骤——一步开门、一步放大象、一步关门。每个步骤独立,各干各的,最后组装起来。
这章学完,你就能:
1. 把大代码拆成小文件,想用哪个引入哪个
2. 搞清楚现在最流行的两种模块系统 ESM 和 CommonJS\n\n
\n\n
\n\n 区别
3. 写出第一版能维护、敢给人看的代码
🧱 基础 25 分钟:核心概念
什么是模块?
生活类比:就像拼乐高!
- 每个小零件(模块)自己是个完整的东西
- 你可以单独组装它
- 最后把所有零件拼在一起,变成一个大战舰
代码类比:一个 .js 文件就是一个模块,里面有它自己的变量、函数,别的文件想用它?得先「引入」。
// math.js - 这是一个模块
function add(a, b) {
return a + b;
}
function multiply(a, b) {
return a * b;
}
// 导出让别人能用
export { add, multiply };
// main.js - 使用模块
import { add, multiply } from './math.js';
console.log(add(2, 3)); // 输出: 5
console.log(multiply(4, 5)); // 输出: 20
两种模块系统:ESM vs CommonJS
这是 JavaScript 世界里两套「拼乐高」的规则,就像普通话和粤语——都能交流,但语法不一样。
| 特性 | ESM (ES Modules) | CommonJS (CJS) |
|---|---|---|
| 语法关键字 | import / export |
require / module.exports |
| 诞生时间 | 2015 年(ES6) | 2009 年(Node.js 早期) |
| 使用场景 | 现代浏览器 + 新项目 | Node.js + 老项目 |
| 运行时机 | 编译时(代码跑之前就确定) | 运行时(代码跑起来才知道) |
| 能否动态加载 | ❌ 不能 | ✅ 能(require() 可以写条件里) |
用人话解释:
- ESM 像订外卖——下单时(编译时)就知道要什么,送到手里(运行时)直接吃
- CommonJS 像去超市——推着购物车逛,需要什么拿什么(运行时才确定)
CommonJS 怎么用?
Node.js 老项目基本都用这套。
// calculator.js
function sum(arr) {
return arr.reduce((a, b) => a + b, 0);
}
function average(arr) {
return sum(arr) / arr.length;
}
// 导出方式一:一个个导出
module.exports = { sum, average };
// 或者导出方式二(简写):
// module.exports.sum = sum;
// module.exports.average = average;
// main.js
// 引入 CommonJS 模块
const { sum, average } = require('./calculator.js');
const scores = [85, 90, 78, 92, 88];
console.log('总分:', sum(scores)); // 输出: 433
console.log('平均分:', average(scores)); // 输出: 86.6
ESM 怎么用?
现代浏览器 + 现代 Node.js 项目推荐用这个。
// greeter.js
export function sayHello(name) {
return `你好, ${name}!`;
}
export function sayGoodbye(name) {
return `再见, ${name},下次见!`;
}
// 还可以导出默认成员(每个文件只能有一个 default)
export default function getMessage() {
return '欢迎使用 ESM 模块系统';
}
// main.js
// 引入时用花括号 { } 拿命名导出,用不用花括号拿默认导出
import getMessage, { sayHello, sayGoodbye } from './greeter.js';
console.log(getMessage()); // 输出: 欢迎使用 ESM 模块系统
console.log(sayHello('小明')); // 输出: 你好, 小明!
console.log(sayGoodbye('小明')); // 输出: 再见, 小明,下次见!
别名导入:换个名字用
有时候模块导出的名字太长,或者和你已有的名字冲突,可以用 as 换个名。
// 用 as 解决命名冲突
import { sayHello as hello, sayGoodbye as bye } from './greeter.js';
console.log(hello('小红')); // 输出: 你好, 小红!
console.log(bye('小红')); // 输出: 再见, 小红,下次见!
整体导入:用 * as 一网打尽
不想一个个记名字?用星号一口气全拿过来。
// 把 greeter 模块整体导入,起了个代号叫 g
import * as g from './greeter.js';
console.log(g.sayHello('小明')); // 输出: 你好, 小明!
console.log(g.sayGoodbye('小明')); // 输出: 再见, 小明,下次见!
console.log(g.default()); // 输出: 欢迎使用 ESM 模块系统
动态导入:想什么时候加载就什么时候加载
ESM 还有个厉害的功能——import() 可以当函数用,返回一个 Promise,想什么时候加载就什么时候加载。
// 当你需要用的时候再加载,比如用户点了按钮才加载
button.addEventListener('click', async () => {
// 动态导入,返回 Promise
const module = await import('./greeter.js');
console.log(module.sayHello('动态加载')); // 输出: 你好, 动态加载!
});
这在代码分割、懒加载场景特别有用——用户没点按钮,永远不加载这部分代码。
🔥 实战 35 分钟:3 个递进的小项目
项目 1:5 分钟 - 搭建你的第一个模块(跟着抄就能跑)
目标:学会创建模块、导出、引入,跑通整个流程。
项目结构:
project1/
├── utils.js # 工具模块
└── main.js # 入口文件
// utils.js - 工具函数模块
// 计算字符串长度(中文算 2 个字符)
function getStrLength(str) {
let length = 0;
for (let char of str) {
// 中文、全角符号算 2 个字符
length += char.charCodeAt(0) > 255 ? 2 : 1;
}
return length;
}
// 判断是否是空对象
function isEmptyObject(obj) {
return Object.keys(obj).length === 0;
}
// 格式化日期
function formatDate(date) {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
// 导出给别人用
module.exports = { getStrLength, isEmptyObject, formatDate };
// main.js - 入口文件
const { getStrLength, isEmptyObject, formatDate } = require('./utils.js');
// 试试字符串长度计算
const str = 'Hello你好';
console.log(`"${str}" 的长度是: ${getStrLength(str)}`); // 输出: 11
// 试试空对象判断
console.log('空对象是空的?', isEmptyObject({})); // 输出: true
console.log('有内容的对象是空的?', isEmptyObject({ name: '小明' })); // 输出: false
// 试试日期格式化
const today = new Date();
console.log('今天是:', formatDate(today)); // 输出: 2024-XX-XX
预期输出:
"Hello你好" 的长度是: 11
空对象是空的? true
有内容的对象是空的? false
今天是: 2024-06-26
一句话解释:我们把工具函数单独放在 utils.js,谁想用就 require 进来,再也不用复制粘贴了。
项目 2:15 分钟 - 用模块化处理 CSV 数据
目标:从 CSV 文件读取数据,用模块拆分成「读取」「处理」「输出」三个模块。
项目结构:
project2/
├── dataReader.js # 读取模块
├── dataProcessor.js # 处理模块
├── output.js # 输出模块
└── main.js # 入口
假设有一份学生成绩 students.csv:
name,chinese,math,english
小明,85,92,88
小红,90,85,95
小刚,78,92,86
核心代码:
// dataReader.js - 读取 CSV 文件
const fs = require('fs');
function readCSV(filepath) {
const content = fs.readFileSync(filepath, 'utf-8');
const lines = content.trim().split('\n');
// 第一行是表头
const headers = lines[0].split(',');
const data = [];
// 从第二行开始是数据
for (let i = 1; i < lines.length; i++) {
const values = lines[i].split(',');
const row = {};
headers.forEach((header, index) => {
row[header] = isNaN(values[index]) ? values[index] : Number(values[index]);
});
data.push(row);
}
return data;
}
module.exports = { readCSV };
// dataProcessor.js - 数据处理模块
function calcTotalScore(student) {
return student.chinese + student.math + student.english;
}
function calcAverage(student) {
return calcTotalScore(student) / 3;
}
function findTopStudent(students) {
return students.reduce((top, s) => {
const topTotal = calcTotalScore(top);
const sTotal = calcTotalScore(s);
return sTotal > topTotal ? s : top;
});
}
function processStudents(students) {
// 给每个学生加上总分和平均分
return students.map(s => ({
...s,
total: calcTotalScore(s),
average: calcAverage(s).toFixed(1)
}));
}
module.exports = { calcTotalScore, calcAverage, findTopStudent, processStudents };
// output.js - 输出模块
function printStudentList(students) {
console.log('\n=== 学生成绩单 ===');
console.log('姓名 | 语文 数学 英语 | 总分 平均分');
console.log('-'.repeat(45));
students.forEach(s => {
const name = s.name.padEnd(4);
const scores = `${s.chinese} ${s.math} ${s.english}`;
console.log(`${name} | ${scores} | ${s.total} ${s.average}`);
});
}
function printTopStudent(student) {
console.log('\n🏆 全班第一名:', student.name);
console.log(` 总分: ${student.total} 分`);
}
module.exports = { printStudentList, printTopStudent };
// main.js - 入口文件
const { readCSV } = require('./dataReader.js');
const { findTopStudent, processStudents } = require('./dataProcessor.js');
const { printStudentList, printTopStudent } = require('./output.js');
// 读取数据
const students = readCSV('./students.csv');
// 处理数据:加总分和平均分
const processedStudents = processStudents(students);
// 输出成绩单
printStudentList(processedStudents);
// 输出第一名
const topStudent = findTopStudent(processedStudents);
printTopStudent(topStudent);
预期输出:
=== 学生成绩单 ===
姓名 | 语文 数学 英语 | 总分 平均分
---------------------------------------------
小明 | 85 92 88 | 265 88.3
小红 | 90 85 95 | 270 90.0
小刚 | 78 92 86 | 256 85.3
🏆 全班第一名: 小红
分: 270 分
一句话解释:每个模块只管一件事——读取的只管读取,处理的只管处理,输出只管输出。改需求时改一个文件就行。
项目 3:15 分钟 - 做一个待办清单小工具
目标:综合运用模块化,做一个命令行待办清单,支持添加、完成、查看、删除。
项目结构:
project3/
├── storage.js # 本地存储模块
├── todoManager.js # 待办管理逻辑模块
├── ui.js # 命令行界面模块
└── main.js # 入口
// storage.js - 本地存储模块
const fs = require('fs');
const path = require('path');
const FILE_PATH = path.join(__dirname, 'todos.json');
// 读取待办列表
function loadTodos() {
if (!fs.existsSync(FILE_PATH)) {
return [];
}
const content = fs.readFileSync(FILE_PATH, 'utf-8');
return JSON.parse(content);
}
// 保存待办列表
function saveTodos(todos) {
fs.writeFileSync(FILE_PATH, JSON.stringify(todos, null, 2));
}
module.exports = { loadTodos, saveTodos };
// todoManager.js - 待办管理逻辑
const { loadTodos, saveTodos } = require('./storage.js');
// 添加待办
function addTodo(text) {
const todos = loadTodos();
const newTodo = {
id: Date.now(),
text: text,
done: false,
createdAt: new Date().toISOString()
};
todos.push(newTodo);
saveTodos(todos);
return newTodo;
}
// 完成待办(标记为 done)
function completeTodo(id) {
const todos = loadTodos();
const todo = todos.find(t => t.id === id);
if (todo) {
todo.done = true;
todo.completedAt = new Date().toISOString();
saveTodos(todos);
return true;
}
return false;
}
// 删除待办
function deleteTodo(id) {
const todos = loadTodos();
const index = todos.findIndex(t => t.id === id);
if (index !== -1) {
todos.splice(index, 1);
saveTodos(todos);
return true;
}
return false;
}
// 获取所有待办
function getAllTodos() {
return loadTodos();
}
// 获取待办统计
function getStats() {
const todos = loadTodos();
const total = todos.length;
const done = todos.filter(t => t.done).length;
const pending = total - done;
return { total, done, pending };
}
module.exports = { addTodo, completeTodo, deleteTodo, getAllTodos, getStats };
// ui.js - 命令行界面
const { getAllTodos, getStats } = require('./todoManager.js');
function showHeader() {
console.log('\n========== 📝 待办清单 ==========');
}
function showHelp() {
console.log('\n可用命令:');
console.log(' add <内容> - 添加新待办');
console.log(' done <ID> - 完成待办');
console.log(' delete <ID> - 删除待办');
console.log(' list - 查看所有待办');
console.log(' stats - 查看统计');
console.log(' help - 显示帮助');
console.log(' exit - 退出');
}
function showList() {
const todos = getAllTodos();
if (todos.length === 0) {
console.log('\n📭 还没有待办,快去添加一个吧!');
return;
}
console.log('\n待办列表:');
todos.forEach(todo => {
const status = todo.done ? '✅' : '⬜';
const id = `[${todo.id}]`;
const text = todo.done ? todo.text + ' (已完成)' : todo.text;
console.log(` ${status} ${id} ${text}`);
});
}
function showStats() {
const stats = getStats();
console.log('\n📊 统计信息:');
console.log(` 总计: ${stats.total} 项`);
console.log(` 已完成: ${stats.done} 项`);
console.log(` 待完成: ${stats.pending} 项`);
}
module.exports = { showHeader, showHelp, showList, showStats };
// main.js - 入口
const { addTodo, completeTodo, deleteTodo } = require('./todoManager.js');
const { showHeader, showHelp, showList, showStats } = require('./ui.js');
const readline = require('readline');
// 创建命令行交互接口
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout
});
function prompt() {
rl.question('\n> ', (input) => {
const parts = input.trim().split(' ');
const command = parts[0];
const args = parts.slice(1).join(' ');
switch (command) {
case 'add':
if (args) {
const todo = addTodo(args);
console.log(`✅ 已添加: "${args}" (ID: ${todo.id})`);
} else {
console.log('❌ 请输入待办内容: add <内容>');
}
break;
case 'done':
if (args) {
const id = parseInt(args);
if (completeTodo(id)) {
console.log(`✅ 已完成待办 #${id}`);
} else {
console.log(`❌ 未找到 ID 为 ${id} 的待办`);
}
} else {
console.log('❌ 请输入待办 ID: done <ID>');
}
break;
case 'delete':
if (args) {
const id = parseInt(args);
if (deleteTodo(id)) {
console.log(`🗑️ 已删除待办 #${id}`);
} else {
console.log(`❌ 未找到 ID 为 ${id} 的待办`);
}
} else {
console.log('❌ 请输入待办 ID: delete <ID>');
}
break;
case 'list':
showList();
break;
case 'stats':
showStats();
break;
case 'help':
showHelp();
break;
case 'exit':
console.log('👋 再见!');
rl.close();
return;
default:
console.log(`❓ 未知命令: ${command},输入 help 查看帮助`);
}
prompt(); // 继续等待下一条命令
});
}
// 启动程序
showHeader();
console.log('📌 输入 help 查看帮助');
prompt();
预期输出(运行 node main.js 后):
========== 📝 待办清单 ==========
📌 输入 help 查看帮助
> add 买牛奶
✅ 已添加: "买牛奶" (ID: 1719400000000)
> add 写周报
✅ 已添加: "写周报" (ID: 1719400000001)
> list
待办列表:
⬜ [1719400000000] 买牛奶
⬜ [1719400000001] 写周报
> done 1719400000000
✅ 已完成待办 #1719400000000
> list
待办列表:
⬜ [1719400000001] 写周报
✅ [1719400000000] 买牛奶 (已完成)
> stats
📊 统计信息:
总计: 2 项
已完成: 1 项
待完成: 1 项
> exit
👋 再见!
一句话解释:每个模块各司其职,数据存在文件里不怕丢,下次打开还在。
💪 进阶 20 分钟:常见坑 + 性能小贴士
坑 1:ESM 不能用 require(),CommonJS 不能用 import
❌ 错误示例:
// greeter.js (CommonJS)
const { sayHello } = require('./greeter.js'); // ❌ 在 ESM 文件里用 require
✅ 正确做法:
// greeter.js (CommonJS) 用 module.exports
module.exports.sayHello = function(name) {
return `你好, ${name}!`;
};
// main.js (CommonJS) 用 require
const { sayHello } = require('./greeter.js');
坑 2:ESM 的 import 必须写在文件最顶层
❌ 错误示例:
// 条件导入 —— ESM 不支持!
if (needModule) {
import('./module.js').then(module => {
// ...
});
}
✅ 正确做法(用动态 import):
// 动态导入返回 Promise,可以写在条件里
if (needModule) {
const module = await import('./module.js');
module.doSomething();
}
坑 3:循环依赖(模块 A 引用模块 B,模块 B 又引用模块 A)
这种情况容易出现「拿到的是 undefined」。
// a.js
const { bMethod } = require('./b.js');
console.log('A 中调用 B 的方法:', bMethod()); // ❌ 可能是 undefined
module.exports = { aMethod: () => 'A 的方法' };
// b.js
const { aMethod } = require('./a.js');
console.log('B 中调用 A 的方法:', aMethod()); // ❌ 可能是 undefined
module.exports = { bMethod: () => 'B 的方法' };
✅ 解决思路:
1. 重构代码,打破循环
2. 把公共部分提取到第三个模块
3. 用「延迟执行」——不在顶部 require,在函数里 require
坑 4:路径大小写问题(Windows 不敏感,Linux 敏感)
❌ 错误示例:
// 写成 './MyModule.js' 但实际文件是 './mymodule.js'
const m = require('./MyModule.js'); // Linux 上找不到!
✅ 正确做法:保持文件名和引用路径完全一致,或者用 path 模块处理。
坑 5:忘记给 Node.js 开启 ESM 模式
ESM 需要在 package.json 里声明,或者文件用 .mjs 扩展名。
✅ 方法一:在 package.json 加一行:
{
"type": "module"
}
✅ 方法二:文件改名为 .mjs(不推荐,混乱)
性能小贴士:按需加载,用到再引
// ❌ 一开始就全部加载
import * as bigModule from './big-module.js'; // 无论用不用,都加载了
// ✅ 按需加载
async function useFeature() {
const { featureA } = await import('./big-module.js');
featureA();
}
调试技巧:打印导入的模块
// 不知道模块导出了什么?打出来看看
const myModule = require('./myModule.js');
console.log('模块导出内容:', Object.keys(myModule));
console.log('具体内容:', myModule);
✏️ 练习题 + 作业题
练习题(5 道,10 分钟内完成)
练习 1(1 分钟):换个名字用
- 输入:把项目 1 utils.js 里的 getStrLength 导入后改名为 strLen
- 预期输出:strLen('Hello') 返回 5
- 提示:CommonJS 用 const { getStrLength: strLen } = require(...)
练习 2(2 分钟):加个判断
- 输入:在项目 1 的 main.js 里,判断 formatDate 返回的日期是不是今天
- 预期输出:true 或 false
- 提示:把 formatDate 返回值和今天的日期字符串比较
练习 3(3 分钟):处理新数据
- 输入:有一份商品 CSV:name,price,count\n苹果,5,10\n香蕉,3,20
- 预期输出:计算每种商品的总价(price × count)
- 提示:复用项目 2 的模块读写 CSV,计算部分新写
练习 4(4 分钟):串起两个项目
- 输入:用项目 2 的方式处理学生成绩,结果用项目 1 的 formatDate 给每个学生加个「处理日期」字段
- 预期输出:处理后的学生对象包含 处理日期: xxxx-xx-xx
- 提示:模块可以混用,各自导出各自的方法
练习 5(挑战题,看 5 分钟):报错分析
- 输入:有人运行代码遇到这个错误:
SyntaxError: Cannot use import statement outside a module
- 预期输出:写出导致这个错误的 2 种可能原因及修复方法
- 提示:检查文件扩展名、
package.json里的type字段
作业题(30 分钟 - 2 小时)
作业:做一个「个人理财小账本」
- 需求描述:命令行小工具,记录你的日常收入和支出,随时查看余额和统计
-
功能点:
1. 添加记录:收入或支出,金额,备注
2. 查看流水:按时间顺序列出所有记录
3. 查看余额:收入总计 - 支出总计 = 当前余额
4. 月度统计:指定月份,输出该月收入/支出/结余
5. 删除记录:按 ID 删除某条记录 -
加分项:
1. 数据持久化到 JSON 文件,关闭后再打开不丢失
2. 支持按类型(收入/支出)筛选查看 -
验收标准:
- 能跑起来(
node main.js) - 增删查功能都能正常工作
-
数据存在文件里,重启程序还在
-
提交方式:评论区贴代码或 GitHub 链接
📚 总结 + 资源
一句话总结本文学到的 3 个核心点:
1. 模块就是文件——每个 .js 文件是个独立模块,用 require 或 import 引入想用的
2. 两套语法要分清——CommonJS 用 require/module.exports,ESM 用 import/export
3. 好处是解耦和复用——代码拆开写,改一处不影响另一处,还能给下个项目直接用
推荐延伸学习资源:
1. Node.js 官方文档 - 模块系统 (权威、全面)
2. 《JavaScript 高级程序设计》第 4 章 - 讲得很细,适合啃
3. ESM 规范原文 (进阶阅读,想深入必看)
互动钩子:你在实际项目中遇到过「模块循环依赖」或者「ESM/CJS 混用报错」的问题吗?评论区聊聊怎么解决的,老粉优先回复!
下章预告:写代码哪有不报错的,学会了模块化,下一章我们来聊聊「万一出错了怎么办」——第 5 章 5.2 错误处理与 try/catch,让你的程序遇到问题不再崩溃。

评论(0)