微前端学习系列(二):single-spa

1,509 阅读22分钟

本文使用「署名 4.0 国际 (CC BY 4.0)」 许可协议,欢迎转载、或重新修改使用,但需要注明来源。

作者: 百应前端团队 @0o华仔o0 首发于 juejin.cn/post/695534…

目录

前言

通过对上一篇 微前端学习系列(一): 微前端介绍 学习,相信大家对微前端及当前流行技术方案已经有了一个初步的认识了吧。本文将在上一篇的基础上,就 single-spa 方案用法常用API实现原理等方面进行详细梳理,希望能给到大家一定的帮助。

初次使用 single-spa

在开始之前,我们先做一个简单回顾。

single-spa 提供了一种基于路由的基座化微前端方案,它将应用分为两类:基座应用子应用。其中,子应用对应前面我们讲到的需要聚合的应用,基座应用是另外一个单独的应用,用于聚合子应用。在基座应用中,我们会维护一个路由注册表 - 每个路由对应一个子应用基座应用启动以后,当我们切换路由时,如果是一个新的子应用,会动态获取子应用的 js 脚本,然后执行脚本并渲染出相应的页面;如果是一个已经访问过的子应用,那么就会从基座应用的缓存中获取已经缓存的子应用,激活子应用并渲染出对应的页面。

接下来,我们就来通过一个简单的 demo - micro-frontend/single-spa/application 来具体看看 single-spa 该如何使用。

demo 的项目结构如下:

|-- single-spa
    |--application
        |--main
           |--package.json
           |--public
           |--src
           |--...
        |--app1
           |--package.json
           |--public
           |--src
           |--...
        |--app2
           |--package.json
           |--public
           |--src
           |--...
        |--app3
           |--package.json
           |--public
           |--src
           |--...

其中,app1app2app3 是三个子应用,其中 app1 使用 vue2 技术栈,app2 使用 vue3 技术栈,app3 使用 react 技术栈;main基座应用,使用的技术栈为 vue2

所有应用启动后,效果如下:

image.png

如果想要 single-spa 能正常工作,我们需要做分别对基座应用子应用进行改造。

  • 基座应用改造

    single-spa 中,基座应用主要用于管理子应用,包括根据路由切换子应用以及子应用间的通信等。

    基座应用的改造很简单,我们只需要创建一个关于子应用的路由注册表,然后根据路由注册表使用 single-spa 提供的 registerApplication 方法注册子应用,最后在基座应用挂载完成以后,执行 single-spa 提供的 start 方法即可。

    具体代码如下:

    import Vue from 'vue'
    import App from './App.vue'
    import VueRouter from 'vue-router';
    const  { registerApplication, start } =  require('single-spa');
    
    Vue.use(VueRouter)
    
    Vue.config.productionTip = false
    
    // 接入 single-spa 的标志
    window.__SINGLE_SPA__ = true
    
    const router = new VueRouter({
      mode: 'history',
      routes: []
    });
    
    // 远程加载子应用
    function createScript(url) {
      return new Promise((resolve, reject) => {
        const script = document.createElement('script')
        script.src = url
        script.onload = resolve
        script.onerror = reject
        const firstScript = document.getElementsByTagName('script')[0]
        firstScript.parentNode.insertBefore(script, firstScript)
      })
    }
    // 加载子应用
    function loadApp(url, globalVar, entrypoints) {
      return async () => {
        for(let i = 0; i < entrypoints.length; i++) {
          await createScript(url + entrypoints[i])
        }
        return window[globalVar]
      }
    }
    // 子应用路由注册表
    const apps = [
      {
        // 子应用名称
        name: 'app1',
        // 子应用加载函数
        app: loadApp('http://localhost:8081', 'app1', [ "/js/chunk-vendors.js", "/js/app.js" ]),
        // 当路由满足条件时(返回true),激活(挂载)子应用
        activeWhen: location => location.pathname.startsWith('/app1'),
        // 传递给子应用的对象
        customProps: {}
      },
      {
        name: 'app2',
        app: loadApp('http://localhost:8082', 'app2', [ "/js/chunk-vendors.js", "/js/app.js" ]),
        activeWhen: location => location.pathname.startsWith('/app2'),
        customProps: {}
      },
      {
        // 子应用名称
        name: 'app3',
        // 子应用加载函数
        app: loadApp('http://localhost:3000', 'app3', ["/main.js"]),
        // 当路由满足条件时(返回true),激活(挂载)子应用
        activeWhen: location => location.pathname.startsWith('/app3'),
        // 传递给子应用的对象
        customProps: {}
      }
    ]
    
    // 注册子应用
    for (let i = apps.length - 1; i >= 0; i--) {
      registerApplication(apps[i])
    }
    
    new Vue({
      router,
      render: h => h(App),
      mounted() {
        // 启动
        start()
      },
    }).$mount('#app')
    

    在上述代码中,最关键的就是创建子应用的路由注册表。在注册表中,每个子应用对应的配置项需要指定 nameappactiveWhencustomProps

    • name

      子应用的唯一标识,是一个字符串,不可重复。

    • activeWhen

      子应用激活的条件,是一个函数。当页面 url 发生变化时,会遍历执行注册的子应用的 activeWhen 方法。如果 activeWhen 返回 true,对应的子应用就会被激活。

    • app

      函数,用于获取子应用提供给基座应用生命周期方法bootstrapmountunmount 等。

      我们知道,在基于 vuereact单页应用中,每个页面都是以组件的形式存在。根据路由切换页面时,通常会先执行上一个页面组件的卸载 - unmount 操作,然后再执行下一个页面组件的挂载 - mount 操作

      基座应用切换子应用时,也是同样的操作,即先执行上一个子应用的 unmount 操作,然后再执行下一个子应用的 mount 操作。因此就需要子应用提供 mountunmount 等生命周期方法,供基座应用调用。

      单页应用的懒加载一样,基座应用激活子应用时,如果子应用是首次激活,就会执行 app 方法,动态去加载子应用的入口 js 文件,然后执行,得到子应用的生命周期方法。

    • customProps

      子应用激活时,可以传递给子应用的自定义属性,是一个对象。

  • 子应用改造

    子应用的改造,涉及两个方面:

    • 入口文件 index.js 添加生命周期方法 - mount、unmount、update 等
    • 打包构建改造

    以 app1 为例,代码如下:

    // index.js
    
    import Vue from 'vue'
    import App from './App.vue'
    
    Vue.config.productionTip = false
    
    const appOptions = {
      render: (h) => h(App)
    };
    
    let vueInstance;
    
    // 子应用没有接入 single-spa
    if (!window.__SINGLE_SPA__) {
      new Vue(appOptions).$mount('#app')
    }
    
    // 提供 bootstrap 生命周期方法
    export function bootstrap () {
      console.log('app1 bootstrap')
      return Promise.resolve().then(() => {
    
      });
    }
    // 提供 mount 生命周期方法
    export function mount (props) {
      console.log('app1 mount', props)
      return Promise.resolve().then(() => {
        vueInstance = new Vue(appOptions)
        vueInstance.$mount('#microApp')
      })
    }
    
    // 提供 unmount 生命周期方法
    export function unmount () {
      console.log('app1 unmount')
      return Promise.resolve().then(() => {
        if (!vueInstance.$el.id) {
          vueInstance.$el.id = 'microApp'
        }
        vueInstance.$destroy()
        vueInstance.$el.innerHTML = ''
      })
    }
    
    // 提供 update 生命周期方法
    export function update () {
      console.log('app1 update');
    }
    

    上述生命周期方法中,最关键的就是 mountunmount。其中,mount 会在子应用激活时调用,通过框架(vue / react / angular)提供的 api,挂载子应用;unmount 会在子应用冻结时调用,也是通过框架提供的 api 来卸载子应用。

    mount、unmount 缺失会抛出异常。

    另外,我们还要对项目的构建脚本进行改造,如下:

    // vue.config.js
    
    module.exports = {
        configureWebpack: {
            ...
            publicPath: 'http://localhost:8081'
            output: {
                library: 'app1',
                libraryTarget: 'var'
            },
            ...
        }
    }
    

    通常情况下,通过 webpack 构建工具生成的 js 脚本,表现形式都是 IIFF,即立即执行函数表达式。这种情况下,各个子应用对应的 js 脚本执行时是相互隔离的。如果是这样,那么基座应用激活子应用时,是无法获取到子应用的生命周期方法的,也就无法挂载子应用。

    为了打破这种隔离,我们就需要对 output 配置项做改造,添加 libaraylibraryTarget 配置项,将子应用入口文件的返回值即生命周期方法暴露给 window,这样基座应用就可以从 window 中获取子应用的生命周期方法

    另外,我们还需要配置 publicPathpublicPath 配置项指定了输出目录(path)对应的公开URL,主要是对页面里面引入的资源的路径做对应的补全,默认值为 '/'。 以 app1 为例,如果未配置 publicPath,子应用在懒加载 js 文件时,使用的 url 默认为当前应用 host 和文件位置,如 http://localhost:8081/js/0.chunk.js 。如果 app1 接入 single-spa,那么 app1 在懒加载 js 文件时使用的 url 为基座应用的 host 和文件位置,如 http://localhost:8080/js/0.chunk.js ,加载文件失败。因此,我们需要在 app1 中添加 publicPath 配置项,指定 app1 懒加载文件的 host 为 http://localhost:8081

以上示例,即 single-spa 的基本使用,该使用模式也称之为 application 模式。application 模式下,子应用的切换(挂载、卸载)都是由修改路由触发的,整个切换过程由 single-spa 框架控制,子应用仅需提供正确的生命周期方法即可。

single-spa 进阶使用: parcels

在项目开发过程中,我们可能会遇到这样的情况:子应用 A 是使用 react 开发的,在项目迭代过程中,抽离出一个关于某个具体业务的通用组件 component1 并上传为 npm 包子应用 B 是使用 vue 开发的,在项目迭代过程中,也需要用到刚才提到的某个具体业务,如果再开发一个 vue 的版本,那就有点重复造轮子了。

那么我们可不可以直接在 vue 应用中直接使用 react 组件呢? 答案是当然可以。我们可以利用 ReactDOM 提供的 renderunmountComponentAtNode 方法在 vue 应用中直接加载/更新/卸载 react 组件。

同样的,我们还是通过一个简单的 demo - micro-frontend/single-spa/parcel 来具体看 react 组件是如何添加到 vue 应用的。

demo 的项目结构:

|-- single-spa
    |-- application
        |-- ...
    |-- parcel
        |-- app
            |-- package.json
            |-- public
            |-- src
                |-- main.js
                |-- App.vue
                |-- components
                    |-- vue-component.vue
                    |-- react-component.js

在上面 demo 中,app 是一个基于 vue2 的应用。其中,react-component 是一个 react 组件,用来模拟一个外部的 npm 包,具体代码如下:

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

const ReactComponent = (props) => {
    return React.createElement("div", null, `react component: ${props.val}`);
}

export default ReactComponent;

vue 组件 vue-component.vue 的代码如下:

// vue-component.vue
<template>
  <div>
    <div>vue 组件: <input v-model="val" /></div>
    <div id="parcel"></div>
  </div>
</template>

<script>
import ReactComponent from './react-component'
import ReactDOM from 'react-dom'

export default {
  name: 'vue',
  data() {
    return {
      val: '123'
    }
  },
  mounted() {
    // vue 组件挂载完成以后,手动挂载 react 组件
    ReactDOM.render(ReactComponent({val: this.val}), document.getElementById('parcel'))
  },
  updated() {
    // vue 组件更新以后,重新手动挂载 react 组件
    ReactDOM.render(ReactComponent({val: this.val}), document.getElementById('parcel'))
  },
  beforeDestory() {
    // vue 组件卸载之前,手动卸载 react 组件
    ReactDOM.unmountComponentAtNode(document.getElementById('parcel'));
  }
  
}
</script>

通过上述代码,我们即可在 vue 应用中挂载/更新/卸载 react 组件。但是这种方式有一个问题,就是不够优雅,我们需要在 vue 应用中引入 react-dom。如果引入的多个 react 组件涉及的 react-dom 版本不一致,那么比较麻烦了。

那么,有没有一种更好的方式呢?

有的。single-spa 提供了一种称为 parcel 的模式来帮助我们更优雅方便的实现上述要求。

要使用 single-spaparcel 模式,我们需要对 react 组件vue 应用做相关改造。

  • 组件改造

    首先是组件改造,我们需要像 application 模式中改造子应用一样来改造组件,给组件添加生命周期方法bootstrapmountunmountupdate

    具体如下:

    import React from 'react';
    import ReactDOM from 'react-dom';
    
    const ReactComponent = (props) => {
        return React.createElement("div", null, `react component: ${props.val}`);
    }
    
    export default ReactComponent;
    
    export function bootstrap() {
        return Promise.resolve().then(() => {
            console.log('bootstrap');
        })
    }
    
    export function mount(props) {
        return Promise.resolve().then(() => {
            console.log('mount');
            ReactDOM.render(ReactComponent(props), props.domElement);
        });
    }
    
    export function unmount(props) {
        return Promise.resolve().then(() => {
            console.log('unmount');
            ReactDOM.unmountComponentAtNode(props.domElement);
        })
    }
    
    export function update(props) {
        return Promise.resolve().then(() => {
            console.log('update');
            ReactDOM.render(ReactComponent(props), props.domElement);
        })
    }
    
  • 应用改造

    application 模式下基座应用的改造不同,在 parcel 模式下,我们需要使用 single-spa 提供的 mountRootParcel 方法来手动挂载/更新/卸载组件,具体代码如下:

    <template>
      <div>
        <div>vue 组件: <input v-model="val" /></div>
        <div id="parcel"></div>
      </div>
    </template>
    
    <script>
    import { bootstrap, mount, unmount, update} from './react-component'
    import { mountRootParcel } from 'single-spa'
    export default {
      name: 'vue',
      data() {
        return {
          val: '123'
        }
      },
      mounted() {
        this.parcel = mountRootParcel({bootstrap, mount, unmount, update}, {
          val: this.val,
          domElement: document.getElementById('parcel')
        })
    
      },
      updated() {
        if (this.parcel && this.parcel.update) {
          this.parcel.update({
            val: this.val,
            domElement: document.getElementById('parcel')
          })
        }
      },
      beforeDestory() {
        if (this.parcel && this.parcel.unmount) {
          this.parcel.unmount({
            domElement: document.getElementById('parcel1')
          })
        }
      }
    
    }
    </script>
    

通过上述改造,我们即可在一个 vue 应用中优雅方便的使用 react 组件,而且各个 react 组件的 react、react-dom 版本不一致也没有关系了。

另外,parcel 模式下,除了可以使用 single-spa 提供的 mountRootParcel 方法来挂载组件外,还可以通过另一个 api - mountParcel 来挂载组件。mountRootParcelmountParcel 的用法完全一样,只不过 mountParcel 方法不能直接从 single-spa 中获取,需要从子应用/组件mount 生命周期方法执行时传入的 props 中获取,如下:

...

export function mount(props) {
    ...
    props.mountParcel(...)
    ...
}
...

mountRootParcelmountParcel 这两个方法获取方式的不同,导致它们的应用场景也不同。

mountParcel,只能从子应用/组件mount 生命周期方法执行时传入的 props 中获取,这就使得 mountParcel 只有在子应用/组件中挂载其他组件时才能使用。mountParcel要挂载的组件和子应用(父组件)绑定到一起,当子应用(父组件)要被卸载时,组件unmount 方法自动被触发。

mountRootParcel 没有 mountParcel 的使用限制,我们可以在任何地方通过 mountRootParcel 方法来挂载组件。不过要注意的是,通过 mountRootParcel 挂载的组件,必须自己手动卸载

举个栗子,一个基于 vue 开发的子应用 app1 接入了 single-spa,在 app1 中我们又使用了一个基于 react 开发的组件 comp2app1comp2 都提供了完整的生命周期方法 - bootstrapmountunmount。如果我们使用 mountParcel 挂载 comp2,那么我们就不需要在 comp2父组件的 componentWillUnmount显示的卸载 comp2app1 应用卸载的时候会自动卸载 comp2。如果我们使用 mountRootParcel 挂载 comp2, 那我们必须要在 comp2 的父组件的 componentWillUnmount显示的卸载 comp2,否则 app1 卸载的时候是不会卸载 comp2 的。

到这里, parcel 模式的使用方式就介绍完了。在这里,我们对 applicationparcel 模式,做一个简单的对比:

标题applicationparcel
路由控制
UI 渲染
生命周期方法single-spa 管理用户自己管理
应用场景多个子应用聚合跨框架使用组件

常用 API 用法

single-spa 的相关 API,官方文档 已经有了详细说明,本文就不再一一说明。下面列出了 single-spa 所有 API 的官网链接,大家可以移步官网去查看。

关于 single-spa,你需要深入了解的知识点

  • application 模式下子应用是如何切换的

    当下流行的单页应用的路由切换功能都是基于 window.history( window.location.hash) 实现的。在单页面应用中,我们会给 window 对象注册 popstate(hashchange) 事件,在 popstate(hashchange)callback 中,添加页面切换的逻辑。当通过执行 pushState(replaceState) 方法修改 hash 值使用浏览器前进后退(go、back、forward)功能改变 url 时,会触发 popstate(hashchange) 事件,然后切换页面。

    子应用的切换也是基于上述原理实现的。

    基座应用加载执行 single-spa 时,也会给 window 对象注册 popstate (hashchange) 事件, popstate(hashchange)calback 中,就是激活子应用的逻辑。当基座应用通过执行pushState(replaceState)修改 hash使用浏览器前进后退(go、back、forward)功能的方式修改 url 时,popstate(hashchange) 就会触发,相应的子应用的激活逻辑就会执行。

    我们知道,在使用 window.history 时,如果执行 pushState(repalceState) 方法,是不会触发 popstate 事件的,而 single-spa 通过一种巧妙的方式,实现了执行 pushState(replaceState) 方法可触发 popstate 事件,具体代码如下:

    // 通过原生构造函数 - PopStateEvent,创建一个 popstate 事件对象
    function createPopStateEvent(state, originalMethodName) {
        var evt;
        try {
          evt = new PopStateEvent("popstate", {
            state: state
          });
        } catch (err) {
          evt = document.createEvent("PopStateEvent");
          evt.initPopStateEvent("popstate", false, false, state);
        }
        evt.singleSpa = true;
        evt.singleSpaTrigger = originalMethodName;
        return evt;
    }
    // 重写 updateState、replaceState 方法,通过 window.dispatchEvent 方法,手动触发 popstate 事件
    function patchedUpdateState(updateState, methodName) {
        return function () {
          var urlBefore = window.location.href;
          var result = updateState.apply(this, arguments);
          var urlAfter = window.location.href;
    
          if (!urlRerouteOnly || urlBefore !== urlAfter) {
            window.dispatchEvent(createPopStateEvent(window.history.state, methodName));
          }
          return result;
        };
    }
    // 重写 pushState 方法
    window.history.pushState = patchedUpdateState(window.history.pushState, "pushState");
    // 重写 replaceState 方法
    window.history.replaceState = patchedUpdateState(window.history.replaceState, "replaceState");
    

    之所以能在执行 pushStatereplaceState 方法时,触发 popstate 事件,是因为 single-spa 重写了 window.historypushStatereplaceState 方法。在执行 pushStatereplaceState 方法时,会通过原生方法 - PopStateEvent 构建一个事件对象,然后调用 window.dispatchEvent 方法,手动触发 popState 事件

    另外,关于 single-spa 的路由控制,有一个点需要关注,那就是如果接入 single-spa 的子应用有自己的路由,那就需要对子应用的路由做改造,即给子应用的路由添加前缀,具体如下:

    ...
    const router = new VueRouter({
      mode: 'history',
      base: '/app1',
      routes: [{
        path: '/foo',
        name: 'foo',
        component: {
          ...
        }
      }, {
        path: '/bar',
        name: 'bar',
        component: {
          ...
        }
      }]
    })
    ...
    

    子应用 app1,如果基座应用中对应的路由为 '/app1', 那么子应用中的路由都需要添加前缀 '/app1'。这是因为,url 发生变化时,是先匹配子应用,然后再匹配子应用页面。如果子应用不添加前缀,那么修改以后的 url 可能就匹配不到子应用,也找不到对应的子应用页面了。

    子应用切换页面时,尽管 url 发生变化,子应用也不会重复挂载。

  • single-spa 是如何工作的

    single-spa 有两种使用模式:applicationparcel使用模式不同,single-spa工作流程也不相同。

    • application 模式下,single-spa 的工作流程

      application 模式下,我们需要先通过 registerApplication 注册子应用,然后在基座应用挂载完成以后执行 start 方法, 这样基座应用就可以根据 url 的变化来进行子应用切换,激活对应的子应用。

      整个工作过程,具体如下:

    image.png

    • parcel 模式下,single-spa 的工作流程

      对比 application 模式, parcel 模式的工作流程就比较简单了,我们只需要获取到组件的生命周期方法,然后通过 mountRootParcel 方法直接挂载就可以了。

      mountRootParcel 方法会返回一个 parcel 实例对象,内部包含 updateunmount 方法。当我们需要更新组件时,直接调用 parcel 对象的 update 方法,就可以触发组件的 update 生命周期方法;当我们需要卸载组件时,直接调用 parcel 对象的 unmount 方法。

      在执行 mountRootParcel 方法时,传入的第二个参数,会作为组件 mount 生命周期方法入参;在执行 parcel.update 方法时,传入的参数,会作为组件 update 生命周期方法入参

      组件的挂载、更新、卸载,都必须要有对应的 root 节点,因此参数中必须要有 domElement 属性,否则会抛出异常。

  • 子应用/组件的生命周期方法如何被获取

    为了能使子应用/组件生命周期方法能被外部获取到,我们需要对项目的 webpack 配置文件output 配置项做改造,添加 librarylibraryTarget 配置项。其中, library 定义了子应用/组件的 export 的名称,libraryTarget 定义了暴露 export 的方式

    在上面的 micro-frontend/single-spa/application 示例中,我们在 app1 中定义了 library'app1', libraryTarget'var', 那么基座应用就可以通过 window.app1 获取到子应用的 export,得到子应用的生命周期方法

    libraryTarget 配置项的值有很多,可以根据自身实际需要,进行配置。

  • 如何判断一个子应用(组件)是否也被激活(挂载)

    每个子应用都有一个 status 字段,表示子应用生命周期中的各个阶段:

    • NOT_LOADED: 未加载/待加载

      未加载(待加载)每个子应用的默认状态,意味着主应用还没有获取到子应用的 bootstrap、mount、unmount、unload 方法。

      NOT_LOADED 的下一个阶段为 LOAD_SOURCE_CODE

    • LOAD_SOURCE_CODE: 加载源代码

      在这个阶段,single-spa 会执行子应用注册时提供的 loadApp 方法,去动态获取子应用的入口 js 文件,然后执行,得到子应用的 bootstrap、mount、unmount、unload 方法。

      LOAD_SOURCE_CODE 的下一个阶段为 NOT_BOOTSTRAPPED

    • NOT_BOOTSTRAPPED: 未启动/待启动

      得到子应用的 bootstrap、mount、unmount、unload、update 方法以后,single-spa 会将这些方法添加到子应用对象中。 添加完毕以后,子应用的状态就变为 not_bootstrapped,等待被启动。

      NOT_BOOTSTRAPPED 的下一个阶段为 BOOTSTRAPPING

    • BOOTSTRAPPING: 子应用启动中

      子应用被激活以后,就会进入 BOOTSTRAPPING 阶段。此时如果子应用提供了 bootstrap 方法,那么 bootstrap 方法就会触发。

      BOOTSTRAPPING 的下一个阶段为 NOT_MOUNTED

    • NOT_MOUNTED: 未挂载/待挂载

      子应用启动以后,自动进入 NOT_MOUNTED 阶段。

      NOT_MOUNTED 的下一个阶段为 MOUNTING.

    • MOUNTING: 子应用挂载中

      在这个阶段,自动触发子应用提供的 mount 方法。

      MOUNTING 的下一个阶段为 MOUNTED

    • MOUNTED: 子应用已挂载

      子应用挂载完毕,子应用的状态变为 MOUNTED

    • UNMOUNTING

      如果一个子应用需要被卸载,那么这个子应用的状态就会变为 UNMOUNTING

      此时,子应用的 unmount 方法会执行。

    • UNMOUNTED

      子应用卸载完毕以后,子应用的状态就会变为 UNMOUNTED

    • LOAD_ERROR

      子应用加载失败,子应用的状态就会变为 LOAD_ERROR

    组件和子应用的 status 基本相同,状态包含 NOT_BOOTSTRAPPEDBOOTSTRAPPINGNOT_MOUNTEDMOUNTEDUNMOUNTINGUNMOUNTED 等,此外还包含一个 UPDATING,即 parcel.update 方法执行时,组件的状态变为 UPDATING

    UPDATING 状态仅存在于 parcel 组件

    当子应用(组件)的状态变为 MOUNTED 时,说明子应用(组件)已被激活(挂载)。

    子应用的 status 值,可以通过 getAppStatus 获取;组件的 status 值,需要通过 parcel.getStatus 获取

  • 子应用/组件如何通信

    使用 single-spa 时,不可避免的会遇到子应用/组件的通信,常见通信主要包括父组件和 parcel 组件之间的通信parcel 组件之间的通信基座应用和子应用的通信子应用之间的通信

    • 父组件和 parcel 组件之间的通信

      父组件和 parcel 组件之间的通信比较简单,父组件可以通过 props 属性向 parcel 组件传递值。

      具体的方式为:mount 阶段,父组件在执行 mountRootParcel 时,可以将要传递给 parcel 组件的值作为第二个参数,这个参数会作为 parcel 组件 mount 方法执行时的入参,这样 parcel 组件就可以拿到父组件传递的值。update 阶段也一样,父组件执行 parcel.update 时,传入的参数会作为 parcel 组件 update 方法执行时的入参。

      我们可以在父组件传递给 parcel 组件的值中添加一个方法,当 parcel 组件需要通知父组件更新时,可以执行这个方法。

    • parcel 组件之间的通信

      parcel 组件之间的通信也比较好实现,本质上也是 parcel 组件和父组件之间的通信。 parcel 组件可以通过父组件传递的方法,触发父组件的更新,父组件更新以后,在触发另一个 parcel 组件的更新。

    • 基座应用和子应用之间的通信

      基座应用在定义路由注册表时,会给每个子应用定义一个 customProps,这个 customProps 会作为子应用 mount 方法的入参。在子应用中, customProps(或者 customProps 里面的某个值) 可以作为子应用的共享状态(使用 vuex、mobx、redux 等)。这样,当基座应用修改 customProps 时,子应用就可接受到通知,然后更新。

      我们也可以在 cusProps 中添加一个方法,当子应用需要通知基座应用更新时,可以执行这个方法。

    • 子应用之间的通信

      子应用之间的通信也一样,本质上也是基座应用和子应用的通信。

  • 子应用/组件生命周期方法为什么必须要返回一个 promise 对象

    以子应用/组件的 mount 方法为例,我们需要以如下的形式定义一个 mount 方法:

    export function mount(props) {
        return Promise.resolve().then(() => {
            // 子应用/组件具体的挂载逻辑
            ...
        })
    }
    

    single-spa 中,子应用/组件的生命周期方法触发的具体逻辑如下:

    function reasonableTime(appOrParcel, lifecycle) {
        ...
        return new Promise(function (resolve, reject) {
          var finished = false;
          var errored = false; 
          // 子应用/组件的生命周期方法触发,返回一个 promise 对象
          appOrParcel[lifecycle](getProps(appOrParcel)).then(function (val) {
            finished = true;
            resolve(val);
          }).catch(function (val) {
            finished = true;
            reject(val);
          });
          
          ...
        });
    }
    
    ...
    // 执行子应用/组件的生命周期方法
    reasonableTime(appOrParcel, "mount").then(function () {
        // 子应用的状态变为 Mounted
        appOrParcel.status = MOUNTED;
        ...
      }).catch(function (err) {
        ...
    });
    
    

    综上,子应用/子组件的生命周期方法,需要返回一个 promise 对象,而且这个 promise 对象的状态可变为 resolved,否则 single-spa 在执行子应用的生命周期方法时会出现异常。

  • single-spa 生命周期 hooks

    single-spa 定义了一些生命周期 hooks,可以帮助我们在子应用/组件生命周期中执行自定义操作,这些 hooks 包括: 'single-spa:before-first-mount''single-spa:first-mount''single-spa:before-no-app-change''single-spa:before-app-change''single-spa:before-routing-event''single-spa:before-mount-routing-event''single-spa:no-app-change''single-spa:app-change''single-spa:routing-event'

    single-spa:before-first-mount 为例,before-first-mount 表示第一次挂载子应用/组件开始之前,使用方式如下:

    // 在 callback 中我们可以自定义操作
    window.addEventListener('single-spa:before-first-mount', event => {...})
    

    single-spa 在第一次挂载子应用/组件之前,会触发 single-spa:before-first-mount 事件,方式如下:

    window.dispatch(new customEvent("single-spa:before-first-mount"));
    
    • single-spa:before-first-mount

      single-spa 第一次挂载子应用/组件之前触发,之后就不会再触发。

    • single-spa:first-mount

      single-spa 第一次挂载子应用/组件之后触发,之后就不会在触发了。

      子应用/组件挂载完成之后,fisrt-mount 才会触发。

    • single-spa:before-no-app-change

      application 模式下,修改 url 会触发子应用的切换。如果路由注册表中没有匹配当前 url 的子应用,那么 single-spa:before-no-app-change 事件会触发。

    • single-spa:before-app-change

      single-spa:before-no-app-change 相反,修改 url 导致子应用切换时,如果路由注册表中匹配当前 url 的子应用, single-spa:before-app-change 事件会触发。

    • single-spa:before-routing-event

      application 模式下, hashchangepopstate 触发以后,single-spa:before-routing-event 事件就会触发。

    • single-spa:before-mount-routing-event

      application 模式下, 旧的子应用卸载完成之后,新的子应用挂载之前触发。

    • single-spa:app-change

      application 模式下,旧的子应用卸载完成,新的子应用挂载完成之后触发。

      single-spa:before-app-change 触发, single-spa:app-change 就会触发。

    • single-spa:no-app-change

      application 模式下,如果 single-spa:before-no-app-change 触发,那么最后 single-spa:no-app-change 会触发。

    • single-spa:routing-even

      application 模式下, single-spa:no-app-change / single-spa:no-app-change 触发以后, single-spa:routing-event 触发。

  • 配合 single-spa 使用的 npm 包

    在实际的项目中,我们可以使用一些 npm 包配合 single-spa 使用,提高开发效率。常用的 npm 包有 single-spa-vuesingle-spa-react 等。

    • single-spa-vue

      子应用/组件接入 single-spa 时,我们需在在 mount 方法中添加挂载逻辑,在 unmount 方法中添加卸载逻辑,在 update 方法中添加更新逻辑。如果我们有多个子应用/子组件要接入 single-spa,那我们就需要在每个子应用/组件中将同样的逻辑编写一遍。这种行为,对于任何一名稍微有点追求的程序员,都是深恶痛绝的。遇到这种情况,我们肯定会想着把这种相同的逻辑抽象封装,使其可复用,而 single-spa-vue 就是这样的一个 npm 包。

      single-spa-vue 可以快速帮我们给每个基于 vue 开发的子应用/组件定义 mountunmountupdate 方法。

      具体用法详见:single-spa-vue

    • single-spa-react

      single-spa-reactsingle-spa-vue 的作用一样,可以快速帮我们给每个基于 react 开发的子应用/组件定义 mountunmountupdate 方法。

      具体用法详见:single-spa-react

    • 其他

      此外,可配合使用的 npm 包还有:single-spa-angularsingle-spa-angularjssingle-spa-cycle 等,具体详见:ecosystem

single-spa 的不足

尽管 single-spa 给我们提供了一套完整的微前端解决方案,但在实际的项目中,依然有很多的问题需要我们去面对解决,如复杂的子应用加载逻辑应用之间 js、css 的隔离子应用切换遗留的副作用子应用状态恢复以及子应用之间的通信子应用的预加载等。

  • 复杂的子应用加载逻辑

    实际项目中的子应用加载过程,远比我们在示例 - micro-frontend/single-spa/application 中的要复杂的多,我们需要考虑的点很多,比如:

    • 图片、css 等静态资源的加载
    • 不同子应用的打包生成的入口文件的名称、数量不一致
    • 子应用更新导致打包文件 hash 值发生变化
    • 懒加载

    对于第一个问题,我们可以通过将子应用中的图片、css 全部打包为 js 代码的方式来解决。但这样的话,css 独立打包资源并行加载等优化就没有了。

    对于第二个和第三个问题,我们可以在子应用打包时生成一个 manifest 文件,内部包含子应用的入口文件的名称,然后将这个 manifest 存起来。在基座应用构建路由注册表时,可以通过动态获取子应用对应的 manifest 文件来拿到子应用的入口文件

    对于最后一个懒加载的问题,我们可以在子应用配置文件中添加 publicPath 配置,对子应用资源的路径做补全。(详见 application - 子应用改造章节)。

  • 应用之间的 js、css 隔离

    由于多个应用(基座应用 + 子应用、子应用 + 子应用)共存,我们就需要特别关注各个应用之间全局变量(如 window)、样式不能相互干扰

    针对这类问题,我们一般通过命名约定的方式来解决,给各个应用的全局变量、样式根据子应用来分别添加前缀。这种方式虽能解决问题,但重度依赖于人为约束,费时费力且容易引发 bug。

  • 子应用切换遗留的副作用

    尽管子应用会提供 unmount 生命周期方法供基座应用在切换子应用时调用,但依然会遗留一些副作用,如:

    • 子应用定义的 setInterval
    • 子应用通过 window.addEventListener 注册的事件
    • 子应用添加修改的 window 对象的属性
    • 子应用动态添加的 dom 节点,如 style 节点

    这些副作用的存在,有些会造成内存泄漏,有些会对下一个子应用造成影响,因此需要我们在切换子应用时对这些副作用做清除处理。

  • 子应用状态恢复

    有些时候,我们需要在重新挂载子应用时,恢复子应用之前的状态,如子应用之前修改的 window子应用动态添加的 style 等。这就要求我们需要在卸载子应用时,存储上述状态,然后在子应用重新挂载时,将存储的状态恢复。

  • 应用之间的通信

    使用 single-spa 时,应用之间的通信也是我们需要重点关注的。在子应用/组件之间如何通信章节中,我们已做了说明,感兴趣的同学可以再回顾一下。

  • 子应用预加载

    在使用 single-spa 进行微前端开发时,我们也可以应用资源预加载这种优化手段,在当前子应用工作过程中,利用空闲时间,提前加载其他子应用所需的资源,使得下一个子应用可以快速打开。

上述这些问题,其实是微前端开发过程中的常见问题。我们虽然可以通过相关手段解决,但我们更希望的是框架能帮我们解决,使得微前端开发更加简单方便。

那么有没有一个框架能帮我们解决上述问题呢?

答案是有的。qiankun 就是这样的一个框架,它在 single-spa 的基础上做了二次开发,能帮我们很好的解决上述提到的问题。关于 qiankun微前端学习系列(三):qiankun 中已做了相关说明,感兴趣的同学可以前往了解。

结束语

到这里,single-spa 的学习就结束了。本文主要介绍了 single-spa 的用法一些关键知识点以及 single-spa 的不足。篇幅较长,阅读起来比较花时间,希望能给到大家帮助,如果有疑问或者错误,欢迎大家提出,共同学习,一起进步,😁。

single-spa 源码注释

完成本文,参考文档如下: