跳到主要内容

Single Spa

single-spa 概述

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

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

    • single-spa-[application / parcel] 微前端架构中的微应用,可以使用 vue、react、angular 等框架

      • single-spa-application 是和路由相关联的,比如说当访问/a 路由的时候要访问A 微应用,访问 /b 路由的时候要访问 B 微应用
      • single-spa-parcel 是和 single-spa-application 相关联的一种类型,两者使用方式都是一样的,区别是 single-spa-parcel 不跟路由相关联,主要用于跨应用共享UI组件
    • single-spa root config 创建微前端容器应用

      • 微前端架构中的容器应用

      • 通过容器应用来加载和管理微应用

    • utility modules 公共模块应用,非渲染组件,用于跨应用共享 javascript 逻辑的微应用

​ ❤ 这三种微应用都是独立的应用,均可单独的开发、构建和发布

快速开始-demo

准备工作

安装 single-spa 脚手架工具: npm install create-single-spa@2.0.3 -g 查看版本 npm info create-single-spa

创建容器应用

  1. 创建微前端容器应用: create-single-spa

    1. 应用文件夹填写 container

    2. 应用选择 single-spa root config

    3. 组织名称填写 study

      组织名称可以理解为团队名称,微前端架构允许多团队共同开发应用,组织名称可以标识应用由哪个团队开发。 应用名称的命名规则为 @组织名称/应用名称 ,比如 @study/todos

  2. 启动应用: npm start

  3. 访问应用:localhost:9000

    📢 1. 在容器应用中默认注册了一个微应用

    ​ 2. 在整个微前端项目中只有一个模板文件

  4. 默认代码解析

    1. root-config.js

      // workspace/container/src/study-root-config.js 
      import { registerApplication, start } from "single-spa"

      /**
      * 注册微前端应用
      * 1. name: 字符串类型, 微前端应用名称 "@组织名称/应用名称"
      * 2. app: 函数类型, 返回 Promise, 通过 systemjs 引用打包好的微前端应用模块代码 (umd)
      * 3. activeWhen: 路由匹配时激活应用
      */
      registerApplication({
      name: "@single-spa/welcome",
      app: () => System.import( "https://unpkg.com/single-spa-welcome/dist/single-spa-welcome.js" ),
      activeWhen: ["/"]
      })

      // start 方法必须在 single spa 的配置文件中调用
      // 在调用 start 之前, 应用会被加载, 但不会初始化, 挂载或卸载.
      // 启动微应用
      start({
      // 是否可以通过 history.pushState() 和 history.replaceState() 更改触发 single-spa 路由
      // true 不允许 false 允许
      urlRerouteOnly: true
      })
    2. index.ejs

      <!-- 导入微前端容器应用 -->
      <script>
      // 加载模块
      System.import("@study/root-config")
      </script>

      <!--
      import-map-overrides 可以覆盖导入映射
      当前项目中用于配合 single-spa Inspector 调试工具使用.
      可以手动覆盖项目中的 JavaScript 模块加载地址, 用于调试.
      -->
      <import-map-overrides-full
      show-when-local-storage="devtools"
      dev-libs
      ></import-map-overrides-full>
      <!-- 模块加载器 --> 
      <script src="https://cdn.jsdelivr.net/npm/systemjs@6.8.0/dist/system.min.js"></script>
      <!-- systemjs 用来解析 AMD 模块的插件 -->
      <script src="https://cdn.jsdelivr.net/npm/systemjs@6.8.0/dist/extras/amd.min.js" ></script>
      <!-- 用于覆盖通过 import-map 设置的 JavaScript 模块下载地址 -->
      <script src="https://cdn.jsdelivr.net/npm/import-map- overrides@2.2.0/dist/import-map-overrides.js"></script>
      <!-- 用于支持 Angular 应用 -->
      <script src="https://cdn.jsdelivr.net/npm/zone.js@0.10.3/dist/zone.min.js"> </script>
      <!-- single-spa 预加载 --> 
      <link
      rel="preload"
      href="https://cdn.jsdelivr.net/npm/single-spa@5.8.3/lib/system/single- spa.min.js"
      as="script"
      />
      <!-- JavaScript 模块下载地址 此处可放置微前端项目中的公共模块 --> 
      <script type="systemjs-importmap">
      {
      "imports": {
      "single-spa": "https://cdn.jsdelivr.net/npm/single- spa@5.8.3/lib/system/single-spa.min.js"
      }
      }
      </script>

      📢 single-spa 框架可以支持angular.js 到 11,目前对最新的 Anugular 11 支持还不是太好,所以目前不推荐在 single-spa 中加载 最新的 angular 应用,但是可以做兼容

创建不基于框架的微应用

  1. 应用初始化: mkdir noframe && cd "$_"

  2. 安装依赖

    {
    "name": "noframe",
    "version": "1.0.0",
    "description": "",
    "main": "webpack.config.js",
    "scripts": {
    "start": "webpack serve"
    },
    "keywords": [],
    "author": "",
    "license": "ISC",
    "dependencies": {
    "@babel/core": "^7.12.10",
    "single-spa": "^5.9.0",
    "webpack": "^5.8.0",
    "webpack-cli": "^4.2.0",
    "webpack-config-single-spa": "^2.0.0",
    "webpack-dev-server": "^4.0.0-beta.0",
    "webpack-merge": "^5.4.0"
    }
    }
  3. 配置 webpack

    const singleSpaDefaults = require("webpack-config-single-spa")
    const { merge } = require("webpack-merge")

    module.exports = () => {
    const defaultConfig = singleSpaDefaults({
    orgName: "study", // 组织名称
    projectName: "application" // 微应用的名字
    })
    return merge(defaultConfig, {
    devServer: {
    port: 9001
    }
    })
    }
  4. 建立入口文件,文件名称规则:组织名称-项目名称 (要和配置的地方对应哦~)src/study-application.js

  5. 在应用入口文件中导出微前端应用所需的生命周期函数(是应用级别的生命周期函数哦~),生命周期函数必须返回 Promise

    let div_container = null

    export async function bootstrap () {
    console.log('I am starting up...')
    }

    export async function mount () {
    console.log("I'm mounting...")
    div_container = document.createElement('div')
    div_container.innerHTML = 'hello world'
    document.body.appendChild(div_container)
    }

    export async function unmount () {
    console.log('I will be unmount')
    document.body.removeChild(div_container)
    }
  6. 在 package.json 文件中添加应用启动命令

    "scripts": {
    "start": "webpack serve"
    },
  7. 在微前端容器应用中注册微前端应用

    registerApplication({
    name: "@study/application",
    app: () => System.import("@study/application"),
    activeWhen: ["/application"]
    })
  8. 在模板文件中指定模块访问地址

    <script type="systemjs-importmap">
    {
    "imports": {
    "@study/application": "//localhost:9001/study-application.js",
    }
    }
    </script>
  9. 修改默认应用代码

    // 注意: 参数的传递方式发生了变化, 原来是传递了一个对象, 对象中有三项配置, 现在是传递了三 个参数 
    registerApplication(
    "@single-spa/welcome",
    () => System.import( "https://unpkg.com/single-spa-welcome/dist/single-spa-welcome.js" ),
    location => location.pathname === "/"
    )

创建基于 React 的微应用

  1. 创建应用: create-single-spa

    1. 应用目录输入 todos
    2. 框架选择 react
  2. 修改应用端口 && 启动应用

    "scripts": {
    "start": "webpack serve --port 9002",
    }
  3. 注册应用,将 React 项目的入口文件注册到基座应用中

    registerApplication({
    name: "@study/todos",
    app: () => System.import("@study/todos"),
    activeWhen: ["/todos"]
    })
  4. 指定微前端应用模块的引用地址

    <!--
    在注册应用时 systemjs 引用了 @study/todos 模块, 所以需要配置该模块的引用地址
    -->
    <script type="systemjs-importmap">
    {
    "imports": {
    "@study/todos": "//localhost:9002/study-todos.js",
    }
    }
    </script>
  5. 指定公共库的访问地址 默认情况下,应用中的 react 和 react-dom 没有被 webpack 打包, 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",
    "react": "https://cdn.jsdelivr.net/npm/react@17.0.1/umd/react.production.min.js",
    "react-dom": "https://cdn.jsdelivr.net/npm/react-dom@17.0.1/umd/react-dom.production.min.js",
    "react-router-dom": "https://cdn.jsdelivr.net/npm/react-router-dom@5.2.0/umd/react-router-dom.min.js"
    }
    }
    </script>
  6. 微前端 React 应用入口文件代码解析

    // react、react-dom 的引用是 index.ejs 文件中 import-map 中指定的版本
    import React from "react"
    import ReactDOM from "react-dom"
    // single-spa-react 用于创建使用 React 框架实现的微前端应用
    import singleSpaReact from "single-spa-react"
    // 用于渲染在页面中的根组件
    import Root from "./root.component"

    // 创建基于 React 框架的微前端应用, 返回生命周期函数对象
    const lifecycles = singleSpaReact({
    React,
    ReactDOM,
    rootComponent: Root,
    // 错误边界函数
    errorBoundary(err, info, props) {
    // Customize the root error boundary for your microfrontend here.
    return null
    },
    // 指定根组件的渲染位置
    domElementGetter: () => document.getElementById("root")
    })

    // 暴露必要的生命周期函数
    export const { bootstrap, mount, unmount } = lifecycles

  7. 路由配置

    // 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>
    )
    }
  8. 修改 webpack 配置

    const { merge } = require("webpack-merge");
    const singleSpaDefaults = require("webpack-config-single-spa-react");

    module.exports = (webpackConfigEnv, argv) => {
    const defaultConfig = singleSpaDefaults({
    orgName: "mic-demo",
    projectName: "reactframe",
    webpackConfigEnv,
    argv,
    });

    return merge(defaultConfig, {
    externals: ["react-router-dom"]
    });
    };

创建基于 Vue 的微应用

  1. 创建应用: create-single-spa

    1. 项目文件夹填写 vueframe
    2. 框架选择 Vue
    3. 生成 Vue 2 项目
  2. 修改 webpack, 提取 vue && vue-router

    // touch vue.config.js
    module.exports = {
    chainWebpack: config => {
    config.externals(["vue", "vue-router"])
    }
    }
  3. 配置vue && vue-router

    <script type="systemjs-importmap">
    {
    "imports": {
    "vue": "https://cdn.jsdelivr.net/npm/vue@2.6.10/dist/vue.js",
    "vue-router": "https://cdn.jsdelivr.net/npm/vue-router@3.0.7/dist/vue-router.min.js"
    }
    }
    </script>
  4. 修改启动命令 && 启动应用

    "scripts": {
    "start": "vue-cli-service serve --port 9003",
    }
  5. 配置到容器应用中

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

    配置应用到模板中

    <script type="systemjs-importmap">
    {
    "imports": {
    "@study/vueframe": "//localhost:9003/js/app.js"
    }
    }
    </script>
  6. 取消访问限制

    // 注释掉这一句就好啦~ 在container 的 index.ejs 中哦~
    <meta http-equiv="Content-Security-Policy" content="default-src 'self' https: localhost:*; script-src 'unsafe-inline' 'unsafe-eval' https: localhost:*; connect-src https: localhost:* ws://localhost:*; style-src 'unsafe-inline' https:; object-src 'none';">
  7. Vue 应用配置路由

    // main.js
    import Vue from 'vue';
    import VueRouter from "vue-router"
    import singleSpaVue from 'single-spa-vue';

    import App from './App.vue';

    Vue.use(VueRouter)

    Vue.config.productionTip = false;

    // 组件
    const Foo = { template: "<div>Foo</div>" }
    const Bar = { template: "<div>Bar</div>" }

    // 规则
    const routes = [
    { path: "/foo", component: Foo },
    { path: "/bar", component: Bar }
    ]

    // 实例: 记住要配置 base 昂~~
    const router = new VueRouter({ routes, mode: "history", base: "/vueframe" })

    const vueLifecycles = singleSpaVue({
    Vue,
    appOptions: {
    // 路由
    router,
    // 渲染组件
    render(h) {
    return h(App, {
    // 向组件中传递数据
    props: {
    /*
    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">
    <router-link to="/foo">foo</router-link>&nbsp;
    <router-link to="/bar">bar</router-link>
    <router-view></router-view>
    </div>
    </template>

创建 Parcel 应用

Parcel 用来创建公共 UI,涉及到跨框架共享 UI 时需要使用 Parcel。

Parcel 的定义可以使用任何 single-spa 支持的框架,它也是单独的应用,需要单独启动,但是它不关联路由。

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

栗子 🌰

需求:创建 navbar parcel,在不同的应用中使用它。

  1. 使用 React 创建 Parcel 应用

    1. create-single-spa

    2. 编辑navbar/src/root.component.js

      import React from 'react'
      import { Link, BrowserRouter } from 'react-router-dom'

      const Navbar = () => {
      return (
      <BrowserRouter>
      <Link to="/">@single-spa/welcome</Link>&nbsp;
      <Link to="/application">@study/application</Link>&nbsp;
      <Link to="/todos">@study/todos</Link>&nbsp;
      <Link to="/vueframe">@study/vueframe</Link>
      </BrowserRouter>
      )
      }

      export default Navbar
    3. 修改启动命令

      "scripts": {
      "start": "webpack serve --port 9004",
      }
  2. 在 webpack 配置文件中去除 react-router-dom

    externals: ["react-router-dom"]
  3. 启动应用: npm start

  4. 在模板文件中指定应用模块地址

    {
    "imports": {
    "@study/navbar": "//localhost:9004/study-navbar.js"
    }
    }
  5. 在 React 应用中使用它

    import Parcel from "single-spa-react/parcel"
    <Parcel config={System.import("@study/navbar")} />
  6. 在 Vue 应用中使用它

    <Parcel :config="parcelConfig" :mountParcel="mountParcel" />
    <script>
    import Parcel from "single-spa-vue/dist/esm/parcel"
    import { mountRootParcel } from "single-spa"
    export default {
    components: {
    Parcel
    },
    data() {
    return {
    parcelConfig: window.System.import("@study/navbar"),
    mountParcel: mountRootParcel
    }
    }
    }
    </script>
    • 需要排除single spa 的包

      // vue.config.js
      config.externals(["vue", "vue-router", 'single-spa'])

创建 utility modules

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

  1. 创建应用: create-single-spa

    1. 文件夹填写 tools
    2. 应用选择 in-browser utility module (styleguide, api cache, etc)
  2. 修改端口,启动应用

    "scripts": {
    "start": "webpack serve --port 9005",
    }
  3. 应用中导出方法

    export function consoleFunc(param) {
    console.log(`%c${param} wahahah~`, "color: skyblue")
    }
  4. 在模板文件中声明应用模块访问地址

    <script type="systemjs-importmap">
    {
    "imports": {
    "@study/tools": "//localhost:9005/study-tools.js"
    }
    }
    </script>
  5. 在 React 应用中使用该方法

    import React, {useState, useEffect} from 'react'

    function useToolsModule() {
    const [tools, setTools] = useState()
    useEffect(() => {
    System.import('@study/tools').then(setTools)
    }, [])
    return tools
    }

    const Home = () => {
    const tools = useToolsModule();
    if (tools) tools.consoleFunc('react yyds')
    return (
    <div>
    Home
    </div>
    )
    }

    export default Home
  6. 在 Vue 应用中使用该方法

    <template>
    <div id="app">
    ...
    <h2 @click="handleClick">hello a~~</h2>
    </div>
    </template>

    <script>
    export default {
    ...
    methods: {
    async handleClick() {
    const toolsModule = await window.System.import("@mic-demo/tools")
    toolsModule.consoleFunc("@mic-demo/vue~~~")
    }
    },
    }
    </script>

实现跨应用通信

要实现跨应用通信需要借助 utility modules 和 rxJS,因为在微前端架构的应用当中我们通常使用发布订阅模式来实现跨应用通信和状态共享的,rxJS 就是基于这样的一个设计模式的,并且它无关于框架,也就是可以在任何其他框架中使用。

  1. 在 index.ejs 文件中添加 rxjs 的 import-map

    {
    "imports": {
    "rxjs": "https://cdn.jsdelivr.net/npm/rxjs@6.6.3/bundles/rxjs.umd.min.js"
    }
    }
  2. 在 utility modules 中导出一个 ReplaySubject,它可以广播历史消息,就算应用是动态加载进来的,也可以接收到数据。

    import { ReplaySubject } from 'rxjs'

    export const sharedSubject = new ReplaySubject()
  3. 在 React 应用中订阅它

    useEffect(() => {
    let subjection = null
    if (tools) {
    tools.consoleFunc('react yyds')
    subjection = tools.sharedSubject.subscribe(console.log)
    }
    return () => subjection.unsubscribe()
    }, [])
  4. 在 Vue 应用中订阅它

    async mounted() {
    const toolsModule = await window.System.import("@study/tools")
    toolsModule.sharedSubject.subscribe(console.log)
    }
  5. 注册事件

    这里作为示例将事件注册到了react 相关的应用中

    <button
    onClick={() => toolsModule.sharedSubject.next("Hello Hello Hello")}
    >
    button
    </button>
  6. 验证 现在你可以点击button 然后再控制台里看你的输出啦~

Layout Engine(布局引擎)

允许使用组件的方式声明顶层路由(类似于 react 中配置路由的方式:访问什么样的地址跳转什么应用),并且提供了更加便捷的路由API用来注册应用。

  1. 下载布局引擎 npm install single-spa-layout

  2. 构建路由

    <-- index.ejs -->
    <html>
    <head>
    <template id="single-spa-layout">
    <single-spa-router>
    <nav class="topnav">
    <application name="@study/navbar"></application>
    </nav>
    <div class="main-content">
    <route default>
    <application name="@single-spa/welcome"></application>
    </route>
    <route path="application">
    <application name="@study/application"></application>
    </route>
    <route path="todos">
    <application name="@study/todos"></application>
    </route>
    <route path="realworld">
    <application name="@study/realworld"></application>
    </route>
    </div>
    </single-spa-router>
    </template>
    </head>
    </html>
    // 注册首页
    {
    "imports": {
    "@single-spa/welcome": "https://unpkg.com/single-spa-welcome/dist/single-spa-welcome.js"
    }
    }
  3. 获取路由信息 && 注册应用

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

    // 获取路由配置对象
    const routes = constructRoutes(document.querySelector("#single-spa-layout"))
    // 获取路由信息数组
    const applications = constructApplications({
    routes,
    loadApp({ name }) {
    return System.import(name)
    }
    })
    // 遍历路由信息注册应用
    applications.forEach(registerApplication)

    start({
    urlRerouteOnly: true,
    });

完整版Demo Link