前言
本文从前端模块化的发展历程,介绍下不同时期使用方式的优缺点,每种模块的加载方式、运行时机、用法,给出对应的案例加深对模块化的知识学习。主要内容如下图所示:
1. 模块化的理解
1.1 什么是模块化
模块化是一种将程序划分为独立功能块的开发方法,通过将代码组织成模块,使得每个模块具有明确的功能和责任。每个模块都可以独立地开发、测试、维护和重用。
1.2 为什么需要模块化
需要模块化的几个主要原因:
- 代码组织与可维护性
- 代码的可复用性
- 依赖管理与可扩展性
总体来说,模块化可以帮助我们构建可靠、灵活和可维护的程序。
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)
案例:
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>
执行后结果:
上面的例子通过jquery方法将页面的背景颜色改成红色,所以必须先引入jQuery库,就把这个库当作参数传入。这样做除了保证模块的独立性,还使得模块之间的依赖关系变得明显。
但是如果修改script的引入顺序,那么页面就会报错,这种必须严格依赖加载顺序。
总结:
-
作用:解决全局命名冲突问题,模拟模块的功能;解决按需导出;模块依赖关系明显
-
问题:每次引入的script需要严格按照顺序引入,那么有没有不用关心依赖导入的顺序的方案呢?
2.4 CommonJS (2009)
CommonJS是为服务器端开发提供了一种同步加载模块的方式,解决模块化和依赖管理的问题。
用法:
- 导出module.exports 或者是exports
- 导入使用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")运行过程如下:
注意:exports和module.exports的区别
module.exports是真正的导出对象,而exports只是module.exports的一个引用。 两者同时存在,以module.exports为准。- 当模块只需要导出一个单一的对象、函数或值时,可以使用
exports来简化导出的语法。例如,exports.add = function(x, y) { return x + y; }。 - 当模块需要导出多个变量、函数或对象时,必须使用
module.exports。直接给exports赋值,只会将新的变量添加到exports对象上,并不会改变module.exports的指向。
总结:
作用:
- 不需要依赖script加载的顺序,模块加载的顺序,按照代码中require引入的顺序
- 所有代码都运行在模块作用域,不会污染全局作用域
- 模块可以多次加载,在第一次加载时运行,之后加载,读取缓存结果。要想让模块再次运行,必须清除缓存。
问题:
- 文件加载是同步运行,加载时间长
- 不适用客户端,因为客户端都是通过网络进行下载,如果依赖过多,导致页面非常卡顿。
2.5 异步模块定义 AMD(Asynchronous Module Definition)(2010)
前端开发中对于异步加载的需求越来越多,RequireJS推出了AMD规范。AMD允许在代码运行时异步加载模块,通过define和require函数来定义和引用模块,从而解决了模块依赖管理和异步加载的问题。
用法:
- 定义模块 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>
它的主要原理是通过异步加载和定义模块,以解决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>
原理解析:
-
定义了一个全局对象
modules,用于存储已加载的模块。 -
define函数用于定义模块。它接受三个参数:name(模块名称)、deps(依赖模块列表)和factory(模块工厂函数)。-
如果所有的依赖模块都已经加载完毕,则直接执行模块工厂函数,并将结果保存到
modules对象中。 -
如果有未加载的依赖模块,则通过
loadScript函数动态加载对应的脚本文件,并在加载完成后执行回调函数,再次检查是否所有依赖模块都已加载,若全部加载完成则执行模块工厂函数。
-
-
require函数用于异步加载和使用模块。它接受两个参数:deps(依赖模块列表)和callback(回调函数)。-
如果所有的依赖模块都已经加载完毕,则直接执行回调函数。
-
如果有未加载的依赖模块,则通过
loadScript函数动态加载对应的脚本文件,并在加载完成后执行回调函数,再次检查是否所有依赖模块都已加载,若全部加载完成则执行回调函数。
-
-
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原生支持了模块化,引入了import和export关键字来定义和引用模块。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
}
输出结果
4. 总结
了解模块化的发展历史,有助于对知识的加深。最后从模块的运行环境等方面进行对比,希望对大家有帮助^O^。
| 模块方式 | 运行环境 | 同步异步 | 运行时、编译时 |
|---|---|---|---|
| 函数、命名空间、IIFE | 客户端和服务器 | 同步 | 运行时 |
| CommonJS | 服务器 | 同步 | 运行时 |
| AMD | 客户端 | 异步 | 运行时 |
| CMD | 客户端 | 异步 | 运行时 |
| UMD | 根据环境区分CommonJS、AMD | 根据环境区分CommonJS、AMD | 运行时 |
| ESM | 客户端、服务器 | 异步 | 编译时 |
参考文章