As a popular technology field in front end, the micro front end concept is warmly welcomed by the majority of front end technicians, and single-SPA is a mature micro front end technology solution at present. Single-spa is a JavaScript micro-front-end framework that aggregates multiple single-page applications into a Single application.

Microfront-end core processes

The core implementation process of single-SPA is as follows:

First, load the main application, use the micro-front-end framework to register the sub-application to start and run the micro-front-end, and rewrite the event listener to add and remove functions to intercept the registered event listener, add the route event listener to monitor the route changes, and match the corresponding sub-application route to load and unload the sub-application. When the sub-application life cycle function is executed after the sub-application is loaded, and the intercepted route event listener is registered to trigger the sub-application route, a complete micro-front-end core process is completed.

Next, we started implementing a single-SPA framework from scratch.

Application of registration

The single-SPA framework can be used as follows:

// Declare the child application
const app = {
  bootstrap: () = > Promise.resolve(), //bootstrap function
  mount: () = > Promise.resolve(), //mount function
  unmount: () = > Promise.resolve(), //unmount function
};

// Register child applications
singleSpa.registerApplication('appName', app, '/subApp');

// Run the micro front end
singleSpa.start();
Copy the code

The application of Sing-SPA can be divided into three steps:

  1. Declare subapplications and expose the life cycle of subapplications, including:bootstrap,mount,unmount,update.
  2. callsingleSpa.registerApplicationFunction to register child applications.
  3. The last executionsingleSpa.startThe function starts running the micro front end.

The sub-application registration process is relatively simple, just use an array to maintain the storage for the registration of the sub-application, application registration logic is as follows:

// Used to store the registered app
const apps = [];

export function registerApplication(name, loadApp, activeWhen, customProps) {
  const registration = {
    name,
    loadApp: sanitizeLoadApp(loadApp),
    activeWhen: sanitizeActiveWhen(activeWhen),
    customProps: sanitizeCustomProps(customProps),
  };

  apps.push(
    Object.assign(
      {
        status: NOT_LOADED, // Default state
      },
      registration
    )
  );
}

function sanitizeLoadApp(loadApp) {
  // Make sure the loadApp implementation returns a promise
  if (typeofloadApp ! = ='function') {
    return () = > Promise.resolve(loadApp);
  }

  return loadApp;
}

function sanitizeActiveWhen(activeWhen) {
  let activeWhenArray = Array.isArray(activeWhen) ? activeWhen : [activeWhen];
  return (location) = > activeWhenArray.some((activeWhen) = > activeWhen(location));
}

function sanitizeCustomProps(customProps) {
  return customProps ? customProps : {};
}
Copy the code

After all the sub-applications are registered, the singlespa. start function is called to start the micro-front-end framework, initialize the applications, and load the sub-applications matched by the route.

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

let started = false;

export function start() {
  started = true;
  reroute(); // Make the application change and load the matched child application
}
Copy the code

Applying the change function Reroute is the core implementation of the single-SPA framework, which will be explained later.

Routing to intercept

Set route listening for the primary application to ensure that the sub-application is loaded when the route is overridden to listen for route changes.

In addition, before triggering the route listening of the mounted subapplication, check whether the micro-front-end is started. If it is not started, the matched subapplication is not mounted. Load the corresponding subapplication first and then trigger the route listening switchover of the subapplication. The implementation is simple enough to override the history API functions pushState and replaceState to intercept history changes.

function urlReroute() {
  reroute([], arguments);
}

function patchedUpdateState(updateState, methodName) {
  return function () {
    const urlBefore = window.location.href;
    const result = updateState.apply(this.arguments);
    const urlAfter = window.location.href;

    if(urlBefore ! == urlAfter) {if (isStarted()) {
        // If yes, the history event will be distributed normally
        window.dispatchEvent(createPopStateEvent(window.history.state, methodName));
      } else {
        // Otherwise, initialize the application lifecyclereroute([]); }}return result;
  };
}

function createPopStateEvent(state, methodName) {
  let evt = new PopStateEvent('popstate', { state });

  evt.singleSpa = true;
  evt.singleSpaTrigger = methodName;
  return evt;
}

// Listen for routing events to trigger changes to the application lifecycle
window.addEventListener('hashchange', urlReroute);
window.addEventListener('popstate', urlReroute);

// Rewrite the history API to determine whether the micro front end is started and match the corresponding operation
window.history.pushState = patchedUpdateState(window.history.pushState, 'pushState');
window.history.replaceState = patchedUpdateState(window.history.replaceState, 'replaceState');
Copy the code

In the application after completion of loading but not yet mounted to the main application, child application has not been created, the mount node routing changes will trigger a child application routing node event access does not exist, lead to code execution error, so need to be right by the event listener registered to intercept, ensure that the application of mount after the completion of the registration to intercept routing event listeners.

// Collect route event listeners
const eventListeners = {
  hashchange: [].popstate: [],};export const eventNames = ['hashchange'.'popstate'];

// Override event listeners add and remove functions that intercept and collect route event listeners
const originAddEventListener = window.addEventListener;
const originRemoveEventListener = window.removeEventListener;
window.addEventListener = function (eventName, fn) {
  if (
    eventNames.indexOf(eventName) >= 0 &&
    !find(eventListeners[eventName], (listener) = > listener === fn)
  ) {
    eventListeners[eventName].push(fn);
    return;
  }

  return originAddEventListener.apply(this.arguments);
};
window.removeEventListener = function (eventName, fn) {
  if (eventNames.indexOf(eventName) >= 0) {
    eventListeners[eventName] = eventListeners[eventName].filter((listener) = >listener ! == fn);return;
  }

  return originRemoveEventListener.apply(this.arguments);
};
Copy the code

After the child application is mounted, all collected route event interceptors are registered.

export function callAllEventListener(eventArgs) {
  if(! eventArgs)return;

  const eventType = eventArgs[0].type;
  if (eventNames.indexOf(eventType) >= 0) {
    eventListeners[eventType].forEach((listener) = > {
      try {
        listener.apply(this, eventArgs);
      } catch (err) {
        throwerr; }}); }}Copy the code

Apply the changes

Before changing an application, filter the applications to be unmounted and unmounted based on the application status.

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

  apps.forEach((app) = > {
    constappShouldBeActive = apps.status ! == SKIP_BECAUSE_BROKEN && shouldBeActive(app);switch (app.status) {
      case LOAD_ERROR:
        if (appShouldBeActive) {
          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(app.name)) { appsToUnload.push(app); }else if (appShouldBeActive) {
          appsToMount.push(app);
        }
        break;
      case MOUNTED:
        if(! appShouldBeActive) { appsToUnmount.push(app); }break; }});return { appsToUnload, appsToUnmount, appsToLoad, appsToMount };
}
Copy the code

After obtaining the applications to be unmounted and mounted, mount and unmount the subapplication queues. Ensure that all application change queues are executed before registering route event listeners. In addition, it is necessary to consider that the last application changes due to network reasons have not been completed, so cache the application queue and wait for the last change to be completed.

// The lifecycle performs the transformation and returns a promise when it's done
import { getAppChanges, getMountedApps } from '.. /applications/apps';
import { shouldBeActive } from '.. /applications/helper';
import { toBootstrapPromise } from '.. /lifecycles/bootstrap';
import { toLoadPromise } from '.. /lifecycles/load';
import { toMountPromise } from '.. /lifecycles/mount';
import { toUnloadPromise } from '.. /lifecycles/unload';
import { toUnmountPromise } from '.. /lifecycles/unmount';

import { isStarted } from '.. /start';
import { callCapturedEventListeners } from './navigation';

let appChangeUnderway = false; // Check whether the application is changing
let waitingOnAppChange = []; // Used to store last applied changes in wait

export function reroute(pendingPromises = [], eventArgs) {
  // Apply changes, cache this application change for execution after the change is complete
  if (appChangeUnderway) {
    return new Promise((resolve, reject) = > {
      waitingOnAppChange.push({
        resolve,
        reject,
        eventArgs,
      });
    });
  }

  const {
    appsToUnload,
    appsToUnmount,
    appsToLoad,
    appsToMount,
  } = getAppChanges();

  if (isStarted()) {
    // The micro front end is starting, making application changes
    appChangeUnderway = true;
    return preformAppChanges();
  } else {
    // Load related applications
    return loadApps();
  }

  function loadApps() {
    return Promise.resolve().then(() = > {
      const loadPromises = appsToLoad.map(toLoadPromise);

      return Promise.all(loadPromises)
        .then(callAllEventListeners)
        .catch((err) = > {
          callAllEventListeners();
          throw err;
        });
    });
  }

  function preformAppChanges() {
    return Promise.resolve().then(() = > {
      // Remove and uninstall the application to be uninstalled
      const unloadPromises = appsToUnload.map(toUnloadPromise);
      const unmountPromises = appsToUnmount
        .map(toUnmountPromise)
        .map((promise) = > promise.then(toUnloadPromise));
      const unmountAllPromises = Promise.all(unmountPromises.concat(unloadPromises));
  
      // Load and mount the application to be mounted
      const loadPromises = appsToLoad.map((app) = > {
        return toLoadPromise(app).then((app) = > {
          bootstrapAndMount(app);
        });
      });
      const mountPromises = appsToMount.map((app) = > bootstrapAndMount(app));

      // Make sure to register the route event listener after the application is unmounted and unmounted
      return unmountAllPromises
        .then(() = > {
          callAllEventListeners();
          // Finish the application mount and continue to execute the waiting application change queue
          Promise.all(mountPromises.concat(loadPromises)).then(finishUpAndReturn);
        })
        .catch((err) = > {
          callAllEventListeners();
          throw err;
        });
    });
  }

  function finishUpAndReturn() {
    // The name of the mounted application is returned
    const returnValue = getMountedApps();
    pendingPromises.forEach((promise) = > promise.resolve(returnValue));

    appChangeUnderway = false;

    if (waitingOnAppChange.length > 0) {
      const nextPromises = waitingOnAppChange;
      waitingOnAppChange = [];
      // Perform the application changes from the last wait
      reroute(nextPromises);
    }

    return returnValue;
  }

  function callAllEventListeners() {
    pendingPromises.forEach((promise) = >{ callCapturedEventListeners(promise.eventArgs); }); callCapturedEventListeners(eventArgs); }}function bootstrapAndMount(app) {
  if (shouldBeActive(app)) {
    return toBootstrapPromise(app).then((app) = > {
      return shouldBeActive(app) ? toMountPromise(app) : app;
    });
  }
  return app;
}
Copy the code

The life cycle

The Single-SPA framework implements a complete application lifecycle process to better manage all sub-applications and ensure that the right sub-applications are loaded and unloaded. Each sub-application has 12 life cycle states, and the transformation process of each state is as follows:

Refer to theGithub.com/YataoZhang/…

After the subapplication is loaded from the loadApp function, the life cycle function corresponding to the subapplication is executed according to the matching result returned by the route matching function activeWhen of the subapplication, the matched subapplication is mounted, the unmatched subapplication is unloaded, the route event monitoring is set, and the life cycle process is continuously executed by monitoring the route changes.

Single-spa lifecycle sequential execution follows the chained call sequence, which is implemented based on promises. Here is the pseudocode for all lifecycle execution functions:

function toLifecyclePromise(app) {
  // Via the promise chain call, return the app for the next lifecycle execution
  return Promise.resolve().then(() = > {
    // Check whether the app state is the pre-life cycle state. If not, return directly
    if(app.status ! == preStatus) {return app;
    }

    app.status = currentStatus; // Change the status to In progress
    // Execute the app lifecycle function
    return app.lifecycle(app.customProps)
      .then(() = >{...// The lifecycle function executes the result processing
        app.status = endStatus; // Change the status to End
        return app;
      })
      .catch((err) = > {
        app.status = errStatus // Change the status to End
        return app;
      });
  });
}
Copy the code

Each lifecycle execution function returns a Promise object, and each promise returns the current app object, which is passed to the next lifecycle to continue execution. Internally, the promise first determines whether the status of the APP is the pre-state of the current life cycle, if not, it will directly return, then changes the status of the APP to the ongoing state of the current life cycle, then executes the current life cycle function of the APP, and processes the execution result after the execution of the life cycle function. At the same time, change the status of APP to the end state of the current life cycle, and finally return the current APP object to complete the execution of a life cycle execution function.

The entire single-SPA lifecycle implementation process is actually the core implementation process of applying the change function reoute. If you have any questions about the logic of applying the change function in the previous section, please refer to this diagram for a review. I believe you will have a good understanding of the core implementation of single-SPA.

The specific practices

From single-SPA application registration and operation, to setting up route interception, to application switching and lifecycle management for application switching, we have thoroughly analyzed the implementation logic of single-SPA micro-front-end framework, and the following is the concrete practice.

Specific code implementation see warehouse address: github.com/jackenl/my-…

At this point, you’re done implementing a complete tutorial on single-SPA micro front-end frameworks from scratch!

If you have any questions, please feel free to comment.