A link to the

This paper is a recent analysis of single-SPA, and all the articles are as follows:

  1. An in-depth analysis of single-SPA — navigation events and Reroute
  2. In-depth analysis of Single-SPA — Startup and application management
  3. An in-depth look at single-SPA — the event mechanism
  4. Additional in-depth analysis of module mechanisms, life cycles, and microfront-end types is ongoing

Open single-Spa on Github and the About section reads:

The router for easy microfrontends.

Sing-spa is a route-driven micro front-end framework, so we will start with the Router and then gradually analyze the routing mechanism inside single-SPA.

Front-end knowledge – About front-end routing

Whether we use React, Vue or Angular to develop SPA applications, Router is indispensable. In the browser environment, common routers fall into two categories:

  • Browser Router
  • Hash Router

Browser Router

In HTML5, the DOM’s window object provides access to the browser’s session history through history, allowing for forward and backward jumps in the user’s browsing history. We can:

// Jump backwards in history
window.history.back()
// Jump forward in history
window.history.forward()
// Or use go to load a specific interface in the session history
window.history.go(-1) // Same as back
window.history.go(1) // Equivalent to forward
Copy the code

In addition, the history API provides pushState/replaceState/popState event, used to add and modify items in a historical record.

, which uses these events to keep the UI and URL consistent.

window.onpopstate = function(event) {
  // ... 
}

history.pushState({page: 1}, "title 1"."? page=1")
history.pushState({page: 2}, "title 2"."? page=2")
history.replaceState({page: 3}, "title 3"."? page=3")
Copy the code

Hash Router

The Hash Router listens for hashchange events to keep the UI and URL consistent based on changes in location. Hash. It is also a Router that we often encounter and has good browser compatibility.

window.onhashchange = function (event) { 
    // ... 
}

window.addEventListener('hashchange'.function (event) {
    // ...
})
Copy the code

For more information on these types of events, see the History API and Window: HashChange Event.

Routing mechanisms in single-SPA

Navigation-events

Single-spa implements application-level routing navigation and provides Browser Router and Hash Router support. The main implementation is as follows:

  • Listening to thehashchangeandpopstateEvent to implement reroute
  • rewritewindow.addEventListenerandwindow.removeEventListenerTo realize the hijacking of custom events
  • rightpushStateandreplaceStateEvent custom processing

The basic flow

forhashchangeandpopstateListening processing of

const capturedEventListeners = {
  hashchange: [].popstate: [],};// Route events to listen on, i.e. Hashchange and popState
export const routingEventsListeningTo = ["hashchange"."popstate"];
// The implementation of the event listening call
export function callCapturedEventListeners(eventArguments) {
  if (eventArguments) {
    const eventType = eventArguments[0].type;
    if (routingEventsListeningTo.indexOf(eventType) >= 0) {
      capturedEventListeners[eventType].forEach((listener) = > {
        try {
          listener.apply(this, eventArguments);
        } catch (e) {
          setTimeout(() = > {
            throwe; }); }}); }}}if (isInBrowser) {
  // Register listening for hashchange and popState events
  window.addEventListener("hashchange", urlReroute);
  window.addEventListener("popstate", urlReroute);
  // ...
}
Copy the code

rewritewindow.addEventListenerandwindow.removeEventListener

After registering for listening on hashchange and popState events, we override the addEventListener and removeEventListener methods as follows:

if (isInBrowser) {
  // ...
  // Override addEventListener and removeEventListener
  const originalAddEventListener = window.addEventListener;
  const originalRemoveEventListener = window.removeEventListener;
  window.addEventListener = function (eventName, fn) {
    if (typeof fn === "function") {
      if (
        routingEventsListeningTo.indexOf(eventName) >= 0 &&
        !find(capturedEventListeners[eventName], (listener) = > listener === fn)
      ) {
        capturedEventListeners[eventName].push(fn);
        return; }}return originalAddEventListener.apply(this.arguments);
  };

  window.removeEventListener = function (eventName, listenerFn) {
    if (typeof listenerFn === "function") {
      if (routingEventsListeningTo.indexOf(eventName) >= 0) {
        capturedEventListeners[eventName] = capturedEventListeners[
          eventName
        ].filter((fn) = >fn ! == listenerFn);return; }}return originalRemoveEventListener.apply(this.arguments);
  };
  // ...
}

// urlReroute is simply called reroute to implement the corresponding route navigation operation
function urlReroute() {
  reroute([], arguments);
}
Copy the code

forpushStateandreplaceStateCustom processing of

Single-spa implements a patchUpdateState method to add some custom logic to the pushState and replaceState events in window.history:

if (isInBrowser) {
  // ...
  // For pushState and replaceState events, add some custom logic via patchedUpdateState
  window.history.pushState = patchedUpdateState(
    window.history.pushState,
    "pushState"
  );
  window.history.replaceState = patchedUpdateState(
    window.history.replaceState,
    "replaceState"
  );
  / /...
}
Copy the code

In patchUpdateState, the following processing is mainly done:

  • Determine whether urlRerouteOnly or the URL has changed
  • Determine if single-SPA has been started
    • If single-SPA has been started, a corresponding event will be sent
    • If it is not started, it is triggeredreroute
function patchedUpdateState(updateState, methodName) {
  return function () {
    const urlBefore = window.location.href;
    const result = updateState.apply(this.arguments);
    const urlAfter = window.location.href;

    if(! urlRerouteOnly || urlBefore ! == urlAfter) {if (isStarted()) {
       	// Send the corresponding event
        window.dispatchEvent(
          createPopStateEvent(window.history.state, methodName)
        );
      } else {
        / / call reroutereroute([]); }}return result;
  };
}

// cratePopStateEvent is used to create a PopStateEvent and add singleSpa and singleSpaTrigger identifiers
function createPopStateEvent(state, originalMethodName) {
  let evt;
  try {
    evt = new PopStateEvent("popstate", { state });
  } catch (err) {
    evt = document.createEvent("PopStateEvent");
    evt.initPopStateEvent("popstate".false.false, state);
  }
  evt.singleSpa = true;
  evt.singleSpaTrigger = originalMethodName;
  return evt;
}
Copy the code

reroute

In the introduction of navigation-events, we can find:

  • Have statedcallCapturedEventListeners, but does not call; The actual call is inreroute.jsThe trigger
  • inpatchUpdateStateIs triggered if single-SPA is not startedreroute
  • forhashchangeandpopstate“, registered oneurlRerouteMethod, which is also triggered herereroute

So let’s look at Reroute.

About the reroute

Reroute is the core method of single-SPA. The method updates the state of the microapplication, triggers the life cycle function of the microfront-end application, and issues a series of custom events.

trigger
  • Manual invocation: Reroute execution is triggered when the micro front-end application is registered and the start method is called
  • Automatic trigger: Listen for changes in route events in navigation-Events and automatically trigger reroute execution

The basic flow

  • Determine appChangealtering, if it is true, route changes after reroute is executed are stored and processed after reroute is executed
  • Use getAppChanges to get the status of each application in the app and classify it
  • Determine if single-SPA has been started, and if so, call performAppChanges; Otherwise, call loadApps
export function reroute(pendingPromises = [], eventArguments) {
  /* The variable appChangeconspicuous is used to determine whether the execution is ongoing, and its initial value is false; * If true, store the route changes since reroute started with peopleWaitingOnAppChange; And returns a Promise to wait until the reroute execution completes */
  if (appChangeUnderway) {
    return new Promise((resolve, reject) = > {
      peopleWaitingOnAppChange.push({
        resolve,
        reject,
        eventArguments,
      });
    });
  }
  // Use getAppChanges to get the microfront-end application status, which is divided into four categories
  const {
    appsToUnload,
    appsToUnmount,
    appsToLoad,
    appsToMount,
  } = getAppChanges();
  let appsThatChanged,
    navigationIsCanceled = false,
    oldUrl = currentUrl,
    newUrl = (currentUrl = window.location.href);
  // Check if single-SPA has been started
  if (isStarted()) {
    // Change appChangeconspicuous to true, get the appThatChanged list, and execute performAppChanges
    appChangeUnderway = true;
    appsThatChanged = appsToUnload.concat(
      appsToLoad,
      appsToUnmount,
      appsToMount
    );
    return performAppChanges();
  } else {
    // If single-spa is not started, loadApps is executed
    appsThatChanged = appsToLoad;
    returnloadApps(); }}Copy the code

Get the microfront-end application that needs to change — getAppChanges

In the previous code, we saw that the getAppChanges method was called in Reroute to get the microfront-end application with a state change. First we can look at the definition of application state:

// App statuses, defined in app.helpers.js, explains the state used in getAppChanges
// The initial state indicates that the resources of the micro front-end application are not loaded
export const NOT_LOADED = "NOT_LOADED";
// represents loading the source code for the microfront-end application
export const LOADING_SOURCE_CODE = "LOADING_SOURCE_CODE";
// the next state of NOT_LOADED, indicating that it is not initialized
export const NOT_BOOTSTRAPPED = "NOT_BOOTSTRAPPED";
export const BOOTSTRAPPING = "BOOTSTRAPPING";
// The next state of NOT_BOOTSTRAPPED, which means that the microfront-end application code is not executed/loaded on the interface
export const NOT_MOUNTED = "NOT_MOUNTED";
export const MOUNTING = "MOUNTING";
// Indicates that the micro front-end application code has been executed/loaded to the interface
export const MOUNTED = "MOUNTED";
export const UPDATING = "UPDATING";
export const UNMOUNTING = "UNMOUNTING";
export const UNLOADING = "UNLOADING";
export const LOAD_ERROR = "LOAD_ERROR";
export const SKIP_BECAUSE_BROKEN = "SKIP_BECAUSE_BROKEN";
Copy the code

GetAppChanges defines four categories of applications and classifies them accordingly:

  • AppsToUnload: Applies to theNOT_BOOTSTRAPPEDandNOT_MOUNTEDStatus, and does not match the current URL
  • AppsToUnmount: Indicates the statusMOUNTEDStatus, and does not match the current URL
  • AppsToLoad: Specifies the statusNOT_LOADEDandLOADING_SOURCE_CODETurntable, and the current URL matches the application
  • AppsToMount: As opposed to appsToUnload, forNOT_BOOTSTRAPPEDandNOT_MOUNTEDState, and the application matches the current URL

The specific source code is as follows:

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

  // We re-attempt to download applications in LOAD_ERROR after a timeout of 200 milliseconds
  const currentTime = new Date().getTime();

  apps.forEach((app) = > {
    constappShouldBeActive = app.status ! == SKIP_BECAUSE_BROKEN && shouldBeActive(app);switch (app.status) {
      case LOAD_ERROR:
        if (appShouldBeActive && currentTime - app.loadErrorTime >= 200) {
          appsToLoad.push(app);
        }
        break;
      case NOT_LOADED:
      case LOADING_SOURCE_CODE:
        if (appShouldBeActive) {
          appsToLoad.push(app);
        }
        break;
      case NOT_BOOTSTRAPPED:
      case NOT_MOUNTED:
        if(! appShouldBeActive && getAppUnloadInfo(toName(app))) { appsToUnload.push(app); }else if (appShouldBeActive) {
          appsToMount.push(app);
        }
        break;
      case MOUNTED:
        if(! appShouldBeActive) { appsToUnmount.push(app); }break;
      // all other statuses are ignored}});return { appsToUnload, appsToUnmount, appsToLoad, appsToMount };
}
Copy the code

After capturing the microfront-end application with a state change, it is time to perform specific operations.

Perform the micro front-end application changes — performAppChanges

Execute changes mainly through CustomEvent for CustomEvent distribution and subsequent processing:

  • Based on the number of appsThatChangedsingle-spa:before-no-app-changeorsingle-spa:before-app-changeThe event
  • distributedsingle-spa:before-routing-eventThe event
  • If navigation has been cancelled
    • distributedsingle-spa:before-mount-routing-eventThe event
    • Call finishUpAndReturn
    • Call naviagteToUrl to return the previous URL
  • Microfront-end applications of various states are processed
  • Finally, finishUpAndReturn is called
  function performAppChanges() {
    return Promise.resolve().then(() = > {
      // Send custom events based on the number of appsThatChanged
      window.dispatchEvent(
        new CustomEvent(
          appsThatChanged.length === 0
            ? "single-spa:before-no-app-change"
            : "single-spa:before-app-change",
          getCustomEventDetail(true)));// Dispatch the single-spa:before-routing-event event
      window.dispatchEvent(
        new CustomEvent(
          "single-spa:before-routing-event",
          getCustomEventDetail(true, { cancelNavigation })
        )
      );
      // Handle navigation cancellations
      if (navigationIsCanceled) {
        window.dispatchEvent(
          new CustomEvent(
            "single-spa:before-mount-routing-event",
            getCustomEventDetail(true))); finishUpAndReturn(); navigateToUrl(oldUrl);return;
      }
      // Handle applications in various states
      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);
      unmountAllPromise.then(() = > {
        window.dispatchEvent(
          new CustomEvent(
            "single-spa:before-mount-routing-event",
            getCustomEventDetail(true))); });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);
        });
      return unmountAllPromise
        .catch((err) = > {
          callAllEventListeners();
          throw err;
        })
        .then(() = > {
          callAllEventListeners();
          return Promise.all(loadThenMountPromises.concat(mountPromises))
            .catch((err) = > {
              pendingPromises.forEach((promise) = > promise.reject(err));
              throw err;
            })
            .then(finishUpAndReturn); // Finally call finishUpAndReturn
        });
    });
  }
Copy the code

The handling of microfront-end applications in different states, as well as custom events, will be analyzed in a separate article later, which will not be expanded here.

The final processing — finishUpAndReturn

In the previous reroute process analysis, there were two other points:

  • Appchangeconspicuous is recorded with an initial value of false and set to true after isStarted() === true
  • According to appChangevariation === true, route changes after reroute are recorded in peopleWaitingOnAppChange for subsequent processing

FinishUpAndReturn acts as the end code to reroute and issues some custom end events, revalues appChangevariation, and handles the route events previously recorded in peopleWaitingOnAppChange. The return value is Mounted apps. The source code is as follows:

  function finishUpAndReturn() {
    // Obtain Mounted apps
    const returnValue = getMountedApps();
    pendingPromises.forEach((promise) = > promise.resolve(returnValue));
	// Publish custom events
    try {
      const appChangeEventName =
        appsThatChanged.length === 0
          ? "single-spa:no-app-change"
          : "single-spa:app-change";
      window.dispatchEvent(
        new CustomEvent(appChangeEventName, getCustomEventDetail())
      );
      window.dispatchEvent(
        new CustomEvent("single-spa:routing-event", getCustomEventDetail())
      );
    } catch (err) {
      setTimeout(() = > {
        throw err;
      });
    }
    // Reset appChangealtering to false for subsequent calls to reroute
    appChangeUnderway = false;
    // Call reroute to process the previous record in peopleWaitingOnAppChange
    if (peopleWaitingOnAppChange.length > 0) {
      const nextPendingPromises = peopleWaitingOnAppChange;
      peopleWaitingOnAppChange = [];
      reroute(nextPendingPromises);
    }
	// Return the mounted apps obtained before
    return returnValue;
  }
Copy the code

References:

  • History API

  • Window: hashchange event

  • single-spa