Skip to content

single-spa 流程解析及简单实现

什么是 single-spa

官方描述:

Single-spa 是一个将多个单页面应用聚合为一个整体应用的 JavaScript 微前端框架。

实际上:

设计了生命周期的概念,并负责调度子应用的生命周期,生命周期钩子里如何实现(如 dom 挂载等)由开发者决定。

挟持 url 在 url 变化时(或直接手动挂载 parcel),匹配对应子应用,并执行生命周期流程。

虽然 single-spa 说自己是微前端框架,但是一个微前端的特性都没有实现,本质是子应用加载器 + 状态机。都是需要开发者在加载自己子 App 的时候实现的。框架里面维护了各个子应用的状态,以及在适当的时候负责更改子应用的状态、执行相应的生命周期函数。

一些概念

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 指定依赖库的地址。与 webpack 的模块化功能重叠。

single-spa-layout

在 index.html 指定在哪里渲染哪个子应用。

single-spa-react single-spa-vue

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

single-spa-css

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

single-spa-leaked-globals

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

demo

index.html

html
<div class="navbar">
  <ul>
    <a onclick="singleSpaNavigate('/app1')">
      <li>App 1</li>
    </a>
    <a onclick="singleSpaNavigate('/app2')">
      <li>App 2</li>
    </a>
  </ul>
</div>

root-application.js

js
import * as singleSpa from 'single-spa';

const pathPrefix = (prefix) => (location) => location.pathname.startsWith(prefix);

singleSpa.registerApplication('app-1', (v) => import('../app1/app1.js'), pathPrefix('/app1'), {
  content: 'app1111111',
});
singleSpa.registerApplication('app-2', () => import('../app2/app2.js'), pathPrefix('/app2'), { content: 'app2222222' });
console.log('start');
singleSpa.start();

app1/app1.js

js
import React from 'react';
import ReactDOMClient from 'react-dom/client';
import singleSpaReact from 'single-spa-react';
import Root from './root.component.tsx';
console.log('app1');
const reactLifecycles = singleSpaReact({
  React,
  ReactDOMClient,
  rootComponent: Root,
  domElementGetter,
});

export function bootstrap(props) {
  console.log('app1bootstrap', props);
  return reactLifecycles.bootstrap(props);
}

export function mount(props) {
  console.log('app1mount', props);
  return reactLifecycles.mount(props);
}

export function unmount(props) {
  console.log('app1unmount', props);
  return reactLifecycles.unmount(props);
}

function domElementGetter() {
  // Make sure there is a div for us to render into
  let el = document.getElementById('app1');
  if (!el) {
    el = document.createElement('div');
    el.id = 'app1';
    document.body.appendChild(el);
  }

  return el;
}

app1/root.component.tsx

jsx
import * as React from 'react';

export default class Root extends React.Component<any, any> {
  render() {
    const message: string = 'This was rendered by app 1 which was written in React';

    return <div style={{ marginTop: '100px' }}>{message}</div>;
  }
}

app2 与上述配置代码一致。

主流程大致分析

打开 http://localhost:8080/

应用注册流程 registerApplication

通过 sanitizeArguments 格式化用户传递的子应用配置参数,然后 push 到 apps 里,执行 reroute

reroute 更改 app.status 和执行生命周期函数

reroute 里执行 getAppChanges,该方法会遍历 app 应用数组判断生命周期,根据 shouldBeActive 方法 location 匹配的 app 激活规则判断子应用是已激活,返回不同状态的应用:将 apps 应用根据 app.status 分为 4 大类appsToUnload、appsToUnmount、appsToLoad、appsToMount。此时路由没有匹配上,所以不会将 app 放到 appsToLoad 里,即 appsToLoad 为空;

判断是否 isStarted,执行 loadApps,loadApps 返回一个立即 resolved 的 promise,内部执行 appsToLoad.map(toLoadPromise),通过 Promise.all 保证所有的子应用都加载完成; 因为 appsToLoad 为空,至此结束。

start 流程

执行 singleSpa.start(),将 started 置为 true,然后执行 patchHistoryApi

patchHistoryApi

该方法里完成两件事:

1.监听 hashchange、popstate

js
function urlReroute() {
  reroute([], arguments);
}
window.addEventListener('hashchange', urlReroute);
window.addEventListener('popstate', urlReroute);

此时浏览器导航操作的 hasChange 或 popState 事件回调函数将收集到 capturedEventListeners 对象中待 reroute 后遍历执行.

2.重写 window.history.pushState、window.history.replaceState

js
window.history.pushState = patchedUpdateState(window.history.pushState, 'pushState');
window.history.replaceState = patchedUpdateState(originalReplaceState, 'replaceState');

接着执行 reroute,内部判断是否 isStarted,此时执行 performAppChanges()。因 apps 里没有任何应用,故结束 start 流程,暂不展开 performAppChanges 分析。

点击切换流程

点击按钮 app1 执行 singleSpaNavigate->navigateToUrl-> window.history.pushState(null, null, url)->urlReroute->reroute,getAppChanges 里 appsToLoad 既有对应路由的 app,再次执行 performAppChanges

performAppChanges 执行状态改变及钩子

立即返回 Promise.resolve(),回调里执行 apps 里各状态对应的钩子列表

js
const unloadPromises = appsToUnload.map(toUnloadPromise);
const unmountUnloadPromises = appsToUnmount
  .map(toUnmountPromise)
  .map((unmountPromise) => unmountPromise.then(toUnloadPromise));
const allUnmountPromises = unmountUnloadPromises.concat(unloadPromises);
const unmountAllPromise = Promise.all(allUnmountPromises);
//...
const loadThenMountPromises = appsToLoad.map((app) => {
  return toLoadPromise(app).then((app) => tryToBootstrapAndMount(app, unmountAllPromise));
});
const mountPromises = appsToMount
  .filter((appToMount) => appsToLoad.indexOf(appToMount) < 0)
  .map((appToMount) => {
    return tryToBootstrapAndMount(appToMount, unmountAllPromise);
  });

toUnloadPromise、toUnmountPromise、包括 tryToBootstrapAndMount 内部的 toBootstrapPromise、toMountPromise 都是执行对应子应用暴露出来的钩子方法,通过异步保证执行顺序。其中 toLoadPromise

toLoadPromise 通过微任务加载子应用

立即返回 Promise.resolve(),回调里执行 loadApp 即注册应用时配置的 import('../app1/app1.js'),在回调中对返回进行了生命周期函数的合法性检查,获取子应用的各个生命周期函数赋到 app,并通过 flattenFnArray 可串联执行钩子方法。

最后执行收尾工作 callAllEventListeners 即执行之前收集的 capturedEventListeners。

performAppChanges 里会触发钩子:

  • single-spa:before-no-app-change or single-spa:before-no-app-change
  • single-spa:before-routing-event
    • single-spa:before-mount-routing-event(依赖上一个钩子是否调用 cancelNavigation 或 unmountAllPromise 回调)
  • single-spa:no-app-change or single-spa:app-change
  • single-spa:routing-event
  • single-spa:before-first-mount
  • single-spa:first-mount

micro-3-1

single-spa 的不足

  • 复杂的子应用加载逻辑
  • 应用之间的 js、css 隔离
  • 子应用切换遗留的副作用
  • 子应用状态恢复
  • 应用之间的通信
  • 子应用预加载

简单实现

js
const NOT_LOADED = 'NOT_LOADED';
const LOADING_SOURCE_CODE = 'LOADING_SOURCE_CODE';
const NOT_BOOTSTRAPPED = 'NOT_BOOTSTRAPPED';
const BOOTSTRAPPING = 'BOOTSTRAPPING';
const NOT_MOUNTED = 'NOT_MOUNTED';
const MOUNTING = 'MOUNTING';
const MOUNTED = 'MOUNTED';
const UNMOUNTING = 'UNMOUNTING';
// 存放所有的子应用
const apps = [];
let isStarted = false;
export const registerApplication = (appConfig) => {
  apps.push({ ...appConfig, status: NOT_LOADED });
  reroute();
};

export const start = () => {
  isStarted = true;
  reroute();
};

const reroute = async () => {
  const { appsToLoad, appsToMount, appsToUnmount } = getAppChanges();
  if (isStarted) {
    await Promise.all(appsToUnmount.map(toUnmount));
    await Promise.all(appsToLoad.map(toLoad));
    await Promise.all(appsToMount.map(tryToBoostrapAndMount));
  } else {
    await Promise.all(appsToLoad.map(toLoad));
  }
};

const toLoad = async (app) => {
  if (app.status !== NOT_LOADED) return;
  app.status = LOADING_SOURCE_CODE;
  const res = await app.app();
  Object.assign(app, { ...res, status: NOT_BOOTSTRAPPED });
};

const toUnmount = async (app) => {
  if (app.status !== MOUNTED) return;
  app.status = UNMOUNTING;
  await app.unmount(app.customProps);
  app.status = NOT_MOUNTED;
};

const tryToBoostrapAndMount = async (app) => {
  if (!shouldBeActive(app)) return;
  if (app.status !== NOT_BOOTSTRAPPED && app.status !== NOT_MOUNTED) return;
  app.status = BOOTSTRAPPING;
  await app.bootstrap(app.customProps);
  app.status = NOT_MOUNTED;
  if (shouldBeActive(app)) {
    app.status = MOUNTING;
    await app.mount(app.customProps);
    app.status = MOUNTED;
  }
};

const getAppChanges = () =>
  apps.reduce(
    (acc, app) => {
      const { appsToLoad, appsToMount, appsToUnmount } = acc;
      const isActive = shouldBeActive(app);
      if (app.status === NOT_LOADED || (app.status === NOT_BOOTSTRAPPED && isActive)) {
        appsToLoad.push(app);
      } else if ((app.status === NOT_MOUNTED || app.status === BOOTSTRAPPING) && isActive) {
        appsToMount.push(app);
      } else if (app.status === MOUNTED && !isActive) {
        appsToUnmount.push(app);
      }
      return acc;
    },
    { appsToLoad: [], appsToMount: [], appsToUnmount: [] },
  );

const shouldBeActive = (app) => {
  try {
    return app.activeWhen(window.location);
  } catch (err) {
    console.error('activeWhen error', err);
    return false;
  }
};

const patchHistoryUpdate =
  (updateState) =>
  (...args) => {
    const urlBefore = window.location.href;
    const result = Reflect.apply(updateState, window.history, args);
    const urlAfter = window.location.href;
    if (urlBefore !== urlAfter) reroute();
    return result;
  };

window.addEventListener('hashchange', reroute);
window.history.pushState = patchHistoryUpdate(window.history.pushState);
window.history.replaceState = patchHistoryUpdate(window.history.replaceState);