likes
comments
collection
share

探索微前端之简单实现一个single-spa

作者站长头像
站长
· 阅读数 3

前言

效果

我们在实现single-spa之前, 先来看下我们要达到的效果。

  • 先看代码:
<body>
  <div id="root">主应用展示内容的盒子</div>
  <a href="#/a">a应用</a>
  <a href="#/b">b应用</a>
  <script type="module">
    import { registerApplication, start } from './single-spa/single-spa.js';
    const app1 = {
      bootstrap: [
        async () => console.log('app1 bootstrap'),
      ],
      mount: async () => {
        console.log('app1 mount');
        const div = document.createElement('div');
        div.id = 'app1Root';
        div.innerHTML = 'app1';
        root.append(div);
      },
      unmount: async () => {
        console.log('app1 unmount');
        root.removeChild(app1Root);
      },
    };
    const app2 = {
      bootstrap: async () => console.log('app2 bootstrap'),
      mount: async () => {
        const div = document.createElement('div');
        div.id = 'app2Root';
        div.innerHTML = 'app2';
        root.append(div);
        console.log('app2 mount');
      },
      unmount: async () => {
        console.log('app2 unmount');
        root.removeChild(app2Root);
      }
    };

    registerApplication('a', async () => app1, location => location.hash.startsWith('#/a'), { a: 1 });
    registerApplication('b', async () => app2, location => location.hash.startsWith('#/b'), { b: 1 });
    start();
  </script>
</body>

注意引入registerApplication和start是自己实现的single-spa。

这里我们注册了两个子应用,他们很简单,就是分别实现了bootstrap、mount、unmount三个接入协议,这三个协议主要做的事情后就是加载的时候打印自己,挂载的时候自己的内容挂载到主应用去,卸载的时候把自己的内容卸载掉,效果如下。

探索微前端之简单实现一个single-spa

实现

看完效果后,我们该开始实现它了。

在实现之前,这里先贴一下文件结构,方便后面引入导出的代码的理解。

探索微前端之简单实现一个single-spa

注册

要想实现一个东西,我们得先才从入口出发,一点一点往里深入。

这里的入口自然是我们注册子应用了,我们看看registerApplication的传参,分别有四个:

  1. appName:子应用的名称,也可以称之为子应用的一个身份标识
  2. loadApp:加载子应用的方法
  3. activeWhen:展示子应用的条件判断方法
  4. customProps:给子应用传的自定义参数(不太重要,可以不关注)

根据这些信息我们可以写下这些代码:

// 文件名:single-spa/application/app.js

// 所有已经注册的子应用
export const apps = [];
export function registerApplication(appName, loadApp, activeWhen, customProps) {
  const registration = {
    name: appName,
    loadApp,
    activeWhen,
    customProps,
  };
  apps.push(registration);
}

我们需要一个数组记录已经注册的子应用,然后这些所谓的注册的子应用就是记录下他们传的参数。

但我们仅记录这些够吗?

仔细看看上面的效果图,我们切换子应用的时候是要卸载(走他们的unmount)之前的应用的,那么我们怎么知道要卸载哪些应用呢?

或许有的掘友会说,我们定义一个变量,记录当前正在展示的子应用,当切换时就把这个正在展示的子应用给卸载了。

这样的方法好像可行,但如果我一个页面有左右两个区域,同时加载两个不同的子应用a和b,但此时需要将a切换成c,但b不变呢?上面的这种方法是不是就哑火了。

实际上解决方法很简单,就是给每个子应用都提供一个状态属性,也就是记录子应用当前是处于什么状态,是在加载中、还是在挂载中还是已经被卸载掉了。我们定义以下这些状态,也可以称他们为子应用的生命周期。

// 文件名:single-spa/application/app.helper.js

// 没有被加载
export const NOT_LOADED = "NOT_LOADED";
// activeWhen匹配了,要去加载这个资源了,加载中
export const LOADING_SOURCE_CODE = "LOADING_SOURCE_CODE";
// 加载失败
export const LOAD_ERROR = "LOAD_ERROR";

// 启动
// 资源加载完成了,还没启动
export const NOT_BOOTSTRAPED = "NOT_BOOTSTRAPED";
// 正在启动
export const BOOTSTRAPING = "BOOTSTRAPING";

// 挂载
// 没有被挂载
export const NOT_MOUNTED = "NOT_MOUNTED";
// 正在挂载
export const MOUNTING = "MOUNTING";
// 挂载完成
export const MOUNTED = "MOUNTED";

// 卸载
export const UNMOUNTING = "UNMOUNTING";

然后每个子应用增加多一个status记录项。

// 文件名:single-spa/application/app.js

import { NOT_LOADED } from "./app.helper.js";

export const apps = [];
export function registerApplication(appName, loadApp, activeWhen, customProps) {
  const registration = {
    name: appName,
    loadApp,
    activeWhen,
    customProps,
    status: NOT_LOADED, // 这里增加了status,注册的时候自然是未加载的状态
  };
  apps.push(registration);
}

这样我们只要对他们的状态进行维护,切换子应用的时候遍历一下他们的状态,给这些子应用进行分类,然后在一个合适的时机分别执行他们对应的方法就行了。

// 文件名:single-spa/application/app.helper.js

import { apps } from "./app.js";

// 应用是否处于激活中
export function isActive(app) {
  return app.status === MOUNTED;
}

// 应用是否应该被激活
export function shouldBeActive(app) {
  return app.activeWhen(window.location);
}

export function getAppChanges() {
  const appsToLoad = [];
  const appsToMount = [];
  const appsToUnmount = [];

  apps.forEach((app) => {
    const appShouldBeActive = shouldBeActive(app);
    switch (app.status) {
      case NOT_LOADED:
      case LOADING_SOURCE_CODE:
        // 当前路由下要被加载的应用
        if (appShouldBeActive) {
          appsToLoad.push(app);
        }
        break;
      case NOT_BOOTSTRAPED:
      case BOOTSTRAPING:
      case NOT_MOUNTED:
        // 当前路由下要被挂载的应用
        if (appShouldBeActive) {
          appsToMount.push(app);
        }
        break;
      case MOUNTED:
        // 当前路由下要被卸载的应用
        if (!appShouldBeActive) {
          appsToUnmount.push(app);
        }
        break;
      default:
        break;
    }
  });

  return { appsToLoad, appsToMount, appsToUnmount };
}

到此,我们初步的注册应用就可以了。

路由劫持

上面注册完子应用后,我们就该考虑将子应用应用起来了。

我们继续从入口出发,看看activeWhen这个入参,可以看出切换子应用都是与路由相关的,那么我们方向就很明确了,那就是在路由变化的时候去执行这个activeWhen,结果返回true的子应用就是要挂载的。

那么如何监听路由变化并执行这些方法呢?很简单---路由劫持。

路由劫持并不难,主要是改写hashchange和popstate,这里就主要是贴代码了。

代码中的reroute就是我们执行activeWhen以及针对activeWhen返回的结果进行挂载卸载子应用了。

// 文件名:single-spa/navigation/navigation-event.js


import { reroute } from "./reroute.js";

// 对页面路由切换进行劫持,劫持后重新调用reroute方法,进行计算应用的加载
function urlRoute() {
  reroute(arguments);
}

window.addEventListener("hashchange", urlRoute);
window.addEventListener("popstate", urlRoute);

// 需要劫持原生的路由系统,保证应用加载完后再切换路由
const capturedEventListeners = {
  hashchange: [],
  popstate: [],
};

const listeningTo = ["hashchange", "popstate"];
const originAddEventListener = window.addEventListener;
const originRemoveEventListener = window.removeEventListener;

// 改写addEventListener,收集所有hashchange和popstate绑定的事件
window.addEventListener = function (eventName, callback) {
  if (
    listeningTo.includes(eventName) &&
    !capturedEventListeners[eventName].some((listener) => listener === callback)
  ) {
    capturedEventListeners[eventName].push(callback);
    return;
  }
  return originAddEventListener.apply(this, arguments);
};

// 改写removeEventListener,移除对应的hashchange和popstate绑定的事件
window.removeEventListener = function (eventName, callback) {
  if (listeningTo.includes(eventName)) {
    capturedEventListeners[eventName] = capturedEventListeners[
      eventName
    ].filter((listener) => listener !== callback);
    return;
  }
  return originRemoveEventListener.apply(this, arguments);
};

// 在触发了hashchange或popstate后,执行之前收集到的对应的回调
export function callCaptureEventListeners(e) {
  if (e) {
    const eventType = e[0].type;
    if (listeningTo.includes(eventType)) {
      capturedEventListeners[eventType].forEach((listener) => {
        listener.apply(this, e);
      });
    }
  }
}

// 改写pushState和replaceState
function patchFn(updateState) {
  return function () {
    const urlBefore = window.location.href;
    const r = updateState.apply(this, arguments);
    const urlAfter = window.location.href;
    // 只有路径发生变化了才走逻辑
    if (urlBefore !== urlAfter) {
      // 手动派发popstate事件
      window.dispatchEvent(new PopStateEvent("popstate"));
    }
    return r;
  };
}

window.history.pushState = patchFn(window.history.pushState);
window.history.replaceState = patchFn(window.history.replaceState);

代码比较简单,代码中也有不少注释,所以这部分不做过多解释,看代码就能懂。

切换应用

上面我们实现了路由劫持,这里就该实现路由劫持要做的事了,也就是reroute方法。这个方法是核心,一切切换应用的逻辑都在这里。

在注册部分,我们聊过子应用的生命周期,我们在给子应用定义四个生命周期,分别是load -> bootstrap -> mount -> unmount。

那么我们先来完善这个生命周期,先实现他们在各个生命周期要做的事。

为了保证子应用自身生命周期的按顺序执行,所以每一个生命周期函数都应该是promise。

  • load

所谓load,其实就是子应用的初始化,加载对应的子应用的代码,然后进行一些初始化操作,为后续的执行做铺垫,我们直接看代码。

// single-spa/lifecycles/load.js

import {
  LOADING_SOURCE_CODE,
  NOT_BOOTSTRAPED,
  NOT_LOADED,
} from "../application/app.helper.js";

// 把函数进行串联
function flattenArrayToPromise(fns) {
  fns = Array.isArray(fns) ? fns : [fns];
  return function (props) {
    return fns.reduce(
      (rPromise, fn) => rPromise.then(() => fn(props)),
      Promise.resolve()
    );
  };
}

export function toLoadPromise(app) {
  return Promise.resolve().then(() => {
    if (app.status !== NOT_LOADED) {
      // 此应用加载完毕,就不需要加载了,直接返回
      return app;
    }
    // 需要加载,把状态改为加载中
    app.status = LOADING_SOURCE_CODE;

    // 执行注册时提供的加载应用的方法
    return app.loadApp(app.customProps).then((value) => {
      const { bootstrap, mount, unmount } = value;
      app.status = NOT_BOOTSTRAPED;
      app.bootstrap = flattenArrayToPromise(bootstrap);
      app.mount = flattenArrayToPromise(mount);
      app.unmount = flattenArrayToPromise(unmount);
      return app;
    });
  });
}

此处有一个难点,那就是flattenArrayToPromise这个方法,这个方法的作用是将一堆函数进行串联,让他们按照固定的顺序执行。

这个方法的存在主要是为了兼容那三个接入协议是数组的情况,我们返回到最开始的例子中,可以看到app1的bootstrap是一个数组,这是因为single-spa就是支持子应用的接入协议是数组的格式的。

  • bootstrap
// single-spa/lifecycles/bootstrap.js

import {
  BOOTSTRAPING,
  NOT_BOOTSTRAPED,
  NOT_MOUNTED,
} from "../application/app.helper.js";

export function toBootstrapPromise(app) {
  return Promise.resolve().then(() => {
    if (app.status !== NOT_BOOTSTRAPED) {
      return app;
    }
    app.status = BOOTSTRAPING;
    return app.bootstrap(app.customProps).then(() => {
      app.status = NOT_MOUNTED;
      return app;
    });
  });
}
  • mount
// single-spa/lifecycles/mount.js

import { MOUNTED, NOT_MOUNTED } from "../application/app.helper.js";

export function toMountPromise(app) {
  return Promise.resolve().then(() => {
    // 结合bootstrap看,凡是加载完或者曾经被卸载的应用都会是NOT_MOUNTED状态
    // 所以非这个状态就不可能需要去mount
    if (app.status !== NOT_MOUNTED) {
      return app;
    }
    return app.mount(app.customProps).then(() => {
      app.status = MOUNTED;
      return app;
    });
  });
}
  • unmount
// single-spa/lifecycles/unmount.js

import { MOUNTED, NOT_MOUNTED, UNMOUNTING } from "../application/app.helper.js";

export function toUnmountPromise(app) {
  return Promise.resolve().then(() => {
    // 不是挂载状态就不用卸载了
    if (app.status !== MOUNTED) {
      return app;
    }
    app.status = UNMOUNTING;
    return app.unmount(app.customProps).then(() => {
      app.status = NOT_MOUNTED;
    });
  });
}

以上就是这四个生命周期函数了,除了load要进行一些初始化操作外,其他生命周期就是切换自身的状态而已。

ok,到这,准备工作都完成了,我们来分析下切换一个应用的时候都需要做什么事:

  1. 卸载不需要的应用(执行不需要的应用的unmount)
  2. 加载需要的应用(执行需要的应用的load)
  3. 启动需要的应用(执行需要的应用的bootstrap)
  4. 挂载需要的应用(执行需要的应用的mount)
  5. 执行切换路由后对应的绑定事件

把上面这些描述改为代码:

// 文件名:single-spa/navigation/reroute.js

import { getAppChanges, shouldBeActive } from "../application/app.helper.js";
import { toBootstrapPromise } from "../lifecycles/bootstrap.js";
import { toLoadPromise } from "../lifecycles/load.js";
import { toMountPromise } from "../lifecycles/mount.js";
import { toUnmountPromise } from "../lifecycles/unmount.js";
import { callCaptureEventListeners } from "./navigation-event.js";

export function reroute() {
  const { appsToLoad, appsToMount, appsToUnmount } = getAppChanges();

  function performAppChange() {
    // 先卸载不需要的应用,返回一个卸载的promise
    const unmountAllPromises = Promise.all(appsToUnmount.map(toUnmountPromise));
    // 加载需要的应用 => 启动对应的应用 => 挂载对应的应用
    const loadMountPromises = Promise.all(
      appsToLoad.map((app) =>
        toLoadPromise(app).then((app) => {
          // 当应用加载完,需要启动和挂载时,需要保证卸载掉老的应用
          if (shouldBeActive(app)) {
            // 保证卸载完毕后再挂载
            return toBootstrapPromise(app).then((app) =>
              unmountAllPromises.then(() => toMountPromise(app))
            );
          }
        })
      )
    );

    const mountPromises = Promise.all(
      appsToMount.map((app) => tryBootStrapAndMount(app, unmountAllPromises))
    );

    // 执行切换路由后对应的绑定事件
    return Promise.all([loadMountPromises, mountPromises]).then(() => {
      callEventListener();
    });
  }
  
  performAppChange();
}

start

上面已经把核心逻辑完成,我们现在只要完成start方法,那么基本就ok了。

start方法并没有太多的逻辑,主要是记录一下当前是否start已经调用一下上面这个reroute就ok了。

// 文件名:single-spa/start.js

import { reroute } from "./navigation/reroute.js";

// 默认没有调用start方法
export let started = false;
export function start() {
  // 启动了
  started = true;
  reroute();
}

结尾

ok,到此整个简单的single-spa基本就完成了,不过这里需要注意一下,上面贴的代码仅是核心部分,其实很多地方少了一些处理细节的代码,所以需要看完整代码的可以点击文末的链接。

最后,希望这篇文章能对大家有所帮助,另外,觉得本文不错的话,请不要吝啬手中的赞哦🌹🌹🌹

点击查看完整代码