<kbd id="afajh"><form id="afajh"></form></kbd>
<strong id="afajh"><dl id="afajh"></dl></strong>
    <del id="afajh"><form id="afajh"></form></del>
        1. <th id="afajh"><progress id="afajh"></progress></th>
          <b id="afajh"><abbr id="afajh"></abbr></b>
          <th id="afajh"><progress id="afajh"></progress></th>

          微前端框架實(shí)現(xiàn)原理

          共 32672字,需瀏覽 66分鐘

           ·

          2021-09-12 08:24

          本文適合對(duì)微前端感興趣、以及想深入微前端原理學(xué)習(xí)的小伙伴閱讀。

          歡迎關(guān)注前端早茶,與廣東靚仔攜手共同進(jìn)階~

          作者:廣東靚仔

          一、前言

          本文轉(zhuǎn)載于掘金:

          https://juejin.cn/post/7004661323124441102

          業(yè)界微前端的實(shí)現(xiàn)方案有挺多:
          1、qiankun,icestark 自己實(shí)現(xiàn) JS 及樣式隔離
          2、emp,Webpack 5 Module Federation(聯(lián)邦模塊)方案
          3、iframe 、WebComponent 等方案,瀏覽器原生隔離,但存在一些問(wèn)題

          這么多實(shí)現(xiàn)方案解決的場(chǎng)景問(wèn)題還是分為兩類:

          • 單實(shí)例:當(dāng)前頁(yè)面只存在一個(gè)子應(yīng)用,一般使用 qiankun 就行

          • 多實(shí)例:當(dāng)前頁(yè)面存在多個(gè)子應(yīng)用,可以使用瀏覽器原生隔離方案,比如 iframe 或者 WebComponent 這些

          瀏覽器原生隔離方案不足:
              iframe 最大的特性就是提供了瀏覽器原生的硬隔離方案,不論是樣式隔離、js 隔離這類問(wèn)題統(tǒng)統(tǒng)都能被完美解決。但他的最大問(wèn)題也在于他的隔離性無(wú)法被突破,導(dǎo)致應(yīng)用間上下文無(wú)法被共享,隨之帶來(lái)的開(kāi)發(fā)體驗(yàn)、產(chǎn)品體驗(yàn)的問(wèn)題。
          Iframe不足-推薦文章:https://www.yuque.com/kuitos/gky7yw/gesexv
          本文的實(shí)現(xiàn)方案和 qiankun 一致,但是其中涉及到的功能及原理方面的東西都是通用的,你換個(gè)實(shí)現(xiàn)方案也需要這些。

          二、前置工作

              在正式開(kāi)始之前,我們需要搭建一下開(kāi)發(fā)環(huán)境,這邊大家可以任意選擇主 / 子應(yīng)用的技術(shù)棧,比如說(shuō)主應(yīng)用用 React,子應(yīng)用用 Vue,自行選擇即可。每個(gè)應(yīng)用用對(duì)應(yīng)的腳手架工具初始化項(xiàng)目就行,這邊就不帶著大家初始化項(xiàng)目了。記得如果是 React 項(xiàng)目的話,需要另外再執(zhí)行一次 yarn eject
               例子中主應(yīng)用為 React,子應(yīng)用為 Vue,最終我們生成的目錄結(jié)構(gòu)大致如下:


          三、正文

            主應(yīng)用:負(fù)責(zé)整體布局以及子應(yīng)用的配置及注冊(cè)這類內(nèi)容。


          應(yīng)用注冊(cè)

          在有了主應(yīng)用之后,我們需要先在主應(yīng)用中注冊(cè)子應(yīng)用的信息,內(nèi)容包含以下幾塊:

          • name:子應(yīng)用名詞

          • entry:子應(yīng)用的資源入口

          • container:主應(yīng)用渲染子應(yīng)用的節(jié)點(diǎn)

          • activeRule:在哪些路由下渲染該子應(yīng)用

          其實(shí)這些信息和我們?cè)陧?xiàng)目中注冊(cè)路由很像,entry 可以看做需要渲染的組件,container 可以看做路由渲染的節(jié)點(diǎn),activeRule 可以看做如何匹配路由的規(guī)則。

          接下來(lái)我們先來(lái)實(shí)現(xiàn)這個(gè)注冊(cè)子應(yīng)用的函數(shù):

          // src/types.ts
          export interface IAppInfo {
            name: string;
            entry: string;
            container: string;
            activeRule: string;
          }

          // src/start.ts
          export const registerMicroApps = (appList: IAppInfo[]) => {
            setAppList(appList);
          };

          // src/appList/index.ts
          let appList: IAppInfo[] = [];

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

          export const getAppList = () => {
            return appList;
          };

          只需要將用戶傳入的 appList 保存起來(lái)即可


          路由劫持

          在有了子應(yīng)用列表以后,我們需要啟動(dòng)微前端以便渲染相應(yīng)的子應(yīng)用,也就是需要判斷路由來(lái)渲染相應(yīng)的應(yīng)用。但是在進(jìn)行下一步前,我們需要先考慮一個(gè)問(wèn)題:如何監(jiān)聽(tīng)路由的變化來(lái)判斷渲染哪個(gè)子應(yīng)用?


          對(duì)于非 SPA(單頁(yè)應(yīng)用) 架構(gòu)的項(xiàng)目來(lái)說(shuō),這個(gè)完全不是什么問(wèn)題,因?yàn)槲覀冎恍枰趩?dòng)微前端的時(shí)候判斷下當(dāng)前 URL 并渲染應(yīng)用即可;但是在 SPA 架構(gòu)下,路由變化是不會(huì)引發(fā)頁(yè)面刷新的,因此我們需要一個(gè)方式知曉路由的變化,從而判斷是否需要切換子應(yīng)用或者什么事都不干。

          路由原理
          目前單頁(yè)應(yīng)用使用路由的方式分為兩種:
          1. hash 模式,也就是 URL 中攜帶 #
          2. histroy 模式,也就是常見(jiàn)的 URL 格式了
          這兩種模式分別會(huì)涉及到哪些事件及 API:

          從上述圖中我們可以發(fā)現(xiàn),路由變化會(huì)涉及到兩個(gè)事件:

          • popstate

          • hashchange

          因此這兩個(gè)事件我們肯定是需要去監(jiān)聽(tīng)的。除此之外,調(diào)用 pushState 以及 replaceState 也會(huì)造成路由變化,但不會(huì)觸發(fā)事件,因此我們還需要去重寫(xiě)這兩個(gè)函數(shù)。

          知道了該監(jiān)聽(tīng)什么事件以及重寫(xiě)什么函數(shù)之后,接下來(lái)我們就來(lái)實(shí)現(xiàn)代碼:

          // src/route/index.ts

          // 保存原有方法
          const originalPush = window.history.pushState;
          const originalReplace = window.history.replaceState;

          export const hijackRoute = () => {
            // 重寫(xiě)方法
            window.history.pushState = (...args) => {
              // 調(diào)用原有方法
              originalPush.apply(window.history, args);
              // URL 改變邏輯,實(shí)際就是如何處理子應(yīng)用
              // ...
            };
            window.history.replaceState = (...args) => {
              originalReplace.apply(window.history, args);
              // URL 改變邏輯
              // ...
            };

            // 監(jiān)聽(tīng)事件,觸發(fā) URL 改變邏輯
            window.addEventListener("hashchange"() => {});
            window.addEventListener("popstate"() => {});

            // 重寫(xiě)
            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{
              // 如果是以下事件,保存回調(diào)函數(shù)
              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(windowarguments);
            };
          };
          // 后續(xù)渲染子應(yīng)用后使用,用于執(zhí)行之前保存的回調(diào)函數(shù)
          export function callCapturedListeners() {
            if (historyEvent) {
              Object.keys(capturedListeners).forEach((eventName) => {
                const listeners = capturedListeners[eventName as EventType]
                if (listeners.length) {
                  listeners.forEach((listener) => {
                    // @ts-ignore
                    listener.call(this, historyEvent)
                  })
                }
              })
              historyEvent = null
            }
          }

          以上代碼看著很多行,實(shí)際做的事情很簡(jiǎn)單,總體分為以下幾步:

          1. 重寫(xiě) pushState 以及 replaceState 方法,在方法中調(diào)用原有方法后執(zhí)行如何處理子應(yīng)用的邏輯

          2. 監(jiān)聽(tīng) hashchangepopstate 事件,事件觸發(fā)后執(zhí)行如何處理子應(yīng)用的邏輯

          3. 重寫(xiě)監(jiān)聽(tīng) / 移除事件函數(shù),如果應(yīng)用監(jiān)聽(tīng)了 hashchangepopstate 事件就將回調(diào)函數(shù)保存起來(lái)以備后用


          應(yīng)用生命周期

          在實(shí)現(xiàn)路由劫持后,我們現(xiàn)在需要來(lái)考慮如果實(shí)現(xiàn)處理子應(yīng)用的邏輯了,也就是如何處理子應(yīng)用加載資源以及掛載和卸載子應(yīng)用。看到這里,大家是不是覺(jué)得這和組件很類似。組件也同樣需要處理這些事情,并且會(huì)暴露相應(yīng)的生命周期給用戶去干想干的事。
          因此對(duì)于一個(gè)子應(yīng)用來(lái)說(shuō),我們也需要去實(shí)現(xiàn)一套生命周期,既然子應(yīng)用有生命周期,主應(yīng)用肯定也有,而且也必然是相對(duì)應(yīng)子應(yīng)用生命周期的。
          那么到這里我們大致可以整理出來(lái)主 / 子應(yīng)用的生命周期。
          對(duì)于主應(yīng)用來(lái)說(shuō),分為以下三個(gè)生命周期:
          1. beforeLoad:掛載子應(yīng)用前
          2. mounted:掛載子應(yīng)用后
          3. unmounted:卸載子應(yīng)用
          當(dāng)然如果你想增加生命周期也是完全沒(méi)問(wèn)題的,筆者這里為了簡(jiǎn)便就只實(shí)現(xiàn)了三種。
          對(duì)于子應(yīng)用來(lái)說(shuō),通用也分為以下三個(gè)生命周期:
          1. bootstrap:首次應(yīng)用加載觸發(fā),常用于配置子應(yīng)用全局信息
          2. mount:應(yīng)用掛載時(shí)觸發(fā),常用于渲染子應(yīng)用
          3. unmount:應(yīng)用卸載時(shí)觸發(fā),常用于銷毀子應(yīng)用
          接下來(lái)我們就來(lái)實(shí)現(xiàn)注冊(cè)主應(yīng)用生命周期函數(shù):
          // src/types.ts
          export interface ILifeCycle {
            beforeLoad?: LifeCycle | LifeCycle[];
            mounted?: LifeCycle | LifeCycle[];
            unmounted?: LifeCycle | LifeCycle[];
          }

          // src/start.ts
          // 改寫(xiě)下之前的

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

          // src/lifeCycle/index.ts
          let lifeCycle: ILifeCycle = {};

          export const setLifeCycle = (list: ILifeCycle) => {
            lifeCycle = list;
          };

          因?yàn)槭侵鲬?yīng)用的生命周期,所以我們?cè)谧?cè)子應(yīng)用的時(shí)候就順帶注冊(cè)上了。

          然后子應(yīng)用的生命周期:

          // src/enums.ts
          // 設(shè)置子應(yīng)用狀態(tài)

          export enum AppStatus {
            NOT_LOADED = "NOT_LOADED",
            LOADING = "LOADING",
            LOADED = "LOADED",
            BOOTSTRAPPING = "BOOTSTRAPPING",
            NOT_MOUNTED = "NOT_MOUNTED",
            MOUNTING = "MOUNTING",
            MOUNTED = "MOUNTED",
            UNMOUNTING = "UNMOUNTING",
          }
          // src/lifeCycle/index.ts
          export const runBeforeLoad = async (app: IInternalAppInfo) => {
            app.status = AppStatus.LOADING;
            await runLifeCycle("beforeLoad", app);

            app = await 加載子應(yīng)用資源;
            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);
            }
          };


          以上代碼看著很多,實(shí)際實(shí)現(xiàn)也很簡(jiǎn)單,總結(jié)一下就是:

          • 設(shè)置子應(yīng)用狀態(tài),用于邏輯判斷以及優(yōu)化。比如說(shuō)當(dāng)一個(gè)應(yīng)用狀態(tài)為非 NOT_LOADED 時(shí)(每個(gè)應(yīng)用初始都為 NOT_LOADED 狀態(tài)),下次渲染該應(yīng)用時(shí)就無(wú)需重復(fù)加載資源了

          • 如需要處理邏輯,比如說(shuō) beforeLoad 我們需要加載子應(yīng)用資源

          • 執(zhí)行主 / 子應(yīng)用生命周期,這里需要注意下執(zhí)行順序,可以參考父子組件的生命周期執(zhí)行順序


          完善路由劫持

          實(shí)現(xiàn)應(yīng)用生命周期以后,我們現(xiàn)在就能來(lái)完善先前路由劫持中沒(méi)有做的如何處理子應(yīng)用的這塊邏輯。
          這塊邏輯在我們做完生命周期之后其實(shí)很簡(jiǎn)單,可以分為以下幾步:
          1. 判斷當(dāng)前 URL 與之前的 URL 是否一致,如果一致則繼續(xù)
          2. 利用當(dāng)然 URL 去匹配相應(yīng)的子應(yīng)用,此時(shí)分為幾種情況:
            • 初次啟動(dòng)微前端,此時(shí)只需渲染匹配成功的子應(yīng)用
            • 未切換子應(yīng)用,此時(shí)無(wú)需處理子應(yīng)用
            • 切換子應(yīng)用,此時(shí)需要找出之前渲染過(guò)的子應(yīng)用做卸載處理,然后渲染匹配成功的子應(yīng)用
          3. 保存當(dāng)前 URL,用于下一次第一步判斷
          理清楚步驟之后,我們就來(lái)實(shí)現(xiàn)它:
          let lastUrl: string | null = null
          export const reroute = (url: string) => {
            if (url !== lastUrl) {
              const { actives, unmounts } = 匹配路由,尋找符合條件的子應(yīng)用
              // 執(zhí)行生命周期
              Promise.all(
                unmounts
                  .map(async (app) => {
                    await runUnmounted(app)
                  })
                  .concat(
                    actives.map(async (app) => {
                      await runBeforeLoad(app)
                      await runBoostrap(app)
                      await runMounted(app)
                    })
                  )
              ).then(() => {
                // 執(zhí)行路由劫持小節(jié)未使用的函數(shù)
                callCapturedListeners()
              })
            }
            lastUrl = url || location.href
          }


          以上代碼主體就是在按順序執(zhí)行生命周期函數(shù),但是其中匹配路由的函數(shù)并未實(shí)現(xiàn),因?yàn)槲覀冃枰葋?lái)考慮一些問(wèn)題。
          大家平時(shí)項(xiàng)目開(kāi)發(fā)中肯定是用過(guò)路由的,那應(yīng)該知道路由匹配的原則主要由兩塊組成:
          • 嵌套關(guān)系
          • 路徑語(yǔ)法
          嵌套關(guān)系指的是:假如我當(dāng)前的路由設(shè)置的是 /vue,那么類似 /vue 或者 /vue/xxx 都能匹配上這個(gè)路由,除非我們?cè)O(shè)置 excart 也就是精確匹配。
          路徑語(yǔ)法這里就拿個(gè)文檔里的例子呈現(xiàn)了:
          <Route path="/hello/:name">         // 匹配 /hello/michael 和 /hello/ryan
          <Route path="/hello(/:name)">       // 匹配 /hello, /hello/michael 和 /hello/ryan
          <Route path="/files/*.*">           // 匹配 /files/hello.jpg 和 /files/path/to/hello.jpg

          這樣看來(lái)路由匹配實(shí)現(xiàn)起來(lái)還是挺麻煩的,那么我們是否有簡(jiǎn)便的辦法來(lái)實(shí)現(xiàn)該功能呢?答案肯定是有的,我們只要閱讀 Route 庫(kù)源碼就能發(fā)現(xiàn)它們內(nèi)部都使用了path-to-regexp這個(gè)庫(kù),有興趣的可以閱讀下這個(gè)庫(kù)的文檔,這里我們只看其中一個(gè) API 的使用就行。


          有了解決方案以后,我們就快速實(shí)現(xiàn)下路由匹配的函數(shù):

          export const getAppListStatus = () => {
            // 需要渲染的應(yīng)用列表
            const actives: IInternalAppInfo[] = []
            // 需要卸載的應(yīng)用列表
            const unmounts: IInternalAppInfo[] = []
            // 獲取注冊(cè)的子應(yīng)用列表
            const list = getAppList() as IInternalAppInfo[]
            list.forEach((app) => {
              // 匹配路由
              const isActive = match(app.activeRule, { end: false })(location.pathname)
              // 判斷應(yīng)用狀態(tài)
              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 }
          }
          完成以上函數(shù)之后,大家別忘了在 reroute 函數(shù)中調(diào)用一下,至此路由劫持功能徹底完成了。


          完善生命周期

          之前在實(shí)現(xiàn)生命周期過(guò)程中,我們還有很重要的一步加載子應(yīng)用資源未完成,這一小節(jié)我們就把這塊內(nèi)容搞定。
          既然要加載資源,那么我們肯定就先需要一個(gè)資源入口,就和我們使用的 npm 包一樣,每個(gè)包一定會(huì)有一個(gè)入口文件。回到 registerMicroApps 函數(shù),我們最開(kāi)始就給這個(gè)函數(shù)傳入了 entry 參數(shù),這就是子應(yīng)用的資源入口。
          資源入口其實(shí)分為兩種方案:
          1. JS Entry
          2. HTML Entry
          這兩個(gè)方案都是字面意思,前者是通過(guò) JS 加載所有靜態(tài)資源,后者則通過(guò) HTML 加載所有靜態(tài)資源。
          JS Entry 是 single-spa 中使用的一個(gè)方式。但是它限制有點(diǎn)多,需要用戶將所有文件打包在一起,除非你的項(xiàng)目對(duì)性能無(wú)感,否則基本可以 pass 這個(gè)方案。
          HTML Entry 則要好得多,畢竟所有網(wǎng)站都是以 HTML 作為入口文件的。在這種方案里,我們基本無(wú)需改動(dòng)打包方式,對(duì)用戶開(kāi)發(fā)幾乎沒(méi)侵入性,只需要尋找出 HTML 中的靜態(tài)資源加載并運(yùn)行即可渲染子應(yīng)用了,因此我們選擇了這個(gè)方案。
          接下來(lái)我們開(kāi)始來(lái)實(shí)現(xiàn)這部分的內(nèi)容。

          加載資源

          首先我們需要獲取 HTML 的內(nèi)容,這里我們只需調(diào)用原生 fetch 就能拿到東西了。

          // src/utils
          export const fetchResource = async (url: string) => {
            return await fetch(url).then(async (res) => await res.text())
          }
          // src/loader/index.ts
          export const loadHTML = async (app: IInternalAppInfo) => {
            const { container, entry } = app

            const htmlFile = await fetchResource(entry)

            return app
          }

          切換路由至 /vue 之后,我們可以打印出加載到的 HTML 文件內(nèi)容。


          <!DOCTYPE html>
          <html lang="">
            <head>
              <meta charset="utf-8">
              <meta http-equiv="X-UA-Compatible" content="IE=edge">
              <meta name="viewport" content="width=device-width,initial-scale=1.0">
              <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 id="app"></div>
              <!-- built files will be auto injected -->
            <script type="text/javascript" src="/js/chunk-vendors.js"></script>
            <script type="text/javascript" src="/js/app.js"></script></body>
          </html>

          我們可以在該文件中看到好些相對(duì)路徑的靜態(tài)資源 URL,接下來(lái)我們就需要去加載這些資源了。但是我們需要注意一點(diǎn)的是,這些資源只有在自己的 BaseURL 下才能被正確加載到,如果是在主應(yīng)用的 BaseURL 下肯定報(bào) 404 錯(cuò)誤了。
          然后我們還需要注意一點(diǎn):因?yàn)槲覀兪窃谥鲬?yīng)用的 URL 下加載子應(yīng)用的資源,這很有可能會(huì)觸發(fā)跨域的限制。因此在開(kāi)發(fā)及生產(chǎn)環(huán)境大家務(wù)必注意跨域的處理。
          舉個(gè)開(kāi)發(fā)環(huán)境下子應(yīng)用是 Vue 的話,處理跨域的方式:
          // vue.config.js
          module.exports = {
            devServer: {
              headers: {
                'Access-Control-Allow-Origin''*',
              },
            },
          }

          接下來(lái)我們需要先行處理這些資源的路徑,將相對(duì)路徑拼接成正確的絕對(duì)路徑,然后再去 fetch

          // vue.config.js
          // src/utils
          export function getCompletionURL(src: string | null, baseURI: string{
            if (!src) return src
            // 如果 URL 已經(jīng)是協(xié)議開(kāi)頭就直接返回
            if (/^(https|http)/.test(src)) return src
           // 通過(guò)原生方法拼接 URL
            return new URL(src, getCompletionBaseURL(baseURI)).toString()
          }
          // 獲取完整的 BaseURL
          // 因?yàn)橛脩粼谧?cè)應(yīng)用的 entry 里面可能填入 //xxx 或者 https://xxx 這種格式的 URL

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

          以上代碼的功能就不再贅述了,注釋已經(jīng)很詳細(xì)了,接下來(lái)我們需要找到 HTML 文件中的資源然后去 fetch

          既然是找出資源,那么我們就得解析 HTML 內(nèi)容了:

          // src/loader/parse.ts
          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)) {
                // 處理 link
              } else if (/^(script)$/i.test(dom.tagName)) {
                // 處理 script
              } else if (/^(img)$/i.test(dom.tagName) && dom.hasAttribute('src')) {
                // 處理圖片,畢竟圖片資源用相對(duì)路徑肯定也 404 了
                dom.setAttribute(
                  'src',
                  getCompletionURL(dom.getAttribute('src')!, app.entry)!
                )
              }
            }

            return {  }
          }


          解析內(nèi)容這塊還是簡(jiǎn)單的,我們遞歸尋找元素,將 linkscriptimg 元素找出來(lái)并做對(duì)應(yīng)的處理即可。

          首先來(lái)看我們?nèi)绾翁幚?nbsp;link

          // src/loader/parse.ts
          // 補(bǔ)全 parseHTML 邏輯

          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
            // 判斷是不是獲取 CSS 資源
            if (rel === 'stylesheet' && href) {
              comment = document.createComment(`link replaced by micro`)
              // @ts-ignore
              comment && parent.replaceChild(comment, script)
              return getCompletionURL(href, app.entry)
            } else if (href) {
              link.setAttribute('href', getCompletionURL(href, app.entry)!)
            }
          }

          處理 link 標(biāo)簽時(shí),我們只需要處理 CSS 資源,其它 preload / prefetch 的這些資源直接替換 href 就行。

          // src/loader/parse.ts
          // 補(bǔ)全 parseHTML 邏輯

          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')
            // 有 src 說(shuō)明是 JS 文件,沒(méi) src 說(shuō)明是 inline script,也就是 JS 代碼直接寫(xiě)標(biāo)簽里了
            if (src) {
              comment = document.createComment('script replaced by micro')
            } else if (script.innerHTML) {
              comment = document.createComment('inline script replaced by micro')
            }
            // @ts-ignore
            comment && parent.replaceChild(comment, script)
            return { url: getCompletionURL(src, app.entry), text: script.innerHTML }
          }
          處理 script 標(biāo)簽時(shí),我們需要區(qū)別是 JS 文件還是行內(nèi)代碼,前者還需要 fecth 一次獲取內(nèi)容。
          然后我們會(huì)在 parseHTML 中返回所有解析出來(lái)的 scripts, links, inlineScript
          接下來(lái)我們按照順序先加載 CSS 再加載 JS 文件:


          // src/loader/index.ts
          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
          }


          以上我們就實(shí)現(xiàn)了從加載 HTML 文件到解析文件找出所有靜態(tài)資源到最后的加載 CSS 及 JS 文件。但是實(shí)際上我們這個(gè)實(shí)現(xiàn)還是有些粗糙的,雖然把核心內(nèi)容實(shí)現(xiàn)了,但是還是有一些細(xì)節(jié)沒(méi)有考慮到的。
          因此我們也可以考慮直接使用三方庫(kù)來(lái)實(shí)現(xiàn)加載及解析文件的過(guò)程,這里我們選用了 import-html-entry 這個(gè)庫(kù),內(nèi)部做的事情和我們核心是一致的,只是多處理了很多細(xì)節(jié)。
          如果你想直接使用這個(gè)庫(kù)的話,可以把 loadHTML 改造成這樣:
          export const loadHTML = async (app: IInternalAppInfo) => {
            const { container, entry } = app

            // template:處理好的 HTML 內(nèi)容
            // getExternalStyleSheets:fetch CSS 文件
            // getExternalScripts:fetch JS 文件

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

            if (!dom) {
              throw new Error('容器不存在 ')
            }
            // 掛載 HTML 到微前端容器上
            dom.innerHTML = template
            // 加載文件
            await getExternalStyleSheets()
            const jsCode = await getExternalScripts()

            return app
          }

          運(yùn)行 JS

          當(dāng)我們拿到所有 JS 內(nèi)容以后就該運(yùn)行 JS 了,這步完成以后我們就能在頁(yè)面上看到子應(yīng)用被渲染出來(lái)了。

          這一小節(jié)的內(nèi)容說(shuō)簡(jiǎn)單的話可以沒(méi)幾行代碼就寫(xiě)完,說(shuō)復(fù)雜的話實(shí)現(xiàn)起來(lái)會(huì)需要考慮很多細(xì)節(jié),我們先來(lái)實(shí)現(xiàn)簡(jiǎn)單的部分,也就是如何運(yùn)行 JS。

          對(duì)于一段 JS 字符串來(lái)說(shuō),我們想執(zhí)行的話大致上有兩種方式:

          1. eval(js string)

          2. new Function(js string)()

          這邊我們選用第二種方式來(lái)實(shí)現(xiàn):


          const runJS = (value: string, app: IInternalAppInfo) => {
            const code = `
              ${value}
              return window['${app.name}']
            `

            return new Function(code).call(windowwindow)
          }
          不知道大家是否還記得我們?cè)谧?cè)子應(yīng)用的時(shí)候給每個(gè)子應(yīng)用都設(shè)置了一個(gè) name 屬性,這個(gè)屬性其實(shí)很重要,我們?cè)谥蟮膱?chǎng)景中也會(huì)用到。另外大家給子應(yīng)用設(shè)置 name 的時(shí)候別忘了還需要略微改動(dòng)下打包的配置,將其中一個(gè)選項(xiàng)也設(shè)置為同樣內(nèi)容。
          舉個(gè)例子,我們假如給其中一個(gè)技術(shù)棧為 Vue 的子應(yīng)用設(shè)置了 name: vue,那么我們還需要在打包配置中進(jìn)行如下設(shè)置:


          // vue.config.js
          module.exports = {
            configureWebpack: {
              output: {
                // 和 name 一樣
                library: `vue`
              },
            },
          }

          這樣配置后,我們就能通過(guò) window.vue 訪問(wèn)到應(yīng)用的 JS 入口文件 export 出來(lái)的內(nèi)容了:

          大家可以在上圖中看到導(dǎo)出的這些函數(shù)都是子應(yīng)用的生命周期,我們需要拿到這些函數(shù)去調(diào)用。

          最后我們?cè)?nbsp;loadHTML 中調(diào)用一下 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
          }

          完成以上步驟后,我們就能看到子應(yīng)用被正常渲染出來(lái)了!


          但是到這一步其實(shí)還不算完,我們考慮這樣一個(gè)問(wèn)題:子應(yīng)用改變?nèi)肿兞吭趺崔k? 我們目前所有應(yīng)用都可以獲取及改變 window 上的內(nèi)容,那么一旦應(yīng)用之間出現(xiàn)全局變量沖突就會(huì)引發(fā)問(wèn)題,因此我們接下來(lái)需要來(lái)解決這個(gè)事兒。


          JS 沙箱

          我們即要防止子應(yīng)用直接修改 window 上的屬性又要能訪問(wèn) window 上的內(nèi)容,那么就只能做個(gè)假的 window 給子應(yīng)用了,也就是實(shí)現(xiàn)一個(gè) JS 沙箱。
          實(shí)現(xiàn)沙箱的方案也有很多種,比如說(shuō):
          1. 快照
          2. Proxy
          先來(lái)說(shuō)說(shuō)快照的方案,其實(shí)這個(gè)方案實(shí)現(xiàn)起來(lái)特別簡(jiǎn)單,說(shuō)白了就是在掛載子應(yīng)用前記錄下當(dāng)前 window 上的所有內(nèi)容,然后接下來(lái)就隨便讓子應(yīng)用去玩了,直到卸載子應(yīng)用時(shí)恢復(fù)掛載前的 window 即可。這種方案實(shí)現(xiàn)容易,唯一缺點(diǎn)就是性能慢點(diǎn),有興趣的讀者可以直接看看 qiankun 的實(shí)現(xiàn),這里就不再貼代碼了。
          再來(lái)說(shuō)說(shuō) Proxy,也是我們選用的方案,這個(gè)應(yīng)該挺多讀者都已經(jīng)了解過(guò)它的使用方式了,畢竟 Vue3 響應(yīng)式原理都被說(shuō)爛了。如果你還不了解它的話,可以先自行閱讀 MDN 文檔。


          export class ProxySandbox {
            proxy: any
            running = false
            constructor() {
              // 創(chuàng)建個(gè)假的 window
              const fakeWindow = Object.create(null)
              const proxy = new Proxy(fakeWindow, {
                set(target: any, p: string, value: any) => {
                  // 如果當(dāng)前沙箱在運(yùn)行,就直接把值設(shè)置到 fakeWindow 上
                  if (this.running) {
                    target[p] = value
                  }
                  return true
                },
                get(target: any, p: string): any {
                  // 防止用戶逃課
                  switch (p) {
                    case 'window':
                    case 'self':
                    case 'globalThis':
                      return proxy
                  }
                  // 假如屬性不存在 fakeWindow 上,但是存在于 window 上
                  // 從 window 上取值

                  if (
                    !window.hasOwnProperty.call(target, p) &&
                    window.hasOwnProperty(p)
                  ) {
                    // @ts-ignore
                    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
            }
          }
          以上代碼只是一個(gè)初版的沙箱,核心思路就是創(chuàng)建一個(gè)假的 window 出來(lái),如果用戶設(shè)置值的話就設(shè)置在 fakeWindow 上,這樣就不會(huì)影響全局變量了。如果用戶取值的話,就判斷屬性是存在于 fakeWindow 上還是 window 上。
          當(dāng)然實(shí)際使用的時(shí)候我們還是需要完善一下這個(gè)沙箱的,還需要處理一些細(xì)節(jié),這里推薦大家直接閱讀 qiankun 的源碼,代碼量不多,無(wú)非多處理了不少邊界情況。
          另外需要注意的是:一般快照和 Proxy 沙箱都是需要的,無(wú)非前者是后者的降級(jí)方案,畢竟不是所有瀏覽器都支持 Proxy 的。
          最后我們需要改造下 runJS 里的代碼以便使用沙箱:
          const runJS = (value: string, app: IInternalAppInfo) => {
            if (!app.proxy) {
              app.proxy = new ProxySandbox()
              // 將沙箱掛在全局屬性上
              // @ts-ignore

              window.__CURRENT_PROXY__ = app.proxy.proxy
            }
            // 激活沙箱
            app.proxy.active()
            // 用沙箱替代全局環(huán)境調(diào)用 JS 
            const code = `
              return (window => {
                ${value}
                return window['${app.name}']
              })(window.__CURRENT_PROXY__)
            `

            return new Function(code)()
          }

          至此,我們其實(shí)已經(jīng)完成了整個(gè)微前端的核心功能。

          接下來(lái)我們會(huì)來(lái)做一些改善型功能。

          改善型功能

          prefetch

          我們目前的做法是匹配一個(gè)子應(yīng)用成功后才去加載子應(yīng)用,這種方式其實(shí)不夠高效。我們更希望用戶在瀏覽當(dāng)前子應(yīng)用的時(shí)候就能把別的子應(yīng)用資源也加載完畢,這樣用戶切換應(yīng)用的時(shí)候就無(wú)需等待了。
          實(shí)現(xiàn)起來(lái)代碼不多,利用我們之前的 import-html-entry 就能馬上做完了:
          // src/start.ts
          export const start = () => {
            const list = getAppList()
            if (!list.length) {
              throw new Error('請(qǐng)先注冊(cè)應(yīng)用')
            }

            hijackRoute()
            reroute(window.location.href)

            // 判斷狀態(tài)為 NOT_LOADED 的子應(yīng)用才需要 prefetch
            list.forEach((app) => {
              if ((app as IInternalAppInfo).status === AppStatus.NOT_LOADED) {
                prefetch(app as IInternalAppInfo)
              }
            })
          }
          // src/utils.ts
          export const prefetch = async (app: IInternalAppInfo) => {
            requestIdleCallback(async () => {
              const { getExternalScripts, getExternalStyleSheets } = await importEntry(
                app.entry
              )
              requestIdleCallback(getExternalStyleSheets)
              requestIdleCallback(getExternalScripts)
            })
          }


          接下來(lái)主要來(lái)聊下 requestIdleCallback 這個(gè)函數(shù)。

          window.requestIdleCallback() 方法將在瀏覽器的空閑時(shí)段內(nèi)調(diào)用的函數(shù)排隊(duì)。這使開(kāi)發(fā)者能夠在主事件循環(huán)上執(zhí)行后臺(tái)和低優(yōu)先級(jí)工作,而不會(huì)影響延遲關(guān)鍵事件,如動(dòng)畫(huà)和輸入響應(yīng)。

          我們利用這個(gè)函數(shù)實(shí)現(xiàn)在瀏覽器空閑時(shí)間再去進(jìn)行 prefetch,其實(shí)這個(gè)函數(shù)在 React 中也有用到,無(wú)非內(nèi)部實(shí)現(xiàn)了一個(gè) polyfill 版本。因?yàn)檫@個(gè) API 有一些問(wèn)題(最快 50ms 響應(yīng)一次)尚未解決,但是在我們的場(chǎng)景下不會(huì)有問(wèn)題,所以可以直接使用。

          資源緩存機(jī)制

          當(dāng)我們加載過(guò)一次資源后,用戶肯定不希望下次再進(jìn)入該應(yīng)用的時(shí)候還需要再加載一次資源,因此我們需要實(shí)現(xiàn)資源的緩存機(jī)制。

          上一小節(jié)我們因?yàn)槭褂玫搅?import-html-entry,內(nèi)部自帶了緩存機(jī)制。如果你想自己實(shí)現(xiàn)的話,可以參考內(nèi)部的實(shí)現(xiàn)方式。

          簡(jiǎn)單來(lái)說(shuō)就是搞一個(gè)對(duì)象緩存下每次請(qǐng)求下來(lái)的文件內(nèi)容,下次請(qǐng)求的時(shí)候先判斷對(duì)象中存不存在值,存在的話直接拿出來(lái)用就行。


          全局通信及狀態(tài)

          這部分內(nèi)容在筆者的代碼中并未實(shí)現(xiàn),如果你有興趣自己做的話,筆者可以提供一些思路。

          全局通信及狀態(tài)實(shí)際上完全都可以看做是發(fā)布訂閱模式的一種實(shí)現(xiàn),只要你自己手寫(xiě)過(guò) Event 的話,實(shí)現(xiàn)這個(gè)應(yīng)該不是什么難題。

          另外你也可以閱讀下 qiankun 的全局狀態(tài)實(shí)現(xiàn),總共也就 100 行代碼。

          五、總結(jié)

              在我們閱讀有關(guān)微前端相關(guān)解決方案的官方文檔后后,我們一定會(huì)進(jìn)行更深層次的學(xué)習(xí),比如看下框架底層是如何運(yùn)行的,以及源碼的閱讀。
              這里廣東靚仔給下一些小建議:
          • 在看源碼前,我們先去官方文檔復(fù)習(xí)下框架設(shè)計(jì)理念、源碼分層設(shè)計(jì)
          • 閱讀下框架官方開(kāi)發(fā)人員寫(xiě)的相關(guān)文章
          • 借助框架的調(diào)用棧來(lái)進(jìn)行源碼的閱讀,通過(guò)這個(gè)執(zhí)行流程,我們就完整的對(duì)源碼進(jìn)行了一個(gè)初步的了解
          • 接下來(lái)再對(duì)源碼執(zhí)行過(guò)程中涉及的所有函數(shù)邏輯梳理一遍

          關(guān)注我,一起攜手進(jìn)階

          歡迎關(guān)注前端早茶,與廣東靚仔攜手共同進(jìn)階~

          瀏覽 151
          點(diǎn)贊
          評(píng)論
          收藏
          分享

          手機(jī)掃一掃分享

          分享
          舉報(bào)
          評(píng)論
          圖片
          表情
          推薦
          點(diǎn)贊
          評(píng)論
          收藏
          分享

          手機(jī)掃一掃分享

          分享
          舉報(bào)
          <kbd id="afajh"><form id="afajh"></form></kbd>
          <strong id="afajh"><dl id="afajh"></dl></strong>
            <del id="afajh"><form id="afajh"></form></del>
                1. <th id="afajh"><progress id="afajh"></progress></th>
                  <b id="afajh"><abbr id="afajh"></abbr></b>
                  <th id="afajh"><progress id="afajh"></progress></th>
                  日韩AV无码一区二区三 | 欧美在线黄片 | 日本视频一区二区三区 | 无码三级毛片 | 翔田千里av无码 翔田千里无码av |