【微前端】single-spa 到底是个什么鬼

前言

说起微前端框架,很多人第一反应就是 single-spa。但是再问深入一点:它是干嘛的,它有什么用,可能就回答不出来了。

一方面没多少人研究和使用微前端。可能还没来得及用微前端扩展项目,公司就已经倒闭了。

另一方面是中文博客对微前端的研究少之又少,很多文章只是简单翻译一下官方文档,读几个API,放个官方的 Demo 就完事了。很少有深入研究到底 single-spa 是怎么一回事的。

还有一方面是 single-spa 的文档非常难看懂,和 Redux 文档一样喜欢造概念。讲一个东西的时候,总是把别的库拉进来一起讲,把一个简单的东西变得非常复杂。最令人吐槽的一点就是官方的 sample code 都是只言片语,完全拼凑不出来一个 Demo,而 Github 的 Demo 还贼复杂,没解释,光看完都要 clone 好几个 repo。

最后,求人不如求己,刚完源码再刚一下文档。

这篇文章将不会聊怎么搭建一个 Demo,而是会从 “Why” 和 “How” 的角度来聊一下官方文档的都讲了哪些内容,相信看完这篇文章就能看懂 官方的 Demo 了。

一个需求

让我们从一个最小的需求开始说起。有一天产品经理突然说:我们要做一个 A 页面,我看到隔壁组已经做过这个 A 页面了,你把它放到我们项目里吧,应该不是很难吧?明天上线吧。

此时,产品经理想的是:应该就填一个 URL 就好吧?再不行,复制粘贴也很快吧。而程序员想的却是:又要看屎山了。又要重构了。又要联调了。测试数据有没有啊?等一下,联调的后端是谁啊?

估计这是做大项目时经常遇到的需求了:搬运一个现有的页面。我想大多数人都会选择在自己项目里复制粘贴别人的代码,然后稍微重构一下,再测试环境联调,最后上线。

但是,这样就又多了一份代码了,如果别人的页面改了,那么自己项目又要跟着同步修改,再联调,再上线,非常麻烦。

所以程序员就想能不能我填一个 url,然后这个页面就到项目里来了呢?所以,<iframe/> 就出场了。

iframe 的弊端

iframe 就相当于页面里再开个窗口加载别的页面,但是它有很多弊端:

  • 每次进来都要加载,状态不能保留
  • DOM 结构不共享。比如子应用里有一个 Modal,显示的时候只能在那一小块地方展示,不能全屏展示
  • 无法跟随浏览器前进后退
  • 天生的硬隔离,无法与主应用进行资源共享,交流也很困难

而 SPA 正好可以解决上面的问题:

  • 切换路由就是切换页面组件,组件的挂载和卸载非常快
  • 单页应用肯定共享 DOM
  • 前端控制路由,想前就前,想后就后
  • React 通信有 Redux,Vue 通信有 Vuex,可与 App 组件进行资源共享,交流很爽

这就给我们一个启发:能不能有这么一个巨型 SPA 框架,把现有的 SPA 当成 Page Component 来组装成一个新的 SPA 呢?这就是微前端的由来。

微前端是什么

微前端应该有如下特点:

  • 技术栈无关,主框架不限制接入应用的技术栈,微应用具备完全自主权
  • 独立开发、独立部署,微应用仓库独立,前后端可独立开发,部署完成后主框架自动完成同步更新
  • 增量升级,在面对各种复杂场景时,我们通常很难对一个已经存在的系统做全量的技术栈升级或重构,而微前端是一种非常好的实施渐进式重构的手段和策略
  • 独立运行时,每个微应用之间状态隔离,运行时状态不共享

等一下等一下,说了一堆,到底啥是 single-spa 啊。

嘿嘿,single-spa 框架并没有实现上面任何特点,对的,一个都没有,Just Zero。

single-spa 到底是干嘛的

single-spa 仅仅是一个子应用生命周期的调度者。single-spa 为应用定义了 boostrap, load, mount, unmount 四个生命周期回调:

只要写过 SPA 的人都能理解,无非就是生、老、病、死。不过有几个点需要注意一下:

  • Register 不是生命周期,指的是调用 registerApplication 函数这一步
  • Load 是开始加载子应用,怎么加载由开发者自己实现(等会会说到)
  • Unload 钩子只能通过调用 unloadApplication 函数才会被调用

OK,上面 4 个生命周期的回调顺序是 single-spa 可以控制的,我能理解,那什么时候应该开始这一套生命周期呢?应该是有一个契机来开始整套流程的,或者某几个流程的。

契机就是当 window.location.href 匹配到 url 时,开始走对应子 App 的这一套生命周期嘛。所以,single-spa 还要监听 url 的变化,然后执行子 app 的生命周期流程。

到此,我们就有了 single-spa 的大致框架了,无非就两件事:

  • 实现一套生命周期,在 load 时加载子 app,由开发者自己玩,别的生命周期里要干嘛的,还是由开发者造的子应用自己玩
  • 监听 url 的变化,url 变化时,会使得某个子 app 变成 active 状态,然后走整套生命周期

画个草图如下:

是不是感觉 single-spa 很鸡贼?虽然 single-spa 说自己是微前端框架,但是一个微前端的特性都没有实现,都是需要开发者在加载自己子 App 的时候实现的,要不就是通过一些第三方工具实现。

注册子应用

有了上面的了解之后,我们再来看 single-spa 里最重要的 API:registerApplication,表示注册一个子应用。使用如下:

singleSpa.registerApplication({
    name: 'taobao', // 子应用名
    app: () => System.import('taobao'), // 如何加载你的子应用
    activeWhen: '/appName', // url 匹配规则,表示啥时候开始走这个子应用的生命周期
    customProps: { // 自定义 props,从子应用的 bootstrap, mount, unmount 回调可以拿到
        authToken: 'xc67f6as87f7s9d'
    }
})

singleSpa.start() // 启动主应用

上面注册了一个子应用 'taobao'。我们自己实现了加载子应用的方法,通过 activeWhen 告诉 single-spa 什么时候要挂载子应用,好像就可以上手开撸代码喽。

可以个鬼!请告诉我 System.import 是个什么鬼。哦,是 SystemJS,诶,SystemJS 听说过,它是个啥?为啥要用 SystemJS?凭啥要用 SystemJS?

SystemJS

相信很多人看过一些微前端的博客,它们都会说 single-spa 是基于 SystemJS 的。错!single-spa 和 SystemJS 一点关系都没有!这里先放个主应用和子应用的关系图:

single-spa 的理念是希望主应用可以做到非常非常简单的和轻量,简单到只要一个 index.html + 一个 main.js 就可以完成微前端工程,连 Webpack 都不需要,直接在浏览器里执行 singleSpa.registerApplication 就收工了,这种执行方式也就是 in-browser 执行。

但是,浏览器里执行 JS,别说实现 import xxx from 'https://taobao.com' 了,我要是在浏览器里实现 ES6 的 import/export 都不行啊: import axios from 'axios'

其实,也不是不行,只需要在 <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!`);
}

但是,遇到导入模块依赖的,像 import axios from 'axios' 这样的,就需要 importmap 了:

<script type="importmap">
    {
       "imports": {
          "vue": "https://cdn.jsdelivr.net/npm/vue@2.6.12/dist/vue.esm.browser.js"
       }
    }
</script>

<div id="container">我是:{{name}}</div>

<script type="module">
  import Vue from 'vue'

  new Vue({
    el: '#container',
    data: {
      name: 'Jack'
    }
  })
</script>

importmap 的功能就是告诉 'vue' 这个玩意要从 "https://cdn.jsdelivr.net/npm/vue@2.6.12/dist/vue.esm.browser.js" 这里来的。不过,importmap 现在只有 Chrome 是支持的。

所以,SystemJS 就将这一块补齐了。当然,除了 importmap,它还有很多的功能,比如获取当前加载的所有模块、当前模块的 URL、可以 import html, import css,import wasm。

等等,这在 Webpack 不也可以做到么?Webpack 还能 import less, import scss 呢?这不比 SystemJS 牛逼?对的,如果不是因为要在浏览器使用 import/export,没人会用 SystemJS。SystemJS 的好处和优势有且仅有一点:那就是在浏览器里使用 ES6 的 import/export。

而正因为 SystemJS 可以在浏览器里可以使用 ES6 的 import/export 并支持动态引入,正好符合 single-spa 所提倡的 in-browser 执行思路,所以 single-spa 文档里才反复出现 SystemJS 的身影,而且 Github Demo 里依然是使用 SystemJS 的 importmap 机制来引入不同模块:

<script type="systemjs-importmap">
    {
      "imports": {
        "@react-mf/root-config": "//localhost:9000/react-mf-root-config.js"
      }
    }
</script>

<script>
  singleSpa.registerApplication({
    name: 'taobao', // 子应用名
    app: () => System.import('@react-mf/root-config'), // 如何加载你的子应用
    activeWhen: '/appName', // url 匹配规则,表示啥时候开始走这个子应用的生命周期
    customProps: { // 自定义 props,从子应用的 bootstrap, mount, unmount 回调可以拿到
        authToken: 'xc67f6as87f7s9d'
    }
  })
</script>

公共依赖

SystemJS 另一个好处就是可以通过 importmap 引入公共依赖。

假如,我们有三个子应用,它们都有公共依赖项 antd,那每个子应用打包出来都会有一份 antd 的代码,就显示很冗余。

一个解决方法就是在主应用里,通过 importmap 直接把 antd 代码引入进来,子应用在 Webpack 设置 external 把 antd 打包时排除掉。子应用打包就不会把 antd 打包进去了,体积也变小了。

有人会说了:我用 CDN 引入不行嘛?不行啊,因为子应用的代码都是 import {Button} from 'antd' 的,浏览器要怎么直接识别 ES6 的 import/export 呢?那还不得 SystemJS 嘛。

难道 Webpack 就没有办法可以实现 importmap 的效果了么?Webpack 5 提出的 Module Federation 模块联邦就可以很好地做的 importmap 的效果。这是 Webpack 5 的新特性,使用的效果和 importmap 差不多。关于模块联邦是个啥,可以参考 这篇文章

至于用 importmap 还是 Webpack 的 Module Federation,singles-spa 是推荐使用 importmap 的,但是,文档也没有反对使用 Webpack 的 Module Federation 的理由。能用就OK。

SystemJS vs Webpack ES

有人可能会想:都 1202 年了,怎么还要在浏览器环境写 JS 呢?不上个 Webpack 都不好意思说自己是前端开发了。

没错,Webpack 是非常强大的,而且可以利用 Webpack 很多能力,让主应用变得更加灵活。比如,写 less,scss,Webpack 的 prefetch 等等等等。然后在注册子应用时,完全可以利用 Webpack 的动态引入:

singleSpa.registerApplication({
    name: 'taobao', // 子应用名
    app: () => import('taobao'), // 如何加载你的子应用
    activeWhen: '/appName', // url 匹配规则,表示啥时候开始走这个子应用的生命周期
    customProps: { // 自定义 props,从子应用的 bootstrap, mount, unmount 回调可以拿到
        authToken: 'xc67f6as87f7s9d'
    }
})

那为什么 single-spa 还要推荐 SystemJS 呢?个人猜测是因为 single-spa 希望主应用应该就一个空壳子,只需要管内容要放在哪个地方,所有的功能、交互都应该交由 index.html 来统一管理

当然,这仅仅是一种理念,可以完全不遵循它。像我个人还是喜欢用 Webpack 多一点,SystemJS 还是有点多余,而且觉得有点奥特曼了。不过,为了跟着文档的节奏来,这里假设就用 SystemJS 来实现主应用。

Root Config

由于 single-spa 非常强调 in-browser 的方式来实现主应用,所以 index.html 就充当了静态资源、子应用的路径声明的角色。

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <meta http-equiv="X-UA-Compatible" content="ie=edge">
  <title>Polyglot Microfrontends</title>
  <meta name="importmap-type" content="systemjs-importmap" />
  <script type="systemjs-importmap" src="https://storage.googleapis.com/polyglot.microfrontends.app/importmap.json"></script>
  <% if (isLocal) { %>
  <script type="systemjs-importmap">
    {
      "imports": {
        "@polyglot-mf/root-config": "//localhost:9000/polyglot-mf-root-config.js"
      }
    }
  </script>
  <% } %>
  <script src="https://cdn.jsdelivr.net/npm/import-map-overrides@2.2.0/dist/import-map-overrides.js"></script>
  <script src="https://cdn.jsdelivr.net/npm/systemjs@6.8.3/dist/system.min.js"></script>
  <script src="https://cdn.jsdelivr.net/npm/systemjs@6.8.3/dist/extras/amd.min.js"></script>
</head>
<body>
  <script>
    System.import('@polyglot-mf/root-config');
    System.import('@polyglot-mf/styleguide');
  </script>
  <import-map-overrides-full show-when-local-storage="devtools" dev-libs></import-map-overrides-full>
</body>
</html>

而 main.js 则实现子应用注册、主应用启动。

import { registerApplication, start } from "single-spa";

registerApplication({
  name: "@polyglot-mf/navbar",
  app: () => System.import("@polyglot-mf/navbar"),
  activeWhen: "/",
});

registerApplication({
  name: "@polyglot-mf/clients",
  app: () => System.import("@polyglot-mf/clients"),
  activeWhen: "/clients",
});

registerApplication({
  name: "@polyglot-mf/account-settings",
  app: () => loadWithoutAmd("@polyglot-mf/account-settings"),
  activeWhen: "/settings",
});

start();

// A lot of angularjs libs are compiled to UMD, and if you don't process them with webpack
// the UMD calls to window.define() can be problematic.
function loadWithoutAmd(name) {
  return Promise.resolve().then(() => {
    let globalDefine = window.define;
    delete window.define;
    return System.import(name).then((module) => {
      window.define = globalDefine;
      return module;
    });
  });
}

像这样的资源声明 + 主子应用加载的组件,single-spa 称之为 Root Config。 它不是什么新概念,就只有两个东西:

  • 一个主应用的 index.html
  • 一个执行 registerApplication 函数的 JS 文件

single-spa-layout

虽然一个 index.html 是完美的轻量微前端主应用,但是就算再压缩主应用的交互,那总得告诉子应用放置的位置吧,那不还得 DOM API 一把梭?一样麻烦?

为了解决这个问题,single-spa 说:没事,我帮你搞,然后造了 single-spa-layout。具体使用请看代码:

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

不能说和 Vue Router 很像,只能说一模一样吧。当然上面这么写很直观,但是浏览器并不认识这些元素,所以 single-spa-layout 把识别这些元素的逻辑都封装成了函数,并暴露给开发者,开发者只要调用一下就能识别出 appName 等信息了:

import { registerApplication, start } from 'single-spa';
import {
  constructApplications,
  constructRoutes,
  constructLayoutEngine,
} from 'single-spa-layout';

// 获取 routes
const routes = constructRoutes(document.querySelector('#single-spa-layout'));

// 获取所有的子应用
const applications = constructApplications({
  routes,
  loadApp({ name }) {
    return System.import(name); // SystemJS 引入入口 JS
  },
});

// 生成 layoutEngine
const layoutEngine = constructLayoutEngine({ routes, applications });

// 批量注册子应用
applications.forEach(registerApplication);

// 启动主应用
start();

没什么好说的,constrcutRoutes, constructApplicationconstructLayoutEngine 本质上就是识别 single-spa-layout 定义的元素标签,然后获取里面的属性,再通过 registerApplication 函数一个个注册就完事了。

改造子应用

上面说的都是主应用的事情,现在我们来关心一下子应用。

子应用最关键的一步就是导出 bootstrap, mount, unmount 三个生命周期钩子。

import SubApp from './index.tsx'

export const bootstrap = () => {}
export const mount = () => {
  // 使用 React 来渲染子应用的根组件
  ReactDOM.render(<SubApp/>, document.getElementById('root'));
}
export const unmount = () => {}

single-spa-react, single-spa-vue, single-spa-angular, single-spa-xxx, ...

emmmm,怎么说的呢,上面三个 export 不太好看,能不能有一种更直接的方法就实现 3 个生命周期的导出呢?

single-spa 说:可以啊,搞!所以有了 single-spa-react:

import React from 'react';
import ReactDOM from 'react-dom';
import SubApp from './index.tsx';
import singleSpaReact, {SingleSpaContext} from 'single-spa-react';

const reactLifecycles = singleSpaReact({
  React,
  ReactDOM,
  rootComponent: SubApp,
  errorBoundary(err, info, props) {
    return (
      <div>出错啦!</div>
    );
  },
});

export const bootstrap = reactLifecycles.bootstrap;
export const mount = reactLifecycles.mount;
export const unmount = reactLifecycles.unmount;

single-spa 说:我不能单给 react 搞啊,别的框架也要给它们整上一个,一碗水端平,所以有这了这些牛鬼蛇神:

不禁感慨:这些小轮子是真能造啊。

导入子应用的 CSS

不知道你有没有注意到,在刚刚的子应用注册里我们仅仅用 System.import 导入了一个 JS 文件,那 CSS 样式文件怎么搞呢?可能可以 System.import('xxx.css') 来导入。

但是,这又有问题了:在切换了应用时,unmount 的时候要怎么把已有的 CSS 给删掉呢?官方说可以这样:

const style = document.createElement('style');
style.textContent = `.settings {color: blue;}`;

export const mount = [
  async () => {
    document.head.appendChild(styleElement);
  },
  reactLifecycles.mount,
]

export const unmount = [
  reactLifecycles.unmount,
  async () => {
    styleElement.remove();
  }
]

我:single-spa,求求你做个人吧,搭个 Demo,还要我来处理 CSS?single-spa 说:好,等我再去造一个轮子。于是,就有了 single-spa-css。用法如下:

import singleSpaCss from 'single-spa-css';

const cssLifecycles = singleSpaCss({
  // 这里放你导出的 CSS,如果 webpackExtractedCss 为 true,可以不指定
  cssUrls: ['https://example.com/main.css'],

  // 是否要使用从 Webpack 导出的 CSS,默认为 false
  webpackExtractedCss: false,

  // 是否 unmount 后被移除,默认为 true
  shouldUnmount: true,

  // 超时,不废话了,都懂的
  timeout: 5000
})

const reactLifecycles = singleSpaReact({...})

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

export const mount = [
  // 加入到子应用的 mount 里,一定要在前面,不然 mount 后会有样式闪一下的问题
  cssLifecycles.mount,
  reactLifecycles.mount
]

export const unmount = [
  // 和 mount 同理
  reactLifecycles.unmount,
  cssLifecycles.unmount
]

这里要注意一下,上面的 https://example.com/main.css 并没有看起来那么简单易用。

假如你用了 Webpack 来打包,很有可能会用分包或者 content hash 来给 CSS 文件命名,比如 filename: "[name].[contenthash].css"。那请问 cssUrls 要怎么写呀,每次都要改 cssUrls 参数么?太麻烦了吧。

single-spa-css 说:我可以通过 Webpack 导出的 __webpack_require__.cssAssetFileName 获取导出之后的真实 CSS 文件名。ExposeRuntimeCssAssetsPlugin 这个插件正好可以解决这个问题。这么一来 cssUrls 就可以不用指定了,直接把 Webpack 导出的真实 CSS 名放到 cssUrls 里了。

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({
      // The filename here must match the filename for the MiniCssExtractPlugin
      filename: "[name].css",
    }),
  ],
};

子应用 CSS 样式隔离

虽然 single-spa-css 解决了子应用的 CSS 引入和移除问题,但是又带来了另一个问题:怎么保证各个子应用的样式不互相干扰呢?官方给出的建议是:

第一种方法:使用 Scoped CSS,也即在子应用的 CSS 选择器上加前缀就好了嘛,像这样:

.app1__settings-67f89dd87sf89ds {
  color: blue;
}

要是嫌麻烦,可以在 Webpack 使用 PostCSS Prefix Selector 给样式自动加前缀:

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

module.exports = {
  plugins: [
    prefixer({
      prefix: "#single-spa-application\\:\\@org-name\\/project-name"
    })
  ]
}

另一种方法是在加载子应用的函数里,将子应用挂载到 Shadow DOM 上,可以实现完美的样式隔离。Shadow DOM 是什么,怎么玩可见 MDN这里

公共 CSS 样式怎么处理

上面说的都是子应用自己的 CSS 样式,那如果子应用之间要共享 CSS 怎么办呢?比如有两个子应用都用了 antd,那都要 import 两次 antd.min.css 了。

这个问题和上面提到的处理“公共依赖”的问题是差不多的。官方给出两个建议:

  1. 将公共的 CSS 放到 importmap 里,也可以理解为在 index.html 里直接加个 link 获取 antd 的 CSS 完事
  2. 将所有的公共的 UI 库都 import 到 utility 里,将 antd 所有内容都 export,再把 utility 包放到 importmap 里,然后 import { Button } from '@your-org-name/utility'; 去引入里面的组件

其实上面两个方法都大同小异,思路都是在主应用一波引入,只是一个统一引入CSS,另一个统一引入 UI 库。

子应用的 JS 隔离

我们来想想应用的 JS 隔离本质是什么,本质其实就是在 B 子应用里使用 window 全局对象里的变量时,不要被 A 子应用给污染了。

一个简单的解决思路就是:在 mount A 子应用时,正常添加全局变量,比如 jQuery 的 $, lodash 的 _。在 unmount A 子应用时,用一个对象记录之前给 window 添加的全局变量,并把 A 应用里添加 window 的变量都删掉。下一次再 mount A 应用时,把记录的全局变量重新加回来就好了。

single-spa 再次站出来:这个不用你自己手动记录 window 的变更了。single-spa-leaked-globals 已经实现好了,直接用就好了:

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

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

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

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

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

export const unmount = [
  leakedGlobalsLifecycles.unmount, // 删掉新添加的全局变量
  frameworkLifecycles.unmount,
]

但是,这个库的局限性在于:每个 url 只能加一个子 app,如果多个子 app 之间还是会访问同一个 window 对象,也因此会互相干扰,并不能做到完美的 JS 沙箱。

比如:一个页面里,导航栏用 3.0 的 jQuery,而页面主体用 5.0 的 jQuery,那就会有冲突了。

所以这个库的场景也仅限于:首页用 3.0 的 jQuery,订单详情页使用 5.0 的 jQuery 这样的场景。

子应用的分类

上面我们说到了,当 url 匹配 activeWhen 参数时,就会执行对应子应用的生命周期。那这样就相当于子应用和 url 绑定在了一起了。

我们再来看 single-spa-leaked-globals,single-spa-css 这些库,虽然它们也导出了生命周期,但这些生命周期与页面渲染、url 变化没有多大关系。

它们与普通的 application 唯一不同的地方就是:普通 application 的生命周期是通过 single-spa 来自动调度的,而这些库是要通过手动调度的。只不过我们一般选择在子应用里的生命周期里手动调用它们而已。

这种与 url 无关的 “app” 在微前端也有着非常重要的作用,一般是在子应用的生命周期里提供一些功能,像 single-spa-css 就是在 mount 时添加 <link/> 标签。single-spa 将这样的 “类子 app” 称为 Parcel。

同时,single-spa 还分出另一个类:Utility Modules。很多子应用都用 antd, dayjs, axios 的,那么就可以搞一个 utility 集合这些公共库,然后统一做 export,然后在 importmap 里统一导入。子应用就可以不需要在自己的 package.json 里添加 antd, dayjs, axios 的依赖了。

总结一下,single-spa 将微前端分为三大类:

分类 功能 导出 是否与 url 有关
Application 子应用 bootstrap, mount, unmount
Parcel 功能组件,比如子应用的生命周期打一些补丁 bootstrap, mount, unmount, update
Utility Module 公共资源 所有公共资源

create-single-spa

上面介绍了一堆的与子应用相关的库,如果自己要从 0 开始慢慢地配置子应用就比较麻烦。所以,single-spa 说:不麻烦,有脚手架工具,一行命令生成子应用,都给您配好了。

npm install --global create-single-spa

# 或者
yarn global add create-single-spa

然后

create-single-spa

注意!这里的 create-single-spa 指的是创建子应用!

总结

以上就是 singles-spa 文档里的所有内容了(除了 SSR 和 Dev Tools,前者用的不多,后者自己看一下就会了,不多废话)。由于本文是通过发现问题到解决问题来讲述文档内容的,所以从头看到尾还是有点乱,这里就做一下总结:

微前端概念

特点:

  • 技术栈无关
  • 独立开发、独立部署
  • 增量升级
  • 独立运行时

single-spa

只做两件事:

  • 提供生命周期概念,并负责调度子应用的生命周期
  • 挟持 url 变化事件和函数,url 变化时匹配对应子应用,并执行生命周期流程

三大分类:

  • Application:子应用,和 url 强相关,交由 single-spa 调用生命周期
  • Parcel:组件,和 url 无关,手动调用生命周期
  • Utility Module:统一将公共资源导出的模块

“重要”概念

  • Root Config:指主应用的 index.html + main.js。HTML 负责声明资源路径,JS 负责注册子应用和启动主应用
  • Application:要暴露 bootstrap, mount, umount 三个生命周期,一般在 mount 开始渲染子 SPA 应用
  • Parcel:也要暴露 bootstrap, mount, unmount 三个生命周期,可以再暴露 update 生命周期。Parcel 可大到一个 Application,也可以小到一个功能组件。与 Application 不同的是 Parcel 需要开发都手动调用生命周期

SystemJS

可以在浏览器使用 ES6 的 import/export 语法,通过 importmap 指定依赖库的地址。

和 single-spa 没有关系,只是 in-browser import/export 和 single-spa 倡导的 in-browser run time 相符合,所以 single-spa 将其作为主要的导入导出工具。

用 Webpack 动态引入可不可以,可以,甚至可能比 SystemJS 好用,并无好坏之分。

single-spa-layout

和 Vue Router 差不多,主要功能是可以在 index.html 指定在哪里渲染哪个子应用。

single-spa-react, single-spa-xxx....

给子应用快速生成 bootstrap, mount, unmount 的生命周期函数的工具库。

single-spa-css

隔离前后两个子应用的 CSS 样式。

在子应用 mount 时添加子应用的 CSS,在 unmount 时删除子应用的 CSS。子应用使用 Webpack 导出 CSS 文件时,要配合 ExposeRuntimeCssAssetsPlugin 插件来获取最终导出的 CSS 文件名。

算实现了一半的 CSS 沙箱。

如果要在多个子应用进行样式隔离,可以有两种方法:

  • Shadow DOM,样式隔离比较好的方法,但是穿透比较麻烦
  • Scoped CSS,在子应用的 CSS 选择器上添加前缀做区分,可以使用 postcss-prefix-selector 这个包来快速添加前缀

single-spa-leaked-globals

在子应用 mount 时给 window 对象恢复/添加一些全局变量,如 jQuery 的 $ 或者 lodash 的 _,在 unmount 时把 window 对象的变量删掉。

实现了“如果主应用一个url只有一个页面”情况下的 JS 沙箱。

公共依赖

有两种方法处理:

  • 造一个 Utility Module 包,在这个包导出所有公共资源内容,并用 SystemJS 的 importmap 在主应用的 index.html 里声明
  • 使用 Webpack 5 Module Federation 特性实现公共依赖的导入

哪个更推荐?都可以。

最后

single-spa 文档就这些了嘛?没错,就这些了。文档好像给了很多“最佳实践”,但真正将所有“最佳实践”结合起来并落地的又没多少。

比如文档说用 Shadow CSS 来做子应用之间的样式隔离,但是 single-spa-leaked-globals 又不让别人在一个 url 上挂载多个子应用。感觉很不靠谱:这里行了,那里又不行了。

再说回 Shadow CSS 来做样式隔离,但是也没有详细说明要具体要怎么做。像这样的例子还有很多:文档往往只告诉了一条路,怎么走还要看开发者自己。这就你给人一种 “把问题只解决一半” 的感觉。

如果真的想用 single-spa 来玩小 Demo 的,用上面提到的小库来搭建微前端是可以的,但是要用到生产环境真的没那么容易。

所以,为了填平 single-spa 遗留下来的坑,阿里基于 single-spa 造出了 qiankun 微前端框架,真正实现了微前端的所有特性,不过这又是另外一个故事了。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 158,847评论 4 362
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,208评论 1 292
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,587评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,942评论 0 205
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,332评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,587评论 1 218
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,853评论 2 312
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,568评论 0 198
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,273评论 1 242
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,542评论 2 246
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,033评论 1 260
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,373评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,031评论 3 236
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,073评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,830评论 0 195
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,628评论 2 274
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,537评论 2 269

推荐阅读更多精彩内容