Skip to content
微信公众号

single-spa源码解析

我们先从Github上下载single-spa的源码:https://github.com/single-spa/single-spa

整个源码采用rollup来构建的,可以从rollup.config.js中找到入口文件,在src/single-spa.js中,对外提供了一系列的方法,像start、registerApplication等。

single-spa最主要的实现了应用的注册、路由的修改和监听。其中路由的监听在src/navigation/navigation-events.js中,应用的注册主要包括了应用的生命周期相关内容在src/lifecycles/xxx.js中。

应用注册

应用注册提供了registerApplication方法,源码如下:

js
//applications/apps
**
 * 注册应用,两种方式
 * registerApplication('app1', loadApp(url), activeWhen('/app1'), customProps)
 * registerApplication({
 *    name: 'app1',
 *    app: loadApp(url),
 *    activeWhen: activeWhen('/app1'),
 *    customProps: {}
 * })
 * @param {*} appNameOrConfig 应用名称或者应用配置对象
 * @param {*} appOrLoadApp 应用的加载方法,是一个 promise
 * @param {*} activeWhen 判断应用是否激活的一个方法,方法返回 true or false
 * @param {*} customProps 传递给子应用的 props 对象
 */
export function registerApplication(
  appNameOrConfig,
  appOrLoadApp,
  activeWhen,
  customProps
) {
  // 数据整理, 验证传参的合理性, 最后整理得到数据源:
  // {
  // name: xxx,
  // loadApp: xxx,
  // activeWhen: xxx,
  // customProps: xxx,
  // }
  const registration = sanitizeArguments(
    appNameOrConfig,
    appOrLoadApp,
    activeWhen,
    customProps
  );

  // 如果有重名,则抛出错误, 所以 name 应该是要保持唯一值
  if (getAppNames().indexOf(registration.name) !== -1)
    throw Error(
      formatErrorMessage(
        21,
        __DEV__ &&
          `There is already an app registered with name ${registration.name}`,
        registration.name
      )
    );

  // apps 是 single-spa 的一个全局变量, 用来存储当前的应用数据
  apps.push(
    assign(
      {
        loadErrorTime: null,
        status: NOT_LOADED,
        parcels: {},
        devtools: {
          overlays: {
            options: {},
            selectors: [],
          },
        },
      },
      registration
    )
  );

  // 判断 window 是否为空, 进入条件
  if (isInBrowser) {
    ensureJQuerySupport(); // 确保 jq 可用
    reroute();
  }
}

首先对调用了sanitizeArguments对registerApplication的参数进行整合,然后判断子应用是否注册过,注册过则抛出异常,然后将子应用添加到apps这个数组中,等到调用start方法时来进行对应的渲染。

最后调用reroute方法。reroute 是 single-spa 的核心函数, 在注册应用时调用此函数的作用, 就是将应用的 promise 加载函数, 注入一个待加载的数组中 等后面正式启动时再调用, 类似于 ()=>import('xxx')

js
// navigation/reroute.js
export function reroute(pendingPromises = [], eventArguments) {
   // 一开始默认是 false
  if (appChangeUnderway) {
    // 如果是 true, 则返回一个 promise, 在队列中添加 resolve 参数等等
    return new Promise((resolve, reject) => {
      peopleWaitingOnAppChange.push({
        resolve,
        reject,
        eventArguments,
      });
    });
  }

  // 遍历所有应用数组 apps , 根据 app 的状态, 来分类到这四个数组中
  // 会根据 url 和 whenActive 判断是否该 load
  // unload , unmount, to load, to mount
  const {
    appsToUnload,
    appsToUnmount,
    appsToLoad,
    appsToMount,
  } = getAppChanges();
  let appsThatChanged,
    navigationIsCanceled = false,
    oldUrl = currentUrl,
    newUrl = (currentUrl = window.location.href);

  // 存储着一个闭包变量, 是否已经启动, 在注册步骤中, 是未启动的
  if (isStarted()) {
    appChangeUnderway = true;
    // 合并状态需要变更的 app
    appsThatChanged = appsToUnload.concat(
      appsToLoad,
      appsToUnmount,
      appsToMount
    );
    // 返回 performAppChanges 函数
    return performAppChanges();
  } else {
    // 未启动, 直接返回 loadApps, 他的定义在下方
    appsThatChanged = appsToLoad;
    return loadApps();
  }

  function cancelNavigation() {
    navigationIsCanceled = true;
  }

  // 返回一个 resolve 的 promise,通过微任务来加载apps
  // 将需要加载的应用, map 成一个新的 promise 数组
  // 并且用 promise.all 来返回
  // 不管成功或者失败, 都会调用 callAllEventListeners 函数, 进行路由通知
  function loadApps() {
    return Promise.resolve().then(() => {
      // toLoadPromise 主要来定义资源的加载, 以及对应的回调
      const loadPromises = appsToLoad.map(toLoadPromise);

      // 通过 Promise.all 来执行, 返回的是 app.loadPromise,这是资源加载
      return (
        Promise.all(loadPromises)
          .then(callAllEventListeners)
          // there are no mounted apps, before start() is called, so we always return []
          .then(() => [])
          .catch((err) => {
            callAllEventListeners();
            throw err;
          })
      );
    });
  }

  function performAppChanges() {
    return Promise.resolve().then(() => {
      // https://github.com/single-spa/single-spa/issues/545
       //触发自定义事件
      // 当前事件触发 getCustomEventDetail
      // 主要是 app 的状态, url 的变更, 参数等等
      window.dispatchEvent(
        new CustomEvent(
          appsThatChanged.length === 0
            ? "single-spa:before-no-app-change"
            : "single-spa:before-app-change",
          getCustomEventDetail(true)
        )
      );

      window.dispatchEvent(
        new CustomEvent(
          "single-spa:before-routing-event",
          getCustomEventDetail(true, { cancelNavigation })
        )
      );

      // 除非在上一个事件中调用了 cancelNavigation, 才会进入这一步
      if (navigationIsCanceled) {
        window.dispatchEvent(
          new CustomEvent(
            "single-spa:before-mount-routing-event",
            getCustomEventDetail(true)
          )
        );
        // 将 peopleWaitingOnAppChange 的数据重新执行 reroute 函数 reroute(peopleWaitingOnAppChange) 
        finishUpAndReturn();
        // 更新 url
        navigateToUrl(oldUrl);
        return;
      }

      // 准备卸载的 app
      const unloadPromises = appsToUnload.map(toUnloadPromise);

      // 执行子应用中的 unmount 函数, 如果超时也会有报警
      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)
          )
        );
      });

      /* We load and bootstrap apps while other apps are unmounting, but we
       * wait to mount the app until all apps are finishing unmounting
       */
      // 执行 bootstrap 生命周期, tryToBootstrapAndMount 确保先执行 bootstrap
      const loadThenMountPromises = appsToLoad.map((app) => {
        return toLoadPromise(app).then((app) =>
          tryToBootstrapAndMount(app, unmountAllPromise)
        );
      });

      /* These are the apps that are already bootstrapped and just need
       * to be mounted. They each wait for all unmounting apps to finish up
       * before they mount.
       */
      // 执行 mount 事件
      const mountPromises = appsToMount
        .filter((appToMount) => appsToLoad.indexOf(appToMount) < 0)
        .map((appToMount) => {
          return tryToBootstrapAndMount(appToMount, unmountAllPromise);
        });
      return unmountAllPromise
        .catch((err) => {
          callAllEventListeners();
          throw err;
        })
        .then(() => {
          /* Now that the apps that needed to be unmounted are unmounted, their DOM navigation
           * events (like hashchange or popstate) should have been cleaned up. So it's safe
           * to let the remaining captured event listeners to handle about the DOM event.
           */
          callAllEventListeners();

          return Promise.all(loadThenMountPromises.concat(mountPromises))
            .catch((err) => {
              pendingPromises.forEach((promise) => promise.reject(err));
              throw err;
            })
            .then(finishUpAndReturn);
        });
    });
  }

  function finishUpAndReturn() {
    const returnValue = getMountedApps();
    pendingPromises.forEach((promise) => promise.resolve(returnValue));

    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) {
      /* We use a setTimeout because if someone else's event handler throws an error, single-spa
       * needs to carry on. If a listener to the event throws an error, it's their own fault, not
       * single-spa's.
       */
      setTimeout(() => {
        throw err;
      });
    }

    /* Setting this allows for subsequent calls to reroute() to actually perform
     * a reroute instead of just getting queued behind the current reroute call.
     * We want to do this after the mounting/unmounting is done but before we
     * resolve the promise for the `reroute` function.
     */
    appChangeUnderway = false;

    if (peopleWaitingOnAppChange.length > 0) {
      /* While we were rerouting, someone else triggered another reroute that got queued.
       * So we need reroute again.
       */
      const nextPendingPromises = peopleWaitingOnAppChange;
      peopleWaitingOnAppChange = [];
      reroute(nextPendingPromises);
    }

    return returnValue;
  }

  /* We need to call all event listeners that have been delayed because they were
   * waiting on single-spa. This includes haschange and popstate events for both
   * the current run of performAppChanges(), but also all of the queued event listeners.
   * We want to call the listeners in the same order as if they had not been delayed by
   * single-spa, which means queued ones first and then the most recent one.
   */
  function callAllEventListeners() {
    pendingPromises.forEach((pendingPromise) => {
      callCapturedEventListeners(pendingPromise.eventArguments);
    });

    callCapturedEventListeners(eventArguments);
  }

  function getCustomEventDetail(isBeforeChanges = false, extraProperties) {
    const newAppStatuses = {};
    const appsByNewStatus = {
      // for apps that were mounted
      [MOUNTED]: [],
      // for apps that were unmounted
      [NOT_MOUNTED]: [],
      // apps that were forcibly unloaded
      [NOT_LOADED]: [],
      // apps that attempted to do something but are broken now
      [SKIP_BECAUSE_BROKEN]: [],
    };

    if (isBeforeChanges) {
      appsToLoad.concat(appsToMount).forEach((app, index) => {
        addApp(app, MOUNTED);
      });
      appsToUnload.forEach((app) => {
        addApp(app, NOT_LOADED);
      });
      appsToUnmount.forEach((app) => {
        addApp(app, NOT_MOUNTED);
      });
    } else {
      appsThatChanged.forEach((app) => {
        addApp(app);
      });
    }

    const result = {
      detail: {
        newAppStatuses,
        appsByNewStatus,
        totalAppChanges: appsThatChanged.length,
        originalEvent: eventArguments?.[0],
        oldUrl,
        newUrl,
        navigationIsCanceled,
      },
    };

    if (extraProperties) {
      assign(result.detail, extraProperties);
    }

    return result;

    function addApp(app, status) {
      const appName = toName(app);
      status = status || getAppStatus(appName);
      newAppStatuses[appName] = status;
      const statusArr = (appsByNewStatus[status] =
        appsByNewStatus[status] || []);
      statusArr.push(appName);
    }
  }
}

我们看一下toLoadPromise,注册流程中 reroute的主要执行函数,它的主要功能是赋值 loadPromise 给 app, 其中 loadPromise 函数中包括了: 执行函数、来加载应用的资源、定义加载完毕的回调函数、状态的修改、还有加载错误的一些处理。

js
//lifecycles/load.js
export function toLoadPromise(app) {
  return Promise.resolve().then(() => {
    // 是否重复注册 promise 加载了
    if (app.loadPromise) {
      return app.loadPromise;
    }
    // 刚注册的就是 NOT_LOADED 状态
    if (app.status !== NOT_LOADED && app.status !== LOAD_ERROR) {
      return app;
    }

    // 修改状态为, 加载源码
    app.status = LOADING_SOURCE_CODE;

    let appOpts, isUserErr;

    // 返回的是 app.loadPromise
    return (app.loadPromise = Promise.resolve()
      .then(() => {
        // 这里调用的了 app的 loadApp 函数(由外部传入的), 开始加载资源
        // getProps 用来判断 customProps 是否合法, 最后传值给 loadApp 函数
        const loadPromise = app.loadApp(getProps(app));

        // 判断 loadPromise 是否是一个 promise
        if (!smellsLikeAPromise(loadPromise)) {
          // The name of the app will be prepended to this error message inside of the handleAppError function
          isUserErr = true;
          throw Error(
            formatErrorMessage(
              33,
              __DEV__ &&
                `single-spa loading function did not return a promise. Check the second argument to registerApplication('${toName(
                  app
                )}', loadingFunction, activityFunction)`,
              toName(app)
            )
          );
        }
        return loadPromise.then((val) => {
          // 资源加载成功
          app.loadErrorTime = null;

          appOpts = val;

          let validationErrMessage, validationErrCode;

          if (typeof appOpts !== "object") {
            validationErrCode = 34;
            if (__DEV__) {
              validationErrMessage = `does not export anything`;
            }
          }

          if (
            // ES Modules don't have the Object prototype
            Object.prototype.hasOwnProperty.call(appOpts, "bootstrap") &&
            !validLifecycleFn(appOpts.bootstrap)
          ) {
            validationErrCode = 35;
            if (__DEV__) {
              validationErrMessage = `does not export a valid bootstrap function or array of functions`;
            }
          }

          if (!validLifecycleFn(appOpts.mount)) {
            validationErrCode = 36;
            if (__DEV__) {
              validationErrMessage = `does not export a mount function or array of functions`;
            }
          }

          if (!validLifecycleFn(appOpts.unmount)) {
            validationErrCode = 37;
            if (__DEV__) {
              validationErrMessage = `does not export a unmount function or array of functions`;
            }
          }

          const type = objectType(appOpts);

          if (validationErrCode) {
            let appOptsStr;
            try {
              appOptsStr = JSON.stringify(appOpts);
            } catch {}
            console.error(
              formatErrorMessage(
                validationErrCode,
                __DEV__ &&
                  `The loading function for single-spa ${type} '${toName(
                    app
                  )}' resolved with the following, which does not have bootstrap, mount, and unmount functions`,
                type,
                toName(app),
                appOptsStr
              ),
              appOpts
            );
            handleAppError(validationErrMessage, app, SKIP_BECAUSE_BROKEN);
            return app;
          }

          if (appOpts.devtools && appOpts.devtools.overlays) {
            app.devtools.overlays = assign(
              {},
              app.devtools.overlays,
              appOpts.devtools.overlays
            );
          }

          // 设置app状态为未初始化,表示加载完了
          app.status = NOT_BOOTSTRAPPED;
          // 在app对象上挂载生命周期方法,每个方法都接收一个props作为参数,方法内部执行子应用导出的生命周期函数,并确保生命周期函数返回一个promise
          app.bootstrap = flattenFnArray(appOpts, "bootstrap");
          app.mount = flattenFnArray(appOpts, "mount");
          app.unmount = flattenFnArray(appOpts, "unmount");
          app.unload = flattenFnArray(appOpts, "unload");
          app.timeouts = ensureValidAppTimeouts(appOpts.timeouts);

          // 执行完毕之后删除 loadPromise
          delete app.loadPromise;

          return app;
        });
      })
      .catch((err) => {
        // 报错也会删除 loadPromise
        delete app.loadPromise;

        // 修改状态为 用户的传参报错, 或者是加载出错
        let newStatus;
        if (isUserErr) {
          newStatus = SKIP_BECAUSE_BROKEN;
        } else {
          newStatus = LOAD_ERROR;
          app.loadErrorTime = new Date().getTime();
        }
        handleAppError(err, app, newStatus);

        return app;
      }));
  });
}

应用启动

注册完应用之后, 最后是 start 方法执行。调用start之前,应用会被加载,但不会初始化、挂载和卸载,有了start可以更好的控制性能。

js
// start.js
export function start(opts) {
  // 主要作用还是将标记符 started设置为 true 了
  started = true;
  if (opts && opts.urlRerouteOnly) {
    // 使用此参数可以人为地触发事件 popstate
    setUrlRerouteOnly(opts.urlRerouteOnly);
  }
  if (isInBrowser) {
    reroute();
  }
}

启动后也会调用reroute方法,在reroute方法中,就会触发此函数 performAppChanges,并返回结果,该函数的作用主要是事件的触发, 包括自定义事件和子应用中的一些事件。

路由监听

在navigation-events.js中定义了许多的方法,路由监听代码被放在全局作用域内,bundle被加载后自动执行。

其中路由监听部分代码如下:

js
// navigation/navigation-events.js
if (isInBrowser) {
  // We will trigger an app change for any routing events.
  window.addEventListener("hashchange", urlReroute);
  window.addEventListener("popstate", urlReroute);

  // Monkeypatch addEventListener so that we can ensure correct timing
  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);
  };

  window.history.pushState = patchedUpdateState(
    window.history.pushState,
    "pushState"
  );
  window.history.replaceState = patchedUpdateState(
    window.history.replaceState,
    "replaceState"
  );

  if (window.singleSpaNavigate) {
    console.warn(
      formatErrorMessage(
        41,
        __DEV__ &&
          "single-spa has been loaded twice on the page. This can result in unexpected behavior."
      )
    );
  } else {
    /* For convenience in `onclick` attributes, we expose a global function for navigating to
     * whatever an <a> tag's href is.
     */
    window.singleSpaNavigate = navigateToUrl;
  }
}

对hashchange和popstate进行监听,如果有路由切换的操作就会执行urlReroute函数,然后对history的pushState和replaceState进行了一层封装,通过patchedUpdateState方法来提供。

js
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()) {
        // fire an artificial popstate event once single-spa is started,
        // so that single-spa applications know about routing that
        // occurs in a different application
        window.dispatchEvent(
          createPopStateEvent(window.history.state, methodName)
        );
      } else {
        // do not fire an artificial popstate event before single-spa is started,
        // since no single-spa applications need to know about routing events
        // outside of their own router.
        reroute([]);
      }
    }

    return result;
  };
}

patchedUpdateState这个方法对旧路由和新路由进行一个对照,如果旧路由不等于新路由,表示子应用进行切换了,则执行微前端自定义的操作代码。

生命周期

子应用生命周期包含bootstrap,mount,unmount三个回调函数。在reroute中的toLoadPromise函数中加载应用的资源、定义加载完毕的回调函数。

js
....
// 设置app状态为未初始化,表示加载完了
app.status = NOT_BOOTSTRAPPED;
// 在app对象上挂载生命周期方法,每个方法都接收一个props作为参数,方法内部执行子应用导出的生命周期函数,并确保生命周期函数返回一个promise
app.bootstrap = flattenFnArray(appOpts, "bootstrap");
app.mount = flattenFnArray(appOpts, "mount");
app.unmount = flattenFnArray(appOpts, "unmount");
app.unload = flattenFnArray(appOpts, "unload");
app.timeouts = ensureValidAppTimeouts(appOpts.timeouts);
....

其中flattenFnArray,返回一个接受props作为参数的函数,这个函数负责执行子应用中的生命周期函数,并确保生命周期函数返回的结果为promise。

js
// lifecycles/lifecycle.helpers.js
/**
 * 
 * @param {*} appOrParcel => window.singleSpa,子应用打包后的对象
 * @param {*} lifecycle => 字符串,生命周期名称
 */
export function flattenFnArray(appOrParcel, lifecycle) {
  // fns = fn or []
  let fns = appOrParcel[lifecycle] || [];
  // fns = [] or [fn]
  fns = Array.isArray(fns) ? fns : [fns];
  // 有些生命周期函数子应用可能不会设置,比如unload
  if (fns.length === 0) {
    fns = [() => Promise.resolve()];
  }

  const type = objectType(appOrParcel);
  const name = toName(appOrParcel);

  return function (props) {
    // 这里最后返回了一个promise链,这个操作似乎没啥必要,因为不可能出现同名的生命周期函数,所以,这里将生命周期函数放数组,没太理解目的是啥
    return fns.reduce((resultPromise, fn, index) => {
      return resultPromise.then(() => {
        // 执行生命周期函数,传递props给函数,并验证函数的返回结果,必须为promise
        const thisPromise = fn(props);
        return smellsLikeAPromise(thisPromise)
          ? thisPromise
          : Promise.reject(
              formatErrorMessage(
                15,
                __DEV__ &&
                  `Within ${type} ${name}, the lifecycle function ${lifecycle} at array index ${index} did not return a promise`,
                type,
                name,
                lifecycle,
                index
              )
            );
      });
    }, Promise.resolve());
  };
}

相关文章

Released under the MIT License.