微前端

109 阅读6分钟

核心思想

Be Technology Agnostic; Isolate Team Code; Establish Team Prefixes; Favor Native Browser Features over Custom APIs; Build a Resilient Site.

  • 主框架不限制子应用的技术栈。
  • 子应用代码隔离,可以独立开发、独立部署。
  • 运行时,子应用状态隔离,不会相互影响。

实现方式

  • Nginx路由转发:通过Nginx配置反向代理来实现不同路径映射到不同应用,但是在切换应用时会触发浏览器刷新,影响体验。
  • iframe嵌套:父应用单独是一个页面,每个微应用嵌套一个iframe,可以采用postMessage或者contentWindow方式通信,但是样式展示、兼容性等性能都有局限性。
  • Web Components集成:对于历史系统改造成本高,微应用通信较为复杂。
  • 通用技术栈基座式:微应用独立构建和部署,有基座应用来进行路由管理、应用加载、启动卸载、通信。基座框架主要需要解决路由切换、微应用隔离、主应用与微应用之间的通信问题。

image.png

微前端框架(通用技术栈基座式)

single-spa

官方功能:

流程

image.png

  • Register:注册微应用并监听微应用url变化,通过url匹配来决定何时进入该微应用的生命周期;
  • Load:加载微应用;
  • 微应用中必须存在bootstrap、mount、unmount生命周期,并在mount开始时渲染微应用;
  • bootstrap:只会在微应用初始化的时候调用一次,下次微应用重新进入时会直接调用 mount 钩子,不会再重复触发 bootstrap。

注册机制—registerApplication

export function registerApplication(
  appNameOrConfig, //微应用名称
  appOrLoadApp, // 微应用激活后的回调
  activeWhen, // 微应用激活规则
  customProps // 主应用传递给微应用的参数

) {
// 1. 处理参数,使参数规范化,保证每个子应用注册的参数合法;
  const registration = sanitizeArguments(
    appNameOrConfig,
    appOrLoadApp,
    activeWhen,
    customProps
  );
  ...

// 2. 将微应用保存到数组apps(全局数组)中,single-spa的核心工作就是对apps中保存的微应用进行管理和控制。
  apps.push(
    assign(
      {
        loadErrorTime: null,
        status: NOT_LOADED,
        parcels: {},
        devtools: {
          overlays: {
            options: {},
            selectors: [],
          },
        },
      },
      registration
    )
  );
  if (isInBrowser) {
   // 3. 对JQuery的某些监听事件进行拦截
    ensureJQuerySupport();
    // 4. 调用reroute函数,加载微应用
    reroute();
  }
}

路由管理

We capture navigation event listeners so that we can make sure that application navigation listeners are not called until single-spa has ensured that the correct applications are unmounted and mounted.

  // 1. 劫持hashchange,popstate (urlReroute函数调用了reroute方法)
  // We will trigger an app change for any routing events.
  window.addEventListener("hashchange", urlReroute);
  window.addEventListener("popstate", urlReroute);
  const originalAddEventListener = window.addEventListener;
  const originalRemoveEventListener = window.removeEventListener;

  // 2. 拦截设置hashchange、popstate监听事件的监听函数
  // 这里将hashchange、popstate事件存入capturedEventListeners中,在加载子应用(loadApps)和卸载(unmount)时执行所有捕获事件的回调。
  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);
  };

  // 3. 拦截可能改变路由状态的api方法
  window.history.pushState = patchedUpdateState(
    window.history.pushState,
    "pushState"
  );
  window.history.replaceState = patchedUpdateState(
    window.history.replaceState,
    "replaceState"
  );

微应用状态管理

// App statuses
export const NOT_LOADED = "NOT_LOADED";
export const LOADING_SOURCE_CODE = "LOADING_SOURCE_CODE";
export const NOT_BOOTSTRAPPED = "NOT_BOOTSTRAPPED";
export const BOOTSTRAPPING = "BOOTSTRAPPING";
export const NOT_MOUNTED = "NOT_MOUNTED";
export const MOUNTING = "MOUNTING";
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";

微应用的状态对应微应用的生命周期(Load, Bootstrap, Mount、Unmount),在状态变化时修改微应用的status。

image.png

Reroute

在执行start、registerApplication、路由发生变化都会执行reroute,那么它是干什么的呢?

export function reroute(pendingPromises = [], eventArguments) {
  // ...
  const {
    appsToUnload,
    appsToUnmount,
    appsToLoad,
    appsToMount,
  } = getAppChanges();

  //...
  //是否执行过start函数,如果是,则进一步执行每一个微应用的生命周期;如果否,则加载微应用
  if (isStarted()) {
    appChangeUnderway = true;
    appsThatChanged = appsToUnload.concat(
      appsToLoad,
      appsToUnmount,
      appsToMount
    );
    // 1.执行卸载逻辑;2.执行相关挂载逻辑;3.执行在不同阶段派发自定义事件。
    return performAppChanges();
  } else {
    appsThatChanged = appsToLoad;
    // 在loadApps中调用了toLoadPromise方法,作用为:1.调用子应用传入的加载微应用的方法;2. 保存微应用的各种状态。
    return loadApps();
  }
  //...
 }

Reroute->控制微应用状态的流转和事件分发。

qiankun

single-spa只是劫持了单页应用的路由变换,没有考虑到样式和JS的隔离;相较于single-spa,qiankun做了两件重要的事情,其一是加载资源,第二是进行资源隔离。而资源隔离又分为JS资源隔离和CSS资源隔离。

JS资源隔离-沙箱

沙箱分3种:

  • SnapshotSandbox:在沙箱挂载和卸载时记录快照,在应用切换时恢复快照环境,用于不支持 Proxy 的低版本浏览器。
  • LegacySandBox:支持单应用的代理沙箱;
  • proxySandbox:支持多应用的代理沙箱;

沙箱环境

SnapshotSandbox

SnapshotSandbox 的沙箱环境主要是通过激活时记录 window 状态快照,在关闭时通过快照还原 window 对象来实现的。

LegacySandbox

image.png LegacySandbox通过一个proxy对象来记录沙箱运行时被修改的全局变量,对window做一个代理,记录微应用在window上挂载、删除、修改的操作,待恢复全局环境时,反向执行。

    const proxy = new Proxy(fakeWindow, {
      set: (_: Window, p: PropertyKey, value: any): boolean => {
        if (this.sandboxRunning) {
          if (!rawWindow.hasOwnProperty(p)) {
            addedPropsMapInSandbox.set(p, value);
          } else if (!modifiedPropsOriginalValueMapInSandbox.has(p)) {
            // 如果当前 window 对象存在该属性,且 record map 中未记录过,则记录该属性初始值
            const originalValue = (rawWindow as any)[p];
            modifiedPropsOriginalValueMapInSandbox.set(p, originalValue);
          }

          currentUpdatedPropsValueMap.set(p, value);
          // 必须重新设置 window 对象保证下次 get 时能拿到已更新的数据
          // eslint-disable-next-line no-param-reassign
          (rawWindow as any)[p] = value;
          this.latestSetProp = p;
          return true;
        }
        //...
      }, 

      get(_: Window, p: PropertyKey): any {
        // avoid who using window.window or window.self to escape the sandbox environment to touch the really window
        // or use window.top to check if an iframe context
        // see https://github.com/eligrey/FileSaver.js/blob/master/src/FileSaver.js#L13
        if (p === 'top' || p === 'parent' || p === 'window' || p === 'self') {
          return proxy;

        }
        const value = (rawWindow as any)[p];
        return getTargetValue(rawWindow, value);
      },

//...

    });

在微应用脚本执行过程中,会将该proxy对象作为window参数传入,微应用的全局对象就是该微应用沙箱的proxy 对象

eval(
  (function(window) {
    /* 子应用脚本文件内容 */
  })(proxy)

);

LegacySandbox 的沙箱隔离是通过激活沙箱时还原微应用状态,卸载时还原主应用状态实现的。

active() {
    if (!this.sandboxRunning) {
      this.currentUpdatedPropsValueMap.forEach((v, p) => setWindowProp(p, v));
    }

    this.sandboxRunning = true;
  }

inactive() {
    if (process.env.NODE_ENV === 'development') {
      console.info(`[qiankun:sandbox] ${this.name} modified global properties restore...`, [
        ...this.addedPropsMapInSandbox.keys(),
        ...this.modifiedPropsOriginalValueMapInSandbox.keys(),
      ]);
    }   

挂载沙箱

挂载时激活微应用的沙箱,在沙箱启动时开始劫持各类全局监听(patchAtMounting内部调用了patchInterval(计时器劫持)、patchWindowListener(window事件监听)、patchHistoryListener(history事件监听)、patchHTMLDynamicAppendPrototypeFunctions(动态添加样式表和脚本文件劫持)),并触发微应用的mount生命周期。

  async mount() {
    // 1. 启动/恢复 沙箱
    sandbox.active();
      
    const sideEffectsRebuildersAtBootstrapping = sideEffectsRebuilders.slice(0, bootstrappingFreers.length);
    const sideEffectsRebuildersAtMounting = sideEffectsRebuilders.slice(bootstrappingFreers.length); 

    // must rebuild the side effects which added at bootstrapping firstly to recovery to nature state
    if (sideEffectsRebuildersAtBootstrapping.length) {
      sideEffectsRebuildersAtBootstrapping.forEach((rebuild) => rebuild());
    }

    // 2. 开启全局变量补丁
    // render 沙箱启动时开始劫持各类全局监听,尽量不要在应用初始化阶段有 事件监听/定时器 等副作用
    mountingFreers = patchAtMounting(appName, elementGetter, sandbox, scopedCSS, excludeAssetFilter);

    // 3. 重置一些初始化时的副作用
    // 存在 rebuilder 则表明有些副作用需要重建
    if (sideEffectsRebuildersAtMounting.length) {
      sideEffectsRebuildersAtMounting.forEach((rebuild) => rebuild());
    }

    // clean up rebuilders
    sideEffectsRebuilders = [];
  }

卸载沙箱

关闭沙箱,释放劫持状态,并触发微应用的unmount生命周期。

    /**
     * 恢复 global 状态,使其能回到应用加载之前的状态
     */
    async unmount() {
      // record the rebuilders of window side effects (event listeners or timers)

      // note that the frees of mounting phase are one-off as it will be re-init at next mounting

      sideEffectsRebuilders = [...bootstrappingFreers, ...mountingFreers].map((free) => free());

      sandbox.inactive();
    },

css隔离

应用之间样式隔离: Dynamic Stylesheet 动态样式表,当应用切换时移除老应用样式,添加新应用样式

主应用和子应用之间的样式隔离

  • BEM (Block Element Modifier) 约定项目前缀
  • CSS-Modules 打包时生成不冲突的选择器名
  • Shadow DOM严格意义上的隔离:在渲染时向Dom结构中插入一个Shadow Dom元素子树,但是特殊的是,但shadow-dom并不在主 DOM 树中。

Start Api

image.png

用于初始化qiankun的配置:

export function start(opts: FrameworkConfiguration = {}) {
// 设置配置参数默认值
  frameworkConfiguration = { prefetch: true, singular: true, sandbox: true, ...opts };
  const {
    prefetch,
    sandbox,
    singular,
    urlRerouteOnly = defaultUrlRerouteOnly,
    ...importEntryOpts
  } = frameworkConfiguration;


  //判断是否预加载,如果需要预加载,则添加全局事件 single-spa:first-mount 监听,在第一个微应用挂载后预加载其他微应用资源,优化后续其他微应用的加载速度。
  if (prefetch) {
    doPrefetchStrategy(microApps, prefetch, importEntryOpts);
  }

  // 是否启用沙箱
  if (sandbox) {
    if (!window.Proxy) {
    console.warn('[qiankun] Miss window.Proxy, proxySandbox will degenerate into snapshotSandbox');
    frameworkConfiguration.sandbox = typeof sandbox === 'object' ? { ...sandbox, loose: true } : { loose: true };

    if (!singular) {
      console.warn(
        '[qiankun] Setting singular as false may cause unexpected behavior while your browser not support window.Proxy',
      );
    }
  }
}
 
// 调用single-spa中的startSingleSpa方法
  startSingleSpa({ urlRerouteOnly });
  started = true;

  frameworkStartedDefer.resolve();
}

registerMicroApps Api

image.png

复用single-spa的注册机制

export function registerMicroApps<T extends ObjectType>(
  apps: Array<RegistrableApp<T>>,
  lifeCycles?: FrameworkLifeCycles<T>,
) {
  // Each app only needs to be registered once
  const unregisteredApps = apps.filter((app) => !microApps.some((registeredApp) => registeredApp.name === app.name));

  microApps = [...microApps, ...unregisteredApps];

  unregisteredApps.forEach((app) => {
    const { name, activeRule, loader = noop, props, ...appConfig } = app;

    registerApplication({
      name,
      app: async () => {
        loader(true);
        await frameworkStartedDefer.promise;

        const { mount, ...otherMicroAppConfigs } = (
          await loadApp({ name, props, ...appConfig }, frameworkConfiguration, lifeCycles)
          )();
          
        return {
          mount: [async () => loader(true), ...toArray(mount), async () => loader(false)],
          ...otherMicroAppConfigs,
        };
      },

     activeWhen: activeRule,
     customProps: props,
    });
  });
}

loadMicroApp API

加载微应用

image.png

初始化微应用资源

本阶段只会触发1次。

获取微应用资源(import-html-entry):single-spa通过SystemJS加载模块,qiankun直接将微应用打包的 HTML 作为入口,基座应用可以通过 fetch html 的方式获取微应用的静态资源,更方便。

// get the entry html content and script executor
 const { template, execScripts, assetPublicPath } = await importEntry(entry, importEntryOpts);
// template: 经过处理的脚本(处理了link与script标签);
// execScripts:执行js脚本,返回的对象一般包含一些微应用的生命周期钩子函数,主应用可以通过在特定阶段调用这些生命周期钩子函数,进行挂载和销毁微应用的操作。
// assetPublicPath:静态资源地址

execScripts: (proxy, strictGlobal, execScriptsHooks = {}) => {
  if (!scripts.length) {
    return Promise.resolve();
  }
  return execScripts(entry, scripts, proxy, {
    fetch,
    strictGlobal,
    beforeExec: execScriptsHooks.beforeExec,
    afterExec: execScriptsHooks.afterExec,
  });
},

挂载微应用HTML

// as single-spa load and bootstrap new app parallel with other apps unmounting
// (see https://github.com/CanopyTax/single-spa/blob/master/src/navigation/reroute.js#L74)
if (await validateSingularMode(singular, app)) {
  await (prevAppUnmountedDeferred && prevAppUnmountedDeferred.promise);

}

// 生成一个`<div id="__qiankun_microapp_wrapper_for_${snakeCase(id)}__" data-name="${name}">${tpl}</div>`标签包裹微应用

const appContent = getDefaultTplWrapper(appInstanceId, appName)(template);

const strictStyleIsolation = typeof sandbox === 'object' && !!sandbox.strictStyleIsolation;

const scopedCSS = isEnableScopedCSS(sandbox);

let initialAppWrapperElement: HTMLElement | null = createElement(
  appContent,
  strictStyleIsolation,
  scopedCSS,
  appName,
);

const initialContainer = 'container' in app ? app.container : undefined;
const legacyRender = 'render' in app ? app.render : undefined;

const render = getRender(appName, appContent, legacyRender);


// 第一次加载设置应用可见区域 dom 结构
// 确保每次应用加载前容器 dom 结构已经设置完毕
render({ element: initialAppWrapperElement, loading: true, container: initialContainer }, 'loading');

注册生命周期: 在加载过程中,将注册时微应用的生命周期与qiankun内置的生命周期合并:

const { beforeUnmount = [], afterUnmount = [], afterMount = [], beforeMount = [], beforeLoad = [] } = mergeWith(
    {},
    getAddOns(global, assetPublicPath),
    lifeCycles,
    (v1, v2) => concat(v1 ?? [], v2 ?? []),
  );

在qiankun内置的生命周期中,beforeLoad时会注入一个环境变量,并在beforeUnmount时删除环境变量:

export default function getAddOn(global: Window, publicPath = '/'): FrameworkLifeCycles<any> {
  let hasMountedOnce = false;

  return {
    async beforeLoad() {
      // eslint-disable-next-line no-param-reassign
      global.__INJECTED_PUBLIC_PATH_BY_QIANKUN__ = publicPath;
    },

    async beforeMount() {
      if (hasMountedOnce) {
        // eslint-disable-next-line no-param-reassign
        global.__INJECTED_PUBLIC_PATH_BY_QIANKUN__ = publicPath;
      }
    },
 
    async beforeUnmount() {
      if (rawPublicPath === undefined) {
        // eslint-disable-next-line no-param-reassign
        delete global.__INJECTED_PUBLIC_PATH_BY_QIANKUN__;
      } else {
        // eslint-disable-next-line no-param-reassign
        global.__INJECTED_PUBLIC_PATH_BY_QIANKUN__ = rawPublicPath;
      }
      hasMountedOnce = true;
    },
  };
}

可以在微应用中获取该环境变量,将其设置为__webpack_public_path__的值,作为公共路径。

挂载微应用

本阶段可能会触发多次

image.png

卸载微应用

本阶段可能会触发多次

image.png