第1章 1.4 ES Modules(import/export)

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

上一章我们学会了用 require()module.exports 把代码拆成多个文件,终于不用把所有代码都塞在一个巨大的 app.js 里了——这感觉就像终于学会用文件夹整理电脑桌面一样舒服。

但是等等,你有没有遇到过这种情况:

  • 打开一个项目,看到满屏的 const fs = require('fs'),心里嘀咕「这到底是啥?」
  • 想只引入一个函数的一部分,结果把整个模块都引进来了
  • 两个不同的模块互相引用,搞得代码像一团乱麻

ES Modules(简称 ESM)就是来救场的。它是 JavaScript 官方提出的模块化标准,用 importexport 关键字,代码读起来就像读句子一样自然:

// 读出来就是:「从 './utils' 导入 { add } 这个函数」
import { add } from './utils.js';

// 而不是像 CommonJS 那样:
const { add } = require('./utils');

学完这一章,你能:
1. ✅ 用 import/export 重构你的代码结构
2. ✅ 理解 type="module".mjs 文件的区别
3. ✅ 解决 ESM 和 CommonJS 混用时的那些「奇怪报错」


🧱 基础 25 分钟:核心概念

什么是 ES Modules?——「快递盒子的说明书」

想象你从网上买了一个多功能电饭煲。打开箱子,里面有:
- 电饭煲本体
- 一个说明书,写着「配件:蒸笼 x1,量杯 x1」
- 另一个小盒子,标签写着「内含:米饭勺 x1,汤勺 x1」

ESM 就是这个思路
- 每个 .js 文件是一个「快递盒子」
- export 就像是盒子上贴的「里面有什么」的标签
- import 就像是拆开盒子,拿出你需要的东西

export:声明「我这里有什么」

方式一:命名导出(像一个盒子里放多个东西,每个贴标签)

// math.js
export const PI = 3.14159;
export function add(a, b) {

return a + b;
}
export function multiply(a, b) {
return a * b;
}

解释:export 关键字把 PIaddmultiply 这三样东西暴露出去。

方式二:默认导出(一个盒子只装一样东西,不需要起名字)

// greeter.js
export default function(name) {
return `你好,${name}!`;
}

解释:每个文件只能有一个 export default,就像每个快递只能有一个「主物品」。

import:声明「我需要什么」

导入命名导出(必须知道名字,像点外卖要知道菜名)

// main.js
import { add, PI } from './math.js';

console.log(add(2, 3));        // 输出:5
console.log(PI);               // 输出:3.14159

解释:从 ./math.js 导入 addPI,大括号里的名字必须和导出的一致。

导入默认导出(不需要大括号,像去超市买水果直接拿就行)

// app.js
import greet from './greeter.js';

console.log(greet('小明'));     // 输出:你好,小明!

解释:greet 这个名字是自己起的,想叫什么就叫什么。

同时导入默认和命名(混搭)

import greet, { add, PI } from './math.js';

解释:默认导出不用大括号,命名导出用大括号。

as:给导入的东西起别名

如果两个模块都导出了同名的函数,可以给其中一个起别名:

import { add } from './math.js';
import { add as addNumbers } from './calculator.js';

console.log(add(1, 2));           // 来自 math.js
console.log(addNumbers(3, 4));    // 来自 calculator.js

解释:as 就像给人起外号,「原名」和「外号」都能用。

配图1 - 配图1

import *:一次性导入整个模块

import * as MathTools from './math.js';

console.log(MathTools.PI);              // 3.14159
console.log(MathTools.add(1, 2));      // 3
console.log(MathTools.multiply(3, 4)); // 12

解释:* 表示「全部」,as MathTools 给整个模块包起个名字,用起来像「命名空间」。

type="module":告诉 Node.js 「用新版语法」

Node.js 默认用 CommonJS(require/exports)。想在 .js 文件里用 ESM,得先在 package.json 里声明:

{
"name": "my-project",
"type": "module"
}

这样,这个项目里所有的 .js 文件都会用 ESM

注意!一旦加上 "type": "module",原来的 require() 就不能用了,必须全部改成 import

.mjs 扩展名:单文件强制 ESM

如果只想让某个文件用 ESM,其他文件继续用 CommonJS,可以用 .mjs 扩展名:

# 强制这个文件用 ESM
utils.mjs
// utils.mjs
export function hello() {
return '你好!';
}
// app.js(仍然是 CommonJS)
const { hello } = require('./utils.mjs');  // ❌ 这样不行!
// 必须用动态 import:
import('./utils.mjs').then(module => {
console.log(module.hello());  // 你好!
});

动态 import:需要的时候再加载

普通的 import 叫「静态导入」,会一开始就全部加载。有时候我们想「用到的时候再加载」,用动态 import()

// app.js
async function loadModule() {
// 等到需要的时候才加载
const { add } = await import('./math.js');
console.log(add(10, 20));
}

loadModule();  // 输出:30

解释:这就像网购「预约配送」,不是马上送到,而是等你说「现在要」才送。

配图2 - 配图2

顶层 await:异步代码的新写法

在 ESM 里,你可以在模块顶层直接用 await,不需要包在 async 函数里:

// database.js
const data = await fetch('https://api.example.com/data');
export { data };

这在 CommonJS 里是做不到的——必须包一个 async 函数。


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

项目 1(5 分钟):重构「计算器」,体验 ESM 语法

场景:把上一章的 CommonJS 计算器,改写成 ESM 版本。

完整可运行代码

// calculator.mjs(直接用 .mjs 扩展名,强制 ESM)

// 导出加法
export function add(a, b) {
return a + b;
}

// 导出减法
export function subtract(a, b) {
return a - b;
}

// 导出乘法
export function multiply(a, b) {
return a * b;
}

// 导出除法(带错误处理)
export function divide(a, b) {
if (b === 0) {
    throw new Error('除数不能为 0');
}
return a / b;
}
// main.mjs
import { add, subtract, multiply, divide } from './calculator.mjs';

console.log('=== 计算器 v2(ESM版)===');
console.log('加法:', add(10, 5));           // 输出:加法: 15
console.log('减法:', subtract(10, 5));      // 输出:减法: 5
console.log('乘法:', multiply(10, 5));      // 输出:乘法: 50
console.log('除法:', divide(10, 5));        // 输出:除法: 2

预期输出

=== 计算器 v2(ESM版)===
加法: 15
减法: 5
乘法: 50
除法: 2

解释:把 export 放在函数前面,就等于给这个函数贴了个标签,可以被其他文件用 import 领走。


项目 2(15 分钟):「个人支出记录」—— 读 JSON 数据 + 分类统计

场景:读取一份支出记录 JSON,按类别汇总金额。

准备数据文件 expenses.json

[
{ "item": "午餐", "category": "餐饮", "amount": 35 },
{ "item": "电影票", "category": "娱乐", "amount": 60 },
{ "item": "地铁", "category": "交通", "amount": 5 },
{ "item": "咖啡", "category": "餐饮", "amount": 28 },
{ "item": "演唱会", "category": "娱乐", "amount": 380 },
{ "item": "公交车", "category": "交通", "amount": 2 }
]

完整可运行代码

// stats.mjs
import { readFile } from 'fs/promises';

// 加载 JSON 数据
async function loadExpenses() {
const raw = await readFile('expenses.json', 'utf-8');
return JSON.parse(raw);
}

// 按类别汇总
function summarizeByCategory(expenses) {
const summary = {};
for (const expense of expenses) {
    const { category, amount } = expense;
    if (!summary[category]) {
        summary[category] = 0;
    }
    summary[category] += amount;
}
return summary;
}

// 找最大支出
function findMaxExpense(expenses) {
let max = expenses[0];
for (const expense of expenses) {
    if (expense.amount > max.amount) {
        max = expense;
    }
}
return max;
}

// 导出工具函数
export { loadExpenses, summarizeByCategory, findMaxExpense };
// main.mjs
import { loadExpenses, summarizeByCategory, findMaxExpense } from './stats.mjs';

async function main() {
console.log('=== 个人支出记录 ===\n');

// 加载数据
const expenses = await loadExpenses();
console.log('原始记录:', expenses.length, '条\n');

// 分类汇总
const summary = summarizeByCategory(expenses);
console.log('【分类汇总】');
for (const [category, total] of Object.entries(summary)) {
    console.log(`  ${category}:${total} 元`);
}

// 找最大支出
const max = findMaxExpense(expenses);
console.log(`\n【最大支出】${max.item}(${max.category}):${max.amount} 元`);
}

main();

预期输出

=== 个人支出记录 ===

原始记录:6 条

【分类汇总】
餐饮:63 元
娱乐:440 元
交通:7 元

【最大支出】演唱会(娱乐):380 元

解释:先读取 JSON 文件(readFile),然后用循环按类别累加金额(for...of + 对象属性),最后找出最大值。


项目 3(15 分钟):「待办清单 CLI 工具」—— 文件持久化 + 增删查

场景:做一个命令行待办清单,可以添加、查看、删除任务,数据存在本地文件。

完整可运行代码

// todo.mjs
import { readFile, writeFile } from 'fs/promises';

const FILE_NAME = 'todos.json';

// 读取所有任务
async function getTodos() {
try {
    const data = await readFile(FILE_NAME, 'utf-8');
    return JSON.parse(data);
} catch {
    // 文件不存在,返回空列表
    return [];
}
}

// 保存所有任务
async function saveTodos(todos) {
await writeFile(FILE_NAME, JSON.stringify(todos, null, 2));
}

// 添加任务
async function addTodo(task) {
const todos = await getTodos();
const newTodo = {
    id: Date.now(),
    task,
    done: false,
    createdAt: new Date().toLocaleString('zh-CN')
};
todos.push(newTodo);
await saveTodos(todos);
console.log(`✅ 已添加:「${task}」`);
return newTodo;
}

// 查看任务
async function listTodos() {
const todos = await getTodos();
if (todos.length === 0) {
    console.log('📝 暂无任务,休息一下吧~');
    return;
}
console.log('=== 待办清单 ===');
todos.forEach((todo, index) => {
    const status = todo.done ? '✅' : '⬜';
    const line = todo.done ? '已完成' : '待办';
    console.log(`${status} ${index + 1}. ${todo.task} [${line}]`);
});
}

// 删除任务
async function deleteTodo(id) {
const todos = await getTodos();
const index = todos.findIndex(t => t.id === Number(id));
if (index === -1) {
    console.log('❌ 找不到这个任务');
    return;
}
const [removed] = todos.splice(index, 1);
await saveTodos(todos);
console.log(`🗑️ 已删除:「${removed.task}」`);
}

// 标记完成
async function completeTodo(id) {
const todos = await getTodos();
const todo = todos.find(t => t.id === Number(id));
if (!todo) {
    console.log('❌ 找不到这个任务');
    return;
}
todo.done = true;
await saveTodos(todos);
console.log(`✅ 已完成:「${todo.task}」`);
}

// 导出所有函数
export { addTodo, listTodos, deleteTodo, completeTodo };
// main.mjs
import { addTodo, listTodos, deleteTodo, completeTodo } from './todo.mjs';

// 简单的命令行界面
const args = process.argv.slice(2);
const command = args[0];

switch (command) {
case 'add':
    // node main.mjs add "买牛奶"
    const task = args.slice(1).join(' ');
    addTodo(task);
    break;
case 'list':
    // node main.mjs list
    listTodos();
    break;
case 'done':
    // node main.mjs done <id>
    const idToComplete = args[1];
    completeTodo(idToComplete);
    break;
case 'delete':
    // node main.mjs delete <id>
    const idToDelete = args[1];
    deleteTodo(idToDelete);
    break;
default:
    console.log('用法:');
    console.log('  node main.mjs add "任务内容"   - 添加任务');
    console.log('  node main.mjs list            - 查看任务');
    console.log('  node main.mjs done <id>       - 标记完成');
    console.log('  node main.mjs delete <id>     - 删除任务');
}

预期输出(依次执行命令):

$ node main.mjs add "买牛奶"
✅ 已添加:「买牛奶」

$ node main.mjs add "写周报"
✅ 已添加:「写周报」

$ node main.mjs list
=== 待办清单 ===
⬜ 1. 买牛奶 [待办]
⬜ 2. 写周报 [待办]

$ node main.mjs done 1
✅ 已完成:「买牛奶」

$ node main.mjs list
=== 待办清单 ===
✅ 1. 买牛奶 [已完成]
⬜ 2. 写周报 [待办]

解释:数据存在 todos.json 文件里,每次操作先读文件、再修改、最后写回去。Date.now() 生成唯一 ID,findIndex 找到要删除的任务。


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

坑 1:忘了写文件扩展名

// ❌ 错误:Node.js ESM 必须写扩展名
import { add } from './math';

// ✅ 正确:写上 .js
import { add } from './math.js';

原因:CommonJS 的 require 会自动补全,但 ESM 必须显式写出扩展名。

坑 2:同时用 exportmodule.exports

// ❌ 错误:同一个文件不能混用
export const name = '小明';
module.exports = { age: 20 };

// ✅ 正确:只用一种方式
export const name = '小明';

坑 3:在 ESM 里用 __dirname 报错

// ❌ 错误:ESM 没有这两个全局变量
console.log(__dirname);
console.log(__filename);

// ✅ 正确:用 import.meta
console.log(import.meta.url);
// 如果需要 __dirname 的效果,这样转换:
import { fileURLToPath } from 'url';
import { dirname } from 'path';

const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);

坑 4:循环引用导致变量是 undefined

// a.mjs
import { b } from './b.mjs';
export const a = '我是 A';
console.log(b);  // undefined!(因为 b 还没执行完)
// b.mjs
import { a } from './a.mjs';
export const b = '我是 B';

原因:ESM 会先「注册」所有 import,但执行是从入口文件开始往下走。循环引用时,被引用的模块还没执行完。

解决方案:把循环引用拆开,或者把共享的代码提取到第三个文件。

坑 5:默认导出和命名导出混用导致困惑

// greeter.mjs
export default function hello() { return '你好'; }
export function hi() { return 'hi'; }

// main.mjs
import hello, { hi } from './greeter.mjs';  // ✅ ok
import { hello, hi } from './greeter.mjs';  // ❌ hello 是 undefined!

原因export default 的东西不会进入命名导出列表,必须单独 import。

调试技巧:用 console.log 配合 -r 检查模块加载

// 在模块开头加日志,看它什么时候被加载
console.log('=== 模块 A 开始加载 ===');

export const name = '小明';
console.log('=== 模块 A 加载完成 ===');

运行:

node --inspect main.mjs

然后在 Chrome 浏览器打开 chrome://inspect,点击 "Open dedicated DevTools for Node" 可以断点调试 ESM。


✏️ 练习题 + 作业题

练习 1(2 分钟):改名导入

import { add } from './math.js' 改成导入 multiply,并打印 multiply(3, 4) 的结果。

  • 输入:(直接运行代码)
  • 预期输出:12
  • 提示:import 后面的名字改成 multiply

练习 2(2 分钟):加个取模运算

calculator.mjs 里加一个 mod 函数(取模),导出它,然后在 main.mjs 里调用 mod(10, 3),输出 1

  • 输入:(直接运行代码)
  • 预期输出:1
  • 提示:取模用 % 运算符

练习 3(3 分钟):新增一条支出

expenses.json 里加一条你自己的支出记录(如「书本」-「学习」- 50 元),重新运行 main.mjs,看汇总结果变了没有。

  • 输入:添加一条 { "item": "书本", "category": "学习", "amount": 50 }
  • 预期输出:能看到「学习:50 元」这一行
  • 提示:JSON 格式注意逗号分隔

练习 4(3 分钟):串联两个模块

新建一个 report.mjs,用 stats.mjs 的函数打印一份「支出报告」,包含分类汇总和最大支出。

  • 输入:调用 loadExpensessummarizeByCategoryfindMaxExpense
  • 预期输出:类似项目 2 的完整报告
  • 提示:report.mjs 导入 stats.mjs,在 report.mjs 里写 main() 调用

练习 5(5 分钟):分析报错截图

假设运行 node main.mjs list 时出现这个错误:

SyntaxError: Cannot use import statement outside a module
  • 分析:这是什么问题?
  • 修复方法是什么?
  • 预期输出:修复后能正常列出待办
  • 提示:检查 package.json 是否有 "type": "module",或者文件名是否用了 .mjs

作业:做一个「第 1 章 1.4 ES Modules 实战工具」

需求描述:做一个「个人账本 CLI」,可以记录收入和支出,查看余额统计。

功能点
1. add <类型> <金额> <备注> - 添加一笔账目(如 add 支出 50 午餐
2. list - 查看所有账目
3. balance - 计算当前余额(收入 - 支出)
4. delete <id> - 删除某笔账目

加分项
1. 用 import 把功能拆到多个文件(至少 2 个模块文件)
2. 支持按月份筛选账目

验收标准
- 能跑起来(node main.mjs add 支出 100 测试
- 输出符合预期
- 代码有注释(每個函数干什么的)

提交方式:评论区贴代码或 GitHub 链接


📚 总结 + 资源

一句话总结:ES Modules = import 拿进来 + export 交出去,让代码模块化像搭积木一样清晰。

延伸学习
1. Node.js 官方 ESM 文档 —— 权威参考,有细节
2. 《JavaScript 高级程序设计》第 10 章 —— 系统讲解模块化前世今生
3. ESM 时代的模块化指南 —— 浏览器端也适用


互动钩子:你现在的项目用的是 CommonJS 还是 ESM?有没有遇到「循环引用」或者「混用报错」的坑?评论区聊聊,老粉优先回复!

📌 预告:下一章我们学「npm 与 package.json」,学会之后你就能:安装第三方包、管理项目依赖、打包发布自己的工具——npm 就是程序员的「应用商店」,敬请期待!

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