【得物技术】微前端,大世界-qiankun源码研读

微前端,关于他的好和应用场景,不少师兄们也都介绍过了,那么咱们使用的微前端方案qiankun是如何去作到应用的“微前端”的呢?html

几个特性

说到前端微服务,确定不能不提他的几个特性前端

  • 子应用并行
  • 父子应用通讯
  • 预加载
    • 空闲时预加载子应用的资源
  • 公共依赖的加载
  • 按需加载
  • JS沙箱
  • CSS隔离

作到以上的这几点,那么咱们子应用就能多重组合,互不影响,面对大型项目的聚合,也不用担忧项目汇总后的维护、打包、上线的问题。ios

这篇分享,就会简单的读一读qiankun 的源码,从大概流程上,了解他的实现原理和技术方案。git

咱们的应用怎么配置?-加入微前端Arya的怀抱吧

Arya- 公司的前端平台微服务基座github

Arya接入了权限平台的路由菜单和权限,能够动态挑选具备微服务能力的子应用的指定页面组合成一个新的平台,方便各个系统权限的下发和功能的汇聚。bootstrap

建立流程

初始化全局配置 - start(opts)

/src/apis.tsapi

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

  // 检查 prefetch 属性,若是须要预加载,则添加全局事件 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');
      // 快照沙箱不支持非 singular 模式
      if (!singular) {
        console.error('[qiankun] singular is forced to be true when sandbox enable but proxySandbox unavailable');
        frameworkConfiguration.singular = true;
      }
    }
  }
	// 启动主应用-  single-spa
  startSingleSpa({ urlRerouteOnly });

  frameworkStartedDefer.resolve();
}
复制代码
  • start 函数负责初始化一些全局设置,而后启动应用。
  • 这些初始化的配置参数有一部分将在 registerMicroApps 注册子应用的回调函数中使用。

registerMicroApps(apps, lifeCycles?) - 注册子应用

/src/apis.tspromise

export function registerMicroApps<T extends object = {}>(
  apps: Array<RegistrableApp<T>>,
  lifeCycles?: FrameworkLifeCycles<T>,
) {
  // 防止重复注册子应用
  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,
    });
  });
}
复制代码
  • 13行, 调用single-spa 的 registerApplication 方法注册子应用。
    • 传参:name、回调函数、activeRule 子应用激活的规则、props,主应用须要传给子应用的数据。
      • 在符合 activeRule 激活规则时将会激活子应用,执行回调函数,返回生命周期钩子函数。

image - 2021-04-30T161910.059.png

获取子应用资源 - import-html-entry

src/loader.ts浏览器

// get the entry html content and script executor
  const { template, execScripts, assetPublicPath } = await importEntry(entry, importEntryOpts);
复制代码
  • 使用 import-html-entry 拉取子应用的静态资源。
  • 调用以后返回的对象以下:

image - 2021-04-30T162025.198.png 截屏2021-04-30 下午4.21.14.png

  • 拉取代码以下
  • GitHub地址:github.com/kuitos/impo…
    • 若是能拉取静态资源,是否能够作简易的爬虫服务每日爬取页面执行资源是否加载正确?
export function importEntry(entry, opts = {}) {
	// ...

	// html entry
	if (typeof entry === 'string') {
		return importHTML(entry, { fetch, getPublicPath, getTemplate });
	}

	// config entry
	if (Array.isArray(entry.scripts) || Array.isArray(entry.styles)) {

		const { scripts = [], styles = [], html = '' } = entry;
		const setStylePlaceholder2HTML = tpl => styles.reduceRight((html, styleSrc) => `${ genLinkReplaceSymbol(styleSrc) }${ html }`, tpl);
		const setScriptPlaceholder2HTML = tpl => scripts.reduce((html, scriptSrc) => `${ html }${ genScriptReplaceSymbol(scriptSrc) }`, tpl);

		return getEmbedHTML(getTemplate(setScriptPlaceholder2HTML(setStylePlaceholder2HTML(html))), styles, { fetch }).then(embedHTML => ({
			// 这里处理同 importHTML , 省略
			},
		}));

	} else {
		throw new SyntaxError('entry scripts or styles should be array!');
	}
}
复制代码

主应用挂载子应用 HTML 模板

src/loader.tsmarkdown

async () => {
  if ((await validateSingularMode(singular, app)) && prevAppUnmountedDeferred) {
    return prevAppUnmountedDeferred.promise;
  }
  return undefined;
},
复制代码
  • 单实例进行检测。在单实例模式下,新的子应用挂载行为会在旧的子应用卸载以后才开始。
    • 在旧的子应用卸载以后 -- 单例模式下的隔离方案。
const render = getRender(appName, appContent, container, legacyRender);

  // 第一次加载设置应用可见区域 dom 结构
  // 确保每次应用加载前容器 dom 结构已经设置完毕
  render({ element, loading: true }, 'loading');
复制代码
  • render 函数内中将拉取的资源挂载到指定容器内的节点。
const containerElement = document.createElement('div');
containerElement.innerHTML = appContent;
// appContent always wrapped with a singular div
const appElement = containerElement.firstChild as HTMLElement;

const containerElement = typeof container === 'string' ? document.querySelector(container) : container;

if (element) {
  rawAppendChild.call(containerElement, element);
}
复制代码

在这个阶段,主应用已经将子应用基础的 HTML 结构挂载在了主应用的某个容器内,接下来还须要执行子应用对应的 mount 方法(如 Vue.$mount)对子应用状态进行挂载。

此时页面还能够根据 loading 参数开启一个相似加载的效果,直至子应用所有内容加载完成。

沙箱运行环境

src/loader.ts

let global = window;
  let mountSandbox = () => Promise.resolve();
  let unmountSandbox = () => Promise.resolve();
  if (sandbox) {
    const sandboxInstance = createSandbox(
      appName,
      containerGetter,
      Boolean(singular),
      enableScopedCSS,
      excludeAssetFilter,
    );
    // 用沙箱的代理对象做为接下来使用的全局对象
    global = sandboxInstance.proxy as typeof window;
    mountSandbox = sandboxInstance.mount;
    unmountSandbox = sandboxInstance.unmount;
  }
复制代码

这是沙箱核心判断逻辑,若是关闭了 sandbox 选项,那么全部子应用的沙箱环境都是 window,就很容易对全局状态产生污染。

生成应用运行时沙箱

src/sandbox/index.ts

  • app 环境沙箱
    • app 环境沙箱是指应用初始化过以后,应用会在什么样的上下文环境运行。每一个应用的环境沙箱只会初始化一次,由于子应用只会触发一次 bootstrap 。
    • 子应用在切换时,实际上切换的是 app 环境沙箱。
  • render 沙箱
    • 子应用在 app mount 开始前生成好的的沙箱。每次子应用切换事后,render 沙箱都会重现初始化。

这么设计的目的是为了保证每一个子应用切换回来以后,还能运行在应用 bootstrap 以后的环境下。

let sandbox: SandBox;
  if (window.Proxy) {
    sandbox = singular ? new LegacySandbox(appName) : new ProxySandbox(appName);
  } else {
    sandbox = new SnapshotSandbox(appName);
  }
复制代码
  • SandBox 内部的沙箱主要是经过是否支持 window.Proxy 分为 LegacySandbox 和 SnapshotSandbox 两种。

LegacySandbox-单实例沙箱

src/sandbox/legacy/sandbox.ts

const proxy = new Proxy(fakeWindow, {
      set(_: Window, p: PropertyKey, value: any): boolean {
        if (self.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 时能拿到已更新的数据
          (rawWindow as any)[p] = value;

          return true;
        }

        if (process.env.NODE_ENV === 'development') {
          console.warn(`[qiankun] Set window.${p.toString()} while sandbox destroyed or inactive in ${name}!`);
        }
        // 在 strict-mode 下,Proxy 的 handler.set 返回 false 会抛出 TypeError,在沙箱卸载的状况下应该忽略错误
        return true;
      },

      get(_: Window, p: PropertyKey): any {
        if (p === 'top' || p === 'parent' || p === 'window' || p === 'self') {
          return proxy;
        }

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

      has(_: Window, p: string | number | symbol): boolean {
        return p in rawWindow;
      },
    });
复制代码
  • 以简单理解为子应用的 window 全局对象,子应用对全局属性的操做就是对该 proxy 对象属性的操做。
// 子应用脚本文件的执行过程:
eval(
  // 这里将 proxy 做为 window 参数传入
  // 子应用的全局对象就是该子应用沙箱的 proxy 对象
  (function(window) {
    /* 子应用脚本文件内容 */
  })(proxy)
);
复制代码
  • 当调用 set 向子应用 proxy/window 对象设置属性时,全部的属性设置和更新都会先记录在 addedPropsMapInSandbox 或 modifiedPropsOriginalValueMapInSandbox 中,而后统一记录到 currentUpdatedPropsValueMap 中。
  • 修改全局 window 的属性,完成值的设置。
  • 当调用 get 从子应用 proxy/window 对象取值时,会直接从 window 对象中取值。对于非构造函数的取值将会对 this 指针绑定到 window 对象后,再返回函数。

LegacySandbox 的沙箱隔离是经过激活沙箱时还原子应用状态,卸载时还原主应用状态(子应用挂载前的全局状态)实现的,具体源码实如今 src/sandbox/legacy/sandbox.ts 中的 SingularProxySandbox 方法。

ProxySandbox 多实例沙箱

src/sandbox/proxySandbox.ts

constructor(name: string) {
    this.name = name;
    this.type = SandBoxType.Proxy;
    const { updatedValueSet } = this;

    const self = this;
    const rawWindow = window;
    const { fakeWindow, propertiesWithGetter } = createFakeWindow(rawWindow);

    const descriptorTargetMap = new Map<PropertyKey, SymbolTarget>();
    const hasOwnProperty = (key: PropertyKey) => fakeWindow.hasOwnProperty(key) || rawWindow.hasOwnProperty(key);

    const proxy = new Proxy(fakeWindow, {
      set(target: FakeWindow, p: PropertyKey, value: any): boolean {
        if (self.sandboxRunning) {
          // @ts-ignore
          target[p] = value;
          updatedValueSet.add(p);

          interceptSystemJsProps(p, value);

          return true;
        }

        if (process.env.NODE_ENV === 'development') {
          console.warn(`[qiankun] Set window.${p.toString()} while sandbox destroyed or inactive in ${name}!`);
        }

        // 在 strict-mode 下,Proxy 的 handler.set 返回 false 会抛出 TypeError,在沙箱卸载的状况下应该忽略错误
        return true;
      },

      get(target: FakeWindow, p: PropertyKey): any {
        if (p === Symbol.unscopables) return unscopables;

        // avoid who using window.window or window.self to escape the sandbox environment to touch the really window
        // see https://github.com/eligrey/FileSaver.js/blob/master/src/FileSaver.js#L13
        if (p === 'window' || p === 'self') {
          return proxy;
        }

        if (
          p === 'top' ||
          p === 'parent' ||
          (process.env.NODE_ENV === 'test' && (p === 'mockTop' || p === 'mockSafariTop'))
        ) {
          // if your master app in an iframe context, allow these props escape the sandbox
          if (rawWindow === rawWindow.parent) {
            return proxy;
          }
          return (rawWindow as any)[p];
        }

        // proxy.hasOwnProperty would invoke getter firstly, then its value represented as rawWindow.hasOwnProperty
        if (p === 'hasOwnProperty') {
          return hasOwnProperty;
        }

        // mark the symbol to document while accessing as document.createElement could know is invoked by which sandbox for dynamic append patcher
        if (p === 'document') {
          document[attachDocProxySymbol] = proxy;
          // remove the mark in next tick, thus we can identify whether it in micro app or not
          // this approach is just a workaround, it could not cover all the complex scenarios, such as the micro app runs in the same task context with master in som case
          // fixme if you have any other good ideas
          nextTick(() => delete document[attachDocProxySymbol]);
          return document;
        }

        // eslint-disable-next-line no-bitwise
        const value = propertiesWithGetter.has(p) ? (rawWindow as any)[p] : (target as any)[p] || (rawWindow as any)[p];
        return getTargetValue(rawWindow, value);
      },

      // trap in operator
      // see https://github.com/styled-components/styled-components/blob/master/packages/styled-components/src/constants.js#L12
      has(target: FakeWindow, p: string | number | symbol): boolean {
        return p in unscopables || p in target || p in rawWindow;
      },

      getOwnPropertyDescriptor(target: FakeWindow, p: string | number | symbol): PropertyDescriptor | undefined {
        /*
         as the descriptor of top/self/window/mockTop in raw window are configurable but not in proxy target, we need to get it from target to avoid TypeError
         see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Proxy/handler/getOwnPropertyDescriptor
         > A property cannot be reported as non-configurable, if it does not exists as an own property of the target object or if it exists as a configurable own property of the target object.
         */
        if (target.hasOwnProperty(p)) {
          const descriptor = Object.getOwnPropertyDescriptor(target, p);
          descriptorTargetMap.set(p, 'target');
          return descriptor;
        }

        if (rawWindow.hasOwnProperty(p)) {
          const descriptor = Object.getOwnPropertyDescriptor(rawWindow, p);
          descriptorTargetMap.set(p, 'rawWindow');
          return descriptor;
        }

        return undefined;
      },

      // trap to support iterator with sandbox
      ownKeys(target: FakeWindow): PropertyKey[] {
        return uniq(Reflect.ownKeys(rawWindow).concat(Reflect.ownKeys(target)));
      },

      defineProperty(target: Window, p: PropertyKey, attributes: PropertyDescriptor): boolean {
        const from = descriptorTargetMap.get(p);
        /*
         Descriptor must be defined to native window while it comes from native window via Object.getOwnPropertyDescriptor(window, p),
         otherwise it would cause a TypeError with illegal invocation.
         */
        switch (from) {
          case 'rawWindow':
            return Reflect.defineProperty(rawWindow, p, attributes);
          default:
            return Reflect.defineProperty(target, p, attributes);
        }
      },

      deleteProperty(target: FakeWindow, p: string | number | symbol): boolean {
        if (target.hasOwnProperty(p)) {
          // @ts-ignore
          delete target[p];
          updatedValueSet.delete(p);

          return true;
        }

        return true;
      },
    });

    this.proxy = proxy;
  }
复制代码
  • 当调用 set 向子应用 proxy/window 对象设置属性时,全部的属性设置和更新都会命中 updatedValueSet,存储在 updatedValueSet (18行 updatedValueSet.add(p) )集合中,从而避免对 window 对象产生影响。
  • 当调用 get 从子应用 proxy/window 对象取值时,会优先从子应用的沙箱状态池 updatedValueSet 中取值,若是没有命中才从主应用的 window 对象中取值。对于非构造函数的取值将会对 this 指针绑定到 window 对象后,再返回函数。
  • 如此一来,ProxySandbox 沙箱应用之间的隔离就完成了,全部子应用对 proxy/window 对象值的存取都受到了控制。设置值只会做用在沙箱内部的 updatedValueSet 集合上,取值也是优先取子应用独立状态池(updateValueMap)中的值,没有找到的话,再从 proxy/window 对象中取值。
  • 相比较而言,ProxySandbox 是最完备的沙箱模式,彻底隔离了对 window 对象的操做,也解决了快照模式中子应用运行期间仍然会对 window 形成污染的问题。

SnapshotSandbox

src/sandbox/snapshotSandbox.ts

不支持 window.Proxy 属性时,将会使用 SnapshotSandbox 沙箱,这个沙箱主要有如下几个步骤:

  1. 激活时给Window打个快照。

image - 2021-04-30T163731.313.png 2. 把window快照内的属性所有绑定在 modifyPropsMap 上,用于后续恢复变动。 image - 2021-04-30T163815.385.png 3. 记录变动,卸载时若是不同,就恢复改变以前的window属性值。 image - 2021-04-30T163837.704.png

SnapshotSandbox 沙箱就是利用快照实现了对 window 对象状态隔离的管理。相比较 ProxySandbox 而言,在子应用激活期间,SnapshotSandbox 将会对 window 对象形成污染,属于一个对不支持 Proxy 属性的浏览器的向下兼容方案。

动态添加样式表文件劫持

src/sandbox/patchers/dynamicAppend.ts

  • 避免主应用、子应用样式污染。
    • 主应用编译是classID加上hash码,避免主应用影响子应用的样式。
  • 子-子之间避免。
    • 当前子应用处于激活状态,那么动态 style 样式表就会被添加到子应用容器内,在子应用卸载时样式表也能够和子应用一块儿被卸载,从而避免样式污染。

子应用的动态脚本执行

对动态添加的脚本进行劫持的主要目的就是为了将动态脚本运行时的 window 对象替换成 proxy 代理对象,使子应用动态添加的脚本文件的运行上下文也替换成子应用自身。

卸载沙箱 - unmountSandbox

src/loader.ts

unmountSandbox = sandboxInstance.unmount;
复制代码

src/sandbox/index.ts

/**
     * 恢复 global 状态,使其能回到应用加载以前的状态
     */
    async unmount() {
     	// 循环执行卸载函数-移除dom/样式/脚本等;修改状态
      sideEffectsRebuilders = [...bootstrappingFreers, ...mountingFreers].map(free => free());
      sandbox.inactive();
    },
复制代码

通讯

src/globalState.ts

qiankun内部提供了 initGlobalState 方法用于注册 MicroAppStateActions 实例用于通讯,该实例有三个方法,分别是:

  • setGlobalState:设置 globalState - 设置新的值时,内部将执行 浅检查,若是检查到 globalState 发生改变则触发通知,通知到全部的 观察者 函数。
  • onGlobalStateChange:注册 观察者 函数 - 响应 globalState 变化,在 globalState 发生改变时触发该 观察者 函数。
  • offGlobalStateChange:取消 观察者 函数 - 该实例再也不响应 globalState 变化。

公共资源的提取

image - 2021-04-30T164239.214.png

回顾

image - 2021-04-30T164256.474.png

文|鬼灭 关注得物技术,携手走向技术的云端

相关文章
相关标签/搜索