理解微前端技术原理中我们介绍了微前端的概念和核心技术原理。本篇我们结合目前业内主流的微前端实现 single-spaopen in new window 来说明在生产实践中是如何实现微前端的。

single-spa 的文档略显凌乱,概念也比较多,初次接触它的同学容易抓不住重点。今天我们尝试整理出一条清晰的脉络,让感兴趣的同学能够快速理解它。

在 single-spa 的架构设计中,有两种主要角色,主应用和子应用,如下图。

uml diagram

主应用力求足够简单,只负责子应用的调度,业务逻辑都由子应用来承担。

核心能力

其实总结来说,single-spa 的核心就是定义了一套协议。通过这套协议,主应用可以方便的知道在什么情况下激活哪个子应用。而这套协议主要包含两个部分:主应用的配置信息和子应用的生命周期函数

主应用的配置信息

在 single-spa 中,这个配置信息叫 Root Configopen in new window

下面的样例展示了配置信息的结构:

{
  name: "subApp1",
  app: () => System.import("/path/to/subApp1/code"),
  activeWhen: "/subApp1",
}
1
2
3
4
5

name 就是子应用的名称,app 函数告诉主应用如何加载子应用的代码,activeWhen 告诉主应用何时激活子应用,也可以为一个返回布尔值的函数。

通过 registerApplicationopen in new window 将子应用的信息注册到主应用中。

样例如下:

singleSpa.registerApplication({
    name: 'appName',
    app: () => System.import('appName'),
    activeWhen: '/appName',
    customProps: {
        authToken: 'xc67f6as87f7s9d'
    }
})
1
2
3
4
5
6
7
8

子应用的生命周期函数

主应用在管理子应用的时候,通过子应用暴露的生命周期函数来实现子应用的启动和卸载。

主要有如下几个生命周期函数。

  • bootstrap

    这个生命周期函数会在应用第一次挂载前执行一次。就是说在子应用的代码加载完成以后,页面渲染之前执行。函数形式如下:

    export function bootstrap(props) {
      return Promise
        .resolve()
        .then(() => {
          // 可以在这里部署只执行一次的初始化代码
          console.log('bootstrapped!')
        });
    }
    
    1
    2
    3
    4
    5
    6
    7
    8
  • mount

    当主应用判定需要激活这个子应用时会调用这个生命周期函数。在这个函数中实现子应用的挂载、页面渲染等逻辑。这个函数也只会执行一次。我们可以简单的理解为 ReactDOM.render 操作。函数形式如下:

    export function mount(props) {
      return Promise.resolve().then(() => {
        // 页面渲染逻辑
        console.log('mounted!');
      });
    }
    
    1
    2
    3
    4
    5
    6
  • unmount

    当主应用判定需要卸载这个子应用时会调用这个生命周期函数。在这个函数中实现组件卸载、清理事件监听等逻辑。我们可以简单的理解为 ReactDOM.unmountComponentAtNode 操作。函数形式如下:

    export function unmount(props) {
      return Promise.resolve().then(() => {
        // 页面卸载逻辑
        console.log('unmounted!');
      });
    }
    
    1
    2
    3
    4
    5
    6

观察每个生命周期函数的签名我们可以发现,每个函数都有一个 props 参数,主应用可以通过这个参数向子应用传递一些额外信息,后面会做说明。

为了方便各种技术栈的子应用能方便的接入,single-spa 提供了很多工具,可以在这里查到官方维护的工具列表open in new window

其他概念

子应用的分类

single-spa 根据职能的不同,把子应用划分成三类:

不难看出,Parcel 和 Utility 都是为了共享和复用,也算是 single-spa 在框架层面给出的一种复用方案。

Layout Engine

虽然 single-spa 的理念是让主应用尽可能的简单,但是在实践中,主应用通常会负责通用的顶部、底部通栏的渲染。这个时候,如何确定子应用的渲染位置就成了一个问题。

single-spa 提供了 Layout Engineopen in new window的方案。样例代码如下,与 Vue 颇为相似,详细的可以查看文档,这里不做过多叙述。

<html>
  <head>
    <template id="single-spa-layout">
      <single-spa-router>
        <nav class="topnav">
          <application name="@organization/nav"></application>
        </nav>
        <div class="main-content">
          <route path="settings">
            <application name="@organization/settings"></application>
          </route>
          <route path="clients">
            <application name="@organization/clients"></application>
          </route>
        </div>
        <footer>
          <application name="@organization/footer"></application>
        </footer>
      </single-spa-router>
    </template>
  </head>
</html>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22

关于 SystemJS

很多人在提到 single-spa 的时候都会提到 SystemJSopen in new window,认为 SystemJS 是 single-spa 的核心之一。其实这是一个误区, SystemJS 并不是 single-spa 所必须的。

前面说到,子应用要实现生命周期函数,然后导出给主应用使用。关键就是这个“导出”的实现,这就涉及到 JavaScript 的模块化open in new window问题。

在一些现代浏览器中,我们可以通过在 <script> 标签上添加 type="module" 来实现导入导出。

<script type="module" src="module.js"></script>
<script type="module">
  // or an inline script
  import {helperMethod} from './providesHelperMethod.js';
  helperMethod();
</script>

// providesHelperMethod.js
export function helperMethod() {
  console.info(`I'm helping!`);
}
1
2
3
4
5
6
7
8
9
10
11

但是如果我们想要实现 import axios from 'axios' 还需要借助于 importmapopen in new window

<script type="importmap">
    {
       "imports": {
          "axios": "https://cdn.jsdelivr.net/npm/axios@0.20.0/dist/axios.min.js"
       }
    }
</script>
<script type="module">
  import axios from 'axios'
</script>
1
2
3
4
5
6
7
8
9
10

在低版本浏览器中,我们就需要借助于一些 “Polyfill” 来实现模块化了。SystemJS 就是解决这个问题的。所以 single-spa 的样例中大量采用了 SystemJS 来加载应用。

其实也可以不用 SystemJS,webpack 也可以实现类似的能力,但是会加深主应用与子应用间的工程耦合。

隔离

理解微前端技术原理中,我们花了很长的篇幅来说明子应用隔离的思路。那么,single-spa 中是如何来实现隔离的呢?

样式隔离

single-spa 中的样式隔离可以分为两块来说。

首先是子应用样式的加载和卸载。single-spa 提供了 single-spa-cssopen in new window 这个工具来实现。

import singleSpaCss from 'single-spa-css';

const cssLifecycles = singleSpaCss({
  // 需要加载的 css 列表
  cssUrls: ['https://example.com/main.css'],

  // 是否是 webpack 导出的 css,如果是要做额外处理(webpack 导出的文件名通常会有 hash)
  webpackExtractedCss: false,

  // 当子应用 unmount 的时候,css 是否需要一并删除
  shouldUnmount: true,
});

const reactLifecycles = singleSpaReact({...})

// 加入到子应用的 bootstrap 里
export const bootstrap = [
  cssLifecycles.bootstrap,
  reactLifecycles.bootstrap
]

export const mount = [
  // 加入到子应用的 mount 里,css 放前面,不然 mount 后会有样式闪烁(FOUC)的问题
  cssLifecycles.mount,
  reactLifecycles.mount
]

export const unmount = [
  // 后卸载 css,防止样式闪烁
  reactLifecycles.unmount,
  cssLifecycles.unmount
]
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32

如果样式是 webpack 导出的,则每次构建后都要更新样式文件列表。single-spa 贴心的准备了一个插件来解决这个问题。只要在 webpack 的配置文件中添加如下插件即可。

const MiniCssExtractPlugin = require("mini-css-extract-plugin");
const ExposeRuntimeCssAssetsPlugin = require("single-spa-css/ExposeRuntimeCssAssetsPlugin.cjs");

module.exports = {
  plugins: [
    new MiniCssExtractPlugin({
      filename: "[name].css",
    }),
    new ExposeRuntimeCssAssetsPlugin({
      // filename 必须与 MiniCssExtractPlugin 中的 filename 一一对应
      filename: "[name].css",
    }),
  ],
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14

解决了子应用样式加载和卸载问题以后,我们再来看子应用样式隔离的问题。

single-spa 给出了一些建议,比如使用 Scoped CSSopen in new window,每个子应用都有一个固定前缀,类似于下面这样:

/*
<div class="app1__settings-67f89dd87sf89ds"></div>
*/
.app1__settings-67f89dd87sf89ds {
  color: blue;
}

/*
<div data-df65s76dfs class="settings"></div>
*/
.settings[data-df65s76dfs] {
  color: blue;
}

/*
<div id="single-spa-application:@org-name/project-name">
    <div class="settings"></div>
  </div>
*/
#single-spa-application\:\@org-name\/project-name .settings {
  color: blue;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22

有很多工具可以实现 Scoped CSS,比如 CSS Modulesopen in new window 等。

最后一种方式我们可以通过 webpack 自动化的实现。

const prefixer = require('postcss-prefix-selector');

module.exports = {
  plugins: [
    prefixer({
      prefix: "#single-spa-application\\:\\@org-name\\/project-name"
    })
  ]
}
1
2
3
4
5
6
7
8
9

single-spa 也提到了 Shadow DOM,我们在上一篇文章中已经分析过,这里不再赘述了。

JS 隔离

single-spa 采用了类似于快照模式的隔离机制,通过 single-spa-leaked-globalsopen in new window 来实现。

用法如下:

import singleSpaLeakedGlobals from 'single-spa-leaked-globals';

// 其它 single-spa-xxx 提供的生命周期函数
const frameworkLifecycles = ...

// 新添加的全局变量
const leakedGlobalsLifecycles = singleSpaLeakedGlobals({
  globalVariableNames: ['#39;, 'jQuery', '_'],
})

export const bootstrap = [
  leakedGlobalsLifecycles.bootstrap, // 放在第一位
  frameworkLifecycles.bootstrap,
]

export const mount = [
  leakedGlobalsLifecycles.mount, // mount 时添加全局变量,如果之前有记录在案的,直接恢复
  frameworkLifecycles.mount,
]

export const unmount = [
  leakedGlobalsLifecycles.unmount, // 删掉新添加的全局变量
  frameworkLifecycles.unmount,
]
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24

前面已经说过,快照模式的一个缺点是无法保证多个子应用同时运行时的有效隔离。

小结

总体来说,single-spa 算是基本实现了一个微前端框架需要具备的各种功能,但是又实现的不够彻底,遗留了很多问题需要解决。虽然官方提供了很多样例和最佳实践,但是总显得过于单薄,总给人一种“问题解决了,但是又没有完全解决”的感觉。

qiankun 基于 single-spa 开发,一定程度上解决了很多 single-spa 没有解决的问题。我们下篇详细说明。

关注微信公众号,获取最新推送~