乐趣区

关于微前端:得物技术浅谈微前端大世界qiankun源码研读

微前端,对于他的好和利用场景,很多师兄们也都介绍过了,那么咱们应用的微前端计划 qiankun 是如何去做到利用的“微前端”的呢?

几个个性

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

  • 子利用并行
  • 父子利用通信
  • 预加载

    • 闲暇时预加载子利用的资源
  • 公共依赖的加载
  • 按需加载
  • JS 沙箱
  • CSS 隔离
    做到以上的这几点,那么咱们子利用就能多重组合,互不影响,面对大型项目的聚合,也不必放心我的项目汇总后的保护、打包、上线的问题。

这篇分享,就会简略的读一读 qiankun 的源码,从大略流程上,理解他的实现原理和技术计划。

咱们的利用怎么配置?- 退出微前端 Arya 的怀抱吧

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

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

创立流程

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

/src/apis.ts

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.ts

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 激活规定时将会激活子利用,执行回调函数,返回生命周期钩子函数。

获取子利用资源 – 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 拉取子利用的动态资源。
  • 调用之后返回的对象如下:


  • 拉取代码如下
  • GitHub 地址:https://github.com/kuitos/imp…

    • 如果能拉取动态资源,是否能够做繁难的爬虫服务每日爬取页面执行资源是否加载正确?
    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.ts

    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 打个快照。
  2. 把 window 快照内的属性全副绑定在 modifyPropsMap 上,用于后续复原变更。
  3. 记录变更,卸载时如果不一样,就复原扭转之前的 window 属性值。

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 变动。

    公共资源的提取

    回顾

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

退出移动版