微前端框架 qiankun single-spa 原理及源码解析

2,258 阅读7分钟

qiankun 版本: 2.0.20
single-spa 版本: 5.5.5

一 qiankun 使用方法

主应用

  1. 初始化主应用(可选)
  2. 注册子应用 registerMicroApps(apps, lifeCycles)
  3. 设置默认进入的子应用 setDefaultMountApp(defaultAppLink)
  4. 启动应用 start()

子应用

  • 导出相应的生命周期钩子
/**
 * bootstrap 只会在微应用初始化的时候调用一次,下次微应用重新进入时会直接调用 mount 钩子,不会再重复触发 bootstrap。
 * 通常我们可以在这里做一些全局变量的初始化,比如不会在 unmount 阶段被销毁的应用级别的缓存等。
 */
export async function bootstrap() {
  console.log('[vue] vue app bootstraped');
}
/**
 * 应用每次进入都会调用 mount 方法,通常我们在这里触发应用的渲染方法
 */
export async function mount(props) {
  console.log('[vue] props from main framework', props);
  storeTest(props);
  render(props);
}
/**
 * 应用每次 切出/卸载 会调用的方法,通常在这里我们会卸载微应用的应用实例
 */
export async function unmount() {
  instance.$destroy();
  instance.$el.innerHTML = '';
  instance = null;
  router = null;
}
/**
 * 可选生命周期钩子,仅使用 loadMicroApp 方式加载微应用时生效
 */
export async function update(props) {
  console.log('update props', props);
}
  • 配置打包工具生成umd模块
//webpack
const packageName = require('./package.json').name;
module.exports = {
  output: {
    library: `${packageName}-[name]`,
    libraryTarget: 'umd',
    jsonpFunction: `webpackJsonp_${packageName}`,
  },
};

二 qiankun框架实现原理

2.1 简介

  • 基于single-spa封装;
  • 接入简单,只需配置需要的script和style文件,或者直接配置入口html文件,自动解析;
  • 支持子应用样式隔离;
  • 实现了安全沙箱,子应用间不互相影响;

2.2 实现原理

2.2.1 注册子应用

// src/api.ts

/*
 * 调用single-spa注册子应用
 * 在loadApp的Promise中添加了加载钩子函数、加载子应用、创建安全沙箱、创建生命周期钩子函数等一系列操作
 */
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,
});

主要功能在loadApp()中实现:

// src/loader.ts

export async function loadApp<T extends object>(
  app: LoadableApp<T>,
  configuration: FrameworkConfiguration = {},
  lifeCycles?: FrameworkLifeCycles<T>,
): Promise<ParcelConfigObject> {
  ...

  // 获取子应用入口html内容和脚本执行器
  // importEntry由'import-html-entry'库实现
  const { template, execScripts, assetPublicPath } = await importEntry(entry, importEntryOpts);

  // 因为 single-spa 中 加载和初始化应用和卸载其他应用是并行进行的
  // 所以在单应用模式下,需要等其他应用卸载完
  if (await validateSingularMode(singular, app)) {
    await (prevAppUnmountedDeferred && prevAppUnmountedDeferred.promise);
  }

  ...

  // 子应用内的容器代码,通过解析子应用html文件和script,styles生成,已经把style内容生成一个style Node放到容器内
  const appContent = getDefaultTplWrapper(appInstanceId, appName)(template);
  // 生成容器DOM对象
  let element: HTMLElement | null = createElement(appContent, strictStyleIsolation);
  // 样式隔离处理
  if (element && isEnableScopedCSS(configuration)) {
    const styleNodes = element.querySelectorAll('style') || [];
    styleNodes.forEach(stylesheetElement => {
      css.process(element!, stylesheetElement, appName);
    });
  }

  // 存放子应用的容器获取(位于主应用中)
  const container = 'container' in app ? app.container : undefined;
  const legacyRender = 'render' in app ? app.render : undefined;
  
  // 子应用容器render函数
  const render = getRender(appName, appContent, container, legacyRender);

  // 第一次加载设置应用可见区域 dom 结构
  // 确保每次应用加载前容器 dom 结构已经设置完毕
  // 渲染容器到父应用容器,然后是能够显示出来了
  render({ element, loading: true }, 'loading');

  ...
  
  // 创建沙箱
  let global = window;
  let mountSandbox = () => Promise.resolve();
  let unmountSandbox = () => Promise.resolve();
  if (sandbox) {
    const sandboxInstance = createSandbox(
      appName,
      containerGetter,
      Boolean(singular),
      enableScopedCSS,
      excludeAssetFilter,
    );
    // 用沙箱的window代理对象作为接下来使用的全局对象
    global = sandboxInstance.proxy as typeof window;
    // 沙箱启动开始劫持各类全局监听,如:事件监听/定时器
    mountSandbox = sandboxInstance.mount;
    // 恢复到应用加载之前的状态
    unmountSandbox = sandboxInstance.unmount;
  }

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

  // 执行beforeLoad钩子函数
  await execHooksChain(toArray(beforeLoad), app, global);

  // get the lifecycle hooks from module exports
  const scriptExports: any = await execScripts(global, !singular);
  const { bootstrap, mount, unmount, update } = getLifecyclesFromExports(scriptExports, appName, global);

  // 获取子应用状态管理函数
  const {
    onGlobalStateChange,
    setGlobalState,
    offGlobalStateChange,
  }: Record<string, Function> = getMicroAppStateActions(appInstanceId);

  const parcelConfig: ParcelConfigObject = {
    name: appInstanceId,
    bootstrap,
    mount: [
      ... // 开发环境打开性能记录
      
      async () => {
        if ((await validateSingularMode(singular, app)) && prevAppUnmountedDeferred) {
          return prevAppUnmountedDeferred.promise;
        }

        return undefined;
      },
      // 添加 mount hook, 确保每次应用加载前容器 dom 结构已经设置完毕
      async () => {
        // element 如果之前unmount时被销毁了,这里需要重新创建
        element = element || createElement(appContent, strictStyleIsolation);
        render({ element, loading: true }, 'mounting');
      },
      mountSandbox,
      async () => execHooksChain(toArray(beforeMount), app, global),
      // 执行子应用mount
      async props => mount({ ...props, container: containerGetter(), setGlobalState, onGlobalStateChange }),
      // 应用 mount 完成后结束 loading
      async () => render({ element, loading: false }, 'mounted'),
      async () => execHooksChain(toArray(afterMount), app, global),
      // 初始化unmount 延迟函数,等到unmounted时resolve它
      async () => {
        if (await validateSingularMode(singular, app)) {
          prevAppUnmountedDeferred = new Deferred<void>();
        }
      },
      ... // 开发环境性能监测
    ],
    unmount: [
      async () => execHooksChain(toArray(beforeUnmount), app, global),
      async props => unmount({ ...props, container: containerGetter() }),
      unmountSandbox,
      async () => execHooksChain(toArray(afterUnmount), app, global),
      async () => {
        render({ element: null, loading: false }, 'unmounted');
        offGlobalStateChange(appInstanceId);
        // for gc
        element = null;
      },
      async () => {
        if ((await validateSingularMode(singular, app)) && prevAppUnmountedDeferred) {
          prevAppUnmountedDeferred.resolve();
        }
      },
    ],
  };

  if (typeof update === 'function') {
    parcelConfig.update = update;
  }

  return parcelConfig;
}

2.2.2 sandbox实现

基于 Proxy 实现的沙箱

2.2.2.1 最新版基于Proxy的实现

// src/sandbox/proxySandbox.ts

const { fakeWindow, propertiesWithGetter } = createFakeWindow(rawWindow);
const proxy = new Proxy(fakeWindow, {
  set(target: FakeWindow, p: PropertyKey, value: any): boolean {
    if (self.sandboxRunning) {
      target[p] = value;
      // 记录变化
      updatedValueSet.add(p);

      interceptSystemJsProps(p, value);

      return true;
    }
    ...

    // 在 strict-mode 下,Proxy 的 handler.set 返回 false 会抛出 TypeError,在沙箱卸载的情况下应该忽略错误
    return true;
  },
  get(target: FakeWindow, p: PropertyKey): any { ... },
  has(target: FakeWindow, p: string | number | symbol): boolean { ... },
  getOwnPropertyDescriptor(target: FakeWindow, p: string | number | symbol): PropertyDescriptor | undefined { ... },
  ownKeys(target: FakeWindow): PropertyKey[] { ... },
  defineProperty(target: Window, p: PropertyKey, attributes: PropertyDescriptor): boolean { ... },
  deleteProperty(target: FakeWindow, p: string | number | symbol): boolean { ... },
})

createFakeWindow函数返回创建的fakeWindow对象。是部分不可配置属性的浅拷贝
如下图所示:

image

// src/sandbox/proxySandbox.ts

function createFakeWindow(global: Window) {
  // 部分属性在部分浏览器里面可设置 get/set,在其他浏览器中不可设置
  const propertiesWithGetter = new Map<PropertyKey, boolean>();
  const fakeWindow = {} as FakeWindow;

  /*
   拷贝global的不可配置属性到fakeWindow
   */
  Object.getOwnPropertyNames(global)
    .filter(p => {
      const descriptor = Object.getOwnPropertyDescriptor(global, p);
      return !descriptor?.configurable;
    })
    .forEach(p => {
      const descriptor = Object.getOwnPropertyDescriptor(global, p);
      if (descriptor) {
        const hasGetter = Object.prototype.hasOwnProperty.call(descriptor, 'get');

        /*
         让 top/self/window 属性变为可配置, 否则在返回时将导致类型错误。
         see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Proxy/handler/get
         */
        if (
          p === 'top' ||
          p === 'self' ||
          p === 'window' ||
          (process.env.NODE_ENV === 'test' && (p === 'mockTop' || p === 'mockSafariTop'))
        ) {
          descriptor.configurable = true;
          /*
           在Safari/FF浏览器中 window.window/window.top/window.self 是访问器描述符, 我们要避免添加一个数据描述符
           Example:
            Safari/FF: Object.getOwnPropertyDescriptor(window, 'top') -> {get: function, set: undefined, enumerable: true, configurable: false}
            Chrome: Object.getOwnPropertyDescriptor(window, 'top') -> {value: Window, writable: false, enumerable: true, configurable: false}
           */
          if (!hasGetter) {
            descriptor.writable = true;
          }
        }

        if (hasGetter) propertiesWithGetter.set(p, true);

        // 把修改过后的属性描述添加到fakeWindow
        // 冻结descriptor以避免被修改,如 zone.js
        rawObjectDefineProperty(fakeWindow, p, Object.freeze(descriptor));
      }
    });

  return {
    fakeWindow,
    propertiesWithGetter,
  };
}

2.2.3 子应用资源加载

作为一个SPA的容器应用,本身是一套纯前端项目,要想展示微应用的页面除了采用iframe之外,要能先加载到微应用的页面内容。
qiankun 通过 html-import-entry 库来处理。html/script/style文件的加载是通过浏览器的fetch接口来处理的。html-import-entry库支持2种方式加载解析子应用:

  • importEntry 传入代码、CSS样式url,创建一个div容器template,加载CSS样式后以inline的方式插入容器,并生成一个script执行函数,以及加载所有script,style的Promise。
  • importHTML 传入html的url,加载html后,解析其中包含的script/style的url,然后创建一个div容器template,把html里面的内容放入容器中,注释script、style标签,加载CSS样式后以inline的方式插入容器,生成一个sciprt加载执行函数,以及加载所有script,style的Promise。

在script执行函数中,通过以下代码注入window的代理对象proxy,通过闭包实现window对象的替换

// src/index.js getExecutableScript

// scriptText 子App的代码脚本
// sourceUrl 源码链接
// bind(window.proxy) 是为了让 this 也指向 proxy
window.proxy = proxy;
// TODO 通过 strictGlobal 方式切换切换 with 闭包,待 with 方式坑趟平后再合并
return strictGlobal
	? `;(function(window, self){with(window){;${scriptText}\n${sourceUrl}}}).bind(window.proxy)(window.proxy, window.proxy);`
	: `;(function(window, self){;${scriptText}\n${sourceUrl}}).bind(window.proxy)(window.proxy, window.proxy);`;

执行入口script后返回exports的内容(bootstrap, mount, unmount...)

// src/index.js execScripts

// bind window.proxy to change `this` reference in script
geval(getExecutableScript(scriptSrc, inlineScript, proxy, strictGlobal));
const exports = proxy[getGlobalProp(strictGlobal ? proxy : window)] || {};
resolve(exports);

2.2.4 应用启动 start

2.2.4.1 qiankun 中的start处理

start(opts)之后,先处理预加载和sanbox参数处理然后调用single-spastart处理。

// src/api.ts

export function start(opts: FrameworkConfiguration = {}) {
  frameworkConfiguration = { prefetch: true, singular: true, sandbox: true, ...opts };
  const { prefetch, sandbox, singular, urlRerouteOnly, ...importEntryOpts } = frameworkConfiguration;

  if (prefetch) {
    doPrefetchStrategy(microApps, prefetch, importEntryOpts);
  }

  if (sandbox) {
    if (!window.Proxy) {
      console.warn('[qiankun] Miss window.Proxy, proxySandbox will degenerate into snapshotSandbox');
      // 快照沙箱只支持 singular 模式
      if (!singular) {
        console.error('[qiankun] singular is forced to be true when sandbox enable but proxySandbox unavailable');
        frameworkConfiguration.singular = true;
      }
    }
  }

  startSingleSpa({ urlRerouteOnly });

  frameworkStartedDefer.resolve();
}

2.2.4.1 single-spa 中的start处理

// src/start.js

export function start(opts) {
  started = true;
  if (opts && opts.urlRerouteOnly) {
    setUrlRerouteOnly(opts.urlRerouteOnly);
  }
  if (isInBrowser) {
    reroute();
  }
}

核心在于reroute函数,通过Promise链串联起了整个流程,简直精彩

  • 1 如果正在进行中,返回一个Promise
  • 2 获取需要变化的App列表(unload/load/mount/unmount)
  • 2.1 如果还未启动,执行loadApps()
  • 2.2 如果已经启动,按以下顺序执行
  • 2.2.1 先unmount需要unmount的app,并加入unload列表
  • 2.2.2 unload需要unload的app
  • 2.2.3 load并mount需要load的app,如果加载后不需要mount就执行unmount
  • 2.2.4 mount需要mount的app
// src/navigatiton/reroute.js

function performAppChanges() {
  return Promise.resolve().then(() => {
    ...
    
    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);

    ...

    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);
      });
  });
}

single-spa的load其实就是执行registerApplication传入的loadApp获取钩子函数:

  app.status = NOT_BOOTSTRAPPED;
  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);

而unload就是把bootstrap/mount/unmount/unload这些去掉。