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

          從微組件到代碼共享

          共 36770字,需瀏覽 74分鐘

           ·

          2021-08-26 13:57

          前言

          隨著前端應(yīng)用越來越復雜,越來越龐大。前有巨石應(yīng)用像滾雪球一般不斷的疊高,后有中后臺應(yīng)用隨著歷史長河不斷地積累負債,或者急需得到改善。微前端的工程方案在前端er心中像一道曙光不斷的被提起,被實踐,多年至今終于有了比較好的指引。它在解決大型應(yīng)用之間復雜的依賴關(guān)系,或是解決我們技術(shù)棧的遷移歷史負擔,都在一定程度上扮演了極其關(guān)鍵的橋梁。

          本文會先從復用組件,窺探到代碼共享。聊一聊中后臺項目在微前端的場景下,從工程化的角度下如何跨技術(shù)棧復用業(yè)務(wù)組件,再介紹一下其它的共享代碼方案。

          在正文開始之前,希望讀者能對以下關(guān)鍵詞有所了解,以便后文一起交流探討

          • 微前端
          • 共享組件
          • Garfish(字節(jié)開源的微前端框架)
          • Webpack & module federation
          • Bit

          業(yè)務(wù)背景


          如上圖,我們先看這么個場景。這個 modal 被紅色框起來的部分,其實是一個業(yè)務(wù)復雜較復雜的react組件來渲染的。在這里就需要渲染出5個react組件。同時這個modal是過去用vue實現(xiàn)的代碼,我們的react組件是需要被渲染在vue代碼中的,也就是 React in Vue。

          在我們的中后臺系統(tǒng)里,過去全都是vue的技術(shù)棧。而我們新的業(yè)務(wù)希望全面的往react遷移,其中不乏有比較復雜的業(yè)務(wù)組件。如下

          基于微前端的工程方案,我們就可以盡可能少的修改vue的代碼。同時,我們也能達到組件級別的嵌入。

          從工程的角度解決微組件共享

          項目介紹

          先試想一下,其實大多數(shù)中后臺項目,都是像如上的場景一般。我們可能僅是為了應(yīng)用之間的解耦,這有利于構(gòu)建,團隊獨立維護,改善項目結(jié)構(gòu),代碼復用等等。其實更需要解決的是團隊內(nèi)部自身的工程問題,基本不會涉及到跨產(chǎn)品部門的復用或業(yè)務(wù)共享。我們更多關(guān)注的是,當下在不同repo之間的代碼和在不同技術(shù)棧之間的組件,如何達到共享。那么我們需要共享微組件的職責就很清晰了。

          在我們團隊的中后臺應(yīng)用有三個repo,過去的巨石應(yīng)用(vue),新建的兩個monorepo(react)。(拆了兩個是業(yè)務(wù)之間比較獨立。)

          在我們有了monorepo之后,其實所有的業(yè)務(wù)組件或者業(yè)務(wù)代碼,都已經(jīng)在物理的層面上可以良好的復用。剩下的問題就在于如何跨repo(跨物理層面)在過去的技術(shù)棧(vue)中直接復用。而我們的方式就是基于微前端來做。

          當我們有了master這樣的宿主介入之后,項目的可操作空間就不太一樣了。微前端為的是能在同一個應(yīng)用下,提供一個相同的運行環(huán)境。(本文不過多探討iframe的方式。)

          monorepo能很好地解決我們同一個repo下的代碼復用問題。如果我們把每一個 repo 都抽象的看做一個模塊,那就只需要想辦法在這個模塊能exports東西出去,不就可以達到跨repo之間的復用?同時它也是一種解決了物理層面上無法復用的手段。

          所以我們的做法就變得很清晰了,在新的react repo里,其實我們就會自然的沉淀下許許多多的基礎(chǔ)組件或者是帶有復雜業(yè)務(wù)的業(yè)務(wù)組件。比如上圖的biz-ui,每一個biz-ui里的組件,都是一個完整的業(yè)務(wù)組件。而我們最終的目標,就是想辦法把這些業(yè)務(wù)組件通過微前端的方式,給其它項目使用。

          Micro-components app 子應(yīng)用,就是我們的exports,它也是一個子應(yīng)用。所有需要在當前repo exports的業(yè)務(wù)組件,都可以在這里被注冊。

          利用子應(yīng)用復用微組件

          從一個用法開始

          如果是一個組件很簡單,也很好實現(xiàn),我們知道garfish有提供loadApp的接口,我們可以直接通過加載一個子應(yīng)用,這個子應(yīng)用渲染某個react組件。大致代碼如下

          // loadApp.vue 
          <template> 
            <div :id="id"></div> 
          </
          template> 
          <script lang="ts"
          import { defineComponent } from '@vue/composition-api'
           
          let id = 9999
          let beforeDestroy: (() => void) | undefined = undefined
           
          export default defineComponent(
            props: [], 
            data() { 
              return { 
                id 
              }; 
            }, 
            async mounted() { 
              _const_ app = _await_ Garfish.loadApp('xxx', { 
                domGetter: () => document.getElementById(this.id), 
              }

           
              _// 渲染:編譯子應(yīng)用的代碼 -> 創(chuàng)建應(yīng)用容器 -> 調(diào)用 provider.render 渲染_ 
              _const_ success = _await_ app.mount(); 
            }, 
            beforeDestroy() { 
              console.info(this.microComponentKey, '微前端組件卸載'); 
              beforeDestroy?.(); 
            }, 
            watch: { 
            }, 
          }
          ); 
          </script

          這樣的代碼在我們系統(tǒng)里還是跑了幾個月的,沒有任何問題。但是如果有了多例就不一樣了,我們會調(diào)用多次loadApp,加載了大量子應(yīng)用的代碼,導致性能很差,甚至直接卡死。有人說加cache行不行?其實也是不可行的,上述的代碼過于簡陋,我們還需要處理props變化的情況,以及l(fā)oadApp,傳遞props給react的情況。如果單純只是cashe解決不了這樣的場景。

          所以我們特意設(shè)計了一個子應(yīng)用,這個子應(yīng)用專門作為組件級別的渲染,暫且稱之為 微組件子應(yīng)用

          而在vue那,我們需要保證全局只會load 一個微組件子應(yīng)用,這個子應(yīng)用的domGetter可掛在到body上,僅僅作為一個container。而我們的react組件,全通過portal的形式進行渲染到任意位置即可。

          基于這個思路,我們需要去設(shè)計一個微組件渲染的數(shù)據(jù)結(jié)構(gòu)。再看一眼這個圖,我們這個數(shù)據(jù)結(jié)構(gòu)會有哪些東西

          每個組件其實所需要接收的參數(shù)有domId、props和事件或其它屬性。所以我們的數(shù)據(jù)結(jié)構(gòu)其實可以大致如下。

          type Meta = { 
            domId: string
            componentKey: string// 為了指定由哪個組件渲染 
            props?: Record<anyany>; 
            [_key_: string]: any// 事件和其它透傳屬性 
          }; 

          有了這個結(jié)構(gòu),我們 react 的 render 函數(shù)就簡單了,統(tǒng)一渲染一個protal數(shù)組即可。

            portalRender.map(_meta_ => { 
              const { domId, componentKey, props: _props, ...rest } = _meta_; 
              const container = document.getElementById(domId); 
              if (!container) { 
                return null
              } 
               
              return ReactDOM.createPortal( 
                <Suspense _fallback_={null}> 
                  <Portals 
                    _componentKey_={componentKey} 
                    {...{ domId, ..._props, ...rest }} 
                  /> 
                </Suspense>, 
                container, 
                domId, 
              ); 
            }) 

          在vue這邊,我們先設(shè)想一下應(yīng)該如何使用這樣的組件呢?當然肯定是和單純的一個vue組件沒有區(qū)別。比如這樣。

          所以我們就需要封裝一個底層的vue組件去負責管理子應(yīng)用的load和props的傳遞。

          // loadCMSMicro.vue 偽代碼 
          <template> 
            <div :id="id"></div> 
          </
          template> 
          <script> 
          import { microComponentManager } from '../src/MicroComponentManager'
           
          let id = 0
           
          export default { 
            data() { 
              return { 
                id: `${++id}`
                beforeDestroy: undefined
              }; 
            }, 
            props: { 
              props: { 
                required: false
                default() => ({}), 
              }, 
              componentKey: { 
                typeString
                requiretrue
              }, 
              subAppName: { 
                typeString
                requiretrue
                default''
              }, 
            }, 
            async mounted() { 
              const { unMount, error } = await MicroComponent.loadComponent(); 
              this.beforeDestroy = unMount; 
            }, 
            beforeDestroy() { 
              this.beforeDestroy && this.beforeDestroy({ domId: this.id, type'remove' }); 
            }, 
          }; 
          </script> 

          而MicroComponent,需要去負責保持只能load一個子應(yīng)用單例以及props的傳遞和變化

          class MicroComponent { 
            private _loaded = false
            private _app: any
            private _count = 0
           
            async loadComponent() { 
              try { 
                this._count++; 
                if (!this._loaded) { 
                  this._loaded = true
                  this._app = await window.Garfish.loadApp(this._subAppName, { 
                    domGetter: () => document.body, 
                    props: { 
                      subAppName: this._subAppName, 
                    }, 
                  }); 
           
                  await this._app.mount(); 
                } 
           
                const unMount = (_params_: PropsChange) => { 
                  this.emitPropsChange(_params_); 
                  this._count--; 
                  if (this._count === 0) { 
                    console.info('[微組件] 子應(yīng)用卸載了'); 
                    this._app.unmount(); 
                    this._loaded = false
                    this._app = null
                  } 
                }; 
           
                if (!this._app) { 
                  return { 
                    unMount, 
                  }; 
                } 
           
                console.info('[微組件] 加載完畢'); 
                this._debounceEmitPropChange(); 
           
                return { 
                  unMount, 
                }; 
              } catch (e) { 
                console.error(`[微組件] 子應(yīng)用加載失敗: ${e}`); 
                this._loaded = false
                this._app = null
                this._count = 0
                return { 
                  error: 'CMS 加載子應(yīng)用失敗'
                }; 
              } 
            } 

          我們需要用兩個flag來控制mount和unmunt。為了保證只能load一個子應(yīng)用,用一個loaded開關(guān)來控制。而count是因為我們有多例其實就是個引用計數(shù),必須保證每個微組件都卸載了,才能去unmount掉我們的子應(yīng)用。

          props如何傳遞呢?這里其實就是如何進行不同應(yīng)用之間的數(shù)據(jù)共享,同時他是保持一份的。我們可以通過garfish提供的API來實現(xiàn)。

          基于這2個API,我們可以在garfish上構(gòu)建出這么個對象來傳遞我們的數(shù)據(jù)。在之前提到過,我們可能是多個子應(yīng)用export出來的組件,其實這部分的數(shù)據(jù)存儲就是一個二維結(jié)構(gòu)。

          garfish[subAppName][domId] = { 
            domId: 1
            props: {}, 
            ...rest, 

          當我們初始化一個vue的組件時,就需要把對應(yīng)的meta數(shù)據(jù)掛載到garfish上。修改一下我們剛剛上面的組件代碼

          ... 
          export default { 
          ... 
            async mounted() { 
              const formatEvents = Object.keys(this.$listeners).reduce((_pre_, _cur_) => { 
                _pre_[toUpper(_cur_)] = this.$listeners[_cur_]; 
                return _pre_; 
              }, {}); 
           
              microComponentManager.setMeta(this.subAppName, this.id, { 
                ...formatEvents, 
                ...this.$props, 
              }); 
           
              const module = microComponentManager.getSubApp(this.subAppName); 
              const { unMount, error } = await module.loadComponent(); 
              this.beforeDestroy = unMount; 
            }, 
          ... 
          }; 
          </script> 

          因為需要保持每一個子應(yīng)用都是唯一的單例,我們繼續(xù)引入microComponentManager來幫我們管理所有的子應(yīng)用實例。

          搞定了初始化和數(shù)據(jù)傳遞的的問題后,我們來思考一下props change的問題。其實也很簡單,只要三個步驟。

          1. 監(jiān)聽vue組件的props變化,重新修改數(shù)據(jù)set到garfish上
          2. 發(fā)送事件,通知react獲取最新的數(shù)據(jù)
          3. React rerender
          <script> 
          // vue 
          export default { 
            ... 
            watch: { 
              props: { 
                immediate: true
                deep: true
                handler(_newProps_) { 
                  const module = microComponentManager.getSubApp(this.subAppName); 
           
                   microComponentManager.setMeta(this.subAppName, this.id, { 
                    ...microComponentManager.getMeta(this.subAppName, this.id), 
                    ..._newProps_, 
                  }); 
           
                  // 發(fā)送事件通知react 
                  module.emitPropsChange({ domId: this.id, type'new' });  
                }, 
              }, 
            }, 
          }; 
          </script> 

          react組件則接收到事件后,對數(shù)據(jù)進行更新,重新渲染

          // react 
          export const MicroContainer = (_props_: Props) => { 
            const { subAppName, microComponents } = _props_; 
            const [portalRender, setPortalRender] = useState<Meta[]>([]); 
            const pendingUpdate = useRef<Meta[]>([]); 
           
            const { run } = useDebounceFn(() => { 
              setPortalRender([...pendingUpdate.current]); 
            }, 10); 
           
            const onChange = (_params_: PropsChange[]) => { 
              const removeIds = _params_.filter(_item_ => _item_.type === 'remove'); 
              const updateIds = _params_.filter(_item_ => _item_.type === 'new'); 
           
              if (removeIds.length > 0) { 
                pendingUpdate.current = pendingUpdate.current.filter(_item_ => { 
                  return removeIds.find(_elm_ => _elm_.domId !== _item_.domId); 
                }); 
              } 
           
              updateIds.forEach(({ _domId_ }) => { 
                const meta = microComponentManager.getMeta(subAppName, _domId_); 
                const { componentKey, ...rest } = meta; 
           
                const target = pendingUpdate.current.find(_item_ => _item_.domId === _domId_); 
           
                if (target) { 
                  Object.assign(target, rest); 
                } else { 
                  pendingUpdate.current.push({ 
                    ...rest, 
                    domId, 
                    componentKey, 
                  }); 
                } 
              }); 
           
              run(); 
            }; 
           
            useEffect(() => { 
              microComponentManager.on( 
                MICRO_COMPONENT_EVENTS.PROPS_CHANGE, 
                onChange, 
                subAppName, 
              ); 
           
              return () => { 
                microComponentManager.off( 
                  MICRO_COMPONENT_EVENTS.PROPS_CHANGE, 
                  onChange, 
                  subAppName, 
                ); 
              }; 
            }, []); 
           
            return ( 
             ... 
            ); 
          }; 

          我們的MicroComponent也需要增加相應(yīng)的事件發(fā)送代碼。

          export class MicroComponent { 
            private _loaded: boolean = false
            private _app: any
            private readonly _subAppName: string
            private _count: number = 0
            private _pendingPropsChange: PropsChange[] = []; 
            private readonly _debounceEmitPropChange: (..._args_: any[]) => void
           
            constructor(_subAppName_: string) { 
              this._subAppName = _subAppName_; 
              this._debounceEmitPropChange = debounce( 
                () => this._checkPendingProps(), 
                50
              ); 
            } 
           
            async loadComponent() { ... } 
           
            emitPropsChange(_params_: PropsChange) { 
              this._pendingPropsChange.push(_params_); 
              this._debounceEmitPropChange(); 
            } 
           
            private _checkPendingProps() { 
              setTimeout(() => { 
                _// 放到下一個 macrotask 里執(zhí)行,等待微前端框架和子應(yīng)用渲染完畢_ 
                if (this._pendingPropsChange.length === 0 || !this._app) { 
                  return
                } 
           
                this.emit(MICRO_COMPONENT_EVENTS.PROPS_CHANGE, this._pendingPropsChange); 
           
                this._pendingPropsChange = []; 
              }); 
            } 
           
            emit(_event_: keyof typeof MICRO_COMPONENT_EVENTS, _params_?: PropsChange[]) { 
              window.Garfish.channel.emit(genEventKey(this._subAppName, _event_), _params_); 
            } 

          我們用一個pending隊列來存放所有的事件,這是避免一瞬間發(fā)送過多事件導致無意義的開銷。比如一個列表的頁面,可能同時創(chuàng)建了100個微組件,此時如果不做一次debounce則會一瞬間發(fā)送100次。一個優(yōu)化的小細節(jié)。

          另外需要注意的是注意到我們發(fā)送事件的地方用了個setTimeout,這是由于我們的app.mount,其實僅僅只是把子應(yīng)用給渲染完了,此時不代表react的組件被渲染完畢,我們在react里的useEffect還是沒有執(zhí)行的。所以我們需要放到下一個macroTask來發(fā)送事件,為了保證react里先監(jiān)聽。

          以上其實就是整套方案的核心代碼了

          總結(jié)

          總的來說,我們的實現(xiàn)方案就是基于loadApp,把一個子應(yīng)用僅僅當做多應(yīng)用之間渲染和通信的媒介掛在在了body上。所有的組件都通過portal的方式,掛載到指定的dom位置上。

          優(yōu)勢

          1. 原理代碼實現(xiàn)簡單輕量,復用便捷,開發(fā)高效,無關(guān)技術(shù)棧
          2. 接入簡單,可以實現(xiàn)ReactInVue,VueInReact
          3. 無論需要復用多少個組件,都只需要load1個子應(yīng)用,開銷低
          4. 可以掛載到任何garfish的應(yīng)用里,組件復用,達到跨團隊級別的復用
          5. 只需要發(fā)布一次,所有地方全都生效且最新版本
          6. 可以跨repo搭建自己需要共享的組件子應(yīng)用

          劣勢

          1. 無法對組件版本進行管理
          2. 需要基于garfish的環(huán)境才能達到共享
          3. 需要創(chuàng)建一個子項目,相比共享組件的方案更重
          4. keep-alive場景下可能有問題
          5. 依賴管理不方便控制(React,組件庫等)

          可以看出這個方案也有一個最大的局限性。版本不可控,在我們的業(yè)務(wù)里是不需要對這樣需要共享的組件進行版本管理的。以下介紹的方案大家需要注意下,如果你的共享組件需要版本管理則不可采用這種方案。所以,我們再來看看,現(xiàn)在共享組件的標準實現(xiàn)方案。

          運行時組件市場

          我們上述的方案,其實是通過組件復用的場景細分采用工程化的方案來解決物理隔離,技術(shù)棧不同的組件復用。而如果我們需要一個更加通用化的微組件方案,必然會需要平臺的支持,版本的支持,loader的支持。所以我們來看看現(xiàn)有的組件市場的發(fā)展方向。

          Garfish 提供了 loadComponent[1] 的API,可以直接遠程加載一個組件資源。在現(xiàn)有的設(shè)計下,大多數(shù)這個資源都是一個已經(jīng)被編譯好的umd的js文件。

          不過在字節(jié)內(nèi)部的另一個微前端框架有另外一種設(shè)計,使用的API與 federation 非常相似。

          以上的例子無論是哪種API的設(shè)計,都不妨礙我們深入理解微組件。不難發(fā)現(xiàn),需要抽象一個微組件必須具備的API需要有

          • Load(指定資源,無論是key還是url)
          • mount/unmout (生命周期)
          • update (props change)

          當組件的API被合理的設(shè)計好之后,我們還有一個關(guān)鍵就在于如何管理這些組件。于是「組件市場」就這么誕生了。組件市場必須具備的職責只需要兩點

          • 組件的上傳與下架
          • 可以是以name的方式或者url的方式下載代碼

          以往我們已經(jīng)現(xiàn)有的物料平臺或者是區(qū)塊平臺,都可以很簡單且自然的支持這兩個功能。

          共享代碼

          其實上面講了兩種微組件的方案。我們可以擴展性的思考一下,共享組件其實就是共享代碼的一種細分,解決了共享代碼,我們就順便解決了共享組件的問題。而往往共享代碼會有更大的使用場景。

          Module Federation

          概念

          Module Federation(以下簡稱MF)的中文直譯為“模塊聯(lián)邦”,從Webpack官網(wǎng)中我們可以找到使用其的動機:

          Multiple separate builds should form a single application. These separate builds should not have dependencies between each other, so they can be developed and deployed individually.

          This is often known as Micro-Frontends, but is not limited to that. 可以看出MF想要達到的目的就是把多個無相互依賴、單獨部署的應(yīng)用合并為一個應(yīng)用,即MF提供了在某個應(yīng)用中可以遠程加載其他服務(wù)器上應(yīng)用的能力。對于MF來說,有兩種角色:

          • Host:引用了其他應(yīng)用的應(yīng)用
          • Remote:被其他應(yīng)用所使用的應(yīng)用

          同時,一個應(yīng)用既可以作為host也可以作為remote,即可以利用MF實現(xiàn)一個去中心化的應(yīng)用部署群。并且,MF允許應(yīng)用之間共享依賴實例,例如:host使用了react,remote也使用了react,remote經(jīng)過配置后,可以在被host加載運行時優(yōu)先使用host的react實例,而不會重復加載,這樣可以做到在一個應(yīng)用中只存在一個react實例。

          示例

          我們將使用Webpack官網(wǎng)[2]給出的demo[3]作為示例,向大家展示如何使host應(yīng)用(app1)在運行時動態(tài)加載并使用remote應(yīng)用(app2)的內(nèi)容。先來看看demo中的文件結(jié)構(gòu):

          • app1
            • App.js(react頁面入口)
            • bootstrap.js(項目啟動文件)
            • index.js(項目入口文件)
            • src
            • webpack.config.js(webpack配置文件)
          • app2
            • App.js(react頁面入口)
            • Button.js(Button Component)
            • bootstrap.js(項目啟動文件)
            • index.js(項目入口文件)
            • src
            • webpack.config.js(webpack配置文件)

          app1和app2是兩個獨立部署的應(yīng)用。

          下面來看看app1中的具體代碼內(nèi)容:

          // app1 index.js 
          import bootstrap from "./bootstrap"
          bootstrap(() => {}); 
           
           
          // app1 bootstrap.js 
          import React from "react"
          import ReactDOM from "react-dom"
          import App from "./App"
          ReactDOM.render(<App />, document.getElementById("root")); 
           
           
          // app1 App.js 
          import React from "react"
           
          const RemoteButton = React.lazy(() => import("app2/Button")); 
           
          const App = () => ( 
            <div> 
              <h1>Basic Host-Remote</h1> 
              <h2>App 1</
          h2> 
              <React.Suspense fallback="Loading Button"
                <RemoteButton /> 
              </React.Suspense> 
            </
          div> 
          ); 
           
          export default App; 

          可以發(fā)現(xiàn)App.js中有一行非常關(guān)鍵的代碼:

          const RemoteButton = React.lazy(() => import("app2/Button"));

          那么問題來了:

          1. 這個app2/Button是從哪里來的呢?
          2. 這一段引用的組件代碼長啥樣?

          我們先來看看app2項目中的webpack配置(這里我們就不貼app2的代碼內(nèi)容了,因為沒有什么特別的地方并且在這里并不需要關(guān)心):

          // app2 webpack.config.js 
          // ... 
          new ModuleFederationPlugin({ 
            // 作為remote時的模塊名 
            name: "app2"
            library: { type"var", name: "app2" }, 
            // export的內(nèi)容被打成包時的文件名 
            filename: "remoteEntry.js"
            // 作為remote時,export哪些內(nèi)容被host消費 
            exposes: { 
              "./Button""./src/Button"
            }, 
            // 作為remote時,優(yōu)先使用host的這些依賴,若host沒有,則再用自己的 
            shared: { react: { singleton: true }, "react-dom": { singleton: true } }, 
          }), 
          // ... 

          從上面配置可以知道:

          1. app2項目作為remote時的模塊名是app2;
          2. export的內(nèi)容是Button組件;
          3. 要export的內(nèi)容會獨立打包成一個名叫remoteEntry.js的文件;
          4. export的內(nèi)容在被host消費時,會優(yōu)先使用host的react和react-dom實例。

          那么app1中又是如何配置使用app2模塊的內(nèi)容的呢,下面我們來看看app1的webpack配置中關(guān)于MF的部分:

          // app1 webpack.config.js 
          // ... 
          new ModuleFederationPlugin({ 
            // 作為remote時的模塊名 
            name: "app1"
            // 作為host時會消費哪些remote的資源 
            remotes: { 
              app2: 'app2@localhost://3002'
            }, 
            // 作為remote時,優(yōu)先使用host的這些依賴,若host沒有,則再用自己的 
            shared: { 
                react: { singleton: true },  
                "react-dom": { singleton: true }  
            }, 
          }), 
          // ... 

          從上面配置中中可以知道app1中使用了跑在localhost:3002上的app2模塊內(nèi)容。至此,在app1如何配置使用app2內(nèi)容的問題就解決了。

          把項目跑起來,可以看到app1的頁面,從前面的代碼可以知道,App2 Button組件是來自app2中的。

          并且可以看到,app1下載了app2的remoteEntry.js文件,并使用了里面的相關(guān)內(nèi)容,共享代碼成功。

          實現(xiàn)原理

          在講MF的實現(xiàn)原理之前,我們先來簡單簡單講下webpack的模塊打包原理,這對理解MF的模塊原理至關(guān)重要,如果你對這部分內(nèi)容已經(jīng)熟知,可以跳過。

          先看個簡單的栗子??(webpack配置沒有什么特殊的,這里就不貼了):

          // moduleA.js 
          export function aFn({console.log('A')}; 
           
          // moduleB.js 
          export function bFn({console.log('A')}; 
           
          // index.js 項目主入口文件 
          import { aFn } from './ModuleA'
           
          // 或動態(tài)import 
          import('./ModuleB').then((module) => module.bFn());

          經(jīng)過webpack打包后形成兩個chunk文件:

          1. main.js (其中包含index.js和ModuleA.js的內(nèi)容)
          2. src_ModuleB_js.js

          來看看main.js里的內(nèi)容(簡化過后):

          // main.js 
           
          (() => { 
              // 保存著main chunk中的所有模塊,key是module id,value是模塊內(nèi)容 
              // __unused_webpack_module: 當前模塊 
              // __webpack_exports__: 模塊的導出 
              //  __webpack_require__: 模塊加載對象 
              var __webpack_modules__ = ({ 
                  "./src/ModuleA.js"((__unused_webpack_module, __webpack_exports__, __webpack_require__) => { /**內(nèi)容省略**/ }), 
                  "./src/index.js": ((__unused_webpack_module, __webpack_exports__, __webpack_require__) => { /**內(nèi)容省略**/ }), 
              }); 
           
              // 保存已加載的模塊 
              var __webpack_module_cache__ = {}; 
           
              // 模塊加載方法 
              function __webpack_require__(moduleId) { 
                  // 檢查是否已加載過該模塊,若是則直接返回模塊的exports對象 
                  var cachedModule = __webpack_module_cache__[moduleId]; 
                  if (cachedModule !== undefined) { 
                      return cachedModule.exports
                  } 
                  // 創(chuàng)建一個模塊緩存,并放進__webpack_module_cache__中 
                  var module = __webpack_module_cache__[moduleId] = { 
                      idmoduleId
                      loadedfalse
                      exports: {} 
                  }; 
               
                  // 執(zhí)行模塊加載方法,并將模塊內(nèi)容掛在到module.exports上 
                  __webpack_modules__[moduleId].call(module.exports, modulemodule.exports, __webpack_require__); 
               
                  // 標記該模塊已加載 
                  module.loaded = true
               
                  // 返回模塊的exports對象 
                  return module.exports
              } 
           
              // expose the modules object (__webpack_modules__
              __webpack_require__.m = __webpack_modules__
           
              // startup 
              // Load entry module and return exports 
              __webpack_require__("./src/index.js"); 
          })()

          這就是整個項目的啟動文件,其實就是一個IIFE。

          其中內(nèi)部變量__webpack_modules__維護了一個該chunk所包含的所有modules的map,key就是module id,value就是模塊內(nèi)容。

          從上面的main.js中可以知道其實__webpack_require__模塊加載的核心所在,主要做了兩件事:

          1. 先從緩存的模塊列表中尋找,若找到直接返回該模塊的內(nèi)容;
          2. 若在緩存模塊列表中未找到,則執(zhí)行該模塊的加載函數(shù)并加入緩存列表中。

          當我們是動態(tài)import時則會調(diào)用__webpack_require__.e

          var _ModuleA__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__(/*! ./ModuleA */ "./src/ModuleA.js"); 
          __webpack_require__.e(/*! import() */ "src_ModuleB_js").then( 
              __webpack_require__.bind(__webpack_require__, /*! ./ModuleB */ "./src/ModuleB.js"
          ).then( 
              module => module.bFn() 
          ); 

          至此可以發(fā)現(xiàn)__webpack_require__.e只是返回了一個promise,然后再執(zhí)行了__webpack_require__方法。可見,在__webpack_require__.e執(zhí)行完成后,main chunk中的__webpack_modules__就會有ModuleB的內(nèi)容,這是怎么做到的呢:

          簡單來說就是main chunk中維護了一個__webpack_modules__的map,用于維護該chunk中有哪些module,而其他的chunk,也會將自己內(nèi)部的modules加到main chunk的__webpack_modules__

          講到這里,想必那么MF的實現(xiàn)方式,會不會也是將下載好的遠程模塊放進主chunk所維護的模塊列表,從而實現(xiàn)代碼共享 ??。

          仔細看了上面的MF Demo打包后的結(jié)果,發(fā)現(xiàn)果真如此。下面讓我們來簡單看看下面兩個問題:

          1. app1如何下載和使用app2的代碼;
          2. app1與app2如何實現(xiàn)依賴共享。

          來看看從app2的remoteEntry.js里的實現(xiàn),它了一個全局變量 app2,它的值為一個包含init和get方法的對象:

          // app2/remoteEntry.js 
           
          var app2 
          (() => { 
              var moduleMap = { 
                  "./Button"() => { 
                      return Promise.all([ 
                          __webpack_require__.e("webpack_sharing_consume_default_react_react-_2849"),  
                          __webpack_require__.e("src_Button_js")]).then(() => ( 
                              () => ((__webpack_require__(/*! ./src/Button */ "./src/Button.js"))) 
                              )); 
              }}; 
              var get = (module, getScope) => { 
                  // 內(nèi)容省略 
              }; 
              var init = (shareScope, initScope) => { 
                  // 內(nèi)容省略 
              }; 
              // app2的賦值過程遠比這個復雜,這里為了便于讀者理解刪去了許多代碼 
              app2 = { 
                  get() => (get), 
                  init: () => (init), 
              }; 
          })() 

          既然要從app2下載代碼,那么main.js中的__webpack_modules__必然維護著app2/remoteEntry.js的模塊加載方法:

          var __webpack_modules__ = [ 
              // ... 
              { 
                  "webpack/container/reference/app2"((module, __unused_webpack_exports, __webpack_require__) => { 
                      "use strict"; 
                      module.exports = new Promise((resolve, reject) => { 
                          if(typeof app2 !== "undefined"return resolve(); 
                          __webpack_require__.l("//localhost:3002/remoteEntry.js", (event) => { 
                              if(typeof app2 !== "undefined"return resolve(); 
                              // 省略一堆error定義 
                              reject(new Error()); 
                          }, "app2"
          ); 
                      }
          ).then(() => (app2)); 
                  }

              }, 
              // ... 

          其中調(diào)用了__webpack_require__.l來下載app2/remoteEntry.js文件,具體代碼不貼了,簡單講講這個方法做了那些事情:

          1. 新建一個script標簽
          2. src設(shè)置為app2/remoteEntry.js的地址
          3. 將script標簽添加到document中
          4. 下載結(jié)束后執(zhí)行回調(diào)方法(第二個參數(shù))

          而federation實現(xiàn)的核心在于加載器的變化__webpack_require__.e。通過之前的介紹,我們知道它的功能就是異步加載模塊。但是在federation中它就完全不一樣了,他會作為remote的加載器!

          __webpack_require__.e = (chunkId) => { 
              return Promise.all(Object.keys(__webpack_require__.f).reduce((promises, key) => { 
                  __webpack_require__.f[key](chunkId, promises "key"); 
                  return promises; 
              }, [])); 
          }; 

          核心關(guān)鍵就在于__webpack_require__.f對象 我們可以把f理解為federation的縮寫。在.f上掛了3個方法分別為

          webpack_require.f.j 負責創(chuàng)建script加載代碼

          webpack_require.f.consumes 負責執(zhí)行 app2.init

          webpack_require.f.remotes 負責執(zhí)行 app2.get

          到這里基本我們就明白了,federation基于__webpack__require__這個對象作為window上的runtime,而f這個對象管理了其它應(yīng)用的依賴和初始化。在federation下,每一個模塊(main.js 或 remoteEntry.js)其實都是一個__webpack_modules__,是一個不斷套娃的過程。

          總結(jié)一下,federation給我們前端模塊化和應(yīng)用模塊化打開了一種新的思路,他基于window(實際上是__webpack_require__)這個橋梁作為不同的模塊和應(yīng)用之間的通信媒介。而host和expose本身就是一種場景的設(shè)計,不難發(fā)現(xiàn),我們前文所述的微組件解決方案也是基于這種抽象的思維(基于微前端把repo直接作為host和expose)來實現(xiàn)的。

          而 federation 也有一些局限性,比如我們必須要求新項目都是webpack5以上,我們的技術(shù)棧需要保持一致,共享代碼時在runtime下如何解決單例問題,在TS中的話,還需要去考慮如何共享類型的問題等等。

          應(yīng)用場景

          federation 還有許多實用的場景

          一、當我們是一個巨大的應(yīng)用想要拆分獨立部署和構(gòu)建,但是host和subapp之間又有應(yīng)用之間的依賴需要共享同時我們的依賴是有狀態(tài)關(guān)系的。我們可以人為的抽離一個shared層,把需要復用的api或組件放在這個shared層上,不同的sub之間直接互相使用。

          二、另外在某些微前端的場景下,我們的路由配置表其實是可以通過federation直接進行共享,無需統(tǒng)一配置在master上。

          三、federation還能解決構(gòu)建時長的問題。比如Umi甚至通過federation帶來的靈感解決了構(gòu)建時長[4]的問題。有興趣的可以點擊鏈接看一看。

          Bit

          一句話介紹Bit:是一個集成了npm + git功能,組件文檔,可視化,CI/CD一站式的標準化的組件管理平臺

          提到代碼復用,就不得不說一下bit這個平臺。bit整體使用上手都非常簡單,由于篇幅原因就不過多介紹。首先跟著官網(wǎng)教程[5]走一遍,初始化一個bit 組件庫workspace并且發(fā)布好一個組件。

          我們在任意一個已有的項目下,我們通過bit init 即可初始化我們的workspace。再通過 bit import 來“download”一個組件,比如我們這里就 bit import meckodo.test/ui/button

          修改一下這個默認的組件代碼。比如這里我們把div換成了button

          // before 
          export function Button({ _text_ }: ButtonProps
            return <div>{_text_}</div>; 

           
          /
          / after 
          export function Button({ _text_ }: ButtonProps) { 
            return <button _type_="button">{_text_}</
          button>; 

          修改完成后,做一個類似git一樣的提交 bit tag --all --message "change to button" 再通過bit export 發(fā)布一個新版本

          到官網(wǎng)上就可以預覽到我們更新的組件了

          • before:

          • after:

          不難發(fā)現(xiàn),bit的好處就在于。我們?nèi)我庖粋€項目都可以非常方便的“download”(import)組件同時,在當前項目下很方便的直接發(fā)布(export)新版本。bit不僅僅支持了組件的形式,其實還支持了普通的js/ts代碼。在團隊內(nèi)部的業(yè)務(wù)下,如果有這樣跨repo級別共享代碼的需求就會非常方便。

          總結(jié)

          本文介紹在微前端項目中我們是如何跨項目跨技術(shù)棧復用組件的的使用場景,進而思考到其他工具的是如何復用代碼的原理和更廣泛的適用范圍。

          其中較為重要的個人認為是去熟悉內(nèi)在的一些思想。深入的思考分層和抽象搭建新的“橋梁”,如何去尋找“橋梁”把不同的模塊組織起來。會發(fā)現(xiàn)前文所說的工程角度來解決組件的共享,其實就是基于garfish這個橋梁,對共享的數(shù)據(jù)進行了一些同步,這就和webpack的__webpack__require__有異曲同工之處。而把repo抽象為模塊,針對性的進行exports,也是從federation中借鑒了靈感。

          參考鏈接

          https://garfish.top/

          探索 webpack5 新特性 Module federation 在騰訊文檔的應(yīng)用[7]

          webpack 打包的代碼怎么在瀏覽器跑起來的[7]

          https://mp.weixin.qq.com/s/zhkgudIjeuKWi536U5pJhw

          參考資料

          [1]

          loadComponent: https://github.com/bytedance/garfish/wiki/API#loadcomponentname-string-options-loadcomponentoptions--component

          [2]

          Webpack官網(wǎng): https://webpack.js.org/concepts/module-federation/

          [3]

          demo: https://github.com/module-federation/module-federation-examples/tree/master/basic-host-remote

          [4]

          構(gòu)建時長: https://umijs.org/zh-CN/docs/mfsu

          [5]

          官網(wǎng)教程: https://harmony-docs.bit.dev/getting-started/initializing-workspace

          [6]

          探索 webpack5 新特性 Module federation 在騰訊文檔的應(yīng)用: http://www.alloyteam.com/2020/04/14338/

          [7]

          webpack 打包的代碼怎么在瀏覽器跑起來的: https://segmentfault.com/a/1190000022669224



          內(nèi)推社群


          我組建了一個氛圍特別好的騰訊內(nèi)推社群,如果你對加入騰訊感興趣的話(后續(xù)有計劃也可以),我們可以一起進行面試相關(guān)的答疑、聊聊面試的故事、并且在你準備好的時候隨時幫你內(nèi)推。下方加 winty 好友回復「面試」即可。


          瀏覽 47
          點贊
          評論
          收藏
          分享

          手機掃一掃分享

          分享
          舉報
          評論
          圖片
          表情
          推薦
          點贊
          評論
          收藏
          分享

          手機掃一掃分享

          分享
          舉報
          <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>
                  日批网站全免费 | 秘 蜜桃视频在线播放 | 欧美黑人大吊 | A片免费高清在线观看 | 国产人兽在线 |