微前端核心系列(一)模块加载与System.js

背景

本文作为微前端核心系列的开篇,并不急于深入主题去介绍微前端的应用,而是从微前端的基础即模块加载开始铺垫,因为认为微前端实际上做的第一件事其实就是组合"模块",将一群模块构建成应用。一个好的模块标准和流畅的模块调度,能够使上层应用事半功倍。

十年前,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>

使用导入映射标签需要注意一下几点

  1. 不能再在这个标签上指定 src、async、nomodule、defer、crossorigin、integrity 和 referrerpolicy 属性。
  2. 若有多个 importmap scirpt 标签,也仅处理第一个具有内联定义的导入映射。
  3. 导入映射的定义必须是 JSON 对象,否则会报 TypeError。
  4. 若导入映射不符合规范导致报错,则使用模块加载时会报错。
  5. 导入映射必须在使用所有模块加载之前。

在导入映射声明之后,我们就可以在 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?

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 的三种版本

  1. s.js,最小可用版本,仅 2.8KB,仅包含了模块的注册和 importmap 和一些基本的钩子以供自定义,支持 System 格式和 UMD 格式的模块导入

  2. system.js ,4.2KB,在最小版本的基础上增加了

    1. 一些生命周期的钩子和模块注册删除的方法,以供 Reload 需要
    2. 支持 Wasm、CSS、JSON 等多种格式的模块导入
    3. 包括用于加载全局脚本的 global loading 扩展,用于加载传统上由脚本标签加载的依赖项。
  3. 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 可以给我们带来以下好处

  1. 在运行环境中可以获得统一的模块标准格式,使得我们可以使用不同模块规范的脚本,而无需关心处理不同模块加载之间的协同问题。
  2. 模块不一定需要使用 SystemJS 的默认语法来开发,我们可以在本地环境中使用熟知的 ES Module、CommonJS、UMD、AMD 模块规范进行开发,通过 Webpack 或 Babel,统一构建为 SystemJS Module 在运行环境中使用。
  3. 基于 importmap,我们可以很轻松的约束项目中的依赖,并通过 importmap scopes 来集中约束不同模块所加载的依赖版本。
  4. 通过注册后,每个 SystemJS Module 模块可以获得隔离的运行环境。
  5. 通过 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

相关推荐
还是大剑师兰特25 分钟前
D3的竞品有哪些,D3的优势,D3和echarts的对比
前端·javascript·echarts
王解25 分钟前
【深度解析】CSS工程化全攻略(1)
前端·css
一只小白菜~32 分钟前
web浏览器环境下使用window.open()打开PDF文件不是预览,而是下载文件?
前端·javascript·pdf·windowopen预览pdf
方才coding37 分钟前
1小时构建Vue3知识体系之vue的生命周期函数
前端·javascript·vue.js
阿征学IT41 分钟前
vue过滤器初步使用
前端·javascript·vue.js
王哲晓41 分钟前
第四十五章 Vue之Vuex模块化创建(module)
前端·javascript·vue.js
丶213641 分钟前
【WEB】深入理解 CORS(跨域资源共享):原理、配置与常见问题
前端·架构·web
发现你走远了41 分钟前
『VUE』25. 组件事件与v-model(详细图文注释)
前端·javascript·vue.js
Mr.咕咕44 分钟前
Django 搭建数据管理web——商品管理
前端·python·django
张张打怪兽1 小时前
css-50 Projects in 50 Days(3)
前端·css