從零到一實現企業級微前端框架,保姆級教學

前言

這篇文章筆者足足肝了一週多,多次斟酌修改內容,力求最大程度幫助讀者造出一個微前端框架,搞懂原理。覺得內容不錯的讀者點個贊支持下。

微前端是目前比較熱門的一種技術架構,挺多讀者私底下問我其中的原理。爲了講清楚原理,我會帶着大家從零開始實現一個微前端框架,其中包含了以下功能:

另外在實現的過程中,筆者還會聊聊目前有哪些技術方案可以去實現微前端以及做以上功能的時候有哪些實現方式。

這裏是本次文章的最終產出物倉庫地址:toy-micro

微前端實現方案

微前端的實現方案有挺多,比如說:

  1. qiankun,自己實現 JS 及樣式隔離
  2. icestark,iframe 方案,瀏覽器原生隔離,但存在一些問題
  3. emp,Webpack 5 Module Federation(聯邦模塊)方案
  4. WebComponent 等方案

但是這麼多實現方案解決的場景問題還是分爲兩類:

當然了,並不是說單實例只能用 qiankun,瀏覽器原生隔離方案也是可行的,只要你接受它們帶來的不足就行:

iframe 最大的特性就是提供了瀏覽器原生的硬隔離方案,不論是樣式隔離、js 隔離這類問題統統都能被完美解決。但他的最大問題也在於他的隔離性無法被突破,導致應用間上下文無法被共享,隨之帶來的開發體驗、產品體驗的問題。

上述內容摘自 Why Not Iframe

本文的實現方案和 qiankun 一致,但是其中涉及到的功能及原理方面的東西都是通用的,你換個實現方案也需要這些。

前置工作

在正式開始之前,我們需要搭建一下開發環境,這邊大家可以任意選擇主 / 子應用的技術棧,比如說主應用用 React,子應用用 Vue,自行選擇即可。每個應用用對應的腳手架工具初始化項目就行,這邊就不帶着大家初始化項目了。記得如果是 React 項目的話,需要另外再執行一次 yarn eject

推薦大家直接使用筆者倉庫裏的 example 文件夾,該配置的都配置好了,大家只需要安心跟着筆者一步步做微前端就行。 例子中主應用爲 React,子應用爲 Vue,最終我們生成的目錄結構大致如下:

正文

在閱讀正文前,我假定各位讀者已經使用過微前端框架並瞭解其中的概念,比如說知曉主應用是負責整體佈局以及子應用的配置及註冊這類內容。如果還未使用過,推薦各位簡略閱讀下任一微前端框架使用文檔。

應用註冊

在有了主應用之後,我們需要先在主應用中註冊子應用的信息,內容包含以下幾塊:

其實這些信息和我們在項目中註冊路由很像,entry 可以看做需要渲染的組件,container 可以看做路由渲染的節點,activeRule 可以看做如何匹配路由的規則。

接下來我們先來實現這個註冊子應用的函數:

export interface IAppInfo {
  name: string;
  entry: string;
  container: string;
  activeRule: string;
}


export const registerMicroApps = (appList: IAppInfo[]) => {
  setAppList(appList);
};


let appList: IAppInfo[] = [];

export const setAppList = (list: IAppInfo[]) => {
  appList = list;
};

export const getAppList = () => {
  return appList;
};
複製代碼

上述實現很簡單,就只需要將用戶傳入的 appList 保存起來即可。

路由劫持

在有了子應用列表以後,我們需要啓動微前端以便渲染相應的子應用,也就是需要判斷路由來渲染相應的應用。但是在進行下一步前,我們需要先考慮一個問題:如何監聽路由的變化來判斷渲染哪個子應用?

對於非 SPA(單頁應用) 架構的項目來說,這個完全不是什麼問題,因爲我們只需要在啓動微前端的時候判斷下當前 URL 並渲染應用即可;但是在 SPA 架構下,路由變化是不會引發頁面刷新的,因此我們需要一個方式知曉路由的變化,從而判斷是否需要切換子應用或者什麼事都不幹。

如果你瞭解過 Router 庫原理的話,應該馬上能想到解決方案。如果你並不瞭解的話,可以先自行閱讀筆者之前的文章

爲了照顧不了解的讀者,筆者這裏先簡略的聊一下路由原理。

目前單頁應用使用路由的方式分爲兩種:

  1. hash 模式,也就是 URL 中攜帶 #
  2. histroy 模式,也就是常見的 URL 格式了

以下筆者會用兩張圖例展示這兩種模式分別會涉及到哪些事件及 API:

從上述圖中我們可以發現,路由變化會涉及到兩個事件:

因此這兩個事件我們肯定是需要去監聽的。除此之外,調用 pushState 以及 replaceState 也會造成路由變化,但不會觸發事件,因此我們還需要去重寫這兩個函數。

知道了該監聽什麼事件以及重寫什麼函數之後,接下來我們就來實現代碼:

const originalPush = window.history.pushState;
const originalReplace = window.history.replaceState;

export const hijackRoute = () => {
  
  window.history.pushState = (...args) => {
    
    originalPush.apply(window.history, args);
    
    
  };
  window.history.replaceState = (...args) => {
    originalReplace.apply(window.history, args);
    
    
  };

  
  window.addEventListener("hashchange", () => {});
  window.addEventListener("popstate", () => {});

  
  window.addEventListener = hijackEventListener(window.addEventListener);
  window.removeEventListener = hijackEventListener(window.removeEventListener);
};

const capturedListeners: Record<EventType, Function[]> = {
  hashchange: [],
  popstate: [],
};
const hasListeners = (name: EventType, fn: Function) => {
  return capturedListeners[name].filter((listener) => listener === fn).length;
};
const hijackEventListener = (func: Function): any => {
  return function (name: string, fn: Function) {
    
    if (name === "hashchange" || name === "popstate") {
      if (!hasListeners(name, fn)) {
        capturedListeners[name].push(fn);
        return;
      } else {
        capturedListeners[name] = capturedListeners[name].filter(
          (listener) => listener !== fn
        );
      }
    }
    return func.apply(window, arguments);
  };
};

export function callCapturedListeners() {
  if (historyEvent) {
    Object.keys(capturedListeners).forEach((eventName) => {
      const listeners = capturedListeners[eventName as EventType]
      if (listeners.length) {
        listeners.forEach((listener) => {
          
          listener.call(this, historyEvent)
        })
      }
    })
    historyEvent = null
  }
}
複製代碼

以上代碼看着很多行,實際做的事情很簡單,總體分爲以下幾步:

  1. 重寫 pushState 以及 replaceState 方法,在方法中調用原有方法後執行如何處理子應用的邏輯
  2. 監聽 hashchangepopstate 事件,事件觸發後執行如何處理子應用的邏輯
  3. 重寫監聽 / 移除事件函數,如果應用監聽了 hashchangepopstate 事件就將回調函數保存起來以備後用

應用生命週期

在實現路由劫持後,我們現在需要來考慮如果實現處理子應用的邏輯了,也就是如何處理子應用加載資源以及掛載和卸載子應用。看到這裏,大家是不是覺得這和組件很類似。組件也同樣需要處理這些事情,並且會暴露相應的生命週期給用戶去幹想幹的事。

因此對於一個子應用來說,我們也需要去實現一套生命週期,既然子應用有生命週期,主應用肯定也有,而且也必然是相對應子應用生命週期的。

那麼到這裏我們大致可以整理出來主 / 子應用的生命週期。

對於主應用來說,分爲以下三個生命週期:

  1. beforeLoad:掛載子應用前
  2. mounted:掛載子應用後
  3. unmounted:卸載子應用

當然如果你想增加生命週期也是完全沒問題的,筆者這裏爲了簡便就只實現了三種。

對於子應用來說,通用也分爲以下三個生命週期:

  1. bootstrap:首次應用加載觸發,常用於配置子應用全局信息
  2. mount:應用掛載時觸發,常用於渲染子應用
  3. unmount:應用卸載時觸發,常用於銷燬子應用

接下來我們就來實現註冊主應用生命週期函數:

export interface ILifeCycle {
  beforeLoad?: LifeCycle | LifeCycle[];
  mounted?: LifeCycle | LifeCycle[];
  unmounted?: LifeCycle | LifeCycle[];
}



export const registerMicroApps = (
  appList: IAppInfo[],
  lifeCycle?: ILifeCycle
) => {
  setAppList(appList);
  lifeCycle && setLifeCycle(lifeCycle);
};


let lifeCycle: ILifeCycle = {};

export const setLifeCycle = (list: ILifeCycle) => {
  lifeCycle = list;
};
複製代碼

因爲是主應用的生命週期,所以我們在註冊子應用的時候就順帶註冊上了。

然後子應用的生命週期:

export enum AppStatus {
  NOT_LOADED = "NOT_LOADED",
  LOADING = "LOADING",
  LOADED = "LOADED",
  BOOTSTRAPPING = "BOOTSTRAPPING",
  NOT_MOUNTED = "NOT_MOUNTED",
  MOUNTING = "MOUNTING",
  MOUNTED = "MOUNTED",
  UNMOUNTING = "UNMOUNTING",
}

export const runBeforeLoad = async (app: IInternalAppInfo) => {
  app.status = AppStatus.LOADING;
  await runLifeCycle("beforeLoad", app);

  app = await 加載子應用資源;
  app.status = AppStatus.LOADED;
};

export const runBoostrap = async (app: IInternalAppInfo) => {
  if (app.status !== AppStatus.LOADED) {
    return app;
  }
  app.status = AppStatus.BOOTSTRAPPING;
  await app.bootstrap?.(app);
  app.status = AppStatus.NOT_MOUNTED;
};

export const runMounted = async (app: IInternalAppInfo) => {
  app.status = AppStatus.MOUNTING;
  await app.mount?.(app);
  app.status = AppStatus.MOUNTED;
  await runLifeCycle("mounted", app);
};

export const runUnmounted = async (app: IInternalAppInfo) => {
  app.status = AppStatus.UNMOUNTING;
  await app.unmount?.(app);
  app.status = AppStatus.NOT_MOUNTED;
  await runLifeCycle("unmounted", app);
};

const runLifeCycle = async (name: keyof ILifeCycle, app: IAppInfo) => {
  const fn = lifeCycle[name];
  if (fn instanceof Array) {
    await Promise.all(fn.map((item) => item(app)));
  } else {
    await fn?.(app);
  }
};
複製代碼

以上代碼看着很多,實際實現也很簡單,總結一下就是:

完善路由劫持

實現應用生命週期以後,我們現在就能來完善先前路由劫持中沒有做的「如何處理子應用」的這塊邏輯。

這塊邏輯在我們做完生命週期之後其實很簡單,可以分爲以下幾步:

  1. 判斷當前 URL 與之前的 URL 是否一致,如果一致則繼續
  2. 利用當然 URL 去匹配相應的子應用,此時分爲幾種情況:
    • 初次啓動微前端,此時只需渲染匹配成功的子應用
    • 未切換子應用,此時無需處理子應用
    • 切換子應用,此時需要找出之前渲染過的子應用做卸載處理,然後渲染匹配成功的子應用
  3. 保存當前 URL,用於下一次第一步判斷

理清楚步驟之後,我們就來實現它:

let lastUrl: string | null = null
export const reroute = (url: string) => {
  if (url !== lastUrl) {
    const { actives, unmounts } = 匹配路由,尋找符合條件的子應用
    
    Promise.all(
      unmounts
        .map(async (app) => {
          await runUnmounted(app)
        })
        .concat(
          actives.map(async (app) => {
            await runBeforeLoad(app)
            await runBoostrap(app)
            await runMounted(app)
          })
        )
    ).then(() => {
      
      callCapturedListeners()
    })
  }
  lastUrl = url || location.href
}
複製代碼

以上代碼主體就是在按順序執行生命週期函數,但是其中匹配路由的函數並未實現,因爲我們需要先來考慮一些問題。

大家平時項目開發中肯定是用過路由的,那應該知道路由匹配的原則主要由兩塊組成:

嵌套關係指的是:假如我當前的路由設置的是 /vue,那麼類似 /vue 或者 /vue/xxx 都能匹配上這個路由,除非我們設置 excart 也就是精確匹配。

路徑語法筆者這裏就直接拿個文檔裏的例子呈現了:

<Route path="/hello/:name">         
<Route path="/hello(/:name)">       // 匹配 /hello, /hello/michael 和 /hello/ryan
<Route path="/files/*.*">           // 匹配 /files/hello.jpg 和 /files/path/to/hello.jpg
複製代碼

這樣看來路由匹配實現起來還是挺麻煩的,那麼我們是否有簡便的辦法來實現該功能呢?答案肯定是有的,我們只要閱讀 Route 庫源碼就能發現它們內部都使用了 path-to-regexp 這個庫,有興趣的讀者可以自行閱讀下這個庫的文檔,筆者這裏就帶過了,我們只看其中一個 API 的使用就行。

有了解決方案以後,我們就快速實現下路由匹配的函數:

export const getAppListStatus = () => {
  
  const actives: IInternalAppInfo[] = []
  
  const unmounts: IInternalAppInfo[] = []
  
  const list = getAppList() as IInternalAppInfo[]
  list.forEach((app) => {
    
    const isActive = match(app.activeRule, { end: false })(location.pathname)
    
    switch (app.status) {
      case AppStatus.NOT_LOADED:
      case AppStatus.LOADING:
      case AppStatus.LOADED:
      case AppStatus.BOOTSTRAPPING:
      case AppStatus.NOT_MOUNTED:
        isActive && actives.push(app)
        break
      case AppStatus.MOUNTED:
        !isActive && unmounts.push(app)
        break
    }
  })

  return { actives, unmounts }
}
複製代碼

完成以上函數之後,大家別忘了在 reroute 函數中調用一下,至此路由劫持功能徹底完成了,完整代碼可閱讀此處

完善生命週期

之前在實現生命週期過程中,我們還有很重要的一步「加載子應用資源」未完成,這一小節我們就把這塊內容搞定。

既然要加載資源,那麼我們肯定就先需要一個資源入口,就和我們使用的 npm 包一樣,每個包一定會有一個入口文件。回到 registerMicroApps 函數,我們最開始就給這個函數傳入了 entry 參數,這就是子應用的資源入口。

資源入口其實分爲兩種方案:

  1. JS Entry
  2. HTML Entry

這兩個方案都是字面意思,前者是通過 JS 加載所有靜態資源,後者則通過 HTML 加載所有靜態資源。

JS Entry 是 single-spa 中使用的一個方式。但是它限制有點多,需要用戶將所有文件打包在一起,除非你的項目對性能無感,否則基本可以 pass 這個方案。

HTML Entry 則要好得多,畢竟所有網站都是以 HTML 作爲入口文件的。在這種方案裏,我們基本無需改動打包方式,對用戶開發幾乎沒侵入性,只需要尋找出 HTML 中的靜態資源加載並運行即可渲染子應用了,因此我們選擇了這個方案。

接下來我們開始來實現這部分的內容。

加載資源

首先我們需要獲取 HTML 的內容,這裏我們只需調用原生 fetch 就能拿到東西了。

export const fetchResource = async (url: string) => {
  return await fetch(url).then(async (res) => await res.text())
}

export const loadHTML = async (app: IInternalAppInfo) => {
  const { container, entry } = app

  const htmlFile = await fetchResource(entry)

  return app
}
複製代碼

在筆者的倉庫 example 中,我們切換路由至 /vue 之後,我們可以打印出加載到的 HTML 文件內容。

<!DOCTYPE html>
<html lang="">
  <head>
    <meta charset="utf-8">
    <meta http-equiv="X-UA-Compatible" content="IE=edge">
    <meta >
    <link rel="icon" href="/favicon.ico">
    <title>sub</title>
  <link href="/js/app.js" rel="preload" as="script"><link href="/js/chunk-vendors.js" rel="preload" as="script"></head>
  <body>
    <noscript>
      <strong>We're sorry but sub doesn't work properly without JavaScript enabled. Please enable it to continue.</strong>
    </noscript>
    <div></div>
    
  <script type="text/javascript" src="/js/chunk-vendors.js"></script>
  <script type="text/javascript" src="/js/app.js"></script></body>
</html>
複製代碼

我們可以在該文件中看到好些相對路徑的靜態資源 URL,接下來我們就需要去加載這些資源了。但是我們需要注意一點的是,這些資源只有在自己的 BaseURL 下才能被正確加載到,如果是在主應用的 BaseURL 下肯定報 404 錯誤了。

然後我們還需要注意一點:因爲我們是在主應用的 URL 下加載子應用的資源,這很有可能會觸發跨域的限制。因此在開發及生產環境大家務必注意跨域的處理。

舉個開發環境下子應用是 Vue 的話,處理跨域的方式:

module.exports = {
  devServer: {
    headers: {
      'Access-Control-Allow-Origin': '*',
    },
  },
}
複製代碼

接下來我們需要先行處理這些資源的路徑,將相對路徑拼接成正確的絕對路徑,然後再去 fetch

export function getCompletionURL(src: string | null, baseURI: string) {
  if (!src) return src
  
  if (/^(https|http)/.test(src)) return src
	
  return new URL(src, getCompletionBaseURL(baseURI)).toString()
}


export function getCompletionBaseURL(url: string) {
  return url.startsWith('//') ? `${location.protocol}${url}` : url
}
複製代碼

以上代碼的功能就不再贅述了,註釋已經很詳細了,接下來我們需要找到 HTML 文件中的資源然後去 fetch

既然是找出資源,那麼我們就得解析 HTML 內容了:

export const parseHTML = (parent: HTMLElement, app: IInternalAppInfo) => {
  const children = Array.from(parent.children) as HTMLElement[]
  children.length && children.forEach((item) => parseHTML(item, app))

  for (const dom of children) {
    if (/^(link)$/i.test(dom.tagName)) {
      
    } else if (/^(script)$/i.test(dom.tagName)) {
      
    } else if (/^(img)$/i.test(dom.tagName) && dom.hasAttribute('src')) {
      
      dom.setAttribute(
        'src',
        getCompletionURL(dom.getAttribute('src')!, app.entry)!
      )
    }
  }

  return {  }
}
複製代碼

解析內容這塊還是簡單的,我們遞歸尋找元素,將 linkscriptimg 元素找出來並做對應的處理即可。

首先來看我們如何處理 link

if (/^(link)$/i.test(dom.tagName)) {
  const data = parseLink(dom, parent, app)
  data && links.push(data)
}
const parseLink = (
  link: HTMLElement,
  parent: HTMLElement,
  app: IInternalAppInfo
) => {
  const rel = link.getAttribute('rel')
  const href = link.getAttribute('href')
  let comment: Comment | null
  
  if (rel === 'stylesheet' && href) {
    comment = document.createComment(`link replaced by micro`)
    
    comment && parent.replaceChild(comment, script)
    return getCompletionURL(href, app.entry)
  } else if (href) {
    link.setAttribute('href', getCompletionURL(href, app.entry)!)
  }
}
複製代碼

處理 link 標籤時,我們只需要處理 CSS 資源,其它 preload / prefetch 的這些資源直接替換 href 就行。

if (/^(link)$/i.test(dom.tagName)) {
  const data = parseScript(dom, parent, app)
  data.text && inlineScript.push(data.text)
  data.url && scripts.push(data.url)
}
const parseScript = (
  script: HTMLElement,
  parent: HTMLElement,
  app: IInternalAppInfo
) => {
  let comment: Comment | null
  const src = script.getAttribute('src')
  
  if (src) {
    comment = document.createComment('script replaced by micro')
  } else if (script.innerHTML) {
    comment = document.createComment('inline script replaced by micro')
  }
  
  comment && parent.replaceChild(comment, script)
  return { url: getCompletionURL(src, app.entry), text: script.innerHTML }
}
複製代碼

處理 script 標籤時,我們需要區別是 JS 文件還是行內代碼,前者還需要 fecth 一次獲取內容。

然後我們會在 parseHTML 中返回所有解析出來的 scripts, links, inlineScript

接下來我們按照順序先加載 CSS 再加載 JS 文件:

export const loadHTML = async (app: IInternalAppInfo) => {
  const { container, entry } = app

  const fakeContainer = document.createElement('div')
  fakeContainer.innerHTML = htmlFile
  const { scripts, links, inlineScript } = parseHTML(fakeContainer, app)

  await Promise.all(links.map((link) => fetchResource(link)))

  const jsCode = (
    await Promise.all(scripts.map((script) => fetchResource(script)))
  ).concat(inlineScript)

  return app
}
複製代碼

以上我們就實現了從加載 HTML 文件到解析文件找出所有靜態資源到最後的加載 CSS 及 JS 文件。但是實際上我們這個實現還是有些粗糙的,雖然把核心內容實現了,但是還是有一些細節沒有考慮到的。

因此我們也可以考慮直接使用三方庫來實現加載及解析文件的過程,這裏我們選用了 import-html-entry 這個庫,內部做的事情和我們核心是一致的,只是多處理了很多細節。

如果你想直接使用這個庫的話,可以把 loadHTML 改造成這樣:

export const loadHTML = async (app: IInternalAppInfo) => {
  const { container, entry } = app

  
  
  
  const { template, getExternalScripts, getExternalStyleSheets } =
    await importEntry(entry)
  const dom = document.querySelector(container)

  if (!dom) {
    throw new Error('容器不存在 ')
  }
  
  dom.innerHTML = template
  
  await getExternalStyleSheets()
  const jsCode = await getExternalScripts()

  return app
}
複製代碼

運行 JS

當我們拿到所有 JS 內容以後就該運行 JS 了,這步完成以後我們就能在頁面上看到子應用被渲染出來了。

這一小節的內容說簡單的話可以沒幾行代碼就寫完,說複雜的話實現起來會需要考慮很多細節,我們先來實現簡單的部分,也就是如何運行 JS。

對於一段 JS 字符串來說,我們想執行的話大致上有兩種方式:

  1. eval(js string)
  2. new Function(js string)()

這邊我們選用第二種方式來實現:

const runJS = (value: string, app: IInternalAppInfo) => {
  const code = `
    ${value}
    return window['${app.name}']
  `
  return new Function(code).call(window, window)
}
複製代碼

不知道大家是否還記得我們在註冊子應用的時候給每個子應用都設置了一個 name 屬性,這個屬性其實很重要,我們在之後的場景中也會用到。另外大家給子應用設置 name 的時候別忘了還需要略微改動下打包的配置,將其中一個選項也設置爲同樣內容。

舉個例子,我們假如給其中一個技術棧爲 Vue 的子應用設置了 name: vue,那麼我們還需要在打包配置中進行如下設置:

module.exports = {
  configureWebpack: {
    output: {
      
      library: `vue`
    },
  },
}
複製代碼

這樣配置後,我們就能通過 window.vue 訪問到應用的 JS 入口文件 export 出來的內容了:

大家可以在上圖中看到導出的這些函數都是子應用的生命週期,我們需要拿到這些函數去調用。

最後我們在 loadHTML 中調用一下 runJS 就完事了:

export const loadHTML = async (app: IInternalAppInfo) => {
  const { container, entry } = app

  const { template, getExternalScripts, getExternalStyleSheets } =
    await importEntry(entry)
  const dom = document.querySelector(container)

  if (!dom) {
    throw new Error('容器不存在 ')
  }

  dom.innerHTML = template

  await getExternalStyleSheets()
  const jsCode = await getExternalScripts()

  jsCode.forEach((script) => {
    const lifeCycle = runJS(script, app)
    if (lifeCycle) {
      app.bootstrap = lifeCycle.bootstrap
      app.mount = lifeCycle.mount
      app.unmount = lifeCycle.unmount
    }
  })

  return app
}
複製代碼

完成以上步驟後,我們就能看到子應用被正常渲染出來了!

但是到這一步其實還不算完,我們考慮這樣一個問題:子應用改變全局變量怎麼辦? 我們目前所有應用都可以獲取及改變 window 上的內容,那麼一旦應用之間出現全局變量衝突就會引發問題,因此我們接下來需要來解決這個事兒。

JS 沙箱

我們即要防止子應用直接修改 window 上的屬性又要能訪問 window 上的內容,那麼就只能做個假的 window 給子應用了,也就是實現一個 JS 沙箱。

實現沙箱的方案也有很多種,比如說:

  1. 快照
  2. Proxy

先來說說快照的方案,其實這個方案實現起來特別簡單,說白了就是在掛載子應用前記錄下當前 window 上的所有內容,然後接下來就隨便讓子應用去玩了,直到卸載子應用時恢復掛載前的 window 即可。這種方案實現容易,唯一缺點就是性能慢點,有興趣的讀者可以直接看看 qiankun 的實現,這裏就不再貼代碼了。

再來說說 Proxy,也是我們選用的方案,這個應該挺多讀者都已經瞭解過它的使用方式了,畢竟 Vue3 響應式原理都被說爛了。如果你還不瞭解它的話,可以先自行閱讀 MDN 文檔

export class ProxySandbox {
  proxy: any
  running = false
  constructor() {
    
    const fakeWindow = Object.create(null)
    const proxy = new Proxy(fakeWindow, {
      set: (target: any, p: string, value: any) => {
        
        if (this.running) {
          target[p] = value
        }
        return true
      },
      get(target: any, p: string): any {
        
        switch (p) {
          case 'window':
          case 'self':
          case 'globalThis':
            return proxy
        }
        
        
        if (
          !window.hasOwnProperty.call(target, p) &&
          window.hasOwnProperty(p)
        ) {
          
          const value = window[p]
          if (typeof value === 'function') return value.bind(window)
          return value
        }
        return target[p]
      },
      has() {
        return true
      },
    })
    this.proxy = proxy
  }
  
  active() {
    this.running = true
  }
  
  inactive() {
    this.running = false
  }
}
複製代碼

以上代碼只是一個初版的沙箱,核心思路就是創建一個假的 window 出來,如果用戶設置值的話就設置在 fakeWindow 上,這樣就不會影響全局變量了。如果用戶取值的話,就判斷屬性是存在於 fakeWindow 上還是 window 上。

當然實際使用的時候我們還是需要完善一下這個沙箱的,還需要處理一些細節,這裏推薦大家直接閱讀 qiankun 的源碼,代碼量不多,無非多處理了不少邊界情況。

另外需要注意的是:一般快照和 Proxy 沙箱都是需要的,無非前者是後者的降級方案,畢竟不是所有瀏覽器都支持 Proxy 的。

最後我們需要改造下 runJS 裏的代碼以便使用沙箱:

const runJS = (value: string, app: IInternalAppInfo) => {
  if (!app.proxy) {
    app.proxy = new ProxySandbox()
    
    
    window.__CURRENT_PROXY__ = app.proxy.proxy
  }
  
  app.proxy.active()
  
  const code = `
    return (window => {
      ${value}
      return window['${app.name}']
    })(window.__CURRENT_PROXY__)
  `
  return new Function(code)()
}
複製代碼

至此,我們其實已經完成了整個微前端的核心功能。因爲文字表達很難連貫上下文所有的函數完善步驟,所以如果大家在閱讀文章時有對不上的,還是推薦看下筆者倉庫的源碼

接下來我們會來做一些改善型功能。

改善型功能

prefetch

我們目前的做法是匹配一個子應用成功後纔去加載子應用,這種方式其實不夠高效。我們更希望用戶在瀏覽當前子應用的時候就能把別的子應用資源也加載完畢,這樣用戶切換應用的時候就無需等待了。

實現起來代碼不多,利用我們之前的 import-html-entry 就能馬上做完了:

export const start = () => {
  const list = getAppList()
  if (!list.length) {
    throw new Error('請先註冊應用')
  }

  hijackRoute()
  reroute(window.location.href)

  
  list.forEach((app) => {
    if ((app as IInternalAppInfo).status === AppStatus.NOT_LOADED) {
      prefetch(app as IInternalAppInfo)
    }
  })
}

export const prefetch = async (app: IInternalAppInfo) => {
  requestIdleCallback(async () => {
    const { getExternalScripts, getExternalStyleSheets } = await importEntry(
      app.entry
    )
    requestIdleCallback(getExternalStyleSheets)
    requestIdleCallback(getExternalScripts)
  })
}
複製代碼

以上代碼別的都沒啥好說的,主要來聊下 requestIdleCallback 這個函數。

window.requestIdleCallback() 方法將在瀏覽器的空閒時段內調用的函數排隊。這使開發者能夠在主事件循環上執行後臺和低優先級工作,而不會影響延遲關鍵事件,如動畫和輸入響應。

我們利用這個函數實現在瀏覽器空閒時間再去進行 prefetch,其實這個函數在 React 中也有用到,無非內部實現了一個 polyfill 版本。因爲這個 API 有一些問題(最快 50ms 響應一次)尚未解決,但是在我們的場景下不會有問題,所以可以直接使用。

資源緩存機制

當我們加載過一次資源後,用戶肯定不希望下次再進入該應用的時候還需要再加載一次資源,因此我們需要實現資源的緩存機制。

上一小節我們因爲使用到了 import-html-entry,內部自帶了緩存機制。如果你想自己實現的話,可以參考內部的實現方式

簡單來說就是搞一個對象緩存下每次請求下來的文件內容,下次請求的時候先判斷對象中存不存在值,存在的話直接拿出來用就行。

全局通信及狀態

這部分內容在筆者的代碼中並未實現,如果你有興趣自己做的話,筆者可以提供一些思路。

全局通信及狀態實際上完全都可以看做是發佈訂閱模式的一種實現,只要你自己手寫過 Event 的話,實現這個應該不是什麼難題。

另外你也可以閱讀下 qiankun 的全局狀態實現,總共也就 100 行代碼。

最後

文章到這裏就完結了,整篇文章近萬字,讀下來可能不少讀者還會存在一些疑慮,你可以選擇多讀幾遍或者結合筆者的源碼閱讀。

另外大家也可以在交流區提問,筆者會在空閒時間解答問題。

作者:yck

倉庫:Github

公衆號:前端真好玩

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://juejin.cn/post/7004661323124441102