single-spa 概述

single-spa 是一个实现微前端架构的框架。

在 single-spa 框架中有三种类型的微前端应用:

  1. single-spa application / parcel:微前端架构中的微应用,可以使用 vue、react、angular 等框架。
  1. single-spa Application 和路由相关联的,根据路由决定访问哪个微应用。
  2. single-spa Parcel 的使用方式和前者一样,区别是这种类型的微应用不和路由进行关联,它主要是用于跨应用共享 UI 组件的
  1. single-spa root config:创建微前端容器应用,通过容器应用加载和管理普通的微应用。
  2. utility modules:公共模块应用,非渲染组件,用于跨应用共享 javascript 逻辑的微应用。

使用 create-single-spa 脚手架创建容器应用

初始化项目

# 创建工作目录,存放每个微应用(实际开发中每个微应用一般都是放在不同的开发人员的电脑中)
mkdir workspace
cd workspace

# 全局安装脚手架
npm i create-single-spa -g
# 脚手架创建微应用
create-single-spa

# 如果不想将脚手架安装到全局,可以使用 npx 运行脚手架
# npx create-single-spa
? Directory for new project container # 创建项目的文件夹(默认 ./)
? Select type to generate single-spa root config # 创建什么类型的应用
? Which package manager do you want to use? npm # 使用什么工具安装 package
? Will this project use Typescript? No # 是否使用 TS
? Would you like to use single-spa Layout Engine No # 是否使用 single-spa 布局引擎
? Organization name (can use letters, numbers, dash or underscore) study # 组织名称

组织名称可以理解为团队名称,微前端架构允许多团队共同开发应用,组织名称可以标识应用由哪个团队开发。

应用名称的命名规则为 @组织名称/项目名称,比如 @study/todos

安装完后启动应用:

cd container
npm start

访问:http://localhost:9000/,看到 Welcome 欢迎页面即表示成功。

容器应用默认代码解析

容器应用默认应该不包含任何页面,但是在 single-spa 的容器应用启动后显示了 Welcome 欢迎页面,这是因为在 single-spa 的容器应用中默认注册了一个微应用,名为 @single-spa/welcome,下面解析一下 single-spa 容器应用默认代码。

前端微服务架构vue 前端微服务singlespa_微前端

src 目录用于存放源代码文件:

  • index.ejs 是模板文件
  • study-root-config.js 是应用入口文件

注意:在整个微前端项目中只有一个模板文件,也就是说,其它微应用是不包含模板文件的。

xxx-root-config.js

// container\src\study-root-config.js
// 引入两个方法:
// - registerApplication: 用于注册微应用
// - start: 用来启动微前端应用
import { registerApplication, start } from 'single-spa'

/**
 * 注册一个微应用(默认的 Welcome 欢迎页面)
 * name {String} - 微应用名称 `@组织名称/项目名称`
 * app {() => <Function | Promise>} - 一个返回加载的模块或 Promise 的函数
 * activeWhen - 指定微应用在什么条件下激活
 */
registerApplication({
  // welcome 微应用名称
  name: '@single-spa/welcome',

  // 通过 systemjs 引用打包好的微应用模块代码
  app: () => System.import('https://unpkg.com/single-spa-welcome/dist/single-spa-welcome.js'),

  // 使用数组,指定首页路由下激活
  activeWhen: ['/']
})

// registerApplication({
//   name: "@study/navbar",
//   app: () => System.import("@study/navbar"),
//   activeWhen: ["/"]
// });

// 启动当前应用
// start 方法必须在 single-spa 的配置文件中调用
// 调用 start 之前,应用会被加载,但不会初始化、挂载或卸载
start({
  // 是否可以通过 history.pushState() 和 history.replaceState() 更改触发 single-spa 路由
  // true: 不允许; false: 允许
  // 默认是 false
  // 在某些情况下,将此设置为true可以提高性能
  urlRerouteOnly: true
})

index.ejs

以下拆分并解析主要内容。

引入 single-spa 和配置预加载:

<!-- 引入公共模块地址 -->
  <script type="systemjs-importmap">
    {
      "imports": {
        "single-spa": "https://cdn.jsdelivr.net/npm/single-spa@5.9.0/lib/system/single-spa.min.js"
      }
    }
  </script>
  <!-- 预加载 single-spa -->
  <link rel="preload" href="https://cdn.jsdelivr.net/npm/single-spa@5.9.0/lib/system/single-spa.min.js" as="script">

引入 systemjs 模块加载器(区分了开发环境,为了引入压缩版本):

<!-- 引入模块加载器 -->
  <!-- isLocal(Boolean) 表示是否是本地开发环境 -->
  <% if (isLocal) { %>
  <!-- 开发环境 引入未压缩版本 -->
  <!-- systemjs 模块加载器 -->
  <script src="https://cdn.jsdelivr.net/npm/systemjs@6.8.3/dist/system.js"></script>
  <!-- systemjs 用来解析 AMD (浏览器优先)模块的插件(如果不使用 AMD 模块可以不引入) -->
  <script src="https://cdn.jsdelivr.net/npm/systemjs@6.8.3/dist/extras/amd.js"></script>
  <% } else { %>
  <!-- 其它环境 引入压缩版本 -->
  <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>
  <% } %>

引入 root-config 容器应用模块,并通过 system.import() 加载:

<!-- 引入容器应用模块 -->
  <!-- 开发环境指定本地地址 -->
  <% if (isLocal) { %>
  <script type="systemjs-importmap">
    {
      "imports": {
        "@study/root-config": "//localhost:9000/study-root-config.js"
      }
    }
  </script>
  <% } %>
      
  <!-- 加载容器应用模块 -->
  <script>
    System.import('@study/root-config');
  </script>

或者不使用 import-map,直接引入:

<!-- 加载容器应用模块 -->
  <script>
    System.import('./study-root-config.js');
  </script>

引入浏览器调试工具(single-spa)并使用(需安装浏览器插件):

官方介绍:
single-spa-inspector | single-spasingle-spa-inspector | single-spa

<!-- 调试工具:用于覆盖通过 import-map 设置的 JavaScript 模块地址 -->
  <script src="https://cdn.jsdelivr.net/npm/import-map-overrides@2.2.0/dist/import-map-overrides.js"></script>

  <!-- 调试工具。可以通过浏览器调试工具(single-spa-Inspector)更改注册的微应用模块的地址 -->
  <!-- 例如,将线上环境的模块地址更改为开发环境的模块地址 -->
  <import-map-overrides-full show-when-local-storage="devtools" dev-libs></import-map-overrides-full>

前端微服务架构vue 前端微服务singlespa_.net_02

创建不基于框架的微应用

创建一个不基于框架(如 React、Vue)的微应用,只能手动创建,无法用 create-single-spa 脚手架创建。

在 workspace 目录下创建文件夹 foo 存放微应用。

1、添加 package.json

依赖的版本号同上面创建的容器应用中对应依赖一样:

{
  "name": "foo",
  "version": "1.0.0",
  "description": "",
  "main": "webpack.config.js",
  "scripts": {
    "start": "webpack serve"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "dependencies": {
    "@babel/core": "^7.15.0",
    "single-spa": "^5.9.3",
    "webpack": "^5.51.0",
    "webpack-cli": "^4.8.0",
    "webpack-config-single-spa": "^5.0.0",
    "webpack-dev-server": "^4.0.0",
    "webpack-merge": "^5.8.0"
  }
}

npm install 安装依赖。

2、添加 webpack.config.js 配置文件

// foo\webpack.config.js
const { merge } = require('webpack-merge')

// 引入 single-spa 的默认 webpack 配置
const singleSpaDefaults = require('webpack-config-single-spa')

module.exports = () => {
  const defaultConfig = singleSpaDefaults({
    // 微应用组织名称
    orgName: 'study',
    // 微应用项目名称
    projectName: 'foo'
  })

  return merge(defaultConfig, {
    devServer: {
      port: 9001
    }
  })
}

3、添加入口文件

入口文件的命名规则为 <orgName>-<projectName>.js,当前为 src/study-foo.js

single-spa 框架要求在每个微应用的入口文件中必须导出 3 个返回 Promise 的生命周期函数。

这三个周期函数都是应用级别的,分别为启动、挂载和卸载,容器应用要通过微应用提供的三个周期函数执行微应用的启动、挂载和卸载。

// foo\src\study-foo.js
let fooContainer = null

// 启动
export async function bootstrap() {
  console.log('应用正在启动')
}

// 挂载
export async function mount() {
  console.log('应用正在挂载')
  fooContainer = document.createElement('div')
  fooContainer.id = 'fooContainer'
  fooContainer.innerHTML = 'Hello Foo'
}

// 卸载
export async function unmount() {
  console.log('应用正在卸载')
}

当前还不会触发卸载,因为还没有路由功能,添加路由功能后,从当前应用跳转到另一个应用的时候就会触发卸载。

4、在容器应用中注册微应用

container/src/study-root-config.js 添加代码:

registerApplication({
  name: '@study/foo',
  app: () => System.import('@study/foo'),
  activeWhen: ['/foo']
})

container/src/index.ejs 中配置 foo 微应用引入地址:

<!-- 引入容器应用模块 -->
  <!-- 开发环境指定本地地址 -->
  <% if (isLocal) { %>
  <script type="systemjs-importmap">
    {
      "imports": {
        "@study/root-config": "//localhost:9000/study-root-config.js",
        "@study/foo": "//localhost:9001/study-foo.js"
      }
    }
  </script>
  <% } %>

5、启动微应用

npm start 启动微应用,访问容器应用页面 http://localhost:9000/foo,页面中显示了 Hello Foo 内容。

但是 Welcome 微应用也在当前路由显示了,这是因为 /foo 同样匹配到了 /,修改注册配置以完全匹配 / 地址:

registerApplication({
  name: '@single-spa/welcome',
  app: () => System.import('https://unpkg.com/single-spa-welcome/dist/single-spa-welcome.js'),
  // 使用数组,指定首页路由下激活
  // activeWhen: ['/']
  // 使用一个返回 Boolean 的函数指定激活条件
  activeWhen: location => location.pathname === '/'
})

再次访问 /foo 就不会显示 Welcome 微应用了。

创建基于 React 框架的微应用

使用 create-single-spa 可以创建基于 React、Vue、Angular 框架的微应用。

1、创建微应用

在 workspace 目录下打开命令行工具:

create-single-spa
? Directory for new project todos
? Select type to generate single-spa application / parcel
? Which framework do you want to use? react
? Which package manager do you want to use? npm
? Will this project use Typescript? No
? Organization name (can use letters, numbers, dash or underscore) study
? Project name (can use letters, numbers, dash or underscore) todos

2、修改启动端口

修改 package.json 启动脚本:

"scripts": {
    "start": "webpack serve --port 9002",
    ...
  }

npm start 启动微应用。

3、在容器应用中注册微应用

container/src/study-root-config.js 添加代码:

registerApplication({
  name: '@study/todos',
  app: () => System.import('@study/todos'),
  activeWhen: ['/todos']
})

container/src/index.ejs 中配置 foo 微应用引入地址:

<!-- 引入容器应用模块 -->
  <!-- 开发环境指定本地地址 -->
  <% if (isLocal) { %>
  <script type="systemjs-importmap">
    {
      "imports": {
        "@study/root-config": "//localhost:9000/study-root-config.js",
        "@study/foo": "//localhost:9001/study-foo.js",
        "@study/todos": "//localhost:9002/study-todos.js"
      }
    }
  </script>
  <% } %>

4、配置 react 相关模块引入地址

single-spa 创建的 React 微应用默认不会打包 react 和 react-dom 模块,它认为这两个应该是公共模块,所以需要在容器应用中手动指定这两个模块的引入地址。

<!-- 引入公共模块地址 -->
  <script type="systemjs-importmap">
    {
      "imports": {
        "single-spa": "https://cdn.jsdelivr.net/npm/single-spa@5.9.0/lib/system/single-spa.min.js",
        "react": "https://cdn.jsdelivr.net/npm/react@17.0.2/umd/react.production.min.js",
        "react-dom": "https://cdn.jsdelivr.net/npm/react-dom@17.0.2/umd/react-dom.production.min.js"
      }
    }
  </script>

5、查看效果

现在访问 localhost:9000/todos 看到页面中显示 @study/todos is mounted! 就表示该应用已经注册好了。

6、代码解析

todos 中的源代码文件主要是两个:

  • study-todos.js 入口文件
  • root.component.js 根组件文件
// todos\src\study-todos.js
import React from 'react'
import ReactDOM from 'react-dom'
import singleSpaReact from 'single-spa-react'
import Root from './root.component'

// 用于创建基于 React 的微应用
const lifecycles = singleSpaReact({
  // 传递 react 相关模块
  React,
  ReactDOM,
  // 传递根组件
  rootComponent: Root,
  // 错误边界处理
  errorBoundary(err, info, props) {
    // Customize the root error boundary for your microfrontend here.
    // 可以编写 jsx
    return null
  }
})

// singleSpaReact 方法返回的对象包含管理应用的三个周期函数
export const { bootstrap, mount, unmount } = lifecycles
// todos\src\root.component.js
export default function Root(props) {
  // props.name 即注册微应用时指定的微应用名称(name)
  return <section>{props.name} is mounted!</section>
}

7、挂载位置

默认微应用的根组件会挂载在 body 目录下 id 为 single-spa-application:<微应用名称> 的节点上:

前端微服务架构vue 前端微服务singlespa_System_03

要想自定义挂载位置可以在创建微应用的时候指定挂载节点:

// todos\src\study-todos.js

// 用于创建基于 React 的微应用
const lifecycles = singleSpaReact({
  React,
  ReactDOM,
  rootComponent: Root,
  // 指定根组件的挂载节点: 一个返回 DOM 的函数
  domElementGetter: () => document.getElementById('root'),
  errorBoundary(err, info, props) {
    return <div>发生错误时此处内容将会被渲染</div>
  }
})

还要在模板文件 container\src\index.ejs 中添加挂载节点:

<div id="root"></div>

再次查看页面:

前端微服务架构vue 前端微服务singlespa_微前端_04

基于 React 框架的微前端应用配置路由

1、创建组件

// todos\src\Home.js
const Home = () => {
  return <div>Home works</div>
}

export default Home
// todos\src\About.js
const About = () => {
  return <div>About works</div>
}

export default About

2、修改根组件

注意:这里使用的是 v5 版本的 api

// todos\src\root.component.js
import React from 'react'
import { BrowserRouter, Route, Link, Redirect, Switch } from 'react-router-dom'
import Home from './Home'
import About from './About'

export default function Root(props) {
  return (
    <BrowserRouter basename="/todos">
      <div>
        <Link to="/home">Home</Link>
        <Link to="/about">About</Link>
      </div>
      <Switch>
        <Route path="/home">
          <Home />
        </Route>
        <Route path="/about">
          <About />
        </Route>
        <Route path="/">
          <Redirect to="/home" />
        </Route>
      </Switch>
    </BrowserRouter>
  )
}

3、配置公共模块

react-router-dom 应该作为公共模块被引入,修改 container\src\index.ejs

<!-- 引入公共模块地址 -->
  <script type="systemjs-importmap">
    {
      "imports": {
        "single-spa": "https://cdn.jsdelivr.net/npm/single-spa@5.9.0/lib/system/single-spa.min.js",
        "react": "https://cdn.jsdelivr.net/npm/react@17.0.2/umd/react.production.min.js",
        "react-dom": "https://cdn.jsdelivr.net/npm/react-dom@17.0.2/umd/react-dom.production.min.js",
        "react-router-dom": "https://cdn.jsdelivr.net/npm/react-router-dom@5.3.0/umd/react-router-dom.min.js"
      }
    }
  </script>

webpack 配置禁止打包 react-router-dom:

// todos\webpack.config.js
const { merge } = require("webpack-merge");
const singleSpaDefaults = require("webpack-config-single-spa-react");

module.exports = (webpackConfigEnv, argv) => {
  const defaultConfig = singleSpaDefaults({
    orgName: "study",
    projectName: "todos",
    webpackConfigEnv,
    argv,
  });

  return merge(defaultConfig, {
    // 禁止打包 react-router-dom
    externals: ['react-router-dom']
  });
};

4、重新启动

修改配置需要重新启动 todos 应用 npm start

创建基于 Vue 框架的微应用

1、创建微应用

创建 Vue 框架微应用的过程中,在输入组织名称后,create-single-spa 会下载 vue-cli 工具,下载完成后使用 vue-cli 继续创建 Vue 项目,创建完成后回到 create-single-spa 创建应用流程中继续填写项目名称:

create-single-spa
? Directory for new project todos
? Select type to generate single-spa application / parcel
? Which framework do you want to use? react
? Which package manager do you want to use? npm
? Will this project use Typescript? No
? Organization name (can use letters, numbers, dash or underscore) study
# 下载 vue-cli 后继续创建 vue 项目,只提示选择使用的 vue 版本,本例选择 vue2.x
# > Default ([Vue 2] babel, eslint)

2、配置 vue 相关公共模块

single-spa 在创建 Vue 微应用的时候并未将 vue 和 vue-router 作为公共模块配置,需要手动修改配置。

首先配置 webpack 不打包 vue 和 vue-router,在 realworld 目录下添加文件 vue-config-js

// realworld\vue.config.js
module.exports = {
  chainWebpack: config => {
    // 禁止打包的模块
    config.externals(['vue', 'vue-router'])
  }
}

配置公共模块引入地址:

<!-- 引入公共模块地址 -->
  <script type="systemjs-importmap">
    {
      "imports": {
        "single-spa": "https://cdn.jsdelivr.net/npm/single-spa@5.9.0/lib/system/single-spa.min.js",
        "react": "https://cdn.jsdelivr.net/npm/react@17.0.2/umd/react.production.min.js",
        "react-dom": "https://cdn.jsdelivr.net/npm/react-dom@17.0.2/umd/react-dom.production.min.js",
        "react-router-dom": "https://cdn.jsdelivr.net/npm/react-router-dom@5.3.0/umd/react-router-dom.min.js",
        "vue": "https://cdn.jsdelivr.net/npm/vue@2.6.14/dist/vue.min.js",
        "vue-router": "https://cdn.jsdelivr.net/npm/vue-router@3.5.3/dist/vue-router.min.js"
      }
    }
  </script>

注意:该 CDN 地址加载的 vue 和 vue-router 模块是 AMD 类型的,所以请确保 systemjs 的 AMD 模块解析器被引入。

3、启动项目

修改 realworld/package.json

"scripts": {
    "start": "vue-cli-service serve --port 9003",
    "build": "vue-cli-service build",
    "lint": "vue-cli-service lint",
    "serve:standalone": "vue-cli-service serve --mode standalone"
  },

启动项目 npm start

4、注册微应用

// container\src\study-root-config.js
registerApplication({
  name: '@study/realworld',
  app: () => System.import('@study/realworld'),
  activeWhen: ['/realworld']
})

5、引入应用

Vue 项目下并没有 src/study-realword.js 文件,它的入口源文件是 src/main.js,项目打包后仍会生成名为 js/app.js 的入口文件。

要想知道微应用的入口文件,也可以访问启动应用后的根目录,single-spa 会提示入口文件:

前端微服务架构vue 前端微服务singlespa_开发环境_05

在容器应用中注册微应用:

<!-- 引入容器应用模块 -->
  <!-- 开发环境指定本地地址 -->
  <% if (isLocal) { %>
  <script type="systemjs-importmap">
    {
      "imports": {
        "@study/root-config": "//localhost:9000/study-root-config.js",
        "@study/foo": "//localhost:9001/study-foo.js",
        "@study/todos": "//localhost:9002/study-todos.js",
        "@study/realworld": "//localhost:9003/js/app.js"
      }
    }
  </script>
  <% } %>

访问 http://localhost:9000/realworld 查看效果

6、屏蔽报错

现在虽然 Vue 微应用已经运行到容器中,不过控制台还是报错:

Refused to connect to 'http://xxx.xxx.xxx.xxx:9003/sockjs-node/info?t=1642400465303' because it violates the following Content Security Policy directive: "connect-src https: localhost:* ws://localhost:*".

这是因为模板文件中设置了 Content-Security-Policyconnect-src,它限制了能通过脚本接口加载的 URL。

而 vue-cli 创建的项目默认会向本地 ip(而不是 localhost)发送 websocket 请求,地址如下:

  • http://xxx.xxx.xxx.xxx:9003/sockjs-node/info?t=1642400465303
  • ws://xxx.xxx.xxx.xxx:9003/sockjs-node/938/ddz3z0b0/websocket

可以将本地地址添加进去:http://xxx.xxx.xxx.xxx:* ws://xxx.xxx.xxx.xxx:*

也可以直接将 Content-Security-Policy 注释掉。

基于 Vue 框架的微前端应用配置路由

realworld/src/main.js 是基于 Vue 框架的微应用的入口文件:

// realworld\src\main.js
import Vue from 'vue'
import singleSpaVue from 'single-spa-vue'
import VueRouter from 'vue-router'

import App from './App.vue'

Vue.use(VueRouter)

Vue.config.productionTip = false

// 路由组件
const Bar = { template: '<div>Bar works</div>' }
const Baz = { template: '<div>Baz works</div>' }

// 路由规则
const routes = [
  { path: '/bar', component: Bar },
  { path: '/baz', component: Baz }
]

// 路由实例
const router = new VueRouter({
  routes,
  mode: 'history',
  base: '/realworld'
})

// 创建基于 Vue 的微应用
const vueLifecycles = singleSpaVue({
  // 传入 vue 模块
  Vue,

  // 应用配置
  appOptions: {
    // 路由
    router,
    // 渲染组件
    render(h) {
      return h(App, {
        // 向组件中传递的数据
        props: {
          name: this.name
          // single-spa props are available on the "this" object. Forward them to your component as needed.
          // https://single-spa.js.org/docs/building-applications#lifecyle-props
          // if you uncomment these, remember to add matching prop definitions for them in your App.vue file.
          /*
          name: this.name,
          mountParcel: this.mountParcel,
          singleSpa: this.singleSpa,
          */
        }
      })
    }
  }
})

// 导出必要的生命周期函数
export const bootstrap = vueLifecycles.bootstrap
export const mount = vueLifecycles.mount
export const unmount = vueLifecycles.unmount

修改 App.vue 组件:

<template>
  <div id="app">
    <h1>{{ name }}</h1>
    <div>
      <router-link to="/bar">Bar</router-link>
      <router-link to="/baz">Baz</router-link>
    </div>
    <router-view />
  </div>
</template>

<script>
export default {
  name: 'App',
  props: ['name']
}
</script>

<style></style>

创建 Parcel 应用

Parcel 是用来创建跨框架、跨应用的公共 UI 的。

Parcel 可以使用任意 single-spa 支持的框架(如 React、Vue 等),它也是单独的应用,需要单独启动,但是它不关联路由。

Parcel 应用的模块访问地址也需要被添加到 import-map 中,其它微应用通过 System.import 方法加载该模块。

下面创建一个公共的导航模块,并在其它微应用中使用它。

1、创建基于 React 框架的 Parcel 微应用

create-single-spa
? Directory for new project navbar
? Select type to generate single-spa application / parcel
? Which framework do you want to use? react
? Which package manager do you want to use? npm
? Will this project use Typescript? No
? Organization name (can use letters, numbers, dash or underscore) study
? Project name (can use letters, numbers, dash or underscore) navbar

2、编写导航组件

// navbar\src\root.component.js
import { BrowserRouter, Link } from 'react-router-dom'

export default function Root(props) {
  return (
    <BrowserRouter>
      <div>
        <Link to="/">@single-spa/welcome</Link>
        {' | '}
        <Link to="/foo">@study/foo</Link>
        {' | '}
        <Link to="/todos">@study/todos</Link>
        {' | '}
        <Link to="/realworld">@study/realworld</Link>
      </div>
    </BrowserRouter>
  )
}

3、禁止打包 react-router-dom

// navbar\webpack.config.js
const { merge } = require('webpack-merge')
const singleSpaDefaults = require('webpack-config-single-spa-react')

module.exports = (webpackConfigEnv, argv) => {
  const defaultConfig = singleSpaDefaults({
    orgName: 'study',
    projectName: 'navbar',
    webpackConfigEnv,
    argv
  })

  return merge(defaultConfig, {
    // modify the webpack config however you'd like to by adding to this object
    externals: ['react-router-dom']
  })
}

4、修改脚本&启动应用

修改启动端口:

"scripts": {
    "start": "webpack serve --port 9004",
    ...
  }

npm start 启用应用

5、容器应用中引入模块

当前应用不和路由进行关联,所以不需要在容器应用中注册应用,只需要配置引入地址:

<!-- 引入容器应用模块 -->
  <!-- 开发环境指定本地地址 -->
  <% if (isLocal) { %>
  <script type="systemjs-importmap">
    {
      "imports": {
        "@study/root-config": "//localhost:9000/study-root-config.js",
        "@study/foo": "//localhost:9001/study-foo.js",
        "@study/todos": "//localhost:9002/study-todos.js",
        "@study/realworld": "//localhost:9003/js/app.js",
        "@study/navbar": "//localhost:9004/study-navbar.js"
      }
    }
  </script>
  <% } %>

6、在 React 应用中使用

在 React 应用中使用 Parcel 应用,需要使用 single-spa 提供的组件,将 System.import 加载的模块对象传递给组件的 config 属性:

import Parcel from 'single-spa-react/parcel'
<Parcel config={System.import('@study/navbar')} />

修改代码:

// todos\src\root.component.js
import React from 'react'
import Parcel from 'single-spa-react/parcel'
import { BrowserRouter, Route, Link, Redirect, Switch } from 'react-router-dom'
import Home from './Home'
import About from './About'

export default function Root(props) {
  return (
    <BrowserRouter basename="/todos">
      <Parcel config={System.import('@study/navbar')} />
      <div>
        <Link to="/home">Home</Link>
        <Link to="/about">About</Link>
      </div>
      <Switch>
        <Route path="/home">
          <Home />
        </Route>
        <Route path="/about">
          <About />
        </Route>
        <Route path="/">
          <Redirect to="/home" />
        </Route>
      </Switch>
    </BrowserRouter>
  )
}

访问 http://localhost:9000/todos 查看效果。

7、在 Vue 应用中使用

在 Vue 应用中使用 Parcel 应用,也是使用 single-spa 提供的组件,将加载模块的对象传递给组件,还需要向组件从传递一个方法 mountRootParcel,该方法用于挂载 Parcel。

<!-- realworld\src\App.vue -->
<template>
  <div id="app">
    <Parcel :config="parceConfig" :mountParcel="mountParcel" />
    <h1>{{ name }}</h1>
    <div>
      <router-link to="/bar">Bar</router-link>
      <router-link to="/baz">Baz</router-link>
    </div>
    <router-view />
  </div>
</template>

<script>
import Parcel from 'single-spa-vue/dist/esm/parcel'
import { mountRootParcel } from 'single-spa'

export default {
  name: 'App',
  components: { Parcel },
  props: ['name'],
  data() {
    return {
      parceConfig: window.System.import('@study/navbar'),
      mountParcel: mountRootParcel
    }
  }
}
</script>

<style></style>

**注意1:**在 Vue 微应用中使用 System 要使用 window.System

**注意2:**这里加载了模块 single-spa 的 mountRootParcel 方法,打包时会将 single-spa 一起打包。而容器应用已经将 single-spa 作为公共模块引入,所以 Vue 应用需要配置不打包 single-spa。

// realworld\vue.config.js
module.exports = {
  chainWebpack: config => {
    // 禁止打包的模块
    config.externals(['vue', 'vue-router', 'single-spa'])
  }
}

修改配置后需要重新启动 Vue 应用。

创建跨框架共享的 JavaScript 逻辑

utility modules 用于放置跨应用共享的 JavaScript 逻辑,它也是独立的应用,需要单独构建,单独启动。

创建 utility modules 微应用

create-single-spa
? Directory for new project tools
? Select type to generate in-browser utility module (styleguide, api cache, etc)
? Which framework do you want to use? none
? Which package manager do you want to use? npm
? Will this project use Typescript? No
? Organization name (can use letters, numbers, dash or underscore) study
? Project name (can use letters, numbers, dash or underscore) tools

修改启动命令

"scripts": {
    "start": "webpack serve --port 9005",
    ...
  }

容器应用中引入模块

utility modules 同 Parcel 一样,是在微应用中使用,不需要在容器应用中注册,只需要在容器应用中引入即可。

<!-- 引入容器应用模块 -->
  <!-- 开发环境指定本地地址 -->
  <% if (isLocal) { %>
  <script type="systemjs-importmap">
    {
      "imports": {
        "@study/root-config": "//localhost:9000/study-root-config.js",
        "@study/foo": "//localhost:9001/study-foo.js",
        "@study/todos": "//localhost:9002/study-todos.js",
        "@study/realworld": "//localhost:9003/js/app.js",
        "@study/navbar": "//localhost:9004/study-navbar.js",
        "@study/tools": "//localhost:9005/study-tools.js"
      }
    }
  </script>
  <% } %>

在应用中导出方法

在 utility modules 应用入口文件中导出的方法,可以被其它应用使用。

// tools\src\study-tools.js
// Anything exported from this file is importable by other in-browser modules.
export function sayHello(who) {
  console.log(`%c${who} sayHello`, 'color:skyblue')
}

在 React 应用中使用

// todos\src\Home.js
import { useEffect, useState } from 'react'

// 创建一个自定义工具函数用于加载 tools
function useToolsModule() {
  const [toolsModule, setToolsModule] = useState()
  useEffect(() => {
    System.import('@study/tools').then(setToolsModule)
  })
  return toolsModule
}

const Home = () => {
  const toolsModule = useToolsModule()
  if (toolsModule) {
    // 调用 tools 应用导出的方法
    toolsModule.sayHello('@study/todos')
  }
  return <div>Home works</div>
}

export default Home

在 Vue 应用中使用

添加一个按钮,点击后调用 tools 中的方法。

<!-- realworld\src\App.vue -->
<template>
  <div id="app">
    <Parcel :config="parceConfig" :mountParcel="mountParcel" />
    <h1>{{ name }}</h1>
    <div>
      <router-link to="/bar">Bar</router-link>
      <router-link to="/baz">Baz</router-link>
      <button @click="handleClick">button</button>
    </div>
    <router-view />
  </div>
</template>

<script>
import Parcel from 'single-spa-vue/dist/esm/parcel'
import { mountRootParcel } from 'single-spa'

export default {
  name: 'App',
  components: { Parcel },
  props: ['name'],
  data() {
    return {
      // 注意这里要使用 window.System
      parceConfig: window.System.import('@study/navbar'),
      mountParcel: mountRootParcel
    }
  },
  methods: {
    async handleClick() {
      // 注意这里要使用 window.System
      const toolsModule = await window.System.import('@study/tools')
      toolsModule.sayHello('@study/realworld')
    }
  }
}
</script>

<style></style>

实现跨应用通信

在微前端框架中,一般通过发布订阅模式实现应用间的通信和状态共享。

实现跨应用通信可以借助这两个工具:

  • utility modules:实现跨应用共享 JS 逻辑
  • RxJS:提供发布订阅模式的功能,它是单独的库,与框架无关,可以在任何框架中使用。

在容器应用中引入 RxJS

<!-- 引入公共模块地址 -->
  <script type="systemjs-importmap">
    {
      "imports": {
        "single-spa": "https://cdn.jsdelivr.net/npm/single-spa@5.9.0/lib/system/single-spa.min.js",
        "react": "https://cdn.jsdelivr.net/npm/react@17.0.2/umd/react.production.min.js",
        "react-dom": "https://cdn.jsdelivr.net/npm/react-dom@17.0.2/umd/react-dom.production.min.js",
        "react-router-dom": "https://cdn.jsdelivr.net/npm/react-router-dom@5.3.0/umd/react-router-dom.min.js",
        "vue": "https://cdn.jsdelivr.net/npm/vue@2.6.14/dist/vue.min.js",
        "vue-router": "https://cdn.jsdelivr.net/npm/vue-router@3.5.3/dist/vue-router.min.js",
        "rxjs": "https://cdn.jsdelivr.net/npm/rxjs@7.5.2/dist/bundles/rxjs.umd.min.js"
      }
    }
  </script>

在 utilty modules 中导出方法

RxJS 的 ReplaySubject 方法可以广播历史消息

当一个应用发布消息时,另一个应用可能还未加载,发布历史消息,可以保证应用动态加载后仍可以接受到消息。

// tools\src\study-tools.js
// Anything exported from this file is importable by other in-browser modules.

import { ReplaySubject } from 'rxjs'

export function sayHello(who) {
  console.log(`%c${who} sayHello`, 'color:skyblue')
}

// 实例化一个 ReplaySubject 对象
// 它缓存一组值,除了向现有订阅者发送新值外,还会立即向新订阅者发送缓存的值
export const sharedSubject = new ReplaySubject()

在 React 应用中订阅/发布消息

注意:订阅消息的同时也要考虑取消订阅。

// todos\src\Home.js
import { useEffect, useState } from 'react'

// 创建一个自定义工具函数用于加载 tools
function useToolsModule() {
  const [toolsModule, setToolsModule] = useState()
  useEffect(() => {
    System.import('@study/tools').then(setToolsModule)
  })
  return toolsModule
}

const Home = () => {
  const toolsModule = useToolsModule()

  // 组件挂载完成后调用
  useEffect(() => {
    let subjection = null

    if (toolsModule) {
      // 调用 tools 应用导出的方法
      toolsModule.sayHello('@study/todos')

      // 订阅消息
      subjection = toolsModule.sharedSubject.subscribe(console.log)
    }

    // 返回清理函数,用于在组件卸载时取消订阅
    return () => subjection && subjection.unsubscribe()
  }, [toolsModule])

  return (
    <div>
      Home works
      <button onClick={() => toolsModule.sharedSubject.next('Hello')}>发布消息</button>
    </div>
  )
}

export default Home

在 Vue 应用中订阅/发布消息

<!-- realworld\src\App.vue -->
<template>
  <div id="app">
    <Parcel :config="parceConfig" :mountParcel="mountParcel" />
    <h1>{{ name }}</h1>
    <div>
      <router-link to="/bar">Bar</router-link>
      <router-link to="/baz">Baz</router-link>
      <button @click="handleClick">button</button>
    </div>
    <router-view />
  </div>
</template>

<script>
import Parcel from 'single-spa-vue/dist/esm/parcel'
import { mountRootParcel } from 'single-spa'

export default {
  name: 'App',
  components: { Parcel },
  props: ['name'],
  data() {
    return {
      subjection: null,
      // 注意这里要使用 window.System
      parceConfig: window.System.import('@study/navbar'),
      mountParcel: mountRootParcel
    }
  },
  methods: {
    async handleClick() {
      // 注意这里要使用 window.System
      const toolsModule = await window.System.import('@study/tools')
      toolsModule.sayHello('@study/realworld')
    }
  },
  async mounted() {
    const toolsModule = await window.System.import('@study/tools')
    // 订阅消息
    this.subjection = toolsModule.sharedSubject.subscribe(console.log)
  },
  beforeDestroy() {
    // 取消订阅
    this.subjection.unsubscribe()
  },
}
</script>

<style></style>

测试

访问 React 的 todos 应用页面,点击**“发送消息”**按钮,控制台打印了消息内容,这是 todos 应用订阅的处理事件。

然后再切换到 Vue 的 realworld 应用页面,组件挂载后,控制台也会打印消息内容,这是 realworld 应用定于的处理事件。

说明 ReplaySubject 广播了历史消息,其它动态加载的应用也可以在加载后收到广播的历史消息,从而可以实现跨应用通信和状态共享。

布局引擎 Layout Engine

布局引擎允许使用组件的方式声明顶层路由,即访问什么地址激活什么应用,这种方式类似 React 中配置路由的方式。

布局引擎还提供了更加便捷的路由 API 来注册应用,从而改变现在通过多次调用 registerApplication 注册应用的方式。

安装布局引擎

在 workspace/container 容器应用下安装:

# 本例安装的版本是 single-spa-layout@2.0.1
npm install single-spa-layout

构建路由

在模板文件中添加一个 <template> 元素,在里面配置路由,之后在 js 文件中需要获取这个 <template> 元素。

<body>
  <template id="single-spa-layout">
    <single-spa-router>
      <!-- 不包含在 route 组件下的应用是公共模块,会在每个页面都显示 -->
      <application name="@study/navbar"></application>

      <!-- 包含在 route 组件下的应用会在指定路由下显示 -->
      <!-- 应用会在 path 指定的路由下显示 -->
      <!-- default 是默认路由(/) -->
      <route default>
        <application name="@single-spa/welcome"></application>
      </route>
      <route path="foo">
        <application name="@study/foo"></application>
      </route>
      <route path="todos">
        <application name="@study/todos"></application>
      </route>
      <route path="realworld">
        <application name="@study/realworld"></application>
      </route>
    </single-spa-router>
  </template>

  <main></main>
  <div id="root"></div>
  <!-- 加载容器应用模块 -->
  <script>
    System.import('@study/root-config')
    // System.import('./study-root-config.js');
  </script>
  <!-- 调试工具。可以通过浏览器调试工具(single-spa-Inspector)更改注册的微应用模块的地址 -->
  <!-- 例如,将线上环境的模块地址更改为开发环境的模块地址 -->
  <import-map-overrides-full show-when-local-storage="devtools" dev-libs></import-map-overrides-full>
</body>

@single-spa/welcome 应用当前是在注册时指定的引入地址,要在 import-map 中添加一下:

<!-- 引入容器应用模块 -->
    <!-- 开发环境指定本地地址 -->
    <% if (isLocal) { %>
    <script type="systemjs-importmap">
      {
        "imports": {
          "@study/root-config": "//localhost:9000/study-root-config.js",
          "@study/foo": "//localhost:9001/study-foo.js",
          "@study/todos": "//localhost:9002/study-todos.js",
          "@study/realworld": "//localhost:9003/js/app.js",
          "@study/navbar": "//localhost:9004/study-navbar.js",
          "@study/tools": "//localhost:9005/study-tools.js",
          "@single-spa/welcome": "https://unpkg.com/single-spa-welcome/dist/single-spa-welcome.js"
        }
      }
    </script>
    <% } %>

获取路由信息 && 注册应用

现在已经通过组件的方式配置了路由,可以将它理解为一种语法糖,可以通过它获取一个数组,数组中包含的每个对象都是一个有效的 registerApplication 方法的参数,这样就可以在 js 文件中使用了:

// container\src\study-root-config.js
import { registerApplication, start } from 'single-spa'
import { constructRoutes, constructApplications } from 'single-spa-layout'

// 获取路由配置对象
// constructRoutes 会将路由配置解析成一个对象,其中包含用于注册应用的数组
const routes = constructRoutes(document.getElementById('single-spa-layout'))

// 获取路由信息数组
// constructApplications 会解析 routes,返回一个数组,数组的每个元素都是 registerApplication 方法接受的参数对象 `{name,app,activeWhen}`
const applications = constructApplications({
  routes,
  loadApp({ name }) {
    return System.import(name)
  }
})

// 遍历路由信息注册应用
applications.forEach(registerApplication)

start({
  urlRerouteOnly: true
})

现在访问 todos 和 realworld 可以看到两个 navbar,即表示成功。