【得物技術】微前端,大世界-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

文|鬼滅 關注得物技術,攜手走向技術的雲端

相關文章
相關標籤/搜索