JavaScript 模块化演进:从 IIFE 到 ES Module 的完整历程

4 阅读5分钟

从早期的脚本标签加载,到现代的 ES Module,JavaScript 模块化走过了一条漫长而精彩的道路。理解这段历史,不仅能帮我们写出更好的代码,还能深入理解现代构建工具的工作原理。

前言:为什么需要模块化?

在模块化出现之前,我们通常会这样写代码:

var globalData = '我是全局变量';

function utility1() {
    // 可能修改全局变量
    globalData = '被修改了';
}

function utility2() {
    // 依赖utility1
    utility1();
    console.log(globalData);
}

这种写法在小型项目中尚可,但在大型项目中会带来严重的问题:

  1. 全局命名空间污染
  2. 依赖关系不明确
  3. 难以维护和测试
  4. 无法按需加载

模块化的演进历程

原始时期:Script 标签与全局命名空间

<!-- 1995-2009:简单的脚本加载 -->
<script src="jquery.js"></script>
<script src="utils.js"></script>
<script src="main.js"></script>

在这种脚本加载中,存在以下问题:

  1. 所有脚本共享全局作用域
  2. 脚本中的变量可能被其他脚本覆盖
  3. 依赖关系必须手动管理
  4. 如果utils.js依赖jquery.js,必须确保加载顺序

命名空间模式(Namespace Pattern)

// 2005年左右:使用对象作为命名空间
var MYAPP = MYAPP || {}; // 防止重复定义

MYAPP.utils = {
    trim: function(str) {
        return str.replace(/^\s+|\s+$/g, '');
    },
    formatDate: function(date) {
        // ...
    }
};

MYAPP.models = {
    User: function(name) {
        this.name = name;
    }
};

命名空间的优点

  1. 减少了全局变量数量
  2. 有一定的组织结构

命名空间的缺点

  1. 所有数据仍然是公开的
  2. 无法实现私有成员
  3. 依赖关系依然不明确

IIFE 模式(立即执行函数)

// 2008年左右:使用闭包实现模块化
var Module = (function() {
    // 私有变量
    var privateVar = '我是私有的';
    
    // 私有函数
    function privateMethod() {
        console.log(privateVar);
    }
    
    // 公有API
    return {
        publicMethod: function() {
            privateMethod();
            return '公共方法';
        },
        publicVar: '公共变量'
    };
})();

// 使用模块
Module.publicMethod(); // 可以访问
// Module.privateVar; // 报错:undefined
// Module.privateMethod(); // 报错:不是函数

IIFE 模式的优点

  1. 实现了真正的私有成员
  2. 避免了全局污染
  3. 代码更加安全

IIFE 模式的缺点

  1. 依赖管理仍需手动处理
  2. 无法实现按需加载
  3. 模块定义分散

IIFE 依赖注入

var Module = (function($, _) {
    // 使用依赖
    function init() {
        $('#app').hide();
        _.each([1, 2, 3], console.log);
    }
    
    return {
        init: init
    };
})(jQuery, _); // 依赖作为参数传入

IIFE 依赖注入的优点

  1. 依赖关系明确
  2. 可以替换依赖的实现

IIFE 依赖注入的缺点

  1. 依赖需要提前加载
  2. 依赖顺序必须正确

模块加载器的出现

随着 Web 应用越来越复杂,社区开始探索更先进的模块系统:

2009年:CommonJS(服务器端)

var fs = require('fs');
var _ = require('lodash');

exports.myFunction = function() {
    // ...
};

2011年:AMD(浏览器端,异步加载)

define(['jquery', 'lodash'], function($, _) {
    return {
        init: function() {
            // 使用$和_
        }
    };
});

2014年:UMD(通用模块定义:兼容CommonJS和AMD)

(function(root, factory) {
    if (typeof define === 'function' && define.amd) {
        // AMD
        define(['jquery'], factory);
    } else if (typeof exports === 'object') {
        // CommonJS
        module.exports = factory(require('jquery'));
    } else {
        // 浏览器全局变量
        root.myModule = factory(root.jQuery);
    }
}(this, function($) {
    // 模块代码
    return {
        // ...
    };
}));

2015年:ES Module(现代标准)

export const PI = 3.14159;
export function add(a, b) {
    return a + b;
}
export function multiply(a, b) {
    return a * b;
}

四大模块系统对比

特性CommonJSAMDUMDES Module
环境服务器端(Node.js)浏览器端通用现代浏览器/Node.js
加载方式同步加载异步加载根据环境决定静态/动态加载
语法require() / exportsdefine() / require()条件判断多种语法import / export
时机运行时运行时运行时编译时(静态)
Tree Shaking不支持不支持不支持支持
静态分析困难困难困难容易

Tree Shaking 深度解析

什么是 Tree Shaking?

Tree Shaking 是一种通过静态分析从代码中移除未使用代码(死代码)的技术。这个名字源于摇动树木,让枯叶(未使用的代码)掉落。

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

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

export function unusedFunction() {
    console.log('这个函数从未被使用');
    return '无用';
}

// app.js
import { add } from './math.js';

console.log(add(2, 3)); // 只用到了add

// 打包后,multiply和unusedFunction可以被移除

Tree Shaking 的实现条件

1. 使用 ES Module 语法

// ✅ 使用 ES Module 语法可以被Tree Shaking
export function used() { return '使用'; }
export function unused() { return '未使用'; }

// ❌ CommonJS 难以Tree Shaking
module.exports = {
    used: function() { return '使用'; },
    unused: function() { return '未使用'; }
};

2. 无副作用/纯函数(Pure Function)

// ✅ 纯函数,可以安全移除
export const PI = 3.14159;
export function square(x) { return x * x; }

// ⚠️ 有副作用,需要小心处理
export function logMessage(msg) {
    console.log(msg); // 副作用:控制台输出
    return msg;
}

3. 静态导入

// ✅ 静态导入可以被分析
import { add } from './math.js';

// ❌ 动态导入难以分析
const moduleName = './math.js';
import(moduleName).then(module => {
    // 运行时才知道使用什么
});

4. 模块级别的分析

// ✅ 整个模块可以被分析
export { add, multiply } from './math.js';

5. 使用工具标记

// package.json 中的 sideEffects 字段
{
    "name": "my-package",
    "sideEffects": false, // 整个包都无副作用
    "sideEffects": [      // 或指定有副作用的文件
        "*.css",
        "src/polyfills.js"
    ]
}

Tree Shaking 原理详解

// math-complex.js
export function add(a, b) {           // 被使用
    return a + b;
}

export function multiply(a, b) {      // 被使用
    return a * b;
}

export function divide(a, b) {        // 未被使用
    return a / b;
}

export const PI = 3.14159;            // 被使用

export const UNUSED_CONST = '未使用'; // 未被使用

// 副作用代码
export function init() {              // 有副作用,但未被调用
    console.log('初始化');
    window.MATH = { version: '1.0' };
}

// 默认导出(可能有副作用)
export default function() {
    console.log('默认导出');
}

// app.js
import { add, PI } from './math-complex.js';
import mathDefault from './math-complex.js';

console.log(add(2, 3), PI);
mathDefault();

以上述代码为例,Tree Shaking 步骤流程如下:

  1. 源代码分析

  2. 构建依赖图

    app.js
        ├── 导入: add (来自math-complex.js)
        ├── 导入: PI (来自math-complex.js)
        └── 导入: default (来自math-complex.js)
        
        math-complex.js
        ├── 导出: add ✓ (被使用)
        ├── 导出: multiply ✗ (未使用)
        ├── 导出: divide ✗ (未使用)
        ├── 导出: PI ✓ (被使用)
        ├── 导出: UNUSED_CONST ✗ (未使用)
        ├── 导出: init ✗ (未使用,但有副作用)
        └── 导出: default ✓ (被使用)
    
  3. 标记活跃代码:从入口开始,标记所有可达的代码

  4. 消除死代码:移除未被标记的代码

  5. 最终打包结果包含:

    • add 函数
    • PI 常量
    • 默认导出函数
    • 不包含:multiply, divide, UNUSED_CONST, init

注:默认导出即使内部有console.log,但因为被调用了,所以需要保留。 init函数有副作用但未被调用,理论上可以移除,但需要小心。

模块化的未来

模块联邦(Module Federation)

Webpack 5 引入的模块联邦,允许在多个独立构建的应用间共享模块:

// app1/webpack.config.js - 提供者(Host)
const { ModuleFederationPlugin } = require('webpack').container;

module.exports = {
    plugins: [
        new ModuleFederationPlugin({
            name: 'app1',
            filename: 'remoteEntry.js', // 远程入口文件
            exposes: {
                './Button': './src/components/Button.jsx',
                './utils': './src/utils/index.js'
            },
            shared: {
                react: { singleton: true },
                'react-dom': { singleton: true }
            }
        })
    ]
};

// app2/webpack.config.js - 消费者(Remote)
const { ModuleFederationPlugin } = require('webpack').container;

module.exports = {
    plugins: [
        new ModuleFederationPlugin({
            name: 'app2',
            remotes: {
                app1: 'app1@http://localhost:3001/remoteEntry.js'
            },
            shared: {
                react: { singleton: true },
                'react-dom': { singleton: true }
            }
        })
    ]
};

// app2/src/App.js - 使用远程模块
// 从app1动态导入模块
const RemoteButton = () => import('app1/Button');

导入映射(Import Maps)

<!-- 导入映射:控制模块的解析 -->
<script type="importmap">
{
    "imports": {
        "vue": "https://unpkg.com/vue@3/dist/vue.esm-browser.js",
        "lodash": "/node_modules/lodash-es/lodash.js",
        "components/": "/src/components/"
    },
    "scopes": {
        "/src/": {
            "utils": "/src/utils/index.js"
        }
    }
}
</script>

<script type="module">
    // 现在可以这样导入
    import { createApp } from 'vue';
    import { debounce } from 'lodash';
    import Button from 'components/Button.js';
    
    // 不需要写完整路径
    import { formatDate } from 'utils';
</script>

结语

JavaScript 模块化的演进历程是一部精彩的技术发展史。从最初的全局变量污染,到现在的 ES Module 原生支持,我们见证了前端工程化从无到有、从简单到复杂的过程。对于文章中错误的地方或者有任何问题,欢迎在评论区留言讨论!