single-spa-微前端种类

/post/single-spa-note article cover image

single-spa作为一个应用广泛的微前端框架,很值得学习本文主要记录文档中推荐的技术方案、整合方式以及注意点。

single-spa的微前端类型

  1. single-spa application: 根据特定路由渲染组件的微前端
  2. single-spa parcels: 无需控制路由即可呈现组件的微前端
  3. utility modules: 导出共享js洛基无需渲染组件的微前端

single-spa-application构成

  1. single-spa root config:渲染HTML页面和注册应用程序的JavaScript。每个程序都注册三个事情:
  • 名称
  • 用以一加载应用代码的函数
  • 用以确定程序处于active/inactive状态的函数
  1. Applications:每个打包成模块的single-spa应用程序,每个应用都必须知道如何启动并从DOM上挂载和卸载,传统单页面应用和single-spa应用的主要区别在于它们在没有自己HTML页面的同时与其他single-spa应用共存

root-config

  1. 所有single-spa应用共享的根html文件
  2. 微应用注册方法singleSpa.registerApplication
js
import { registerApplication, start } from "single-spa"

// 简单配置
registerApplication(
  // 微应用名称
  'appName',
  // 已解析应用或者加载应用的promise方法
  () => import('src/app1/main.js'),
  // 匹配规则函数,用以判断是否路由至当前应用
  location => location.pathname.startsWith('/app1'),
  { some: value }
)

//简单配置第二个参数,同时也可以是包含了生命周期方法的对象
const application = {
  bootstrap: () => Promise.resolve(), //bootstrap function
  mount: () => Promise.resolve(), //mount function
  unmount: () => Promise.resolve(), //unmount function
}

registerApplication('appName', application, activityFunction)

// 更语义的配置
registerApplication({
  name: 'xxx',
  app: () => import('src/app2/main.js'),
  activeWhen: '/app1',
  customProps: {
    some: 'value'
  }
})

start();

activeWhen触发场景:

  • hashchangepopstate事件
  • pushStatereplaceState被调用
  • triggerAppChange在single-spa被调用
  • 每当checkActivityFunctions方法被调用

application

在single-spa中注册的应用每个都是独立的(框架、路由、库),并且渲染在dom的时机也可以根据activity function随意控制。注册应用同样就有独自的生命周期函数位于其入口文件通过具名形式导出:

ts
interface lifecycleProps {
  name: string,
  singleSpa: SingleSpaInstance,
  mountParcel: () => void,
  customProps?: any
}

// 每个生命周期勾子必须返回Promise或者是async函数(unload为可选)
export function bootstrap(props: lifecycleProps) {
  return Promise.resolve().then(() => {
    console.log('bootstrapped!');
  })
}
export function mount(props: lifecycleProps) {
  return Promise.resolve().then(() => {
    console.log('mounted!');
  })
}
export function unmount(props: lifecycleProps) {
  return Promise.resolve().then(() => {
    console.log('unmounted!');
  })
}
export function unload(props: lifecycleProps) {
  return Promise.resolve().then(() => {
    console.log('unloaded!');
  })
}

// 覆盖对应勾子的全局超时配置
export const timeouts = {
  bootstrap: {
    millis: 5000,
    dieOnTimeout: true,
    warningMillis: 2500,
  },
  mount: {
    millis: 5000,
    dieOnTimeout: false,
    warningMillis: 2500,
  },
  unmount: {
    millis: 5000,
    dieOnTimeout: true,
    warningMillis: 2500,
  },
  unload: {
    millis: 5000,
    dieOnTimeout: true,
    warningMillis: 2500,
  },
};

single-spa-parcel

"包裹应用"提供了一种跨框架共享UI/组件的高级方法,其通过single-spa helper创建并返回一个对象,single-spa可以用它来创建和安装包裹并使用。

js
  const parcelConfig = {
    bootstrap: props => Promise.resolve(), // 可选: 初始化
    mount: props => Promise.resolve(), // 必需: 使用框架创建dom节点并挂载parcel
    unmount: props => Promise.resolve(), // 必需:使用框架卸载dom节点并执行其他清除操作
    update: props => Promise.resolve() // 可选: 使用框架更新dom节点
  }

  const domElement = document.querySelector('#parcelDom');
  const parcelProps = { domElement, customProp1: 'foo' };
  const parcel = singleSpa.mountRootParcel(parcelConfig, parcelProps); // 初始化挂载parcel

  parcel.mountPromise
    .then(() => {
      console.log('finished mounting parcel')
      parcelProps.customProp1 = 'xx';
      return parcel.update(parcelProps);// 修改后再此渲染
    })
    .then(() => {
      return parcel.unmount();
    })

mountRootParcelmountParcel的区别在于其创建"包裹"的上下文以及如何访问这些api:

ts
interface mountRootParcel {
  context: 'singleSpa'
  unMountCondition: 'manual only'
  apiLocation: 'singleSpa named export'
}

interface mountParcel {
  context: 'application'
  unMountCondition: 'manual + application unmount'
  apiLocation: 'provided in lifecycle prop'
}

大多数情况下更推荐使用mountParcel,这样就可以把应用当作组件来使用而无需关心它是什么框架的,并且不需要手动去卸载。

js
export default function singleSpaVue(userOpts) {
  // 省略异常情况判断
  /**
  * Just a shared object to store the mounted object state
  * key - name of single-spa app, since it is unique
  */
  let mountedInstances = {};
  return {
    bootstrap: bootstrap.bind(null, opts, mountedInstances),
    mount: mount.bind(null, opts, mountedInstances),
    unmount: unmount.bind(null, opts, mountedInstances),
    update: update.bind(null, opts, mountedInstances),
  };
}

function bootstrap(opts) {
  if (opts.loadRootComponent) {
    return opts.loadRootComponent().then((root) => (opts.rootComponent = root));
  } else {
    return Promise.resolve();
  }
}

function resolveAppOptions(opts, props) {
  if (typeof opts.appOptions === "function") {
    return opts.appOptions(props);
  }
  return Promise.resolve({ ...opts.appOptions });
}

function mount(opts, mountedInstances, props) {
  const instance = {};
  return Promise.resolve().then(() => {
    return resolveAppOptions(opts, props).then((appOptions) => {
      if (props.domElement && !appOptions.el) {
        appOptions.el = props.domElement;
      }

      let domEl;
      if (appOptions.el) {
        if (typeof appOptions.el === "string") {
          domEl = document.querySelector(appOptions.el);
          if (!domEl) {
            throw Error(
              `If appOptions.el is provided to single-spa-vue, the dom element must exist in the dom. Was provided as ${appOptions.el}`
            );
          }
        } else {
          domEl = appOptions.el;
          if (!domEl.id) {
            domEl.id = `single-spa-application:${props.name}`;
          }
          appOptions.el = `#${CSS.escape(domEl.id)}`;
        }
      } else {
        const htmlId = `single-spa-application:${props.name}`;
        appOptions.el = `#${CSS.escape(htmlId)}`;
        domEl = document.getElementById(htmlId);
        if (!domEl) {
          domEl = document.createElement("div");
          domEl.id = htmlId;
          document.body.appendChild(domEl);
        }
      }

      if (!opts.replaceMode) {
        appOptions.el = appOptions.el + " .single-spa-container";
      }

      // single-spa-vue@>=2 always REPLACES the `el` instead of appending to it.
      // We want domEl to stick around and not be replaced. So we tell Vue to mount
      // into a container div inside of the main domEl
      if (!domEl.querySelector(".single-spa-container")) {
        const singleSpaContainer = document.createElement("div");
        singleSpaContainer.className = "single-spa-container";
        domEl.appendChild(singleSpaContainer);
      }

      instance.domEl = domEl;

      if (!appOptions.render && !appOptions.template && opts.rootComponent) {
        appOptions.render = (h) => h(opts.rootComponent);
      }

      if (!appOptions.data) {
        appOptions.data = {};
      }

      appOptions.data = () => ({ ...appOptions.data, ...props });

      instance.vueInstance = new opts.Vue(appOptions);
      if (instance.vueInstance.bind) {
        instance.vueInstance = instance.vueInstance.bind(
          instance.vueInstance
        );
      }
      if (opts.handleInstance) {
        return Promise.resolve(
          opts.handleInstance(instance.vueInstance, props)
        ).then(function () {
          mountedInstances[props.name] = instance;
          return instance.vueInstance;
        });
      }

      mountedInstances[props.name] = instance;

      return instance.vueInstance;
    });
  });
}

function update(opts, mountedInstances, props) {
  return Promise.resolve().then(() => {
    const instance = mountedInstances[props.name];
    const data = {
      ...(opts.appOptions.data || {}),
      ...props,
    };
    const root = instance.root || instance.vueInstance;
    for (let prop in data) {
      root[prop] = data[prop];
    }
  });
}

function unmount(opts, mountedInstances, props) {
  return Promise.resolve().then(() => {
    const instance = mountedInstances[props.name];
    if (opts.createApp) {
      instance.vueInstance.unmount(instance.domEl);
    } else {
      instance.vueInstance.$destroy();
      instance.vueInstance.$el.innerHTML = "";
    }
    delete instance.vueInstance;

    if (instance.domEl) {
      instance.domEl.innerHTML = "";
      delete instance.domEl;
    }
  });
}

以vue2应用为例,此实现通过传入实例化应用的必要参数外返回single-spa应用所必须的生命周期函数,有通过自定义参数来控制不同勾子内的逻辑实现。

js
import Vue from 'vue';
import App from './App.vue';
import router from './router';
import singleSpaVue from 'single-spa-vue';

const vueLifecycles = singleSpaVue({
  Vue,
  appOptions: {
    render(h) {
      return h(App);
    },
    router
  }
});

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

更纯粹的parcel config:

js
import * as Vue from "vue";

export default {
  props: {
    config: [Object, Promise],
    wrapWith: String,
    wrapClass: String,
    wrapStyle: Object,
    mountParcel: Function,
    parcelProps: Object,
  },
  render(h) {
    const containerTagName = this.wrapWith || "div";
    const props = { ref: "container" };
    if (this.wrapClass) {
      props.class = this.wrapClass;
    }
    if (this.wrapStyle) {
      props.style = this.wrapStyle;
    }
    return Vue.h(containerTagName, props);
  },
  data() {
    return {
      hasError: false
    };
  },
  methods: {
    addThingToDo(action, thing) {
       this.nextThingToDo = romise.resolve()
        .then((...args) => {
          if (this.unmounted && action !== "unmount") {
            return;
          }
          return thing.apply(this, args);
        })
        .catch((err) => {
          this.hasError = true;

          if (err && err.message) {
            err.message = `During '${action}', parcel threw an error: ${err.message}`;
          }

          this.$emit("parcelError", err);

          throw err;
        });
    },
    singleSpaMount() {
      this.parcel = this.mountParcel(this.config, this.getParcelProps());

      return this.parcel.mountPromise.then(() => {
        this.$emit("parcelMounted");
      });
    },
    singleSpaUnmount() {
      if (this.parcel) {
        return this.parcel.unmount();
      }
    },
    singleSpaUpdate() {
      if (this.parcel && this.parcel.update) {
        return this.parcel.update(this.getParcelProps()).then(() => {
          this.$emit("parcelUpdated");
        });
      }
    },
    getParcelProps() {
      return {
        domElement: this.$refs.container,
        ...(this.parcelProps || {}),
      };
    },
  },
  mounted() {
    // ...
    if (this.config) {
      this.addThingToDo("mount", this.singleSpaMount);
    }
  },
  destroyed() {
    this.addThingToDo("unmount", this.singleSpaUnmount);
  },
  watch: {
    parcelProps: {
      handler(parcelProps) {
        this.addThingToDo("update", this.singleSpaUpdate);
      },
    },
  },
};
jsx
<template>
  <Parcel
    v-on:parcelMounted="parcelMounted()"
    v-on:parcelUpdated="parcelUpdated()"
    :config="parcelConfig"
    :mountParcel="mountParcel"
    :wrapWith="wrapWith"
    :wrapClass="wrapClass"
    :wrapStyle="wrapStyle"
    :parcelProps="getParcelProps()"
  />
</template>
// ...