背景
本文作为微前端核心系列的开篇,并不急于深入主题去介绍微前端的应用,而是从微前端的基础即模块加载开始铺垫,因为认为微前端实际上做的第一件事其实就是组合"模块",将一群模块构建成应用。一个好的模块标准和流畅的模块调度,能够使上层应用事半功倍。
十年前,ES Modules 还未成为标准,为了实现模块化开发,社区涌现了一批方案来实现模块加载,例如 Require.js,Sea.js 等优秀项目,而他们对模块的实现方案各不相同,从此出现了 CommonJS,AMD,UMD 等各种规范。
彼时 SystemJS 的出现的初衷,只是为了解决不同模块规范在项目中的收敛和工作流的统一,提出一种统一的模块注册方式,使所有规范的模块以同一种规范去流通,从而使一个应用中实际可以包含多种规范的模块,但是在开发方式上我们无需关心各模块规范的差异,即模块加载无关。
随着 Webpack 的崛起以及 Babel 的成熟,实现了模块加载、按需加载以及降级适配的技术,浏览器也开始支持原生 ES Modules,importmap 也与 2023 年 3 月正式宣布全面支持,作为新特性,考虑浏览器兼容性,我们需要降级适配古老版本的浏览器,SystemJS 顺应时代,在最初的能够兼容多种模块规范的多功能模块加载器的基础之上,逐渐转型为与 ES Modules 语法类似的,在 ES Modules 尚未正式在浏览器环境中流行时的替代品。它能为你提供像原生 ES Modules 一样的开发工作流,同时提供兼容,以供不支持原生 ES Modules 的老浏览器使用,同时性能上几乎达到原生 ES Modules 一样的速度,包含顶层模块异步导入 Top Level Await、动态加载 Dynamic Import 和 导入映射 Import Map 等特性的同时,最低兼容至 IE11。
ES Module
本文对 CommonJS,AMD,UMD 等非标准模块规范不做介绍,我们先来看看标准的模块规范是如何使用原生 ES Module 呢? Can I use module?
声明式加载模块
我们创建一个 script 标签,使用 import 声明语句导入模块。
html
<script>
import myModule from "myModule";
</script>
我们直接在普通脚本中使用 import 声明语句会报错
SyntaxError: import declarations may only appear at top level of a module。
需要把 type="module" 放到 script 标签中,来声明这个脚本是一个模块:
html
<script type="module">
import myModule from "myModule";
</script>
那么,在声明过后是不是意味着我们可以像使用了 Webpack 构建的环境中开发一样去写 import 声明语句呢?
答案还是不能,在浏览器环境中,没有任何路径的模块是不被允许的,import 语句声明的模块路径必须是相对或绝对的 URL 路径。
html
<script type="module">
// import myModule from "myModule";
import myModuleA from "./modules/myModuleA";
import myModuleB from "/modules/myModuleB";
import myModuleC from "https://example.com/modules/myModuleC.js";
</script>
上述方式中,我们导入模块的位置时在一个脚本的顶层,那么这个位置,称为模块顶层。可以看到,我们必须预先声明模块,在此脚本后续代码执行之前,我们必须等待这些脚本加载完成。
动态加载模块
在 JavaScript 模块中最新可用的特性是动态模块加载。它允许你仅在需要时动态加载模块,而不必预先加载所有模块,有助于提升页面性能,提升浏览体验。
我们可以将 import 作为 import()函数调用,将模块的路径作为参数传递。它返回一个 Promise,让你可以访问该对象的导出,例如
js
import("/modules/myModule.js").then((module) => {
// Do something with the module.
});
Import Map 导入映射
前面提到,在原生 ES Module 标准中,没有任何路径的模块是不被允许的,为了解决在 JavaScript 脚本中,能够不用每次都写全路径去导入,而是可以像导入一个 NPM 包一样,不指定任何路径,即裸模块(Bare Module),我们可以使用导入映射功能 Import Map。
在 script 标签上添加 type="importmap"声明一个导入映射标签。
html
<script type="importmap">
{
"imports": {
"myModuleA": "./modules/myModuleA",
"myModuleB": "/modules/myModuleB",
"myModuleC": "https://example.com/modules/myModuleC.js"
}
}
</script>
使用导入映射标签需要注意一下几点
- 不能再在这个标签上指定 src、async、nomodule、defer、crossorigin、integrity 和 referrerpolicy 属性。
- 若有多个 importmap scirpt 标签,也仅处理第一个具有内联定义的导入映射。
- 导入映射的定义必须是 JSON 对象,否则会报 TypeError。
- 若导入映射不符合规范导致报错,则使用模块加载时会报错。
- 导入映射必须在使用所有模块加载之前。
在导入映射声明之后,我们就可以在 import 时不指定路径了
html
<script type="module">
// import myModule from "myModule";
import myModuleA from "myModuleA";
import myModuleB from "myModuleB";
import myModuleC from "myModuleC";
</script>
当然啦,你也不一定非要指定为裸模块,它们也可以包含路径分隔符或者以路径分隔符结尾,或者是绝对 URL 和相对 URL。不过注意,如果模块路径标识如果是以/结尾,那么映射路径也需要以/结尾
html
<script type="module">
{
"imports": {
"modules/myModuleA/": "./module/src/myModuleA/",
"modules/myModuleB": "./module/src/other/myModuleB.js",
"https://example.com/myModuleC.js": "./module/src/other/myModuleC.js",
"../modules/myModule/": "/modules/myModule/"
}
}
</script>
我们可以用更巧妙的方式,利用分隔符结尾以实现通配的效果,从而实现导入模块的不同版本
html
<script type="importmap">
{
"imports": {
"myModule": "./modules/myModule/index.js",
"myModule/": "./modules/myModule/"
}
}
</script>
js
/* 导入通用版本 */
import myModule from "myModule";
/* 导入 v1 版本 */
import myModuleV1 from "myModule/v1.js";
/* 导入 v2 版本 */
import myModuleV2 from "myModule/v2.js";
Import Map Scope 导入映射作用域
导入映射除了通过 imports 字段声明映射路径意外,还可以通过 scope 字段来声明,在导入时仅有路径中包含指定路径的模块才能加载的模块。例如,我们可以以此来实现加载模块的不同版本。
现在我们有两个模块脚本,他们导出了不同的版本 version 标识。
js
/*
* ./myModule/v1.js
*/
const version = v1;
export default version;
/*
* ./myModule/v2.js
*/
const version = v2;
export default version;
在 importmap 声明导入映射,我们希望它在通常情况下使用 v1.js 的版本,但是在 /myModule/custom/ 中的模块中使用时,导入的是 v2.js 的版本,当然啦,我们可以直接声明两个映射,在导入时手动指定模块路径是 v1 还是 v2。
html
<script type="importmap">
{
"imports": {
"myModuleV1": "./myModule/v1.js"
"myModuleV2": "./myModule/v2.js"
}
}
</script>
那如果我们希望每次导入时,模块路径都是 myModule,自动在模块中识别 v1 和 v2 版本,我们可以通过 scopes 字段再声明一段映射,仅当模块路径匹配声明路径时,加载对应的模块映射路径。
html
<script type="importmap">
{
"imports": {
"myModule": "./myModule/v1.js"
},
"scopes": {
"./myModule/custom/": {
"myModule": "./myModule/v2.js"
}
}
}
</script>
由于我们指定了 scopes 映射,除了 ./myModule/custom/ 路径意外的所有地方,我们导入的是 myModule 都是 v1 版本。
html
<script type="module">
import myModule from "myModule";
console.log(myModule); // v1
</script>
那么在 myModule/custom/ 下的模块脚本,在声明导入 myModule 模块时,会匹配到我们声明的映射,自动加载 v2 版本。
js
/*
* ./myModule/custom/index.js
*/
import myModule from "myModule";
console.log(myModule); // v2
以上即为通过 importmap 通过映射巧妙的控制在不同模块中,使用不同的依赖。它使得我们可以在实际开发中,无需在声明导入时时刻关心导入的依赖版本,而将依赖管理通过映射声明,通过模块路径匹配来集中约束。
那么到这为止,标准 ES Module 的使用方法我们就简单介绍完了。
Can I use importmap?
importmap 自 2023 年 3 月起,已经在各大主流浏览器中全面支持。但作为刚刚支持不久的特性,其在兼容性方面是比较堪忧的。
总结
对于 ES Module 的语法大家可能并不陌生,得益于 Webpack 和 Babel 等工具,我们能够的使用 ES Module 来进行实际开发,而在运行环境中将其编译为 CommonJS 或者 UMD 格式来运行。但是我们很少直接在浏览器环境中脱离编译工具,直接使用原生 ES Module。
使用 ES Module 的难度在于,我们所有的 Javascript 文件都必须使用 ES Module 规范来声明,而我们现有的开发工作流中存在很多非 ES Module 规范的 JS 文件,导致我们的工作流无法整合,同时也面临兼容性问题。
SystemJS
定位
SystemJS 很好的找到了定位,他作为原生 ESM 和 importmap 还未完全流行之前的替代品,正如 Babel 对 ES 标准的兼容一样,SystemJS 成为了这个时代要使用这些新特性的最佳选择。同时依托于社区的多样性,丰富了在标准之上的其他扩展能力。
版本
首先介绍一下 SystemJS 的三种版本
-
s.js,最小可用版本,仅 2.8KB,仅包含了模块的注册和 importmap 和一些基本的钩子以供自定义,支持 System 格式和 UMD 格式的模块导入
-
system.js ,4.2KB,在最小版本的基础上增加了
- 一些生命周期的钩子和模块注册删除的方法,以供 Reload 需要
- 支持 Wasm、CSS、JSON 等多种格式的模块导入
- 包括用于加载全局脚本的 global loading 扩展,用于加载传统上由脚本标签加载的依赖项。
-
system-node.cjs,Node 环境版本
还有其他的更多由社区贡献的扩展,可以通过官方文档在 s.js 和 system.js 版本的基础上进行自定义扩展,例如对 AMD 模块导入的扩展支持或者将 SystemJS 集成在 Babel 工作流中。
性能
SystemJS 可以做到与原生几乎一样的性能,官方发表了一个加载 426 个 js 所用的平均耗时对比,与原生方式加载相比,性能相差很小。
Tool | Uncached | Cached |
---|---|---|
Native modules | 1668ms | 49ms |
SystemJS | 2334ms | 81ms |
如何使用 SystemJS
SystemJS Importmap
因本文介绍其基本用法,故我们选择在文档中使用官方完全体版本,即 system.js 版本
html
<script src="https://cdn.jsdelivr.net/npm/systemjs/dist/system.min.js"></script>
在文档上引入之后 window 全局对象上就多了 system 属性,我们就可以使用 system 提供的 importmap 了,相较于原生 importmap,我们需要在其前面加上 system 前缀,基本用法与原生 importmap 语法相同
html
<script type="systemjs-importmap">
{
"imports": {
"react": "https://cdn.jsdelivr.net/npm/react/umd/react.production.min.js",
"react-dom": "https://cdn.jsdelivr.net/npm/react-dom/umd/react-dom.production.min.js"
}
}
</script>
SystemJS Module
在声明了模块映射路径之后,我们就需要创建 module 脚本,与原生 module 声明的方式一样,只不过需要加上 system 前缀
html
<script type="systemjs-module"></script>
添加 src 属性
html
<script type="systemjs-module" src="./mySystemModule.js"></script>
// 如果已经是 systemjs-module,我们可以直接使用在 importmap 里声明的模块名
<script type="systemjs-module" src="import:name-of-module"></script>
如前文背景中所说,SystemJS Module 是 SystemJS 所提出的一种模块规范,在 system.js 版本中已经内置了将 CSS、JSON、Wasm 模块注册为 SystemJS Module 的模块注册器。我们可以直接在 SystemJS Module 脚本中使用。
Module Format | s.js | system.js | File Extension |
---|---|---|---|
System.register | ✔️ | ✔️ | * |
JSON Modules | via Module Types extra | ✔️ | *.json |
CSS Modules | via Module Types extra | ✔️ | *.css |
Web Assembly | via Module Types extra | ✔️ | *.wasm |
Global variable | via Global extra | ✔️ | * |
AMD | via AMD extra | via AMD extra | * |
UMD | via AMD extra | via AMD extra | * |
与其说 SystemJS 提出了一种新的规范,笔者更愿意称其提出了一种标准,一种模块注册方式,通过模块注册 API Systemjs.register,我们可以手写模块注册方式,来"教"浏览器如何解析并执行这个对于浏览器来说是"陌生"模块。
System.register
如上文所说,Systemjs.register 是一个模块注册器 API,我们可以将我们熟知的 React 库的 UMD 版本注册为 SystemJS Module,让其以统一的模块标准在工作流中流通,首先我们在 systemjs-importmap 中声明 React 映射路径
html
<script type="systemjs-importmap">
{
"imports": {
"react": "https://cdn.jsdelivr.net/npm/react/umd/react.production.min.js",
"react-dom": "https://cdn.jsdelivr.net/npm/react-dom/umd/react-dom.production.min.js"
}
}
</script>
在这个 mySystemModule.js 中,我们通过 System.register 来注册 systemjs-importmap 里声明的模块,它的一个参数是我们需要注册的模块名的列表,第二个参数是其回调函数,具体见下
js
System.register(["react", "react-dom"], function (_export, _context) {
"use strict";
// 声明两个变量,类似于通过导入 umd 格式模块后,winodw.React 和 window.ReactDOM
var React, ReactDOM;
return {
// setters 是一个注册模块回调函数的数组,与 System.register 的第一个参数里的顺序相对应
setters: [
function (_react) {
/*
* 因为我们使用的是 react 的 umd 格式的 cdn 文件,
* 所以通过 _react.default 来获取 react 导出对象
*/
React = _react.default;
},
function (_reactDom) {
ReactDOM = _reactDom.default;
},
],
execute: function () {
// 执行
ReactDOM.render("Hello React", document.getElementById("root"));
// 通过 _export 参数方法,我们可以导出对象,供其他 SystemJS Module 使用
_export({
React,
ReactDOM,
});
},
};
});
Top Level Await
类似于我们在原生 ES Module 脚本中的最顶层使用 import 声明语句,execute 作为声明 SystemJS Module 的顶层回调函数,支持异步函数,官方称其为 Top-Level-Await
js
System.register(["react", "react-dom"], function (_export, _context) {
"use strict";
var React, ReactDOM;
return {
setters: [
function (_react) {
React = _react.default;
},
,
function (_reactDom) {
ReactDOM = _reactDom.default;
},
],
// 声明异步函数
execute: async function () {
const container = await getContainerFromRemote();
ReactDOM.render("render to container from remote", container);
},
};
});
Import Assertions
导入断言是 ES Module 中确保模块以正确的格式被加载的方式,在 SystemJS 中它通过社区贡献扩展,依赖于 System.register 的第三个参数实现。
js
import a from "./a.json" assert { type: "json" };
import b from "b";
import c from "./c.css" assert { type: "css" };
const { d } = await import("d");
const { e } = await import("e", { assert: { type: "javascript" } });
-->
js
/*
* 非 js 文件注册
*/
System.register(
["./a.json", "b", "./a.css"],
function (_export, _context) {
var a, b, c;
return {
setters: [
function (m) {
// a 即为 json
a = m["default"];
},
function (m) {
// b 即为 普通 js 导出对象
b = m["default"];
},
function (m) {
// c 即为 StyleSheet
c = m["default"];
},
],
execute: async function () {
// 也可以使用 _context 上下文,通过 import() 来动态加载
const { d } = await _context.import("d");
const { e } = await _context.import("e", {
assert: { type: "javascript" },
});
},
};
},
[
{ assert: { type: "json" } },
undefined, // 普通js, 不需要断言类型,传入 undefined
{ assert: { type: "css" } },
]
);
System.import
上文我们了解到了通过 System.register 模块注册为 SystemJS Module,我们再介绍 System.import API 来导入模块使用,由于在 system.js 版本中已经内置了 JSON、CSS、Web Assembly 的加载器,我们无需自己再写注册,我们可以使用 System.import 来直接导入它们,相当于在原生 ES Modules 中我们将 import 声明语句用作 import() 来动态加载模块。
导入 JSON
js
{
"test": "123"
}
<script>
System.import('example.json').then(function (module) {
console.log(module.default); // { "test": "123" }, 即 json 导入为 js 对象
});
</script>
导入 CSS
js
.red {
color: red;
}
// Polyfill: document.adoptedStyleSheets
<script defer src="https://unpkg.com/construct-style-sheets-polyfill@2.1.0/adoptedStyleSheets.min.js"></script>
<script>
System.import('file.css').then(function (module) {
const styleSheet = module.default;
document.adoptedStyleSheets = [
...document.adoptedStyleSheets,
styleSheet
];
});
</script>
导入 Web Assembly
js
// function called from Wasm
export function exampleImport (num) {
return num * num;
}
<script type="systemjs-importmap">
{
"imports": {
"example": "./wasm-dependency.js"
}
}
</script>
<script>
System.import('/wasm-module.wasm').then(function (m) {
console.log(m.exampleExport(5)); // 25
});
</script>
整合工作流
看到这里,你也许会觉得如果使用 SystemJS Module 和 System.import 来代替普通 js 文件和 import 声明语句,是我们难以适应的工作流,我们可以通过构建工具来解决,比如 Webpack。在 > 4.30.0 版本的 Webpack 已经内置了 SystemJS 编译器,可以将 ES Modules 编译成通过 System.Register 注册的 SystemJS Module,那么在开发工作流中我们就可以使用标准 ES Module 语法,而运行环境中,实际只会存在一种模块规范,即 SystemJS Module。
json
{
...,
output: {
...
// 指定导出格式为 system
libraryTarget: 'system',
}
...,
}
如果 Webpack 版本小于 5,我们还需要加入一下配置,来防止全局 System 被覆盖
json
{
...,
module: {
rules: [
{ parser: { system: false } }
]
}
...,
}
通过上述工具,我们就可以使用我们习惯的开发工作流
js
import React from "react";
import ReactDOM from "react-dom";
ReactDOM.render("hello world", document.getElementById("id"));
自动编译成 -->
js
System.register(["react", "react-dom"], function (_export, _context) {
"use strict";
var React, ReactDOM;
return {
setters: [
function (_react) {
React = _react.default;
},
,
function (_reactDom) {
ReactDOM = _reactDom.default;
},
],
execute: async function () {
ReactDOM.render("hello world", document.getElementById("id"));
},
};
});
当然了,我们也可以在应用加载到运行环境之前,判断浏览器环境是否支持原生 ES Modules 和 importmap,如果不支持,我们再加载 SytemJS,将 SystemJS 作为一种降级兼容手段。像社区中有很多开源项目,例如 jspm.org,一个类似于 npm 的 bundless 管理工具,也是用 SystemJS 来做浏览器兼容性的降级适配的
总结
本文介绍了原生 ES Module 的使用方法和 SystemJS 的核心 API 的使用,使用 SystemJS 可以给我们带来以下好处
- 在运行环境中可以获得统一的模块标准格式,使得我们可以使用不同模块规范的脚本,而无需关心处理不同模块加载之间的协同问题。
- 模块不一定需要使用 SystemJS 的默认语法来开发,我们可以在本地环境中使用熟知的 ES Module、CommonJS、UMD、AMD 模块规范进行开发,通过 Webpack 或 Babel,统一构建为 SystemJS Module 在运行环境中使用。
- 基于 importmap,我们可以很轻松的约束项目中的依赖,并通过 importmap scopes 来集中约束不同模块所加载的依赖版本。
- 通过注册后,每个 SystemJS Module 模块可以获得隔离的运行环境。
- 通过 SystemJS 的 API,我们可以主动发现当前运行环境中的其他模块。
不过 SystemJS 也不是没有缺陷。如果要说唯一的遗憾那就是 SystemJS Module 和原生 ES Module 之间无法直接共享状态和依赖,所以我们在运行环境中只能选择使用两者其中之一,将所有模块构建到这个统一的标准,当然,会丢失一些灵活性。不过基于现在完善和快速的构建工具,相信也并不会带来多少麻烦。
看到这里,我想屏幕前的你应该明白了,为什么我会在微前端核心系列的开篇以模块加载为主题来铺垫。是希望诉说模块加载是贯穿 JavaScript 发展史的重要议题,为什么我会选择 SystemJS 来做呢,我认为 JavaScript 模块的发展趋势一定是 ES Module 和 Bundleless,作为时代的产物,SystemJS 能够很好的帮助我们早日适应新特性,同时,社区在其对于 Module 的扩展功能上做出了很多贡献和想法,也能反推 ES Module 的发展与流行。我希望在我的微前端设计中,它不仅仅是技术无关的,像 React,Vue,Wasm 等等,而且也是模块加载技术无关的,你无需担忧模块的格式是否能与框架和主应用适配,只需要通过工具构建到统一标准。就像曾经的数据线接口五花八门,人们花了数十年的时间确定了并且成功推进到确定 Type-C 接口为统一标准。在"接入"这一个事项中,确立一个接入标准往往是你在想如何接入之前要确定的事情,因为接入标准约束了你在开发行为上能走多宽的路。
本系列将通过多篇幅渐进式微前端核心特性,下一期会介绍如何通过 SystemJS 来做微前端中的加载,以及它能在微前端中带来哪些新特性,欢迎收藏。
作者:ES2049 / 兰礼
文章可随意转载,但请保留此 原文链接。
非常欢迎有激情的你加入 ES2049 Studio,简历请发送至 caijun.hcj@alibaba-inc.com 。