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

          字節(jié)跳動的微組件和代碼共享實(shí)踐

          共 39565字,需瀏覽 80分鐘

           ·

          2022-02-12 15:14

          大廠技術(shù)  高級前端  Node進(jìn)階

          點(diǎn)擊上方 程序員成長指北,關(guān)注公眾號

          回復(fù)1,加入高級Node交流群

          前言

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

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

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

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

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


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

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

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

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

          項(xiàng)目介紹

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

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

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

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

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

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

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

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

          從一個(gè)用法開始

          如果是一個(gè)組件很簡單,也很好實(shí)現(xiàn),我們知道garfish有提供loadApp的接口,我們可以直接通過加載一個(gè)子應(yīng)用,這個(gè)子應(yīng)用渲染某個(gè)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)里還是跑了幾個(gè)月的,沒有任何問題。但是如果有了多例就不一樣了,我們會調(diào)用多次loadApp,加載了大量子應(yīng)用的代碼,導(dǎo)致性能很差,甚至直接卡死。有人說加cache行不行?其實(shí)也是不可行的,上述的代碼過于簡陋,我們還需要處理props變化的情況,以及l(fā)oadApp,傳遞props給react的情況。如果單純只是cashe解決不了這樣的場景。

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

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

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

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

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

          有了這個(gè)結(jié)構(gòu),我們 react 的 render 函數(shù)就簡單了,統(tǒng)一渲染一個(gè)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)該如何使用這樣的組件呢?當(dāng)然肯定是和單純的一個(gè)vue組件沒有區(qū)別。比如這樣。

          所以我們就需要封裝一個(gè)底層的vue組件去負(fù)責(zé)管理子應(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,需要去負(fù)責(zé)保持只能load一個(gè)子應(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)用失敗'
                }; 
              } 
            } 

          我們需要用兩個(gè)flag來控制mount和unmunt。為了保證只能load一個(gè)子應(yīng)用,用一個(gè)loaded開關(guān)來控制。而count是因?yàn)槲覀冇卸嗬鋵?shí)就是個(gè)引用計(jì)數(shù),必須保證每個(gè)微組件都卸載了,才能去unmount掉我們的子應(yīng)用。

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

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

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

          當(dāng)我們初始化一個(gè)vue的組件時(shí),就需要把對應(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àn)樾枰3置恳粋€(gè)子應(yīng)用都是唯一的單例,我們繼續(xù)引入microComponentManager來幫我們管理所有的子應(yīng)用實(shí)例。

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

          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ù)進(jìn)行更新,重新渲染

          // 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(() => { 
                _// 放到下一個(gè) 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_); 
            } 

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

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

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

          總結(jié)

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

          優(yōu)勢

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

          劣勢

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

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

          運(yùn)行時(shí)組件市場

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

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

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

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

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

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

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

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

          共享代碼

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

          Module Federation

          概念

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

          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想要達(dá)到的目的就是把多個(gè)無相互依賴、單獨(dú)部署的應(yīng)用合并為一個(gè)應(yīng)用,即MF提供了在某個(gè)應(yīng)用中可以遠(yuǎn)程加載其他服務(wù)器上應(yīng)用的能力。對于MF來說,有兩種角色:

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

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

          示例

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

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

          app1和app2是兩個(gè)獨(dú)立部署的應(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. 這個(gè)app2/Button是從哪里來的呢?
          2. 這一段引用的組件代碼長啥樣?

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

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

          從上面配置可以知道:

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

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

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

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

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

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

          實(shí)現(xiàn)原理

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

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

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

          經(jīng)過webpack打包后形成兩個(gè)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: 當(dāng)前模塊 
              // __webpack_exports__: 模塊的導(dǎo)出 
              //  __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)建一個(gè)模塊緩存,并放進(jìn)__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__); 
               
                  // 標(biāo)記該模塊已加載 
                  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"); 
          })()

          這就是整個(gè)項(xiàng)目的啟動文件,其實(shí)就是一個(gè)IIFE。

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

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

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

          當(dāng)我們是動態(tài)import時(shí)則會調(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只是返回了一個(gè)promise,然后再執(zhí)行了__webpack_require__方法。可見,在__webpack_require__.e執(zhí)行完成后,main chunk中的__webpack_modules__就會有ModuleB的內(nèi)容,這是怎么做到的呢:

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

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

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

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

          來看看從app2的remoteEntry.js里的實(shí)現(xiàn),它了一個(gè)全局變量 app2,它的值為一個(gè)包含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的賦值過程遠(yuǎn)比這個(gè)復(fù)雜,這里為了便于讀者理解刪去了許多代碼 
              app2 = { 
                  get() => (get), 
                  init: () => (init), 
              }; 
          })() 

          既然要從app2下載代碼,那么main.js中的__webpack_modules__必然維護(hù)著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文件,具體代碼不貼了,簡單講講這個(gè)方法做了那些事情:

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

          而federation實(shí)現(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個(gè)方法分別為

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

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

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

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

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

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

          應(yīng)用場景

          federation 還有許多實(shí)用的場景

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

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

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

          Bit

          一句話介紹Bit:是一個(gè)集成了npm + git功能,組件文檔,可視化,CI/CD一站式的標(biāo)準(zhǔn)化的組件管理平臺

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

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

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

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

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

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

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

          • before:

          • after:

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

          總結(jié)

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

          其中較為重要的個(gè)人認(rèn)為是去熟悉內(nèi)在的一些思想。深入的思考分層和抽象搭建新的“橋梁”,如何去尋找“橋梁”把不同的模塊組織起來。會發(fā)現(xiàn)前文所說的工程角度來解決組件的共享,其實(shí)就是基于garfish這個(gè)橋梁,對共享的數(shù)據(jù)進(jìn)行了一些同步,這就和webpack的__webpack__require__有異曲同工之處。而把repo抽象為模塊,針對性的進(jìn)行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)建時(shí)長: 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)容,希望對你有所幫助^_^

          喜歡的話別忘了 分享、點(diǎn)贊、收藏 三連哦~。

             
          Node 社群



          我組建了一個(gè)氛圍特別好的 Node.js 社群,里面有很多 Node.js小伙伴,如果你對Node.js學(xué)習(xí)感興趣的話(后續(xù)有計(jì)劃也可以),我們可以一起進(jìn)行Node.js相關(guān)的交流、學(xué)習(xí)、共建。下方加 考拉 好友回復(fù)「Node」即可。



          如果你覺得這篇內(nèi)容對你有幫助,我想請你幫我2個(gè)小忙:

          1. 點(diǎn)個(gè)「在看」,讓更多人也能看到這篇文章
          2. 訂閱官方博客 www.inode.club 讓我們一起成長

          點(diǎn)贊和在看就是最大的支持??

          瀏覽 108
          點(diǎn)贊
          評論
          收藏
          分享

          手機(jī)掃一掃分享

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

          手機(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高清无码在线 | 国产女人18毛片水真多果冻 | 97办公室三级电影中文字幕 | 亚洲国产精品成人综合久 |