模块化
把代码拆成积木块,想用哪里用哪里
🎯 学习目标
学完本章,你将能够:
- 能够解释模块化的价值,理解早期方案(IIFE、AMD、CMD)的局限
- 能够使用 CommonJS 的 module.exports 和 require 进行模块导入导出
- 能够使用 ESM 的 export 和 import 语法,区分命名导出和默认导出
- 能够解释 CJS 与 ESM 的核心差异(值拷贝 vs 引用绑定、同步 vs 异步加载)
- 能够使用动态 import() 实现按需加载,优化应用性能
当项目越来越大
一个 HTML 文件塞 5000 行代码,谁来维护?
<!-- all-in-one.html -->
<script>
// 用户管理模块
let users = [];
function addUser() { /* 200 行 */ }
function deleteUser() { /* 150 行 */ }
// 订单管理模块
let orders = [];
function createOrder() { /* 300 行 */ }
function cancelOrder() { /* 200 行 */ }
// 啊——两个模块都用了 getTotal 变量名,冲突了!
</script>
所有代码堆在一个文件里,变量名冲突、难以复用、改一处动全身。模块化就是解决这个问题的方案。
模块化把代码拆分成独立文件,每个文件有自己的作用域,通过 导入/导出 机制互相通信。
Part 1: 为什么模块化
在学具体语法之前,先理解模块化解决了哪些问题
开场那个 5000 行的 HTML 文件,变量名冲突只是最表层的问题。真正的痛点是:代码之间互相纠缠、改一处牵动全身、团队协作时谁也不敢动别人的代码。模块化的核心思想很简单——每个文件是独立王国,想用别人的东西必须显式声明。先搞清楚"为什么",后面学 import/export 才有方向感。
模块化的价值
五个核心好处
命名隔离
不同文件的同名变量互不干扰。
依赖清晰
模块间的依赖关系一目了然。
代码复用
一个模块可以被多处引用。
按需加载
只加载需要的代码,提升性能。
团队协作
关注点分离,并行开发不冲突。
早期方案
IIFE——用闭包模拟模块
在模块系统出现之前,开发者用立即执行函数表达式(IIFE)来创建独立作用域。
// IIFE 模式——最原始的"模块"
const MyModule = (function() {
let count = 0; // 私有变量
function increment() { count++; }
return {
increment,
getCount: () => count
};
})();
MyModule.increment();
console.log(MyModule.getCount()); // 1
// console.log(count); // ReferenceError——外部访问不到
它能解决命名冲突,但无法声明依赖关系,也不能按需加载。所以后来出现了真正的模块系统。
Part 2: CommonJS
CommonJS 是 Node.js 的默认模块系统,2009 年诞生至今仍在广泛使用
理解了模块化的动机,来看第一个真正意义上的模块系统。CommonJS 用 require() 导入、module.exports 导出,语法简单直观。它是同步加载的,所以在服务端完全没问题。你接触的绝大多数 Node.js 项目都在用它。这节掌握 CJS 的基本用法和特点,为后面对比 ESM 打好基础。
CommonJS
require 与 module.exports
// math.js —— 导出(二选一,不要混用)
// 方式一:整体赋值 module.exports(推荐)
module.exports = {
add: (a, b) => a + b,
subtract: (a, b) => a - b,
multiply: (a, b) => a * b
};
// 方式二:逐个挂到 exports 上(两者等价,exports 初始就指向 module.exports)
// exports.add = (a, b) => a + b;
// exports.subtract = (a, b) => a - b;
// app.js —— 导入
const { add, subtract } = require('./math');
const math = require('./math');
console.log(add(1, 2)); // 3
console.log(math.multiply(3, 4)); // 12
module.exports = {...} 和 exports.xxx = ... 不能混用。因为 exports 只是 module.exports 的别名(初始指向同一对象),一旦你写 module.exports = { 新对象 },exports 还指向旧对象,之后 exports.foo = ... 加的属性会被静默丢弃。牢记:整赋值用 module.exports,逐个加用 exports,二选一。
CommonJS 的特点:
require()是同步加载- 运行时加载——代码执行时才解析依赖
- 导出的是值的拷贝(基本类型)或引用(对象)
Part 3: ESM
ES Module 是 JavaScript 官方的模块标准,前端和 Node.js 的推荐方案
CommonJS 有个致命问题:它是运行时加载,打包工具没法分析哪些代码实际用到了、哪些可以删掉。ESM 是编译时解析的,静态结构让 Tree-shaking 成为可能。import/export 语法也更优雅。这节学 ESM 的命名导出、默认导出,以及和 CJS 的关键区别——值的实时绑定。
命名导出
export 什么,import 什么
// utils.js —— 命名导出
export const PI = 3.14;
export function add(a, b) { return a + b; }
export class Calculator { /* ... */ }
// 或者统一导出
const PI = 3.14;
function add(a, b) { return a + b; }
export { PI, add };
// 导入
import { PI, add } from './utils.js';
import { PI as pi, add as sum } from './utils.js'; // 重命名
import * as utils from './utils.js'; // 全部导入
console.log(utils.PI);
默认导出
一个模块只有一个"主角"
// User.js —— 默认导出(每个模块只能有一个)
export default class User {
constructor(name) { this.name = name; }
}
// 导入默认导出——不需要花括号
import User from './User.js';
// 混合使用
export const BASE_URL = "/api";
export function get(url) { return fetch(BASE_URL + url); }
export default { get };
// 导入混合
import api, { BASE_URL } from './api.js';
命名导出可以有很多个,用花括号导入。默认导出只有一个,直接导入。两者可以混合使用。
CJS 与 ESM 对比
一张表看清两种模块系统
| 特性 | CommonJS | ESM |
|---|---|---|
| 导出 | module.exports / exports | export / export default |
| 导入 | require() | import |
| 加载时机 | 运行时、同步 | 编译时、异步 |
| 值的类型 | 值的拷贝 | 值的实时绑定 |
| 顶层 this | module.exports | undefined |
| 循环引用 | 可能拿到不完整模块 | 引用绑定,能获取最新值 |
| Tree-shaking | 不支持 | 支持 |
值拷贝与绑定
两者的核心区别在"变不变"
// CommonJS —— 导出值的拷贝
// counter.js
let count = 0;
function increment() { count++; }
module.exports = { count, increment };
// app.js
const { count, increment } = require('./counter');
increment();
console.log(count); // 0(拷贝,不会跟着变)
// ESM —— 导出值的实时绑定
// counter.mjs
export let count = 0;
export function increment() { count++; }
// app.mjs
import { count, increment } from './counter.mjs';
increment();
console.log(count); // 1(实时绑定,跟着变了!)
"type": "module" 启用 ESM。如果需要兼容旧库,CommonJS 仍然可用。
加载流程对比
同步加载 vs 异步解析

图:CommonJS 同步加载与 ESM 异步解析的流程对比
【AI 生图提示词】请生成一张中文技术示意图,风格简洁、配色柔和、适合教学: - 画面主体:左右两列并排对比流程图。左列标题"CommonJS(同步加载)",右列标题"ESM(异步解析)"。每列包含 5-6 个纵向排列的圆角矩形节点,节点之间用箭头连接。 - 左列(CommonJS)流程节点依次为:遇到 require() → 同步读取文件(橙色高亮) → 执行模块代码 → 返回 module.exports → 继续执行后续代码。整体用实线箭头表示同步阻塞,箭头旁边标注"阻塞等待"。 - 右列(ESM)流程节点依次为:编译阶段 → 解析所有 import(绿色高亮) → 构建依赖图 → 异步加载所有模块 → 执行模块代码 → 建立实时绑定。用虚线箭头表示异步非阻塞,箭头旁边标注"非阻塞"。 - 各元素关系:两列之间用一条竖向虚线分隔,顶部标注"同步 vs 异步"。左列整体色调偏暖(橙/琥珀色系),右列整体色调偏冷(绿/青色系),形成视觉对比。 - 关键标注:在 CommonJS 的"同步读取文件"节点旁标注"阻塞主线程";在 ESM 的"构建依赖图"节点旁标注"静态分析,支持 Tree-shaking"。两个流程的起点和终点分别用不同形状区分(起点为圆角矩形,终点为双线边框矩形)。 - 配色建议:浅蓝/浅灰背景,深色文字,CommonJS 关键箭头用橙色标注,ESM 关键箭头用绿色标注 - 画面比例:16:9 - 避免:过度装饰、3D 效果、杂乱的颜色
Part 4: 动态导入
静态 import 编译时就确定了依赖,但有时你想运行时按条件加载
用户点了按钮才需要图表库,暗色模式才需要加载主题文件——这些场景下,静态 import 一开始就把所有代码拉下来太浪费了。import() 返回一个 Promise,什么时候需要什么时候加载。它是实现代码分割和懒加载的基础,CJS 和 ESM 通用。
动态 import()
按需加载,提升首屏速度
// 按需加载——用户点了再加载
button.addEventListener('click', async () => {
const { Chart } = await import('./chart.js');
new Chart(canvas, config);
});
// 条件加载
if (isDarkMode) {
await import('./dark-theme.js');
} else {
await import('./light-theme.js');
}
// 动态路由
async function loadPage(route) {
const module = await import(`./pages/${route}.js`);
return module.default;
}
动态导入返回一个 Promise。它既是 CommonJS 和 ESM 通用的,又能实现代码分割和懒加载。
Part 5: 浏览器中使用
ESM 不只是 Node.js 的专利,浏览器也原生支持了
学了这么多模块语法,在浏览器里到底怎么跑起来?只需要 <script type="module"> 一行。模块脚本默认严格模式、默认异步加载、默认独立作用域——以前要用 Webpack 才能实现的效果,现在浏览器原生支持。再看 Node.js 的 package.json 怎么配置 type 字段,打通两个环境。
浏览器中的 ESM
script type="module"
<!-- 外部模块 -->
<script type="module" src="./app.js"></script>
<!-- 内联模块 -->
<script type="module">
import { greet } from './utils.js';
greet('World');
</script>
ESM 模块在浏览器中的行为特点:
- 默认严格模式——不需要写
"use strict" - 独立作用域——顶层变量不会污染全局
- 只执行一次——同一模块多次导入只执行一次
- 异步加载——相当于默认
defer - CORS 限制——跨域模块需要服务器设置正确的 CORS 头
Node.js 配置
package.json 中的 type 字段
// package.json
{
"type": "module" // .js 文件被视为 ESM
}
{
"type": "commonjs" // .js 文件被视为 CommonJS(默认)
}
| 扩展名 | type: "commonjs" | type: "module" |
|---|---|---|
.js | CommonJS | ESM |
.mjs | ESM | ESM |
.cjs | CommonJS | CommonJS |
用 .mjs 和 .cjs 扩展名可以覆盖 type 的设置,明确告诉 Node.js 用哪种模块系统。
常见模块错误
import { x } from './utils' 在浏览器中会报错,必须写 './utils.js'。
问题一:require 和 import 混用
// ESM 文件中不能用 require
import fs from 'fs'; // 正确
const fs = require('fs'); // 在 ESM 中报错!
// CommonJS 中不能用 import(不用 Babel 的话)
const fs = require('fs'); // 正确
import fs from 'fs'; // 在 CommonJS 中报错!
问题二:循环依赖
// a.js
const { b } = require('./b'); // b 还没执行完
exports.a = 1;
// b.js
const { a } = require('./a'); // 拿到不完整的 a
exports.b = 2;
// 解决:重构代码,消除循环依赖
// 或者把共享逻辑提取到第三个模块
Re-export
用 re-export 组织模块的导出
export { x } from './module.js' 语法可以从另一个模块中转导出,无需先 import 再 export:
// 转发导出——不用自己实现,只是"传一手"
export { add, subtract } from './math.js';
export { default as User } from './User.js';
export * from './utils.js'; // 转发全部命名导出
大型项目常用 barrel file(桶文件)模式,用一个 index.js 统一汇总导出:
// validators/index.js —— barrel file
export { validateEmail } from './email.js';
export { validatePhone } from './phone.js';
export { validateIdCard } from './id-card.js';
export { default as Validator } from './core.js';
// 使用时只需引入一个路径
import { validateEmail, Validator } from './validators/index.js';
export * from 不会转发其他模块的 default 导出。如果需要转发默认导出,必须显式写 export { default } from './module.js'。
条件导出
package.json 的 exports 字段
exports 字段让包作者精确控制哪些文件可以被外部导入,以及在不同环境下提供不同的入口:
{
"name": "my-lib",
"exports": {
".": {
"import": "./dist/index.mjs",
"require": "./dist/index.cjs",
"browser": "./dist/browser.js"
},
"./utils": "./dist/utils.js",
"./plugins/*": "./dist/plugins/*.js"
}
}
使用者导入时,Node.js 会根据当前环境自动匹配对应入口:
import { hello } from 'my-lib'; // 匹配 "."
import { debounce } from 'my-lib/utils'; // 匹配 "./utils"
没有列在 exports 中的路径无法被导入——这是模块的"黑名单"机制。例如 require('my-lib/internal/helper') 会报错 ERR_PACKAGE_PATH_NOT_EXPORTED。
exports 后,main 字段会被忽略。子路径必须用 "./xxx" 格式(以 ./ 开头),不能省略。
试一试
练习一
任务:创建两个文件。在 math.js 中导出 add、subtract、multiply 函数,在 main.js 中导入并使用。
// math.js
// 你的代码:
// main.js
// 你的代码:
参考答案
// math.js
export function add(a, b) { return a + b; }
export function subtract(a, b) { return a - b; }
export function multiply(a, b) { return a * b; }
// main.js
import { add, subtract, multiply } from './math.js';
console.log(add(2, 3)); // 5
console.log(subtract(10, 4)); // 6
console.log(multiply(3, 7)); // 21
练习二
任务:实现一个按钮点击后动态加载模块的示例。heavy-module.js 导出一个 process() 函数,只在按钮点击时才加载。
// 提示:用 import() 动态导入
// 你的代码:
参考答案
// heavy-module.js
export function process(data) {
console.log("处理数据:", data);
return data.toUpperCase();
}
// main.js
document.getElementById('btn').addEventListener('click', async () => {
const { process } = await import('./heavy-module.js');
const result = process("hello");
console.log(result);
});
进阶练习
练习三
任务:创建 cache.js 模块,导出 createCache() 函数。该函数返回一个对象,包含 get(key)、set(key, value, ttlMs?)、has(key)、clear() 方法,内部数据对外不可见。然后创建 main.js,用命名导出和默认导出混合的方式导入 cache,并实现带 5 秒过期的缓存。
// cache.js —— 你的代码:
// 提示:用闭包隐藏数据,用 setTimeout 或时间戳实现过期
// main.js —— 你的代码:
// 提示:混合导入 createCache 和其他导出
参考答案
// cache.js
const DEFAULT_TTL = 5000; // 默认 5 秒过期
export const DEFAULT_EXPIRY = DEFAULT_TTL;
export default function createCache() {
const store = new Map(); // 外部无法直接访问
const timers = new Map();
return {
set(key, value, ttlMs = DEFAULT_TTL) {
if (timers.has(key)) clearTimeout(timers.get(key));
store.set(key, value);
const id = setTimeout(() => store.delete(key), ttlMs);
timers.set(key, id);
},
get(key) { return store.get(key); },
has(key) { return store.has(key); },
clear() {
for (const id of timers.values()) clearTimeout(id);
store.clear();
timers.clear();
}
};
}
// main.js
import createCache, { DEFAULT_EXPIRY } from './cache.js';
const cache = createCache();
cache.set('user', { name: 'Alice' });
cache.set('token', 'abc123', 3000); // 3 秒过期
console.log(cache.get('user')); // { name: 'Alice' }
console.log(cache.has('token')); // true
setTimeout(() => {
console.log(cache.has('token')); // false(已过期)
console.log(cache.get('user')); // { name: 'Alice' }(还在)
}, 4000);
setTimeout(() => {
console.log(cache.has('user')); // false(5 秒到期)
}, 6000);
📝 要点总结
本章核心知识点
模块化价值
解决命名冲突、依赖管理和代码复用
CommonJS
Node.js 传统模块系统,require/module.exports
ESM
现代标准,export/import,支持静态分析
核心差异
CJS 值拷贝同步加载,ESM 引用绑定异步加载
动态导入
import() 按需加载,优化首屏性能
下一章
模块化让你能把代码拆成清晰的积木块。下一章我们走进浏览器,学习 浏览器环境中的 JavaScript——DOM、事件、BOM。