介绍
去年年底,业务提出支持动态加载插件,以为是低代码的方式,调研了 lowcode-engine、微搭、amis 等低代码平台均被否掉。
最后在参考了 Grafana Plugin Tools 的实现后采用 SystemJS 这个加载方案,目前业务已经落地。在实现的过程中发现 single-spa 的实现也是依赖 SystemJS,而 qiankun 又依赖 single-spa。所以想着重新梳理一下前端模块化的几种方案,深入学习 SystemJS,从而更好理解 single-spa 以及 qiankun。
CommonJS (Node.js)
Node.js 是 2009 年由 Ryan Dahl 开发的一个基于 Chrome V8 引擎的 JavaScript 运行环境,可以认为是 JavaScript 的语言解释器。
Node.js 最初的定位是提升 Ryan 自己的日常工作效率,也就是用来写服务器代码的,但是后来没有想到的是 Node.js 在前端领域却大放异彩。
解决问题
在 CommonJS 规范出现之前,JavaScript 主要被用于浏览器环境,用于网页的交互和简单的客户端脚本。随着 JavaScript 应用的复杂度逐渐增加,特别是在服务器端应用的需求下,开发者开始面临几个关键问题:
- 模块化缺失:早期的 JavaScript 没有原生的模块系统,代码组织混乱,难以复用和维护。
- 依赖管理:在大型项目中,如何有效地管理不同文件之间的依赖关系,防止重复加载和执行成为难题。
- 环境一致性:由于 JavaScript 主要在浏览器中运行,没有统一的标准来处理文件系统访问、网络请求等服务器端操作。
CommonJS 规范正是为了解决上述问题而提出的,它的核心目标是为 JavaScript 提供一套在非浏览器环境(主要是服务器端)下运行的标准模块系统,使得开发人员能够编写结构化、可复用、易于管理的代码。它定义了模块的导入(require)和导出(module.exports或exports)机制,使得开发者可以清晰地划分代码逻辑,同时管理依赖关系。
使用案例
Node.js 是最早采用并普及 CommonJS 规范的平台之一。几乎所有的 Node.js 应用和 npm 包都遵循CommonJS 规范,通过require导入模块,通过module.exports或exports导出模块的功能。
// 导入模块
const fs = require('fs');
// 导出模块
module.exports = function myModule() {
// 模块内容
};
存在问题
- 同步加载:CommonJS 规范采用同步方式加载模块,这意味着在模块加载时,其他代码的执行会被阻塞。这在服务器端通常不是大问题,因为 I/O 操作是非阻塞的,但在浏览器端,同步加载会导致页面渲染被阻塞,影响用户体验。
- 不适用于浏览器环境的原生支持:虽然通过工具可以使其在浏览器中工作,但 CommonJS 并非为浏览器环境设计,因此存在一些限制,如上所述的同步加载问题。
- 代码分割与懒加载:CommonJS 的同步加载特性使得实现高效的代码分割和按需加载比较困难,这对于大型前端应用来说是一个重要需求。
AMD & CMD
AMD(Asynchronous Module Definition)
AMD(Asynchronous Module Definition)规范是在 2009 年左右被提出的。这个规范主要由 James Burke 在开发 RequireJS 的过程中为了解决浏览器端 JavaScript 的模块化和异步加载问题而创立。RequireJS 成为了 AMD 规范的一个重要实现,它允许开发者以异步、非阻塞的方式加载模块,并且方便地管理模块之间的依赖关系。
解决问题
- 异步加载:AMD 允许模块异步加载,避免了同步加载导致的页面渲染阻塞,提高了用户体验。
- 依赖声明:在模块定义时明确声明依赖,加载器(如 RequireJS)负责按需加载并管理依赖关系,保证了模块加载的正确顺序。
- 简化开发:开发者可以更容易地组织和管理大型项目中的代码,提高代码的可维护性和重用性
使用案例
通过 RequireJS,开发者可以轻松地在项目中使用 AMD 风格的模块。它提供了一种机制来定义模块、加载模块以及管理模块间的依赖。
// 定义模块
define(['dependency1', 'dependency2'], function(dep1, dep2) {
return function myModule() {
// 使用依赖
};
});
// 加载模块
require(['moduleA', 'moduleB'], function(moduleA, moduleB) {
// 使用模块
});
存在问题
- 语法相对复杂:与 ES6 模块相比,AMD 的定义和使用语法显得更为繁琐,特别是对于初学者。
- 执行时机控制:异步加载虽然解决了阻塞问题,但也带来了模块执行时机难以预测的挑战,特别是在需要处理复杂的依赖关系时。
- 与ES6模块的兼容性:随着 ES6 模块标准的普及,AMD 规范在某些场景下显得过时,开发者可能需要投入额外的工作来兼容或迁移至 ES6 模块。
CMD (Common Module Definition)
CMD (Common Module Definition) 是另一种 JavaScript 模块化规范,它主要由玉伯在 SeaJS 模块加载器中提出和发展起来,大约在 2017 年前后被广泛讨论和应用。CMD 与 AMD 类似,都是为了解决浏览器环境下的模块化开发问题,但两者之间存在一些设计理念上的差异。
核心特点
- 异步加载:与 AMD 一样,CMD 也支持模块的异步加载,避免了页面渲染被 JavaScript 加载阻塞的问题。
- 依赖就近:CMD 规范提倡依赖的声明尽可能靠近使用的地方,这与 AMD 要求在模块定义一开始就声明所有依赖有所不同。CMD 认为这样更符合编程直觉,也更易于理解和维护。
- 模块定义:CMD 中,模块的导出是通过返回值来完成的,而不是像 AMD 那样通过参数传递。
使用案例
// module.js
define(function(require, exports, module) {
var dependency = require('./dependency');
function doSomething() {
// 使用dependency进行某些操作
}
module.exports = {
doSomething: doSomething
};
});
// 使用模块的文件
define(function(require, exports, module) {
var moduleExports = require('./module');
moduleExports.doSomething();
});
与 AMD 的区别
- 依赖声明时机:AMD 要求在模块定义时明确列出所有依赖,而 CMD 允许在函数体内按需加载依赖。
- 导出方式:AMD 通过参数导出模块接口,CMD 通过
module.exports或直接返回值来导出。 - 设计哲学:AMD 倾向于预加载和优化依赖关系,CMD 则更强调按需加载和代码的自然组织。
存在问题
- 标准普及度:与 AMD 相比,CMD 在中国以外的地区普及度较低,这意味着国际交流和第三方库的兼容性可能是个问题。
- ES6模块竞争:随着 ES6 模块标准的推出和浏览器原生支持,CMD 的许多优势(如异步加载、模块化)被原生支持所覆盖,使得开发者更多转向使用 ES6 模块。
- 学习曲线和工具链:虽然 CMD 设计较为直观,但对于习惯其他模块化规范(如 AMD 或 CommonJS)的开发者而言,需要一定的学习和适应时间。同时,随着 Webpack 等构建工具对 ES6 模块的良好支持,CMD 相关的构建工具链显得不够现代化。
UMD (Universal Module Definition)
UMD 的概念和实践大约形成于 2010 年至 2012 年间,这一时期,JavaScript 社区正面临模块化标准不统一的问题。前端开发者在构建库或框架时,需要考虑如何让其能够兼容多种模块加载机制,包括但不限于 AMD、CommonJS,以及未来的浏览器原生支持的 ES6 模块。UMD 的出现正是为了解决这种模块间不兼容的困境,让一个模块能够在各种环境(Node.js、AMD 加载器、浏览器直接加载等)下都能正常工作。
解决问题
- 兼容性:允许一个 JavaScript 文件既能作为 AMD 模块、CommonJS 模块(如 Node.js 模块)使用,也能直接在浏览器全局作用域中运行,无需修改代码即可适应多种环境。
- 便捷性:开发者只需编写一份代码,不需要为不同环境编写多份适配代码,简化了库和框架的分发和维护。
- 易用性:使用者无需关心模块的具体加载细节,无论是 AMD 环境、Node.js 环境还是传统浏览器环境,UMD 模块都能无缝工作。
使用案例
假设我们要创建一个简单的数学库,包含一个加法函数:
// myMathLib.js
;(function (root, factory) {
if (typeof define === 'function' && define.amd) {
// AMD环境
define(['exports'], factory);
} else if (typeof exports === 'object' && typeof module !== 'undefined') {
// CommonJS环境,如Node.js
factory(exports);
} else {
// 浏览器全局变量环境
factory(root.myMathLib = {});
}
}(this, function (exports) {
// 实现加法函数
function add(a, b) {
return a + b;
}
// 导出加法函数
if (typeof exports !== 'undefined') {
exports.add = add;
} else {
root.myMathLib.add = add;
}
}));
在这个例子中,myMathLib 是一个假想的库名,它提供了 add 函数。这段代码首先检查当前环境是 AMD、CommonJS 还是全局变量环境,然后相应地导出 add 函数。如果在 AMD 环境中,define 函数被调用,并传入一个数组(表示依赖)和一个工厂函数;在 CommonJS 环境中,直接通过exports 对象导出函数;而在全局环境中,则直接挂载到 window(或在 Node.js 的非严格模式下为 global,这里使用 this 来兼容)上。
使用这个 UMD 模块时,在不同环境下的示例如下:
AMD环境:
require(['myMathLib'], function(myMathLib) {
console.log(myMathLib.add(2, 3)); // 输出5
});
Node.js环境:
var myMathLib = require('./myMathLib');
console.log(myMathLib.add(2, 3)); // 输出5
浏览器全局环境:
<script src="path/to/myMathLib.js"></script>
<script>
console.log(myMathLib.add(2, 3)); // 输出5
</script>
存在问题
- 代码体积:UMD 模块通常需要包含额外的环境检测逻辑,这可能会略微增加代码体积。
- 未来兼容性:随着 ES6 模块成为标准并在现代浏览器中广泛支持,UMD 的必要性逐渐降低,因为原生模块提供了更好的静态分析和性能优势。
- 维护复杂性:对于库的维护者来说,维护一份同时兼容多种模块化标准的代码可能比专注于一种标准更加复杂,尤其是在添加新特性或进行重构时。
综上,UMD 是一个过渡时期的解决方案,它在模块化标准尚未统一的背景下,为库和框架的广泛传播提供了便利。然而,随着 ES6 模块标准的普及,UMD 的重要性逐渐减弱,未来开发中直接采用原生 ES 模块成为趋势。
ES6 Modules
ESM 的概念首次出现在 ECMAScript 2015(ES6)标准中,该标准于 2015 年正式发布。不过,直到后来几年,浏览器和 Node.js 等环境才逐步完善了对 ESM 的支持。
在此之前,JavaScript 一直缺乏原生的模块系统,开发者不得不依赖于各种非标准的解决方案,如 CommonJS(在 Node.js 中)、AMD(RequireJS 等)来组织代码和管理依赖。这些方案虽解决了部分问题,但存在互不兼容、使用复杂等问题,特别是在跨环境(浏览器与服务器)开发中。
解决问题
- 原生支持:作为 JavaScript 语言标准的一部分,ESM 提供了统一的、标准化的模块系统,减少了对外部加载器或转译器的依赖。
- 模块化:通过
import和export关键字,ESM 允许开发者将代码组织成独立的模块,便于代码的复用、管理和维护。 - 静态加载:ESM 使用静态解析,编译时确定模块依赖,有助于优化加载性能,支持
tree shaking等优化手段。 - 异步加载:ES M默认支持异步加载模块,避免了阻塞主线程,提高了页面渲染速度和用户体验。
- 模块作用域:每个模块有自己的私有作用域,减少了全局命名冲突的风险。
使用案例
// math.js
export function add(a, b) {
return a + b;
}
// app.js
import { add } from './math.js';
console.log(add(2, 3)); // 输出5
存在问题
尽管 ESM 带来了很多改进,但在其早期推广和应用过程中,也遇到了一些挑战和问题:
- 兼容性:早期,不是所有浏览器和 JavaScript 运行环境都支持 ESM,需要通过工具如Babel 转译为旧版本的 JavaScript 代码或使用 polyfills。
- 动态导入:虽然 ESM 支持动态导入(
import()表达式),但其异步特性可能导致代码执行流控制复杂化。 - 与现有模块系统共存:在 Node.js 等环境中,ESM 与原有的 CommonJS 模块系统共存,导致了一些配置和使用上的复杂性。
- 缓存与更新:浏览器对 ESM 的缓存机制有时可能导致开发者难以即时看到模块更新后的效果,需要清除缓存或使用特定的开发技巧。
随着时间的推移,大多数现代浏览器和 JavaScript 环境已经很好地支持了 ESM,相关的工具链和最佳实践也逐渐成熟,使得 ESM 成为了现代 JavaScript 开发中的标准做法。
SystemJS
SystemJS 是由 Guy Bedford 创建的一个模块加载器,它诞生于 2014 年左右,正值 JavaScript 模块化标准尚未完全统一,开发者需要在 AMD、CommonJS、ES6 模块等多种模块格式之间切换的时期。SystemJS 的目标是提供一个高度灵活的模块加载解决方案,能够无痛地支持多种模块格式,并且能适应从旧的浏览器环境到最新的 JavaScript 标准的过渡。
解决问题
- 模块格式兼容:SystemJS 能够加载 AMD、CommonJS、UMD 以及原生 ES6 模块,解决了不同模块格式之间的兼容性问题。
- 动态加载:支持动态
import,使得模块可以根据运行时条件按需加载,提高性能。 - 转换和加载:内置对 TypeScript、CoffeeScript 等编译到 JavaScript 的脚本的支持,能够在加载前自动转换。
- 配置灵活:提供强大的配置能力,可以指定模块的加载路径、转换规则、插件等,使得大型项目的模块管理更加高效。
- 渐进增强:帮助开发者平滑地从旧的模块系统过渡到 ES6 模块,无需立即重构整个代码库
使用案例
假设我们有一个项目,需要同时使用 ES6 模块和 AMD 模块:
- 安装SystemJS:通过 npm 安装 SystemJS。
npm install systemjs
- 配置SystemJS: 在 HTML 文件中添加如下配置代码,设置基本的 SystemJS 配置来处理模块加载路径:
<script src="path/to/system.js"></script>
<script>
SystemJS.config({
baseURL: '/js',
paths: {
'npm:': 'https://cdn.jsdelivr.net/npm/'
},
map: {
'myLib': 'lib/myLib.umd.js',
'es-module': 'modules/es-module.js'
},
packages: {
'modules/': { format: 'esm' }
}
});
</script>
- 使用模块: 现在可以使用
System.import动态加载模块:
System.import('myLib').then(function(myLib) {
console.log(myLib.someFunction());
});
System.import('es-module').then(function(esModule) {
console.log(esModule.default());
});
存在问题
尽管 SystemJS 提供了强大的灵活性和兼容性,但也存在一些挑战:
- 复杂性:由于高度的灵活性,SystemJS 的配置和使用相对复杂,对于初学者可能有一定的学习成本。
- 性能:与一些更轻量级的加载器相比,SystemJS 由于功能全面,可能会牺牲一定的加载性能。
- 生态地位:随着浏览器和 Node.js 原生支持 ES 模块,以及 Webpack、Rollup 等构建工具的成熟,SystemJS 的使用场景逐渐减少,尽管它依然在某些特定场景下发挥作用,比如快速原型开发或需要高度动态加载的场景。
总结
综上,我们可以得出结论,AMD、CMD 是浏览器端的模块化方案(虽然 AMD 也可以用于服务端),CommonJS 是 Node.js 服务端的模块化方案,UMD、SystemJS 都是为了解决不同模块格式之间的兼容性问题,并且随着大多数现代浏览器和 JavaScript 环境已经很好地支持了 ESM,相关的工具链和最佳实践也逐渐成熟,ESM 逐渐成为了现代 JavaScript 开发中的标准做法。
针对我们需要动态加载插件的场景,最终也是选择了 SystemJS。