探索 webpack5 新特性 Module federation 在騰訊文檔的應用
In 未分類 on 2020年04月08日 by view: 22,239
3

前言:

webpack5 的令人激動的新特性 Module federation 可能并不會讓很多開發者激動,但是對于深受多應用傷害的騰訊文檔來說,卻是著實讓人眼前一亮,這篇文章就帶你了解騰訊文檔的困境以及 Module federation 可以如何幫助我們走出這個困境。

0x1 騰訊文檔的困境

1.1 多應用場景背景

騰訊文檔從功能層面上來說,用戶最熟悉的可能就是 word、excel、ppt、表單這四個大品類,四個品類彼此獨立,可能由不同的團隊主要負責開發維護,那從開發者角度來說,四個品類四個倉庫各自獨立維護,好像事情就很簡單,但是現實情況實際上卻復雜很多。我們來看一個場景:

通知中心的需求

image-20200329125332068

對于復雜的權限場景,為了讓使用者能快速能獲得最新狀態,我們實際上有一個通知中心的需求,在 pc 的樣式大致就是上圖里面的樣子。這里是在騰訊文檔的列表頁看到的入口,實際上在上面提到的四大品類里面,都需要嵌入這樣的一個頁面。

那么問題來了,為了最小化這里的開發和維護成本,肯定是各個品類公用一套代碼是最好的,那最容易想到的就是使用獨立 npm 包的方式來引入。確實,騰訊文檔的內部很多功能現在是使用 npm 包的方式來引入的,但是實際上這里會遇到一些問題:

問題一:歷史代碼

騰訊文檔的歷史很復雜,簡而言之,在剛開始的時候,代碼里面是不支持寫 ES6 的,所以沒辦法引入 npm 包。一時半會想改造完成是不現實的,產品需求也不會等你去完成這樣的改造

問題二:發布效率

這里的問題實際上也是現在我們使用 npm 包的問題,其實還是我們懶,想投機取巧。以 npm 包的方式引入的話,一旦有改動,你需要改 5 個倉庫(四個品類+列表頁)去升級這里的版本,實際上發布成本是蠻大的,對于開發者來說其實也很痛苦

1.2 我們的解決方案

為了能在不支持 ES6 代碼的環境下快速引入 React 來加速需求開發,我們想出了一個所謂的 Script-Loader(下面會簡稱 SL)的模式。

整體架構如圖:

image-20200329131110457

簡單來說就是,參考 jquery 的引入方式,我們用另外一個項目去實現這些功能,然后把代碼打包成 ES5 代碼,對外提供很多接口,然后在各個品類頁,引入我們提供的加載腳本,內部會自動去加載文件,獲取每個模塊的 js 文件的 CDN 地址并且加載。這樣做到各個模塊各自獨立,并且所有模塊和各個品類形成獨立。

在這種模式下,每次發布,我們只需要去發布各個改動的模塊以及最新的配置文件,其他品類就能獲得自動更新。

這個模式并不一定適合所有項目,也不一定是最好的解決方案,從現在的角度來看,有點像微前端的概念,但是實際上卻也是有區別的,這里就不展開了。這種模式目前確實能解決騰訊文檔這種多應用復用代碼的需求。

1.3 遇到的問題

這種模式本質上目前沒有很嚴重的問題,但是有一個很痛點一直困擾我們,那就是品類代碼和 SL 的代碼共享問題。舉個例子:

Excel 品類改造后使用了 React,SL 的模塊 A、模塊 B、模塊 C 引入了 React

因為 SL 的模塊之間是各自獨立的,所以 React 也是各自打包的,那就是說當你打開 Excel 的時候,如果你用了模塊 A、B、C,那你最終頁面會加載四份 React 代碼,雖然不會帶上什么問題,但是對于有追求的前端來說,我們還是想去解決這樣的問題。

解決方案: External

對于 React 來說,我們可以默認品類是加載了 React,所以我們直接把 SL 里面的 React 配置為 External,這樣就不會打包了,但是實際上情況沒有這么簡單:

問題一:模塊可能獨立頁面

就以上面的通知中心來說,在移動端上面就不是嵌入的了,而且獨立頁面,所以這個獨立頁面需要你手動引入 React

問題二:公共包不匹配

簡單來說,就是 SL 依賴的包,在品類里面可能并沒有使用,例如 Mobx 或者 Redux

問題三:不是所有包都可以直接配置 External

這里的問題是說像 React 這種包我們可以通過配置 External 為 window.React 來達到共用,但是不是所有包都可以這樣的,那對于不能配置為全局環境的包來說,還沒法解決這里的代碼共享問題

基于這些問題,我們目前的選擇是一種折中方案,我們把可以配置全局環境的包提取出來,每個模塊指明依賴,然后在 SL 內部,加載模塊代碼之前會去檢測依賴,依賴加載完成才會加載執行實際模塊代碼。

這種方式有很大問題,你需要手動去維護這樣的依賴,每個共享包實際上你都是需要單獨打包成一個 CDN 文件,為的是當依賴檢測失敗的時候,可以有一個兜底加載文件。因此,實際上目前也只有 React 包做了這個共享。

那么到這里,核心問題就變成了品類代碼和 SL 如何做到代碼共享。對于其他項目來說,其實也就是多應用如何做到代碼共享。

0x2 webpack 的打包原理

為了解決上面的問題,我們實際上想從 webpack 入手,去實現這樣的一個插件幫我們解決這個問題。核心思路就是 hook webpack 的內部 require 函數,在這之前我們先來看一下 webpack 打包后的一些原理,這個也是后面理解 Module federation 的核心。如果這里你比較熟悉,也可以快速跳過到第三節,但是不熟悉的同學還是建議認真了解一下。

2.1 chunk 和 module

webpack 里面有兩個很核心的概念,叫 chunk 和 module,這里為了簡單,只看 js 相關的,用筆者自己的理解去解釋一下他們直接的區別:

module:每一個源碼 js 文件其實都可以看成一個 module

chunk:每一個打包落地的 js 文件其實都是一個 chunk,每個 chunk 都包含很多 module

默認的 chunk 數量實際上是由你的入口文件的 js 數量決定的,但是如果你配置動態加載或者提取公共包的話,也會生成新的 chunk。

2.2 打包代碼解讀

有了基本理解后,我們需要去理解 webpack 打包后的代碼在瀏覽器端是如何加載執行的。為此我們準備一個非常簡單的 demo,來看一下它的生成文件。

非常簡單,入口 js 是 main.js,里面就是直接引入 moduleA.js,然后動態引入 moduleB.js,那么最終生成的文件就是兩個 chunk,分別是:

  1. main.jsmoduleA.js 組成的 bundle.js
  2. `moduleB.js 組成的 0.bundle.js

如果你了解 webpack 底層原理的話,那你會知道這里是用 mainTemplate 和 chunkTemplate 分別渲染出來的,不了解也沒關系,我們繼續解讀生成的代碼

import 變成了什么樣

整個 main.js 的代碼打包后是下面這樣的

可以看到,我們的直接 import moduleA 最后會變成 webpack_require,而這個函數是 webpack 打包后的一個核心函數,就是解決依賴引入的。

webpack_require 是怎么實現的

那我們看一下 webpack_require 它是怎么實現的:

如果簡化一下它的實現,其實很簡單,就是每次 require,先去緩存的 installedModules 這個緩存 map 里面看是否加載過了,如果沒有加載過,那就從 modules 這個所有模塊的 map 里去加載。

modules 從哪里來的

那相信很多人都有疑問了,modules 這么個至關重要的 map 是從哪里來的呢,我們把 bundle.js 生成的 js 再簡化一下:

所以可以看到,這其實是個立即執行函數,modules 就是函數的入參,具體值就是我們包含的所有 module,到此,一個 chunk 是如何加載的,以及 chunk 如何包含 module,相信大家一定會有自己的理解了。

動態引入如何操作呢

上面的 chunk 就是一個 js 文件,所以維護了自己的局部 modules,然后自己使用沒啥問題,但是動態引入我們知道是會生成一個新的 js 文件的,那這個新的 js 文件 0.bundle.js 里面是不是也有自己的 modules 呢?那 bundle.js 如何知道 0.bundle.js 里面的 modules?

先看動態 import 的代碼變成了什么樣:

從代碼看,實際上就是外面套了一層 webpck_require.e,然后這是一個 promise,在 then 里面再去執行 webpack_require。

實際上 webpck_require.e 就是去加載 chunk 的 js 文件 0.bundle.js,具體代碼就不貼了,沒啥特別的。

等到加載回來后它認為bundle.js 里面的 modules 就一定會有了 0.bundle.js 包含的那些 modules,這是如何做到的呢?

我們看 0.bundle.js 到底是什么內容,讓它擁有這樣的魔力:

拿簡化后的代碼一看,大家第一眼想到的是 jsonp,但是很遺憾的是它不是一個函數,卻只是向一個全局數組里面 push 了自己的模塊 id 以及對應的 modules。那看起來魔法的核心應該是在 bundle.js 里面了,事實的確也是如此。

bundle.js 的里面,我們看到這么一段代碼,其實就是說我們劫持了 push 函數,那 0.bundle.js 一旦加載完成,我們豈不是就會執行這里,那不就能拿到所有的參數,然后把 0.bundle.js 里面的所有 module 加到自己的 modules 里面去!

2.3 總結一下

如果你沒有很理解,可以配合下面的圖片,再把上面的代碼讀幾遍。

image-20200329143727089

其實簡單來說就是,對于 mainChunk 文件,我們維護一個 modules 這樣的所有模塊 map,并且提供類似 webpack_require 這樣的函數。對于 chunkA 文件(可能是因為提取公共代碼生成的、或者是動態加載)我們就用類似 jsonp 的方式,讓它把自己的所有 modules 添加到主 chunk 的 modules 里面去。

2.4 如何解決騰訊文檔的問題?

基于這樣的一個理解,我們就在思考,那騰訊文檔的多應用代碼共享能不能解決呢?

具體到騰訊文檔的實際場景,就是如下圖:

image-20200329143446668

因為是獨立的項目,所以 webpack 打包也是有兩個 mainChunk,然后有各自的 chunk(其實這里會有 chunk 覆蓋或者 chunk 里面的 module 覆蓋問題,所以 id 要采用 md5)。

那問題的核心就是如何打通兩個 mainChunk 的 modules?

如果是自由編程,我想大家的實現方式可就太多了,但是在 webpack 的框架限制下面,如何快速的實現這個,我們也一直在思考方案,目前想到的方案如下:

SL 模塊內部的 webpack_require 被我們 hack,每次在 modules 里面找不到的時候,我們去 Excel 的 modules 里面去找,這樣需要把 Excel 的 modules 作為全局變量

但是對于 Excel 不存在的模塊我們需要怎么處理?

這種很明顯就是運行時環境,我們需要做好加載時的失敗降級處理,但是這樣就會遇到同步轉異步的問題,本來你是同步引入一個模塊的,但是如果它在 Excel 的 modules 不存在的時候,你就需要先一步加載這個 module 對應的 chunk,變成了類似動態加載,但是你的代碼還是同步的,這樣就會有問題。

所以我們需要將依賴前置,也就是說在加載 SL 模塊后,它知道自己依賴哪些共享模塊,然后去檢測是否存在,不存在則依次去加載,所有依賴就位后才開始執行自己。

0x3 webpack5 的 Module federation

說實話,webpack 底層還是很復雜的,在不熟悉的情況下而且定制程度也不能確定,所以我們也是遲遲沒有去真正做這個事情。但是偶然的機會了解到了 webpack5 的 Module federation,通過看描述,感覺和我們想要的東西很像,于是我們開始一探究竟!

3.1 Module federation 的介紹

關于 Module federation 是什么,有什么作用,現在已經有一些文章去說明,這里貼一篇,大家可以先去了解一下

Module federation allows a JavaScript application to dynamically run code from another bundle/build, on both client and server

簡單來說就是允許運行時動態決定代碼的引入和加載。

3.2 Module federation 的 demo

我們最關心的還是 Module federation 的的實現方式,才能決定它是不是真的適合騰訊文檔。

這里我們用已有的 demo:

module-federation-examples/basic-host-remote

在此之前,還是需要向大家介紹一下這個 demo 做的事情

這是文件結構,其實你可以看成是兩個獨立應用 app1 和 app2,那他們之前有什么愛恨情仇呢?

我這里只貼了 app1 的 js 代碼,app2 的代碼你不需要關心。代碼沒有什么特殊的,只有一點,app1 的 App.js 里面:

也就是關鍵來了,跨應用復用代碼來了!app1 的代碼用了 app2 的代碼,但是這個代碼最終長什么樣?是如何引入 app2 的代碼的?

3.3 Module federation 的配置

先看我們的 webpack 需要如何配置:

這個其實就是 Module federation 的配置了,大概能看到想表達的意思:

  1. 用了遠程模塊 app2,它叫 app2
  2. 用了共享模塊,它叫 shared

remotes 和 shared 還是有一點區別的,我們先來看效果。

生成的 html 文件:

ps:這里的 js 路徑有修改,這個是可以配置的,這里只是表明從哪里加載了哪些 js 文件

app1 打包生成的文件:

ps: app2 你也需要打包,只是我沒有貼 app2 的代碼以及配置文件,后面需要的時候會再貼出來的

最終頁面表現以及加載的 js:

image-20200329152614947

從上往下加載的 js 時序其實是很有講究的,后面將會是解密的關鍵:

這里最需要關注的其實還是每個文件從哪里加載,在不去分析原理之前,看文件加載我們至少有這些結論:

  1. remotes 的代碼自己不打包,類似 external,例如 app2/button 就是加載 app2 打包的代碼
  2. shared 的代碼自己是有打包的

Module federation 的原理

在講解原理之前,我還是放出之前的一張圖,因為這是 webpack 的文件模塊核心,即使升級 5,也沒有發生變化

image-20200329152252834

app1 和 app2 還是有自己的 modules,所以實現的關鍵就是兩個 modules 如何同步,或者說如何注入,那我們就來看看 Module federation 如何實現的。

3.3.1 import 變成了什么

從這里來看,我們好像看不出什么,因為還是正常的 webpack_require,難道說它真的像我們之前所設想的那樣,重寫了 webpack_require 嗎?

遺憾的是,從源碼看這個函數是沒有什么變化的,所以核心點不是這里。

但是你注意看加載的 js 順序:

回想上一節我們自己的分析

所以我們需要將依賴前置,也就是說在加載 SL 模塊后,它知道自己依賴哪些共享模塊,然后去檢測是否存在,不存在依次去加載,所以依賴就位后才開始執行自己。

所以它是不是通過依賴前置來解決的呢?

3.3.2 main.js 文件內容

因為 html 里面和 app1 相關的只有兩個文件:app1/app1.js 以及 app1/main.js

那我們看看 main.js 到底寫了啥

可以看到區別不大,只是把之前的 modules 換成了 webpack_modules,然后把這個 modules 的初始化由參數改成了內部聲明變量。

那我們來看看 webpack_modules 內部的實現:

從代碼看起來就三個 module:

那在加載 src_bootstrap.js 之前加載的那些 react 文件還有 app2/button 文件都是誰做的呢?通過 debug,我們發現秘密就在 webpack_require__.e("src_bootstrap_js") 這句話

在第二節解析 webpack 加載的時候,我們得知了:

實際上 webpck_require.e 就是去加載 chunk 的 js 文件 0.bundle.js,等到加載回來后它認為 bundle.js 里面的 modules 就一定會有了 0.bundle.js 包含的那些 modules

也就是說原來的 webpack_require__.e 平淡無奇,就是加載一個 script,以致于我們都不想去貼出它的代碼,但是這次升級后一切變的不一樣了,它成了關鍵中的關鍵!

3.3.3 webpack_require__.e 做了什么

看代碼,的確發生了變化,現在底層是去調用 webpack_require.f 上面的函數了,等到所有函數都執行完了,才執行 promise 的 then

那問題的核心又變成了 webpack_require.f 上面有哪些函數了,最后發現有三個函數:

一:overridables

二:remotes

三:jsonp

這三個函數我把核心部分節選出來了,其實注釋也寫得比較清楚了,我還是解釋一下:

  1. overridables 可覆蓋的,看代碼你應該已經知道和 shared 配置有關
  2. remotes 遠程的,看代碼非常明顯是和 remotes 配置相關
  3. jsonp 這個就是原有的加載 chunk 函數,對應的是以前的懶加載或者公共代碼提取
3.3.4 加載流程

知道了核心在 webpack_require.e 以及內部實現后,不知道你腦子里是不是對整個加載流程有了一定的思路,如果沒有,容我來給你解析一下

  1. 先加載 src_main.js,這個沒什么好說的,注入在 html 里面的
  2. src_main.js 里面執行 webpack_require("./src/index.js")
  3. src/index.js 這個 module 的邏輯很簡單,就是動態加載 src_bootstrap_js 這個 chunk
  4. 動態加載 src_bootstrap_js 這個 chunk 時,經過 overridables,發現這個 chunk 依賴了 react、react-dom,那就看是否已經加載,沒有加載就去加載對應的 js 文件,地址也告訴你了
  5. 動態加載 src_bootstrap_js 這個 chunk 時,經過 remotes,發現這個 chunk 依賴了?ad8d,那就去加載這個 js
  6. 動態加載 src_bootstrap_js 這個 chunk 時,經過 jsonp,就正常加載就好了
  7. 所有依賴以及 chunk 都加載完成了,就去執行 then 邏輯:webpack_require src_bootstrap_js 里面的 module:./src/bootstrap.js

到此就一切都正常啟動了,其實就是我們之前提到的依賴前置,先去分析,然后生成配置文件,再去加載。

看起來一切都很美好,但其實還是有一個關鍵信息沒有解決!

3.3.5 如何知道 app2 的存在

上面的第 4 步加載 react 的時候,因為我們自己實際上也打包了 react 文件,所以當沒有加載的時候,我們可以去加載一份,也知道地址

但是第五步的時候,當頁面從來沒有加載過 app2/Button 的時候,我們去什么地址加載什么文件呢?

這個時候就要用到前面我們提到的 main.js 里面的 webpack_modules

這里面有三個 module,我們還有 ?8bfd、container-reference/app2 沒有用到,我們再看一下 remotes 的實現

當我們加載 src_bootstrap_js 這個 chunk 時,經過 remotes,發現這個 chunk 依賴了?ad8d,那在運行時的時候:

結合 main.js 的 module ?8bfd 的代碼,那最終就是 app2.get("Button")

這不就是個全局變量嗎?看起來有些蹊蹺??!

3.3.6 再看 app2/remoteEntry.js

我們好像一直忽略了這個文件,它是第一個加載的,必然有它的作用,帶著對全局 app2 有什么蹊蹺的疑問,我們去看了這個文件,果然發現了玄機!

如果你細心看,就會發現,這個文件定義了全局的 app2 變量,然后提供了一個 get 函數,里面實際上就是去加載具體的模塊

所以 app2.get("Button") 在這里就變成了 app2 內部定義的 get 函數,隨后執行自己的 webpack_require

是不是有種煥然大悟的感覺!

原來它是這樣在兩個獨立打包的應用之間,通過全局變量去建立了一座彩虹橋!

當然,app2/remoteEntry.js 是由 app2 根據配置打包出來的,里面實際上就是根據配置文件的導出模塊,生成對應的內部 modules

你可能忽略的 bootstrap.js

細心的讀者如果注意的話,會發現,在入口文件 index.js 和真正的文件 app.js 之間多了一個 bootstrap.js,而且里面內容就是異步加載 app.js

那這個文件是不是多余的,筆者試了一下,直接把入口換成 app.js 或者這里換成同步加載,整個應用就跑不起來了

其實從原理上分析后也是可以理解的:

因為依賴需要前置,并且等依賴加載完成后才能執行自己的入口文件,如果不把入口變成一個異步的 chunk,那如何去實現這樣的依賴前置呢?畢竟實現依賴前置加載的核心是 webpack_require.e

3.3.7 總結

至此,Module federation 如何實現 shared 和 remotes 兩個配置我相信大家都有了理解了,其實還是逃不過在第二節末尾說的問題:

  1. 如何解決依賴問題,這里的實現方式是重寫了加載 chunk 的 webpack_require.e,從而前置加載依賴

  2. 如何解決 modules 的共享問題,這里是使用全局變量來 hook

整體看起來實現還是挺巧妙的,不是 webpack 核心開發者,估計不能想到這樣解決,實際上改動也是蠻大的。

這種實現方式的優缺點其實也明顯:

優點:做到代碼的運行時加載,而且 shared 代碼無需自己手動打包

缺點:對于其他應用的依賴,實際上是強依賴的,也就是說 app2 有沒有按照接口實現,你是不知道的

至于網上一些其他文章所說的 app2 的包必須在代碼里面異步使用,這個你看前面的 demo 以及知道原理后也知道,根本沒有這樣的限制!

0x4 總結

對于騰訊文檔來說,實際上更需要的是目前的 shared 能力,對一些常見的公共依賴庫配置 shared 后就可以解決了,但是也只是理想上的,實際上還是會遇到一些可見的問題,例如:

  1. 不同的版本生成的公共庫 id 不同,還是會導致重復加載
  2. app2 的 remotEntry 更新后如何獲取最新地址
  3. 如何獲知其他應用導出接口

但是至少帶來了解決這個問題的希望,remotes 配置也讓我們看到了多應用共享代碼的可能,所以還是會讓人眼前一亮,期待 webpack5 的正式發布!

最后,如果有寫的不正確的地方,歡迎斧正~

原創文章轉載請注明:

轉載自AlloyTeam:http://www.ecomenagepro.com/2020/04/14338/

  1. Rainsho 2020 年 5 月 5 日

    我們之前的方式是構建的時候從遠程 (主項目) 加載 DLLReference,這個東西看起來可以做到更精細化的控制。

  2. arnold 2020 年 4 月 13 日

    贊,非常喜歡作者的剖析思路

  3. AlienZHOU 2020 年 4 月 11 日

    我們之前的解決方式是 hook 到 webpack 內部,生成所有模塊資源的依賴圖譜。同時會有對應的 Node runtime 將前置資源注入到頁面中,并將頁面需要的依賴圖譜子集也注入到頁面變量中。
    同時 hack webpack_require 相關代碼,通過 hack 的 require 來分發前端運行時依賴。對于編譯期打包進來的依賴還是走 webpack 自己的,非編譯期的外部動態化依賴,則會解析編譯期生成的資源表。
    所以對開發者來說,和同步 import 其他模塊沒有區別,開發者不用關心是否是外部模塊。

發表評論