第5章 5.1 模块化:ESM vs CommonJS

「上章回顾」:上一章我们折腾了「并发请求 + 限流」,学会了怎么同时抓一堆数据还不把自己服务器压垮。代码写得挺爽,但有个问题——所有代码都塞在一个文件里,自己都快看不懂了。

「本章目标」:这章我们要解决一个实际问题——代码一多就乱成一锅粥,学完你能把代码拆成独立的小块,想用哪个用哪个,再也不用「复制粘贴大法」了。


🎯 开场 3 分钟:为什么要学这个?

你有没有遇到过这种情况:

  • 写了一个 handleUser.js,1000 行代码,改一个小功能要翻半天
  • 想要用别人写的好代码,只能「全选复制」过来,和自己的代码搅在一起
  • 两个人同时改一个文件,Git 合并冲突合并到怀疑人生

说白了:模块化就是「把大象装冰箱」的正确步骤——一步开门、一步放大象、一步关门。每个步骤独立,各干各的,最后组装起来。

这章学完,你就能:
1. 把大代码拆成小文件,想用哪个引入哪个
2. 搞清楚现在最流行的两种模块系统 ESM 和 CommonJS\n\nSimple tech illustration expla\n\nAI comic creation scene, creat\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 返回的日期是不是今天
- 预期输出:truefalse
- 提示:把 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 文件是个独立模块,用 requireimport 引入想用的
2. 两套语法要分清——CommonJS 用 require/module.exports,ESM 用 import/export
3. 好处是解耦和复用——代码拆开写,改一处不影响另一处,还能给下个项目直接用

推荐延伸学习资源
1. Node.js 官方文档 - 模块系统 (权威、全面)
2. 《JavaScript 高级程序设计》第 4 章 - 讲得很细,适合啃
3. ESM 规范原文 (进阶阅读,想深入必看)

互动钩子:你在实际项目中遇到过「模块循环依赖」或者「ESM/CJS 混用报错」的问题吗?评论区聊聊怎么解决的,老粉优先回复!


下章预告:写代码哪有不报错的,学会了模块化,下一章我们来聊聊「万一出错了怎么办」——第 5 章 5.2 错误处理与 try/catch,让你的程序遇到问题不再崩溃。

声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理。