模块化

把代码拆成积木块,想用哪里用哪里

🎯 学习目标

学完本章,你将能够:

  • 能够解释模块化的价值,理解早期方案(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
CJS 最经典的坑: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 对比

一张表看清两种模块系统

特性CommonJSESM
导出module.exports / exportsexport / export default
导入require()import
加载时机运行时、同步编译时、异步
值的类型值的拷贝值的实时绑定
顶层 thismodule.exportsundefined
循环引用可能拿到不完整模块引用绑定,能获取最新值
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(实时绑定,跟着变了!)
选哪个? 新项目推荐 ESM。Node.js 通过 "type": "module" 启用 ESM。如果需要兼容旧库,CommonJS 仍然可用。

加载流程对比

同步加载 vs 异步解析

图:CommonJS 同步加载与 ESM 异步解析的流程对比

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"
.jsCommonJSESM
.mjsESMESM
.cjsCommonJSCommonJS

用 .mjs 和 .cjs 扩展名可以覆盖 type 的设置,明确告诉 Node.js 用哪种模块系统。

常见模块错误

ESM 的 import 路径必须写完整扩展名。 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。

1 / X