1

背景

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

十年前,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  声明语句导入模块。

<script>
  import myModule from "myModule";
</script>

我们直接在普通脚本中使用  import  声明语句会报错

SyntaxError: import declarations may only appear at top level of a module。

需要把  type="module"  放到  script  标签中,来声明这个脚本是一个模块:

<script type="module">
  import myModule from "myModule";
</script>

那么,在声明过后是不是意味着我们可以像使用了  Webpack  构建的环境中开发一样去写  import  声明语句呢?

答案还是不能,在浏览器环境中,没有任何路径的模块是不被允许的,import  语句声明的模块路径必须是相对或绝对的  URL  路径。

<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,让你可以访问该对象的导出,例如

import("/modules/myModule.js").then((module) => {
  // Do something with the module.
});

Import Map  导入映射

前面提到,在原生  ES Module  标准中,没有任何路径的模块是不被允许的,为了解决在  JavaScript  脚本中,能够不用每次都写全路径去导入,而是可以像导入一个  NPM  包一样,不指定任何路径,即裸模块(Bare Module),我们可以使用导入映射功能  Import Map。

在  script  标签上添加  type="importmap"声明一个导入映射标签。

<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 时不指定路径了

<script type="module">
  // import myModule from "myModule";
  import myModuleA from "myModuleA";
  import myModuleB from "myModuleB";
  import myModuleC from "myModuleC";
</script>

当然啦,你也不一定非要指定为裸模块,它们也可以包含路径分隔符或者以路径分隔符结尾,或者是绝对  URL  和相对  URL。不过注意,如果模块路径标识如果是以/结尾,那么映射路径也需要以/结尾

<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>

我们可以用更巧妙的方式,利用分隔符结尾以实现通配的效果,从而实现导入模块的不同版本

<script type="importmap">
  {
    "imports": {
      "myModule": "./modules/myModule/index.js",
      "myModule/": "./modules/myModule/"
    }
  }
</script>
/* 导入通用版本 */
import myModule from "myModule";
/* 导入 v1 版本 */
import myModuleV1 from "myModule/v1.js";
/* 导入 v2 版本 */
import myModuleV2 from "myModule/v2.js";

Import Map Scope  导入映射作用域

导入映射除了通过  imports  字段声明映射路径意外,还可以通过  scope  字段来声明,在导入时仅有路径中包含指定路径的模块才能加载的模块。例如,我们可以以此来实现加载模块的不同版本。

现在我们有两个模块脚本,他们导出了不同的版本  version 标识。

/*
 * ./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。

<script type="importmap">
  {
    "imports": {
      "myModuleV1": "./myModule/v1.js"
      "myModuleV2": "./myModule/v2.js"
    }
  }
</script>

那如果我们希望每次导入时,模块路径都是  myModule,自动在模块中识别  v1  和  v2  版本,我们可以通过  scopes  字段再声明一段映射,仅当模块路径匹配声明路径时,加载对应的模块映射路径。

<script type="importmap">
  {
    "imports": {
      "myModule": "./myModule/v1.js"
    },
    "scopes": {
      "./myModule/custom/": {
        "myModule": "./myModule/v2.js"
      }
    }
  }
</script>

由于我们指定了  scopes  映射,除了  ./myModule/custom/  路径意外的所有地方,我们导入的是  myModule  都是  v1  版本。

<script type="module">
  import myModule from "myModule";
  console.log(myModule); // v1
</script>

那么在 myModule/custom/  下的模块脚本,在声明导入 myModule 模块时,会匹配到我们声明的映射,自动加载  v2  版本。

/*
 * ./myModule/custom/index.js
 */
import myModule from "myModule";
console.log(myModule); // v2

以上即为通过  importmap  通过映射巧妙的控制在不同模块中,使用不同的依赖。它使得我们可以在实际开发中,无需在声明导入时时刻关心导入的依赖版本,而将依赖管理通过映射声明,通过模块路径匹配来集中约束。

那么到这为止,标准  ES Module  的使用方法我们就简单介绍完了。

Can I use importmap?

image

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  所用的平均耗时对比,与原生方式加载相比,性能相差很小。

ToolUncachedCached
Native modules1668ms49ms
SystemJS2334ms81ms

如何使用  SystemJS

SystemJS Importmap

因本文介绍其基本用法,故我们选择在文档中使用官方完全体版本,即  system.js  版本

<script src="https://cdn.jsdelivr.net/npm/systemjs/dist/system.min.js"></script>

在文档上引入之后  window  全局对象上就多了  system  属性,我们就可以使用  system  提供的  importmap  了,相较于原生  importmap,我们需要在其前面加上  system  前缀,基本用法与原生  importmap  语法相同

<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  前缀

<script type="systemjs-module"></script>

添加  src  属性

<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 Formats.jssystem.jsFile Extension
System.register✔️✔️*
JSON Modulesvia Module Types extra✔️*.json
CSS Modulesvia Module Types extra✔️*.css
Web Assemblyvia Module Types extra✔️*.wasm
Global variablevia Global extra✔️*
AMDvia AMD extravia AMD extra*
UMDvia AMD extravia AMD extra*

与其说  SystemJS  提出了一种新的规范,笔者更愿意称其提出了一种标准,一种模块注册方式,通过模块注册  API Systemjs.register,我们可以手写模块注册方式,来“教”浏览器如何解析并执行这个对于浏览器来说是“陌生”模块。

System.register

如上文所说,Systemjs.register 是一个模块注册器  API,我们可以将我们熟知的  React  库的  UMD  版本注册为  SystemJS Module,让其以统一的模块标准在工作流中流通,首先我们在 systemjs-importmap 中声明  React  映射路径

<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  里声明的模块,它的一个参数是我们需要注册的模块名的列表,第二个参数是其回调函数,具体见下

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

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  的第三个参数实现。

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 文件注册
 */
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
{
  "test": "123"
}

<script>
  System.import('example.json').then(function (module) {
    console.log(module.default); // { "test": "123" }, 即 json 导入为 js 对象
  });
</script>
导入  CSS
.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
// 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。

{
  ...,
  output: {
    ...
    // 指定导出格式为 system
    libraryTarget: 'system',
  }
  ...,
}

如果  Webpack  版本小于  5,我们还需要加入一下配置,来防止全局  System  被覆盖

{
  ...,
  module: {
    rules: [
      { parser: { system: false } }
    ]
  }
  ...,
}

通过上述工具,我们就可以使用我们习惯的开发工作流

import React from "react";
import ReactDOM from "react-dom";

ReactDOM.render("hello world", document.getElementById("id"));

自动编译成  -->

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

ES2049
3.7k 声望3.2k 粉丝