前端模块化发展阶段IIFE CommonJS CMD AMD UMD ESM

1,948 阅读10分钟

前言

本文从前端模块化的发展历程,介绍下不同时期使用方式的优缺点,每种模块的加载方式、运行时机、用法,给出对应的案例加深对模块化的知识学习。主要内容如下图所示:

image.png

1. 模块化的理解

1.1 什么是模块化

模块化是一种将程序划分为独立功能块的开发方法,通过将代码组织成模块,使得每个模块具有明确的功能和责任。每个模块都可以独立地开发、测试、维护和重用。

1.2 为什么需要模块化

需要模块化的几个主要原因:

  1. 代码组织与可维护性
  2. 代码的可复用性
  3. 依赖管理与可扩展性

总体来说,模块化可以帮助我们构建可靠、灵活和可维护的程序。

2. 模块化的发展历程

2.1 全局变量、函数阶段(1995-2009)

在早期的JavaScript开发中,JavaScript没有内置的模块系统,通常使用全局变量、函数来组织代码。

案例:

// a.js
function add(x, y) {
  return x + y;
}

var sum = add(1, 2);
console.log(sum);

// b.js
function add(){}

总结:

  • 存在命名冲突、代码复杂性的问题
  • 模块成员之间看不出直接关系

2.2 命名空间(namespace)(1995-2009)

针对全局变量、函数这种方式存在代码污染和命名冲突的问题。我们引入了命名空间的概念,通过将相关的函数、变量和对象放置在命名空间中,实现了代码的封装和组织。

用法:

  • 就是使用对象字面量

案例:

var MyApp = {
  score: 100,
  add: function (x, y) {
    return x + y + this.score;
  },
};

var sum = MyApp.add(1, 2);
MyApp.score = 1 

这种方式违反了迪米特法则-最少知识原则,一个对象应该对其他对象有最少的了解。

如果我只想暴露add方法,我的score属性也不得不暴露,并且外部还可以直接修改score属性,造成了数据的不安全。

总结:

  • 作用: 减少了全局变量,降低了命名冲突
  • 问题: 数据不安全(外部可以直接修改模块内部的数据),无法按需导出

2.3 立即执行函数 IIFE(Immediately Invoked Function Expression)(1995-2009)

为了解决命名空间无法按需导出、数据不安全问题。将代码包装在一个匿名函数中,避免污染全局命名空间,并立即执行这个函数,这种方式叫立即执行函数。

用法:

使用自执行函数表达式将代码放在一个独立的函数作用域中,通过传递参数来模拟模块的导入和导出。

自执行函数调用方式

  • 使用 () 括号调用函数
  • 在函数表达式后直接添加 () 调用函数
  • 使用运算符(如 !+- 或 ~ 等)来触发函数执行
; (function (x) {
    console.log(x);
}(1))

; (function (x) {
    console.log(x);
})(2);


var func = function (x) {
    console.log(x);
}(3);


; +function (x) {
    console.log(x);
}(4)
; -function (x) {
    console.log(x);
}(5)
; !function (x) {
    console.log(x);
}(6)
; ~function (x) {
    console.log(x);
}(7)
; void function (x) {
    console.log(x);
}(8)

image.png

案例:

var MyApp = (function () {
  var score = 100;
  return {
    add: function (x, y) {
      return x + y + score;
    },
  };
})();

var sum = MyApp.add(1, 2);
console.log(sum);

无法修改score的值,可以按需导出。

在上面的基础上,为body元素添加红色背景,我们可以引入jquery,那么此时的立即执行函数需要传入jquery。

var MyApp = (function ($) {
var score = 100;
$("body").css("background-color",'red'); // 添加背景色
  return {
    add: function (x, y) {
      return x + y + score;
    },
  };
})($);

var sum = MyApp.add(1, 2);
console.log(sum);

引入到html中:

	<script src="https://cdn.bootcdn.net/ajax/libs/jquery/3.6.4/jquery.js"></script>
	<script src="../v2_IIFE.js"></script>

执行后结果:

image.png

上面的例子通过jquery方法将页面的背景颜色改成红色,所以必须先引入jQuery库,就把这个库当作参数传入。这样做除了保证模块的独立性,还使得模块之间的依赖关系变得明显

但是如果修改script的引入顺序,那么页面就会报错,这种必须严格依赖加载顺序。

总结:

  • 作用:解决全局命名冲突问题,模拟模块的功能;解决按需导出;模块依赖关系明显

  • 问题:每次引入的script需要严格按照顺序引入,那么有没有不用关心依赖导入的顺序的方案呢?

2.4 CommonJS (2009)

CommonJS是为服务器端开发提供了一种同步加载模块的方式,解决模块化和依赖管理的问题。

用法:

  1. 导出module.exports 或者是exports
  2. 导入使用require

这种模块机制非常适合服务器端环境,因为文件系统的IO操作是同步的。

案例:

add.js

function add(x, y) {
  return x + y;
}

module.exports = add;

main.js

const add = require('./add.js')

const sum = add(1, 2)

console.log(sum);

main.js中引入add.js,实际代码运行过程:转换成立即执行函数

(function () {
// 每个模块文件
  var modules = {
    "./add.js": function (module,exports, require) {
      function add(x, y) {
        return x + y;
      }
      module.exports = add;
    },
    "./main.js": function (module,exports, require) {
      var add = require("./add.js");
      var sum = add(1, 2);

      console.log(sum);
    },
  };
// 缓存结果
  var cache = {};
	
  function require(moduleId) {
  // 查看缓存
    if (cache[moduleId]) {
      return cache[moduleId];
    }
//无缓存,定义一个对象含有exports的属性
    var module = { exports: {} };
    // 将定义的module以参数传递
    modules[moduleId](module,module.exports, require);
    // 将获取的结果缓存
    cache[moduleId] = module.exports;
   // 返回结果
    return module.exports;
  }

  require("./main.js");
})();

require("./main.js")运行过程如下:

image.png

注意:exports和module.exports的区别

  1. module.exports是真正的导出对象,而exports只是module.exports的一个引用。 两者同时存在,以module.exports为准。
  2. 当模块只需要导出一个单一的对象、函数或值时,可以使用exports来简化导出的语法。例如,exports.add = function(x, y) { return x + y; }。
  3. 当模块需要导出多个变量、函数或对象时,必须使用module.exports。直接给exports赋值,只会将新的变量添加到exports对象上,并不会改变module.exports的指向。

总结:

作用:

  • 不需要依赖script加载的顺序,模块加载的顺序,按照代码中require引入的顺序
  • 所有代码都运行在模块作用域,不会污染全局作用域
  • 模块可以多次加载,在第一次加载时运行,之后加载,读取缓存结果。要想让模块再次运行,必须清除缓存。

问题:

  • 文件加载是同步运行,加载时间长
  • 不适用客户端,因为客户端都是通过网络进行下载,如果依赖过多,导致页面非常卡顿。

2.5 异步模块定义 AMD(Asynchronous Module Definition)(2010)

前端开发中对于异步加载的需求越来越多,RequireJS推出了AMD规范。AMD允许在代码运行时异步加载模块,通过definerequire函数来定义和引用模块,从而解决了模块依赖管理和异步加载的问题。

用法:

  • 定义模块 define(name, [], factory)模块名称,依赖项,导出模块
  • 使用 require([moduleName],callback) 加载完成执行callback

案例:

math.js :定义模块define

// 定义名称,依赖项,导出模块
define('math', [], function () {
    return {
        add: function (a, b) {
                return a + b
        }
    }
});

main.js:使用模块require

// 加载完成后将math返回的对象以参数传递给回调函数
require(['math'], function (utils) {
    console.log(utils.add(1, 2));
})

index.html

<script src="https://cdn.bootcdn.net/ajax/libs/require.js/2.3.6/require.js"></script>
<script src="./main.js"></script>

image.png

它的主要原理是通过异步加载和定义模块,以解决JavaScript在浏览器环境中模块依赖和加载的问题。

实现一个简单的AMD:

<script>
    var modules = {}

    function define(name, deps, factory) {
        var pending = deps.length
        var resolveDeps = new Array(pending)

        deps.forEach(function (dep, index) {
            if (modules[dep]) {
                resolveDeps[index] = modules[dep]
                pending--
            } else {
                loadScript(dep + '.js', function () {
                    resolveDeps[index] = modules[dep]
                    pending--
                    if (pending === 0) {
                        modules[name] = factory.apply(null, resolveDeps)
                    }
                })
            }
        })

        if (pending === 0) {
            modules[name] = factory.apply(null, resolveDeps)
        }
    }


    function require(deps, callback) {
        var pending = deps.length
        var resolveDeps = new Array(pending)

        deps.forEach(function (dep, index) {
            if (modules[dep]) {
                    resolveDeps[index] = modules[dep]
                    pending--
            } else {
                loadScript(dep + '.js', function () {
                    resolveDeps[index] = modules[dep]
                    pending--
                    if (pending === 0) {
                        modules[name] = callback.apply(null, resolveDeps)
                    }
                })
            }
        })
        if (pending === 0) {
            callback.apply(null, resolveDeps)
        }
    }


    function loadScript(url, callback) {
        var script = document.createElement('script')
        script.src = url
        script.onload = callback || function () { }
        document.head.appendChild(script)
    }

    require(['math'], function (math) {
        console.log(math.add(1, 2));
    })

</script>


原理解析:

  1. 定义了一个全局对象 modules,用于存储已加载的模块。

  2. define 函数用于定义模块。它接受三个参数:name(模块名称)、deps(依赖模块列表)和 factory(模块工厂函数)。

    • 如果所有的依赖模块都已经加载完毕,则直接执行模块工厂函数,并将结果保存到 modules 对象中。

    • 如果有未加载的依赖模块,则通过 loadScript 函数动态加载对应的脚本文件,并在加载完成后执行回调函数,再次检查是否所有依赖模块都已加载,若全部加载完成则执行模块工厂函数。

  3. require 函数用于异步加载和使用模块。它接受两个参数:deps(依赖模块列表)和 callback(回调函数)。

    • 如果所有的依赖模块都已经加载完毕,则直接执行回调函数。

    • 如果有未加载的依赖模块,则通过 loadScript 函数动态加载对应的脚本文件,并在加载完成后执行回调函数,再次检查是否所有依赖模块都已加载,若全部加载完成则执行回调函数。

  4. loadScript 函数用于动态加载脚本文件。它创建一个 <script> 元素,并设置其 src 属性为指定的脚本文件路径,并在加载完成后执行回调函数。

在代码最后,通过 require 函数加载 math 模块,并在回调函数中调用 math.add 函数并输出结果。

通过动态加载模块文件,并在依赖模块全部加载完成后执行回调函数。这种方式能够解决模块之间的依赖关系,并按需异步加载模块,提高了应用的加载性能和可维护性。

总结:

  • 作用: 使用异步加载模块,提高加载性能
  • 问题: 如果引入了多余的依赖,没有进行区分是否调用,都会进行加载

2.6 CMD (Common Module Definition) 2010

CMD(通用模块定义)是由SeaJS提出和实现的一种模块化规范。SeaJS是一个遵循CMD规范的JavaScript模块加载器,可用于浏览器端的模块化开发。

专门用于浏览器端,模块的加载是异步的,模块使用时才会加载执行。

使用:

  • 引入seaJS库
  • 定义define(function(require, exports, module){module.exports})
  • 使用define(function(require) { const xxx = require('xxx') });
  • 调用seajs.use('path')

案例:

math.js

define(function(require, exports, module) {
  var add = function(a, b) {
    return a + b;
  };

  exports.add = add;
});

main.js

define(function(require) {
  var math = require('./math');

  var result = math.add(2, 3);
  console.log(result);
});

index.html

<script src="https://cdn.bootcdn.net/ajax/libs/seajs/3.0.3/sea.js"></script>
<script>
    seajs.use('./main.js')
</script>

实现一个CMD:

// 定义一个模块加载函数 define
function define(factory) {
        var module = { exports: {} };
        factory(require, module.exports, module);
        return module.exports;
}

// 定义一个模块缓存对象来保存已加载的模块
var moduleCache = {};

// 定义 require 函数用于加载模块
function require(moduleId) {
        // 检查模块是否已经加载过,如果加载过就直接返回缓存的模块
        if (moduleCache[moduleId]) {
                return moduleCache[moduleId];
        }

        // 创建一个空的模块对象,并将其加入缓存中
        var module = { exports: {} };
        moduleCache[moduleId] = module;

        // 加载模块并执行工厂函数
        var factory = modules[moduleId];
        factory(require, module.exports, module);

        // 返回模块的导出对象
        return module.exports;
}

// 定义模块的工厂函数
var modules = {};

// 定义 seajs.use 方法
function seajsUse(moduleId, callback) {
        var module = require(moduleId);
        callback(module);
}

// 定义 math.js 模块
modules['./math'] = function (require, exports) {
        var add = function (a, b) {
                return a + b;
        };

        exports.add = add;
};

// 定义 main.js 模块
modules['./main'] = function (require) {
        var math = require('./math');

        var result = math.add(2, 3);
        console.log(result);
};

// 使用 seajsUse 方法加载 main.js 模块
seajsUse('./main', function (module) { });

总结:

  • 作用:类似于AMD,CMD模块也是在运行时进行加载和执行。CMD模块的加载是异步的,但模块的执行是在模块被引用的时候才会执行。因此,CMD模块在运行时根据需要按需加载和执行模块。

2.7 UMD 通用模块定义 (Universal Module Definition)

UMD是一种通用的模块定义规范,旨在解决不同模块加载器和环境之间的兼容性问题。它的设计目标是使同一个模块可以在多种环境下使用,例如浏览器、Node.js 等。

为了解决当时模块化开发中存在的两种主流规范:CommonJS 和 AMD(Asynchronous Module Definition)。它根据当前环境选择使用 CommonJS 还是 AMD 的方式来加载和导出模块。

思路:

UMD 的基本思想是先检测当前环境是否支持 AMD 规范,如果支持则采用 AMD 方式加载模块;如果不支持,再检测是否支持 CommonJS 规范,如果支持则采用 CommonJS 方式导出模块;如果两者都不支持,再将模块挂载到全局对象上。这样一来,无论在什么环境下,都能够正确地加载和使用 UMD 模块。

案例:

; (function (root, factory) {
    if (typeof define === 'function' && define.amd) {
        // amd
        define([utils], factory)
    } else if (typeof exports === 'object' && module.exports) {
        // CommonJs 
        var utils = require('utils')
        module.exports = factory()
    } else {
        root.utils = factory()
    }
}
)(this, function () {
    var add = function (a, b) {
        return a + b;
    };
    return add
})

总结:

  • 作用:解决不同模块加载器和环境之间的兼容性问题

2.8 ESM(ES Modules)(2015)

随着ECMAScript 6的发布,JavaScript原生支持了模块化,引入了importexport关键字来定义和引用模块。ESM提供了一种静态分析的模块加载方式,使得代码更易于优化和打包,并逐渐成为前端开发的主流模块规范。

用法:

  • 使用export导出
  • 使用import导入

案例: 参考文章

3. 注意点

3.1 CommonJS和ES6区别

  • CommonJS模块输出的是一个值的拷贝,ES6模块输出的是值的引用
  • CommonJS是运行时加载,ES6模块是编译时输出接口

因为CommonJS加载的是一个对象,该对象只有在脚本运行完才生成。而ESM模块不是对象,它对外接口知识一种静态定义,在代码静态解析阶段就会完成

案例: main.js

/* ========== ESM =========== */
import { counter, increaseCount } from './utils.js';

console.log(counter); // 3
increaseCount();
console.log(counter); // 4

/* ========== CommonJS =========== */
const { counter, increaseCount } = require('./utils')

console.log(counter); // 3
increaseCount()
console.log(counter); // 3

utils.js

/* ========== ESM =========== */
export let counter = 3;
export function increaseCount() {
  counter++;
}

/* ========== CommonJS =========== */
let counter = 3
function increaseCount() {
	counter++
}
module.exports = {
	counter,
	increaseCount
}

输出结果

image.png

4. 总结

了解模块化的发展历史,有助于对知识的加深。最后从模块的运行环境等方面进行对比,希望对大家有帮助^O^。

模块方式运行环境同步异步运行时、编译时
函数、命名空间、IIFE客户端和服务器同步运行时
CommonJS服务器同步运行时
AMD客户端异步运行时
CMD客户端异步运行时
UMD根据环境区分CommonJS、AMD根据环境区分CommonJS、AMD运行时
ESM客户端、服务器异步编译时

参考文章

前端模块化详解(完整版)