微前端icestark源码解读-控制子应用加载与卸载(一)

1,203 阅读9分钟

我正在参加「掘金·启航计划」

背景

  • 用了icestark做微前端大概有近一年的时间了,一直停留在使用层面上,每次被别人问到icestark怎么做到的,应用之间为什么可以做通信的时候,都是满脸问号。是时候开始看看其内部实现原理了。
  • 第一次看到icestark的官网介绍,就明显的感觉到符合我的业务需求,再看文档的使用方式,简洁明了,并且几乎没有踩到任何坑,有点小疑问在github上提issue,icestark的开发人员都会及时反馈。个人认为现在微前端框架众多,实现目的都是大同小异的,所以彻底弄明白一个即可。
  • 笔者是一位专注于react的开发者,所以本次源码阅读均是以react的思路去阅读。

源码目录结构

githubfork 一份代码,git clone 下来之后,可以看到核心代码是在packages目录下,分别有icestarkicestark-appicestark-dataicestark-modulesandbox五个目录。

  • icestark: 主应用运行的核心逻辑
  • icestark-app: 子应用用到的
  • icestark-data:应用之间进行通信的
  • icestark-module: 开发微模块的
  • sandbox: 创建沙箱的核心逻辑

概念

  • 主应用 & 子应用

icestark 微前端必须要有一个顶层应用,所有微应用的配置都必须在这个顶层应用里面做。根据官方文档描述,我们把这个顶层应用称为主应用,所有要加载的微应用称之为子应用。

解读起点

虽然源码量并不是很大,但是直接漫无目的的阅读还是很枯燥乏味的。我们按照文档的使用方式,从主应用的配置为起点,来去一步步解读微前端的整个过程。

主应用

我们以react作为开发框架的角度来去从主应用配置上来看,需要通过 AppRouter & AppRoute 去注册微应用,这点很像react-router的配置方式,比较友好。根据官方文档上描述,icestark运行的时候,只会有一个子应用,所以可以猜一下,主应用的核心应该是通过路由的变化来控制子应用的加载与卸载。

AppRouter组件

AppRouter组件的作用主要是为了注册子应用,并且把子应用加载进来。看一个组件我喜欢先看render函数,从render函数可以看到这个组件整体是在渲染什么。我们来先看render函数

render 函数

render() {
  const {
    NotFoundComponent,
    ErrorComponent,
    LoadingComponent,
    children,
    basename: frameworkBasename,
  } = this.props;
  const { url, appLoading, started } = this.state;

  // 应用未加载之前渲染Loading组件,该Loading组件由开发者自行提供
  if (!started) {
    return renderComponent(LoadingComponent, {});
  }

  // 微应用加载过程中出现无匹配路由时渲染未发现页面组件,由开发者自行提供
  if (url === ICESTSRK_NOT_FOUND) {
    return renderComponent(NotFoundComponent, {});
    // 微应用加载过程中出现错误时渲染 错误组件, 由开发者自行提供
  } else if (url === ICESTSRK_ERROR) {
    return renderComponent(ErrorComponent, { err: this.err });
  }

  let match = false; // 是否匹配到子应用
  let element: React.ReactElement; // 记录匹配到的子应用

  // 循环遍历拿到 AppRouter 的children,也就是 AppRoute,从而获取到要加载的子应用
  React.Children.forEach(children, (child) => {
    if (!match && React.isValidElement(child)) {
      const { path, activePath, exact, strict, sensitive, hashType } = child.props;

      const compatPath = mergeFrameworkBaseToPath(
        formatPath(activePath || path, {
          exact,
          strict,
          sensitive,
          hashType,
        }),
        frameworkBasename,
      );

      element = child; // 找到了子应用

      match = !!findActivePath(compatPath)(url);
    }
  });

  if (match) {
    const { name, activePath, path } = element.props as AppRouteProps;

    if (isFunction(activePath) && !name) {
      const err = new Error('[icestark]: name is required in AppConfig');
      console.error(err);
      return renderComponent(ErrorComponent, { err });
    }

    this.appKey = name || converArray2String((activePath || path) as AppRoutePath); // 拿到子应用的唯一标识

    // 子应用的属性
    const componentProps: AppRouteComponentProps = {
      location: urlParse(url, true),
      match,
      history: appHistory, // 子应用的history更改为拦截后的history对象
    };
    return (
      <div>
        {appLoading === this.appKey ? renderComponent(LoadingComponent, {}) : null}
        {React.cloneElement(element, {
          key: this.appKey,
          name: this.appKey,
          componentProps,
          cssLoading: appLoading === this.appKey,
          onAppEnter: this.props.onAppEnter,
          onAppLeave: this.props.onAppLeave,
        })}
      </div>
    );
  }
  return renderComponent(NotFoundComponent, {});
}

从上面可以看出,render函数 无非就是根据判断条件, 去渲染NotFoundComponent ErrorComponent LoadingComponent children 其中的某一个。其中children其实就是AppRoute所加载的子应用。

所以弄清楚判断条件就尤为重要,判断条件由 AppRouter组件自身的state props 以及组件自身的常量this.appKey组成。 所以现在我们的目标转向 constructor componentDidMount

constructor函数

constructor(props) {
    super(props);
    this.state = {
      url: '', // 记录子应用的路由url
      appLoading: '', // 记录正在加载的子应用唯一标识
      started: false, // 记录icestark是否启动
    };

    const { fetch, prefetch: strategy, children } = props;

    // prefetch判断是否要对子应用进行预加载
    if (strategy) {
      this.prefetch(strategy, children, fetch);
    }
  }

从上面可以看出,主要是定义一些子应用的唯一标识 子应用路由的url icestark是否处于启动之中。唯一的逻辑对子应用进行预加载 prefetch

prefetch(预加载)

其原理是利用 window.fetch 方法,然后结合 window.requestIdleCallback ,利用浏览器空余时间去获取子应用的js和css。

  • prefetch 函数 (给子应用添加唯一标识name属性)
  /**
   * prefetch for resources.
   * no worry to excute `prefetch` many times, for all prefetched resources have been cached, and never request twice.
   */
  prefetch = (strategy: Prefetch, children: React.ReactNode, fetch: Fetch = window.fetch) => {
    const apps: AppConfig[] = React.Children // 对AppRouter组件包裹的AppRoute对应的子应用进行遍历,然后给子应用设置一个唯一标识name
      /**
       * we can do prefetch for url, entry and entryContent.
       */
      .map(children, (childElement) => {
        if (React.isValidElement(childElement)) {
          const { url, entry, entryContent, name, path } = childElement.props as AppRouteProps;
          if (url || entry || entryContent) {
            return {
              ...childElement.props,
              /**
               * name of AppRoute may be not provided, use `path` instead.
               */
              name: name || converArray2String(path), // AppRoute 如果没指定name属性的话,就用path来作为子应用的唯一标识
            };
          }
        }
        return false;
      })
      .filter(Boolean);
    // 经过上一边给子应用赋值唯一标识的操作之后,开始进行预加载处理
    doPrefetch(apps as MicroApp[], strategy, fetch);
  };
  • doPrefetch 函数 (开启获取子应用资源的idle任务)

doPrefetch 函数主要是开启 requestIdleCallback任务,遍历子应用数组,对每一个子应用都开启一个 requestIdleCallback任务。其核心逻辑如下:

function prefetchIdleTask(fetch = window.fetch) {
  return (app: MicroApp) => {
    window.requestIdleCallback(async () => {
      const { url, entry, entryContent, name } = app;
      const { jsList, cssList } = url ? getUrlAssets(url) : await getEntryAssets({
        entry,
        entryContent,
        assetsCacheKey: name,
        fetch,
      });
      window.requestIdleCallback(() => fetchScripts(jsList, fetch));
      window.requestIdleCallback(() => fetchStyles(cssList, fetch));
    });
  };
}

componentDidMount函数

componentDidMount生命周期函数主要做了三件事

  1. 监听icestark:not-found自定义事件,从而在icestark未发现子应用的时候去触发渲染 NotFoundComponent组件。icestark:not-found自定义事件,是由 new CustomEvent()进行创建,然后经过window.dispatchEvent去触发

核心实现如下:

window.dispatchEvent(new CustomEvent('icestark:not-found'))

  1. 执行start函数 (劫持路由变化,触发子应用的挂载与卸载)
  2. 设置 icestark的启动状态为 true
componentDidMount() {
    // render NotFoundComponent eventListener
    window.addEventListener('icestark:not-found', this.triggerNotFound);

    /** lifecycle `componentWillUnmount` of pre-rendering executes later then
     * `constructor` and `componentWilllMount` of next-rendering, whereas `start` should be invoked before `unload`.
     * status `started` used to make sure parent's `componentDidMount` to be invoked eariler then child's,
     * for mounting child component needs global configuration be settled.
     */
    const { shouldAssetsRemove, onAppEnter, onAppLeave, fetch, basename } = this.props;
    start({
      onAppLeave, // 子应用卸载前的回调
      onAppEnter, // 子应用渲染前的回调
      onLoadingApp: this.loadingApp, // 子应用开始加载的回调
      onFinishLoading: this.finishLoading, // 子应用完成加载的回调
      onError: this.triggerError, // 子应用加载过程发生错误的回调
      reroute: this.handleRouteChange, // 路由变化的回调
      fetch, // window.fetch
      basename, 
      ...(shouldAssetsRemove ? { shouldAssetsRemove } : {}), // 页面资源是否持久化保留
    });

    this.setState({ started: true }); // 设置 `icestark`的启动状态为 `true`
  }

start函数

function start(options?: StartConfiguration) {
  // See https://github.com/ice-lab/icestark/issues/373#issuecomment-971366188
  // todos: remove it from 3.x
  if (options?.shouldAssetsRemove && !temporaryState.shouldAssetsRemoveConfigured) {
    temporaryState.shouldAssetsRemoveConfigured = true;
  }

  if (started) {
    console.log('icestark has been already started');
    return;
  }
  started = true; // 设置icestark的启动状态为true

  recordAssets(); // 此时子应用未加载进来,通过'style', 'link', 'script'标签 找到document文档树上主应用的静态资源元素,并添加icestark=static属性

  // update globalConfiguration
  globalConfiguration.reroute = reroute; // 路由变化事件
  Object.keys(options || {}).forEach((configKey) => {
    globalConfiguration[configKey] = options[configKey];
  });

  const { prefetch, fetch } = globalConfiguration;
  if (prefetch) { // 说明要进行子应用的预加载
    doPrefetch(getMicroApps(), prefetch, fetch);
  }

  // hajack history & eventListener
  hijackHistory(); // 改写pushState 和 replaceState事件
  hijackEventListener(); // 改写原生的事件监听以及原生的移除事件监听

  // 初始化的时候,触发一次路由的变化事件
  globalConfiguration.reroute(location.href, 'init');
}

通过 start 劫持路由变化,触发子应用的挂载/卸载, 这里我们重点关注下,如何劫持路由变化,以及路由变化如何触发子应用的挂载与卸载

hijackHistory & hijackEventListener

  • hijackHistory

劫持history

const originalPush: OriginalStateFunction = window.history.pushState; // 储存原始pushState方法
const originalReplace: OriginalStateFunction = window.history.replaceState; // 储存原始replaceState方法

/**
 * 路由发生变化会执行的事件
 */
const handleStateChange = (event: PopStateEvent, url: string, method: RouteType) => {
  setHistoryEvent(event);
  globalConfiguration.reroute(url, method);
};

/**
 * 路由发生变化会执行的事件
 */
const urlChange = (event: PopStateEvent | HashChangeEvent): void => {
  setHistoryEvent(event);
  globalConfiguration.reroute(location.href, event.type as RouteType);
};

/**
 * 改写 window.history
 */
const hijackHistory = (): void => {
  window.history.pushState = (state: any, title: string, url?: string, ...rest) => {
    originalPush.apply(window.history, [state, title, url, ...rest]);
    const eventName = 'pushState';
    // 触发popstate事件
    handleStateChange(createPopStateEvent(state, eventName), url, eventName);
  };

  window.history.replaceState = (state: any, title: string, url?: string, ...rest) => {
    originalReplace.apply(window.history, [state, title, url, ...rest]);
    const eventName = 'replaceState';
    // 触发popstate事件
    handleStateChange(createPopStateEvent(state, eventName), url, eventName);
  };

  window.addEventListener('popstate', urlChange, false);
  window.addEventListener('hashchange', urlChange, false);
};

// inspired by https://github.com/single-spa/single-spa/blob/master/src/navigation/navigation-events.js#L107
export function createPopStateEvent(state, originalMethodName) {
  // We need a popstate event even though the browser doesn't do one by default when you call replaceState, so that
  // all the applications can reroute.
  let evt;
  try {
    evt = new PopStateEvent('popstate', { state });
  } catch (err) {
    // IE 11 compatibility
    // https://docs.microsoft.com/en-us/openspecs/ie_standards/ms-html5e/bd560f47-b349-4d2c-baa8-f1560fb489dd
    evt = document.createEvent('PopStateEvent');
    evt.initPopStateEvent('popstate', false, false, state);
  }
  evt.icestark = true;
  evt.icestarkTrigger = originalMethodName;
  return evt;
}

从代码上来看,对pushStatereplaceState的改写,无非就是在执行原始pushState replaceState方法之后,去主动触发 popstate 事件(handleStateChangeurlChange函数作用相同,都是去执行reroute函数)。

注意:直接调用原生的pushState&replaceState事件是不会触发 popstate 事件, 只有在点击浏览器的前进/回退按钮才会触发。

  • hijackEventListener
const originalAddEventListener = window.addEventListener; // 储存原始事件监听方法
const originalRemoveEventListener = window.removeEventListener; // 储存原始移除事件监听方法

/**
 * Hijack eventListener
 */
const hijackEventListener = (): void => {
  window.addEventListener = (eventName, fn, ...rest) => {
    if (
      typeof fn === 'function' &&
      routingEventsListeningTo.indexOf(eventName) >= 0 &&
      !isInCapturedEventListeners(eventName, fn)
    ) {
      addCapturedEventListeners(eventName, fn);
      return;
    }

    return originalAddEventListener.apply(window, [eventName, fn, ...rest]);
  };

  window.removeEventListener = (eventName, listenerFn, ...rest) => {
    if (typeof listenerFn === 'function' && routingEventsListeningTo.indexOf(eventName) >= 0) {
      removeCapturedEventListeners(eventName, listenerFn);
      return;
    }

    return originalRemoveEventListener.apply(window, [eventName, listenerFn, ...rest]);
  };
};

从上面代码来看,结合源码中的 capturedListeners.ts文件,对原生的addEventListenerremoveEventListener的改写,其实就是将开发者自己绑定的 popstatehashchange 的事件放在一个全局对象中,单独去触发,防止与icestark内部监听的popstatehashchange 事件有影响。

reroute函数

该函数的作用是判断前后url是否发生变化,以此来控制子应用的加载与卸载。当路由发生变化时,就会触发该函数的执行。

let lastUrl = null; // 记录上次浏览器输入的的url

/**
 * 监听到路由的变化之后,比对路由前后是否发生变化,以此来控制子应用的加载与卸载
 * @param url
 * @param type
 */
export function reroute(url: string, type: RouteType | 'init' | 'popstate' | 'hashchange') {
  const { pathname, query, hash } = urlParse(url, true); // 解析出url中的参数
  if (lastUrl !== url) { // 前后路由进行比对
    globalConfiguration.onRouteChange(url, pathname, query, hash, type); // 触发路由变化事件

    const unmountApps = []; // 储存要卸载的子应用
    const activeApps = []; // 储存要加载的子应用
    // 获取全局中储存的所有子应用进行遍历,分出哪些子应用要卸载,哪些子应用要加载
    getMicroApps().forEach((microApp: AppConfig) => {
      const shouldBeActive = !!microApp.findActivePath(url);
      if (shouldBeActive) {
        activeApps.push(microApp);
      } else {
        unmountApps.push(microApp);
      }
    });
    // 子应用开始被激活的回调
    globalConfiguration.onActiveApps(activeApps);

    // call captured event after app mounted
    Promise.all(
      // call unmount apps
      unmountApps.map(async (unmountApp) => {
        if (unmountApp.status === MOUNTED || unmountApp.status === LOADING_ASSETS) {
          globalConfiguration.onAppLeave(unmountApp); // 子应用卸载前的回调
        }
        // 根据子应用唯一标识去卸载子应用
        await unmountMicroApp(unmountApp.name);
      }).concat(activeApps.map(async (activeApp) => {
        if (activeApp.status !== MOUNTED) {
          globalConfiguration.onAppEnter(activeApp); // 子应用渲染前的回调
        }
        // 加载子应用
        await createMicroApp(activeApp);
      })),
    ).then(() => {
      // 子应用发生了卸载与加载,说明路由变化了,故在这里要去调用下开发者自己对popostate以及hashchange的监听事件
      callCapturedEventListeners();
    });
  }
  lastUrl = url;
}

总结

本章我们只是整体概览下icestark的执行过程,其本质是:history路由模式下通过改写history api 然后监听popstate事件,hash路由模式下,监听 hashchange 事件, 然后根据路由的变化,来对子应用进行加载与卸载的控制。子应用的加载与卸载将在下一章节详细阐述。