JavaScript 模块化详解

小飞兽 JavaScript 279 次阅读 2026-07-16

JavaScript 模块化详解

一、Introduction(引言)

随着前端应用规模的急剧增长,代码组织能力成为衡量工程化水平的关键指标。模块化(Modularization)是一种将复杂系统拆分为独立、可复用、可组合单元的软件设计原则。在 JavaScript 历史上,由于语言本身早期缺乏原生模块支持,社区经历了从全局变量混乱到各种变通方案(IIFE、命名空间模式),再到 AMD、CMD 规范,最终走向 ES Module(ESM)统一标准的漫长演进过程。理解模块化的前世今生,不仅能帮助我们读懂存量代码,更能在现代前端项目中做出正确的技术选型。本文将系统梳理 JavaScript 模块化的核心概念、语法规范、常见模块化方案,以及在实际项目中的应用实践。

二、基础语法

2.1 IIFE 与命名空间模式(早期的模块化变通)

在 ES Module 出现之前,开发者使用 IIFE(立即执行函数表达式)来创建私有作用域,避免全局污染:

const MyModule = (function() {
    let _privateVar = '私有变量';
    function _privateFunc() {
        return '私有方法';
    }
    return {
        publicMethod: function() {
            return _privateFunc();
        },
        publicProp: '公有属性'
    };
})();

console.log(MyModule.publicMethod()); // 私有方法
console.log(MyModule._privateVar); // undefined(外部无法访问)

2.2 ES Module(import/export)

ES Module 是 ECMAScript 2015(ES6)引入的官方模块系统,使用 importexport 关键字。

// math.js — 命名导出(Named Exports)
export const PI = 3.14159;
export function add(a, b) {
    return a + b;
}
export default class Calculator {
    // ...
}
// main.js — 导入方式
// 导入命名导出(需要花括号)
import { PI, add } from './math.js';

// 导入 default 导出
import Calculator from './math.js';

// 导入所有内容为对象
import * as math from './math.js';
console.log(math.PI); // 3.14159

// 动态导入
const module = await import('./math.js');

// 重命名导入
import { add as addNumbers } from './math.js';

2.3 CommonJS(Node.js 环境)

CommonJS 是 Node.js 采用的模块规范,使用 module.exports 导出和 require() 导入:

// utils.js
module.exports = {
    name: 'utils',
    version: '1.0.0'
};

// 或者使用 exports 语法糖
exports.multiply = function(a, b) { return a * b; };

// main.js
const utils = require('./utils');
console.log(utils.name); // utils

2.4 模块的加载与执行机制

ES Module 的几个关键特性:

    • 静态分析import 必须写在模块顶层,不能在条件语句中使用,这使得打包工具可以在编译阶段进行 Tree Shaking
    • 单例模式:每个模块实例在内存中只存在一份,多次 import 同一个模块获取的是同一引用
    • 绑定而非复制:import 进来的是对原模块输出的只读绑定,而非值的拷贝
    • 异步加载:浏览器中 ES Module 默认使用 defer 属性异步加载,不会阻塞 HTML 解析
    // counter.js
    export let count = 0;
    export function increment() {
        count++;
    }
    

    // consumer.js
    import { count, increment } from './counter.js';
    import { count as c2 } from './counter.js';

    console.log(count); // 0
    console.log(c2); // 0(与 count 指向同一绑定)

    increment();
    console.log(count); // 1
    console.log(c2); // 1(绑定同步变化)

    三、代码示例

    3.1 构建一个完整的 ES Module 项目结构

    // project/
    // ├── src/
    // │   ├── utils/
    // │   │   ├── index.js        — utils 模块统一出口
    // │   │   ├── format.js       — 格式化工具函数
    // │   │   └── validate.js     — 校验工具函数
    // │   ├── services/
    // │   │   └── api.js          — API 封装
    // │   ├── main.js             — 入口文件
    // │   └── config.js           — 配置常量
    

    // config.js
    export const API_BASE = 'https://api.example.com';
    export const TIMEOUT = 5000;

    // utils/format.js
    export function formatDate(date) {
    return new Intl.DateTimeFormat('zh-CN').format(date);
    }

    // utils/validate.js
    export function isEmail(email) {
    return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
    }

    // utils/index.js — 统一出口,方便外部引用
    export { formatDate } from './format.js';
    export { isEmail } from './validate.js';

    // services/api.js
    import { API_BASE, TIMEOUT } from '../config.js';
    import { formatDate } from '../utils/index.js';

    export async function fetchUserInfo(userId) {
    const controller = new AbortController();
    const timeoutId = setTimeout(() => controller.abort(), TIMEOUT);

    try {
    const response = await fetch(${API_BASE}/users/${userId}, {
    signal: controller.signal
    });
    clearTimeout(timeoutId);
    return await response.json();
    } catch (err) {
    clearTimeout(timeoutId);
    throw err;
    }
    }

    // main.js — 入口
    import { formatDate, isEmail } from './utils/index.js';
    import { fetchUserInfo } from './services/api.js';
    import { API_BASE } from './config.js';

    const user = await fetchUserInfo(123);
    console.log(用户 ${user.name} 注册于 ${formatDate(new Date(user.createdAt))});

    3.2 CommonJS 与 ES Module 互操作(混用)

    // 在 ES Module 中使用 CommonJS 模块
    // 由于 CommonJS 模块没有 default export,import * as 会将整个 module.exports 作为 default 导出
    import crypto from 'crypto'; // Node.js 内置 CommonJS 模块
    

    console.log(crypto.createHash('md5').update('hello').digest('hex'));

    // 在 CommonJS 中使用 ES Module(需要 .mjs 或 "type": "module" 配置)
    // 不能直接在 CommonJS 中 import ES Module,但可以通过动态 import() 实现
    async function useESModule() {
        const { default: esModule } = await import('./es-module.mjs');
        esModule.run();
    }
    

    3.3 循环依赖与解决方案

    // a.js
    import { b } from './b.js';
    console.log('A 模块加载,b 的值:', b);
    export const a = 'A 导出值';
    

    // b.js
    import { a } from './a.js'; // 此时 a 还未完成初始化,为 undefined
    console.log('B 模块加载,a 的值:', a);
    export const b = 'B 导出值';

    // 执行结果(Node.js CommonJS):
    // B 模块加载,a 的值: undefined (循环依赖,a 尚未完成初始化)
    // A 模块加载,b 的值: B 导出值

    // 解决方案:使用函数封装延迟访问
    // a.js
    import { getB } from './b.js';
    export const a = 'A 导出值';
    export function useB() {
    return getB(); // 在真正需要时调用,避免初始化时访问未定义的值
    }

    3.4 前端模块化:使用 Vite 构建

    // vite.config.js
    import { defineConfig } from 'vite';
    import vue from '@vitejs/plugin-vue';
    

    export default defineConfig({
    plugins: [vue()],
    build: {
    target: 'esnext',
    rollupOptions: {
    output: {
    manualChunks: {
    'vendor': ['vue', 'vue-router'],
    'utils': ['lodash-es']
    }
    }
    }
    }
    });

    // package.json
    {
        "name": "my-app",
        "type": "module",   // 声明为 ES Module 项目
        "scripts": {
            "dev": "vite",
            "build": "vite build"
        }
    }
    

    四、运行效果

    4.1 模块加载顺序验证

    使用 Chrome DevTools 的 Network 面板查看 ES Module 的加载情况。在 HTML 中加入 <script type="module" src="..."> 后,可以观察到浏览器按依赖顺序(而非 script 标签顺序)并行加载各模块文件,并自动添加 CORS 头请求(因此服务器必须正确配置 Access-Control-Allow-Origin)。

    4.2 Tree Shaking 效果

    在生产环境构建后,打开打包后的 bundle 文件,若使用 Vite/Rollup,正确配置 ES Module 后,未使用的导出函数不会出现在最终产物中。可以通过在代码中加入 console.log 来验证:未被调用的工具函数不会触发任何输出。

    4.3 模块作用域隔离验证

    // module1.js
    export let counter = 0;
    export function inc() { counter++; }
    

    // module2.js
    import { counter, inc } from './module1.js';
    console.log(counter); // 0
    inc();
    console.log(counter); // 1

    // module3.js(同一模块实例)
    import { counter } from './module1.js';
    console.log(counter); // 1(与 module2 共享同一状态)

    五、常见问题

    Q1:ES Module 和 CommonJS 的主要区别是什么?

    核心区别在于加载机制:CommonJS 是同步的、运行时加载,适合 Node.js 服务端(文件 I/O 可控);ES Module 是静态的(编译时解析)、异步的,适合浏览器环境。此外,ES Module 输出的是绑定(只读引用),CommonJS 输出的是值的拷贝(但对对象引用仍是共享的)。

    Q2:为什么 import 必须写在顶层?

    这是 ES Module 静态分析设计的一部分。静态结构使得打包工具可以在不执行代码的情况下分析依赖图,从而实现 Tree Shaking、循环依赖检测、模块懒加载等优化。如果 import 出现在条件分支中,依赖关系将变得不可预测,优化空间大幅缩减。

    Q3:循环依赖怎么处理?

    无论是 ES Module 还是 CommonJS,循环依赖本身不是错误,但可能导致某模块在导入时访问到尚未初始化的值(undefined)。最佳实践是:尽量避免循环依赖(重构模块职责)、将需要对方模块的逻辑延迟到函数调用中(而非模块顶层直接使用),或使用 re-export 打破循环链。

    Q4:浏览器中 <script type="module"> 和普通 <script> 有什么区别?

    • 默认 defer:模块脚本自动延迟执行,如同添加了 defer 属性,顺序执行
    • CORS 要求:必须从相同来源或正确配置 CORS 的 URL 加载
    • 始终 strict mode:模块代码自动运行在严格模式下
    • 单例执行:同一模块多次引用只执行一次(普通脚本每次引用都执行)
    • this 指向 undefined:模块顶层 this 为 undefined(非 window)

    六、延伸阅读

    • Vite 官方文档https://vitejs.dev/ 了解现代前端如何利用 ES Module 实现极速开发体验
    • Rollup 官方文档https://rollupjs.org/ 了解 Tree Shaking 与 ES Module 的深度整合
  • 《JavaScript 模块化七日谈》 — 记录了从 CMD/AMD 到 ES Module 的完整演进历史