第1章 1.3 模块系统 CommonJS

⚠️ 注意:你标题写的是「Python 教程」,但 CommonJS 是 Node.js 的模块系统,内容也全是 require/module.exports 这些 JS 概念。我按 Node.js 来写,如果确实要 Python 版本告诉我再调整。


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

上一章我们学了两种运行 JS 的方式:交互式的 REPL 和脚本模式。你有没有发现一个问题——

当代码超过 300 行的时候,整个文件塞在一个 app.js 里,自己都看不下去?

更难受的是,如果你写了一个「计算双十一打折」的函数,想在另一个脚本里用,得:
- 把代码复制粘贴过去(改 bug 要改两处)
- 或者建一个 .html 文件通过 <script> 引入(浏览器才这么做)

Node.js 给你第三种方式:模块系统。

学完这一章,你能:
- 把大文件拆成多个小文件,想用哪个 require() 进来
- 写一个「工具箱」给别人用,你自己的代码也不污染别人的命名空间
- 搞懂为什么有时候改了代码但运行结果没变(缓存的坑)


🧱 基础 25 分钟:核心概念

1.3.1 什么是模块?

生活类比:你家的「工具箱」。

想象你有一个工具箱,里面有螺丝刀、锤子、扳手。你修电脑的时候抽出来用,修完再放回去。你不会担心「螺丝刀被别人借走了」,因为工具箱是独立的,你用完别人还能用。

在代码里:一个 .js 文件就是一个模块。你在这个文件里定义的变量、函数,默认只在文件内部生效,不会跑到外面去。

1.3.2 第一个模块:导出和导入

怎么用

首先,创建一个文件叫 greet.js,内容如下:

// greet.js
function sayHello(name) {
return '你好,' + name + '!';
}

function sayGoodbye(name) {
return '再见,' + name + '!';
}

// 导出:把这两个函数「放工具箱里」
module.exports = {
sayHello: sayHello,
sayGoodbye: sayGoodbye
};

然后,创建 main.js,内容如下:

// main.js
// 导入:把工具箱拿过来用
const greet = require('./greet');

console.log(greet.sayHello('小明'));
console.log(greet.sayGoodbye('小明'));

运行 node main.js,输出:

你好,小明!
再见,小明!

这行在干嘛
- module.exports = {...} = 把东西装进工具箱
- require('./greet') = 把工具箱拿过来

1.3.3 简写形式

如果属性名和变量名一样,可以偷懒:

// greet.js 简写版
function sayHello(name) {
return '你好,' + name + '!';
}

function sayGoodbye(name) {
return '再见,' + name + '!';
}

module.exports = {
sayHello,
sayGoodbye
};

效果完全一样,只是少写了几次名字。

1.3.4 只导出一个函数(常用简写)

有时候你只想导出「一个东西」,比如一个计算器函数:

// calc.js
function add(a, b) {
return a + b;
}

// 导出单个函数,直接等于这个函数
module.exports = add;
// main.js
const add = require('./calc');
console.log(add(1, 2));  // 3

这就是 CommonJS 的核心require() 拿过来,module.exports 送出去。

配图1 - 配图1

1.3.5 内置模块:Node.js 给你准备好的工具箱

Node.js 自带很多内置模块,不用安装直接用:

// 路径处理模块
const path = require('path');

// 获取当前文件的所在目录
console.log(__dirname);        // 当前文件所在文件夹的绝对路径
console.log(path.basename(__dirname));  // 文件夹名字

// 拼接路径(自动处理 / 和 \ 的问题)
const fullPath = path.join(__dirname, 'public', 'index.html');
console.log(fullPath);

运行后你会看到类似:

/Users/apple/workspace/myproject
myproject
/Users/apple/workspace/myproject/public/index.html

为什么要用 path.join:直接字符串拼接 __dirname + '/public/index.html' 在 Windows 上会变成 \/ 混用,容易出 bug。

1.3.6 npm 下载的模块怎么用?

第三章会详细讲 npm,现在先知道这么用:

// 假设你执行了:npm install lodash
const _ = require('lodash');

const arr = [1, 2, 3, 4, 5];
console.log(_.chunk(arr, 2));  // [[1,2], [3,4], [5]]

直接 require('lodash') 就行,不用加 ./

配图2 - 配图2

1.3.7 模块缓存:同一个模块只执行一次

这是新手容易踩的坑,看例子:

// counter.js
let count = 0;
count += 1;
console.log('counter.js 被加载了,第' + count + '次');

module.exports = { count };
// main.js
const a = require('./counter');
const b = require('./counter');

console.log('a.count =', a.count);  // 1
console.log('b.count =', b.count);  // 1

运行 node main.js,你猜输出什么?

counter.js 被加载了,第1次
a.count = 1
b.count = 1

注意:虽然 require 了两次,但 counter.js 只执行了一次!所以 count 只被加了一次。

这是因为 Node.js 会缓存每个加载过的模块。第二次 require 时,直接从缓存拿结果,不重新执行代码。

这意味着:如果你改了被引用模块的代码,需要重启 Node.js 才能看到效果。保存代码但没重启?结果没变?十有八九是缓存的锅。


🔥 实战 35 分钟:3 个递进的小项目

项目 1(5 分钟):小明的购物计算器 🛒

场景:小明买苹果 3 斤,每斤 5 块;香蕉 2 斤,每斤 3 块。算总价和找零。

先建 price_calc.js

// price_calc.js
function calcTotal(items) {
let total = 0;
for (const item of items) {
    total += item.price * item.count;
}
return total;
}

function giveChange(money, total) {
return money - total;
}

module.exports = { calcTotal, giveChange };

再建 main.js

// main.js
const { calcTotal, giveChange } = require('./price_calc');

const shoppingList = [
{ name: '苹果', price: 5, count: 3 },
{ name: '香蕉', price: 3, count: 2 }
];

const total = calcTotal(shoppingList);
const payment = 30;
const change = giveChange(payment, total);

console.log('商品列表:');
for (const item of shoppingList) {
console.log(`  ${item.name} x ${item.count} = ${item.price * item.count}元`);
}
console.log('总计:' + total + '元');
console.log('付款:' + payment + '元');
console.log('找零:' + change + '元');

运行 node main.js

商品列表:
苹果 x 3 = 15元
香蕉 x 2 = 6元
总计:21元
付款:30元
找零:9元

一句话解释:把计算逻辑单独放一个模块,主程序只负责调用,代码干净多了。

项目 2(15 分钟):读取 CSV 文件处理数据

场景:有一个 students.csv 文件,存着学生成绩,要算出平均分。

CSV 文件内容(students.csv):

name,chinese,math,english
张三,85,92,78
李四,90,88,95
王五,76,85,90

csv_parser.js

// csv_parser.js
const fs = require('fs');
const path = require('path');

function readCSV(filename) {
const filePath = path.join(__dirname, filename);
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 = {};
    for (let j = 0; j < headers.length; j++) {
        row[headers[j]] = values[j];
    }
    data.push(row);
}

return data;
}

function calcAverage(data, subject) {
let sum = 0;
for (const row of data) {
    sum += parseFloat(row[subject]);
}
return (sum / data.length).toFixed(2);
}

module.exports = { readCSV, calcAverage };

main.js

// main.js
const { readCSV, calcAverage } = require('./csv_parser');

const students = readCSV('students.csv');
console.log('学生成绩数据:');
console.log(students);

console.log('\n各科平均分:');
console.log('语文:' + calcAverage(students, 'chinese'));
console.log('数学:' + calcAverage(students, 'math'));
console.log('英语:' + calcAverage(students, 'english'));

运行 node main.js

学生成绩数据:
[
{ name: '张三', chinese: '85', math: '92', english: '78' },
{ name: '李四', chinese: '90', math: '88', english: '95' },
{ name: '王五', chinese: '76', math: '85', english: '90' }
]

各科平均分:
语文:83.67
数学:88.33
英语:87.67

一句话解释:读取文件、解析数据、计算统计,三个功能拆成三个部分,逻辑清晰。

项目 3(15 分钟):命令行待办清单工具

场景:写一个命令行工具,可以添加任务、查看任务列表、标记完成。

todo_store.js(数据存储模块):

// todo_store.js
const fs = require('fs');
const path = require('path');

const DATA_FILE = path.join(__dirname, 'todos.json');

function loadTodos() {
if (!fs.existsSync(DATA_FILE)) {
    return [];
}
const content = fs.readFileSync(DATA_FILE, 'utf-8');
return JSON.parse(content);
}

function saveTodos(todos) {
fs.writeFileSync(DATA_FILE, JSON.stringify(todos, null, 2));
}

function addTodo(text) {
const todos = loadTodos();
todos.push({
    id: Date.now(),
    text: text,
    done: false
});
saveTodos(todos);
return todos;
}

function listTodos() {
return loadTodos();
}

function doneTodo(id) {
const todos = loadTodos();
for (const todo of todos) {
    if (todo.id == id) {
        todo.done = true;
    }
}
saveTodos(todos);
return todos;
}

module.exports = { addTodo, listTodos, doneTodo };

main.js(入口程序):

// main.js
const { addTodo, listTodos, doneTodo } = require('./todo_store');

// 获取命令行参数(node main.js add "买牛奶" 这样的格式)
const action = process.argv[2];
const arg = process.argv[3];

if (action === 'add') {
if (!arg) {
    console.log('请输入任务内容:node main.js add "任务名称"');
    return;
}
addTodo(arg);
console.log('已添加任务:' + arg);
} else if (action === 'list') {
const todos = listTodos();
console.log('\n待办清单:');
todos.forEach((todo, index) => {
    const status = todo.done ? '✅' : '⬜';
    console.log(`${index + 1}. ${status} ${todo.text}`);
});
} else if (action === 'done') {
if (!arg) {
    console.log('请输入任务ID:node main.js done 任务ID');
    return;
}
doneTodo(arg);
console.log('已标记完成');
} else {
console.log('用法:');
console.log('  node main.js add "任务名称"  - 添加任务');
console.log('  node main.js list            - 查看列表');
console.log('  node main.js done 任务ID     - 标记完成');
}

运行测试:

node main.js add "买牛奶"
node main.js add "写周报"
node main.js list
node main.js done 上面显示的ID
node main.js list

预期输出:

已添加任务:买牛奶
已添加任务:写周报

待办清单:
1. ⬜ 买牛奶
2. ⬜ 写周报

待办清单:
1. ✅ 买牛奶
2. ⬜ 写周报

一句话解释:数据存在 JSON 文件里,程序重启后数据还在,下次课你会学到更优雅的存储方式。


💪 进阶 20 分钟:常见坑 + 性能小贴士

坑 1:循环依赖导致 undefined

❌ 错误示例

// a.js
const { bMethod } = require('./b');
console.log('a.js 调用 b 的方法:', bMethod);  // undefined!
module.exports = { aMethod: () => 'A方法' };

// b.js
const { aMethod } = require('./a');
console.log('b.js 调用 a 的方法:', aMethod);  // undefined!
module.exports = { bMethod: () => 'B方法' };
// main.js
require('./a');

运行结果:

b.js 调用 a 的方法: undefined
a.js 调用 b 的方法: undefined

✅ 正确做法:避免循环依赖,或者把共享的代码提取到第三个文件。

// shared.js(独立的共享模块)
module.exports = { sharedData: 'hello' };

// a.js
const { sharedData } = require('./shared');
const { bMethod } = require('./b');
console.log('现在能拿到:', bMethod);  // 能拿到
module.exports = { aMethod: () => 'A方法' };

坑 2:require 路径写错

❌ 错误示例

const myModule = require('myModule');        // 找不到,报错
const myModule = require('./myModule');      // 缺扩展名,可能找不到
const myModule = require('../myModule');     // 目录不对

✅ 正确做法

// 本地模块,带扩展名
const myModule = require('./myModule.js');

// node_modules 模块,不用路径
const express = require('express');

// 目录名(自动找 index.js)
const myLib = require('./myLib');

坑 3:修改了模块但效果没变

原因:Node.js 缓存了模块,改了代码没重启不会生效。

✅ 正确做法
- 每次改代码后重新运行 node xxx.js
- 或者用 nodemon 自动监听重启(后续章节会讲)

坑 4:module.exports 和 exports 混用

❌ 错误示例

// 错误!这样会切断联系
exports.sayHello = () => 'hi';
module.exports = { sayGoodbye: () => 'bye' };
// 上面两行,exports 那行白写了!

✅ 正确做法:二选一,不要混用。

// 方式一:只用 module.exports
module.exports = {
sayHello: () => 'hi'
};

// 方式二:只用 exports(但最后还是要赋值给 module.exports)
exports.sayHello = () => 'hi';
// 如果要导出整个对象,必须这样:
module.exports = exports;

坑 5:相对路径在不同目录结果不同

❌ 错误示例

// 在 ./utils/helper.js 里
const config = require('../config.json');  // 相对于 helper.js 的位置

// 在根目录运行 node ./utils/helper.js,能找到
// 但如果 node main.js(main.js 里 require('./utils/helper')),路径就错了

✅ 正确做法:用 __dirname 拼接绝对路径:

const path = require('path');
const configPath = path.join(__dirname, '..', 'config.json');
const config = require(configPath);

调试技巧:console.log + 彩色输出

// 简单的调试方法
console.log('变量 a 的值是:', a);

// 彩色输出,更醒目
console.log('\x1b[31m错误:\x1b[0m 文件读取失败');   // 红色
console.log('\x1b[32m成功:\x1b[0m 操作完成');       // 绿色
console.log('\x1b[33m警告:\x1b[0m 库存不足');       // 黄色

✏️ 练习题

练习 1(2 分钟):改改购物车

题目:把项目 1 的苹果改成 5 斤,香蕉改成 3 斤,看看总价变成多少。

  • 输入:修改 shoppingList 数组
  • 预期输出:新的总价
  • 提示:直接改 count 值就行

练习 2(2 分钟):加个判断

题目:在 price_calc.js 里加个函数 isEnoughMoney(wallet, total),如果 wallet >= total 返回 true,否则返回 false

  • 输入:isEnoughMoney(30, 21)
  • 预期输出:true
  • 提示:用 >= 比较

练习 3(3 分钟):处理新 CSV

题目:新建一个 grades.csv,包含三行数据(姓名、分数),用项目 2 的方法算出平均分。

  • 输入:自己创建的 grades.csv
  • 预期输出:平均分数字
  • 提示:把 students.csv 复制一份改改内容试试

练习 4(5 分钟):串起两个项目

题目:把项目 2 的 CSV 读取结果,传给项目 1 的计算器。

  • 输入:CSV 里的语文、数学、英语分数
  • 预期输出:各科平均分和总平均分
  • 提示:把 calcAverage 的结果传给 calcTotal

练习 5(5 分钟):读报错信息

题目:运行下面代码会报错,修复它。

// test.js
const result = require('./nonexistent');
console.log(result);
  • 预期输出:Error: Cannot find module './nonexistent'
  • 提示:检查路径是否正确,文件是否存在

📚 作业:做一个命令行记账工具

需求描述:帮小明记零花钱,支持收入、支出、查余额。

功能点
1. node main.js add 收入 100 "发工资" - 记一笔收入
2. node main.js spend 50 "买书" - 记一笔支出
3. node main.js balance - 查看余额和明细

加分项
1. 数据存到 ledger.json 文件,重启程序后不丢失
2. 支出时如果余额不足,提示「余额不足,无法支出」

验收标准
- 能跑起来
- 收支记录正确
- 余额计算正确
- 提交方式:评论区贴代码


📚 总结 + 资源

本文学了 3 件事
1. require() 导入模块,module.exports 导出模块
2. 内置模块(fspath)和第三方模块(lodash)的用法
3. 模块缓存机制和常见坑(循环依赖、路径错误)

延伸资源
- Node.js 官方文档 - 模块(英文,但例子很清楚)
- 《Node.js 实战:用 JavaScript 构建高性能服务器》(中文经典书)
- 视频:B 站「Node.js 入门教程」系列

互动钩子:你在项目里有没有遇到过「改了代码但没效果」的坑?当时是怎么发现的?评论区聊聊,老粉优先回复!


下一章我们要解决一个问题:CommonJS 虽然好用,但它是 Node.js 专属的,浏览器不认识。ES Modules(import/export)才是未来的标准,而且写法更简洁。剧透一下:到时候「小明的购物清单」要换个方式登场了。

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