編譯的速度與激情:從 10mins 到 1s

導語:對于大型前端項目而言,構建的穩定性和易用性至關重要,騰訊文檔在迭代過程中,復雜的項目結構和編譯帶來的問題日益增多,極大的增加了新人上手與日常搬磚的開銷。恰逢 Webpack5 上線,不如來一次徹底的魔改~

1. 前言

騰訊文檔最近基于剛剛發布的 Webpack5 進行了一次編譯的大重構,作為一個多個倉庫共同構成的大型項目,任意品類的代碼量都超過百萬。對于騰訊文檔這樣一個快速迭代,高度依賴自動化流水線,常年并行多個大型需求和無數小需求的項目來說,穩定且快速的編譯對于開發效率至關重要。這篇文章,就是筆者最近進行重構,成功將日常開發優化到 1s 的過程中,遇到的一些大型項目特有的問題和思考,希望能給大家在前端項目構建的優化中帶來一些參考和啟發。

2. 大型項目編譯之痛

隨著項目體系的逐漸擴大,往往會遇到舊的編譯配置無法支持新特性,由于各種 config 文件自帶的閱讀 debuff,以及累累的技術債,大家總會趨于不去修改舊配置,而是試圖新增一些配置在外圍對編譯系統進行修正。也是這樣類似的原因,騰訊文檔過去的編譯編譯也并不優雅:

多級的子倉庫結構,復雜的編譯系統造成很高的理解和改動成本,也帶來了較高的編譯耗時,對于整個團隊的開發效率有著不小的影響。

3.All in One

為了解決編譯復雜和緩慢的問題,至關重要的,就是禁止套娃:多層級混合的系統必須廢除,統一的編譯才是王道。在所有編譯系統中,Webpack 在大項目的打包上具備很強優勢,插件系統最為豐滿,并且 Webpack5 的帶來了 Module Federation 新特性,因此筆者選擇了用 Webpack 來統合多個子倉庫的編譯。

3.1. 整合基于 lerna 的倉庫結構

騰訊文檔使用了 lerna 來管理倉庫中的子包,使用 lerna 的好處此處就不作展開了。不過 lerna 的通用用法也帶來了一定的問題,lerna 將一個倉庫變成了結構上的多個倉庫,如果按照默認的使用方式,每個倉庫都會有自己的編譯配置,單個項目的編譯變成了多個項目的聯編聯調,修改配置和增量優化都會變得比較困難。

雖然使用 lerna 的目的是使各個子包相對獨立,但是在整個項目的編譯調試中,往往需要的是所有包的集合,那么,筆者就可以忽略掉這個子包間的物理隔離,把子倉庫作為子目錄來看待。不依賴 lerna,筆者需要解決的,是子包間的引用問題:

事實上,筆者可以通過 webpack 配置中 resolve 的 alias 屬性來達到相應效果:

3.2. 管理游離于打包系統之外的文件

在大型項目中,有時會存在一些特殊的靜態代碼文件,它們往往并不參與到打包系統中,而是由其他方式直接引入 html,或者合并到最終的結果中。

這樣的文件,一般分為如下幾類:

  1. 加載時機較早的外部 sdk 文件,本身以 minify 文件提供
  2. 外部文件依賴的其他框架文件,比如 jquery
  3. 一些 polyfill
  4. 一些特殊的必須早期運行的獨立邏輯,比如初始化 sdk 等

由于 polyfill 和外部 sdk 往往直接通過掛全局變量運行的模式,項目中往往會通過直接寫入 html script 標簽的方式引用它們。不過,隨著此類文件的增多,直接利用標簽引用,對于版本管理和編譯流程都不友好,它們對應的一些初始化邏輯,也無法添加到打包流程中來。這種情況,筆者建議手工的創建一個 js 入口文件,對以上文件進行引用,并作為 webpack 的一個入口。如此,就能通過代碼的方式,將這些散裝文件管理起來了:

但是,一些外部的 js 可能依賴于其他 sdk,比如 jQuery,但是打包系統并不知道它們之間的依賴關系,導致 jQuery 沒有及時暴露到全局中,該怎么辦呢?事實上,webpack 提供了很靈活的方案來處理這些問題,比如,筆者可以通過 expose-loader,將 jQuery 的暴露到全局,供第三方引用。在騰訊文檔中,還包含了一些對遠程 cdn 的 sdk 組件,這些 sdk 也需要引用一些庫,比如 jQuery 的。因此,筆者還通過 splitChunks 的配置,將 jQuery 重新分離出來,放在了較早的加載時機,保證基于 cdn 加載的 sdk 亦能正常初始化。

通過代碼引用,一方面,可以很好的進行依賴文件的版本管理;另一方面,由于對應文件的編譯也加入了打包流程,所有對應文件的改動都可以被動態監視到,有利于后續進行增量編譯。同時,由于 webpack 的封裝特點,每個庫都會被包含在一個 webpack_require 的特殊函數之中,全局變量的暴露數量也變得較為可控。

3.3. 定制化的 webpack 流程

Webpack 提供了一個非常靈活的 html-webpack-plugin 來進行 html 生成,它支持模板和一眾的專屬插件,但是,仍然架不住項目有一些特殊的需求,通用的插件配置要么無法滿足這些需求,要么適配的結果就十分難懂。這也是騰訊文檔在最初使用了 gulp 來生成 html 的原因,在 gulp 配置中,有很多自定義流程來滿足騰訊文檔的發布要求。

既然,gulp 可以自定義流程來實現 html 生成,那么,筆者也可以單獨寫一個 webpack 插件來實現定制的流程。

Webpack 本身是一個非常靈活的系統,它是一個按照特定的流程執行的框架,在每個流程的不同的階段提供了不同的鉤子,通過各種插件去實現這些鉤子的回調,來完成代碼的打包,事實上,webpack 本身就是由無數原生插件組成的。在這整個流程中,筆者可以做各種不同的事情來定制它。

對于生成 html 的場景,通過增加一個插件,在 webpack 處理生成文件的階段,將生成的 js、css 等資源文件,以及 ejs 模板和特殊配置整合到一起,再添加到 webpack 的 assets 集合中,便可以完成一次自定義的 html 生成。

在以上代碼中,大家可以留意到最后一句:compilation.fileDependencies.addAll(dependencies),通過這一句,筆者可以將所有被自定義生成所依賴的文件加入的 webpack 的依賴系統中,那么當這些文件發生變更的時候,webpack 能夠自動再次觸發對應的生成流程。

3.4. 一鍵化的開發體驗

至此,各路編譯都已經統一化,筆者可以用一個 webpack 編譯整個項目了,watch 和 devServer 也可以一起 high 起來。不過,既然編譯可以統一,何不讓所有操作都整合起來呢?

基于 node_modules 不應該手工操作的假設,筆者可以創建 package.json 中依賴的快照,每次根據 package 的變化來判斷是否需要重新安裝,避免開發同學同步代碼后的手動判斷,跳過不必要的步驟。

同樣的,騰訊文檔的本地調試是基于特殊的測試環境,通過 whislte 進行代理,這樣的步驟也可以自動化,那么,對于開發來說,一切就很輕松了,一條命令,輕松搬磚~

不過,作為是一個復雜的系統,第一次使用,總需要初始化的吧,如果編譯系統的依賴尚未安裝,沒有雞,怎么生蛋呢?

其實不然,筆者不妨在整套編譯系統的外層套個娃,做前端開發,node 總會先安裝的吧?那么,在執行正在的編譯命令之前,筆者執行一個只依賴于 node 的腳本,這個腳本會嘗試執行主要命令,如果主命令直接 crash,說明安裝環境尚未準備完畢,那么這個時候,對編譯系統進行初始化就 ok 了。如此,就真的可以做到一鍵開發了。

3.5. 編譯系統代碼化

在這一次的重構過程中,筆者將原本的編譯配置改為了由 ts 調用 webpack 的 nodeApi 來執行編譯。代碼化的編譯系統有諸多好處:

  1. 使用 api 調用,可以享受 IDE 帶來的代碼提示,再也不會因為不小心在配置文件里面打了一個 typo 而調試一整天。

  2. 使用代碼 api,能夠更好的實現編譯的結構,特別是有多重輸出的時候,比起簡單的 config 文件組合,更好管理。

  3. 使用代碼化的編譯系統,還有一個特別的作用,編譯系統也可以寫測試了!啥?編譯系統要寫測試?事實上,在騰訊文檔歷次的發布中,經歷過數次莫名的 bug,在上線前的測試中,整個程序的表現突然就不正常了。相關代碼,并沒有任何改動,大家地毯式的排查了很久,才發現編譯的結果和以前有微小的不同。事實上,在系統測試環境生成的前五個小時,一個編譯所依賴的插件默默的更新了一個小版本,而筆者在 package.json 中對該插件使用的是默認的^xx.xx,流水線 install 到了最新的版本,導致了 crash。當時筆者得出了一個結論,編譯相關的庫需要鎖定版本。但是,鎖定版本并不能真正的解決問題,編譯所使用的組件,總有升級的一天,如果保證這個升級不會引起問題呢?這就是自動化測試的范疇了。事實上,如果大家看看 Webpack 的代碼,會發現他們也做了很多測試用例來編譯的一致性,但是,webpack 的插件五花八門,并不是每一個作者在質量保障上都有足夠的投入,因此,用自動化測試保證編譯系統的穩定性,也是一個可以深入研究的課題。

4. 編譯提速

在涉及 typescript 編譯的項目中,基本的提速操作,就是異步的類型檢查,ts-loader 的 tranpsileOnly 參數和 fork-ts-checker 的組合拳百試不厭。不過,對于復雜的大型項目來說,這一套組合拳的啟用過程未必是一帆風順,不妨隨著筆者一起看看,在騰訊文檔中,啟用快速編譯的坎坷之路。

4.1. 消失的 enum

在啟用 transpileOnly 參數后,編譯速度立即有了質的提升,但是,結果并不樂觀。編譯后,頁面還沒打開,就 crash 了。根據報錯查下去,發現一個從依賴庫導入的對象變成了 undefined,從而引起程序崩潰。這個變為 undefined 的對象,是一個 enum,定義如下:

為什么當筆者啟用了 transpileOnly 后它就為空了呢?這和它的特殊屬性有關,它不是一個普通的 enum,它是一個 const enum。眾所周知,枚舉是 ts 的語法糖,每一個枚舉,對應了 js 中的一個對象,所以,一個普通的枚舉,轉化為 js 之后,會變成這樣:

如果筆者給 Scope 加上一個 const 關鍵字呢?它會變成這樣:

也就是說,const enum 就和宏是等效的,在翻譯成 js 之后,它就不存在了??墒?,為何在關閉 transpileOnly 時,編譯結果可以正常運行呢?其實,仔細翻看外部庫的聲明文件.d.ts,就會發現,在這個.d.ts 文件中,Scope 被原封不動的保留了下來。

在正常的編譯流程下,tsc 會檢查.d.ts 文件,并且已經預知了這一個定義,因此,它能夠正確的執行宏轉換,而對于 transpileOnly 開啟的情況下,所有的類型被忽略,由于原本的庫模塊中已經不存在 Scope 了,所以編譯結果無法正常執行(PS:tsc 官方已經表態 transpile 模式下的編譯不解析.d.ts 是標準 feature,丟失了 const enum 不屬于 bug,所以等待官方支持是無果的)。既然得知了緣由,就可以修復了。四種方案:

  • 方案一,遵循官方指導,對于不導出 const enum,只對內部使用的枚舉 const 化,也就是說,需要修改依賴庫。當然,騰訊文檔本次 crash 所有依賴庫確實屬于自有的 sdk,但是如果是外部的庫引起了該問題呢?所以該方案并不保險。

  • 方案二,完美版,手動解析.d.ts 文件,尋找所有 const enum 并提取定義。但是,transpileOnly 獲取的編譯加速真是得益于忽略.d.ts 文件,如果筆者再去為了一個 enum 手工解析.d.ts,而.d.ts 文件可能存在復雜的引用鏈路,是極其耗時的。

  • 方案三,字符串替換,既然 const enum 是宏,那么筆者可以手工通過 string-replace-loader 達到類似效果。不過,字符串替換方式依舊過于暴力,如果使用了類似于 Scope['VAL1'] 的用法,可能就猝不及防的失效了。
  • 方案四,也是筆者最終所采取的方案,既然定義消失了,重新定義就好,通過 Webpack 的 DefinePlugin,筆者可以重新定義丟失的對象,保證編譯的正常解析。

4.2. 愛恨交加的 decorator 及依賴注入

很不幸,僅僅是解決了編譯對象丟失的問題,代碼依舊無法運行。程序在初始化的時候,依舊迷之失敗了,經過一番調試,發現,初始化流程有一些微妙的不同。很明顯,transpileOnly 開啟的情況下,編譯的結果發生了變化。
要解決這個問題,就需要對 transpileOnly 模式的實現一探究竟了。transpileOnly 底層是基于 tsc 的 transpileModule 功能來實現的,transpileModule 的作用,是將每一個文件當做獨立的個體進行解析,每一個 import 都會被當做一個整體模塊來看待,編譯器不會再解析模塊導出與文件的具體關系,舉個例子:

如上是常見的代碼寫法,我們往往會通過一個 index.ts 導出 base 中的模塊,這樣,在其他模塊中,筆者就不需要引用到文件了。在正常模式下,編輯器解析這段代碼,會附帶信息,告知 webpack,A 是由 a.ts 導出的,因此,webpack 在打包時,可以根據具體場景將 A、B 打包到不同的文件中。但是,在 transpileModule 模式下,webpack 所知道的,只有 base 模塊導出了 A,但是它并不知道 A 具體是由哪個文件導出的,因此,此時的 webpack 一定會將 A、B 打包到一個文件中,作為一整個模塊,提供給 App。對于騰訊文檔,這個情況發生了如下變化(模塊按照 1、2、3、4、5 的順序進行加載,模塊的視覺大小表示體積大?。?/p>

可以看到,在 transpileOnly 開啟的情況下,大量的文件被打包到了模塊 1 中,被提前加載了。不過,一般情況下,模塊被打包到什么位置,并不應該影響代碼的表現,不是么?畢竟,關閉 code splitting,代碼是可以不拆包的。對于一般的情況而言,這樣理解并沒有錯。但是,對于使用了 decorator 的項目而言,就不適用了。在代碼普遍會轉為 es5 的時代,decorator 會被轉換為一個__decorator 函數,這個函數,是代碼加載時的一個自執行函數。如果代碼打包的順序發生了變化,那么自執行函數的執行順序也就可能發生了變化。那么,這又如何導致了騰訊文檔無法正常啟動呢?這,就要從騰訊文檔全面引入依賴注入技術開始說起。

在騰訊文檔中,每一個功能都是一個 feature,這個 feature 并不會手動初始化,而是通過一個特殊裝飾器,注入到騰訊文檔的 DI 框架中,然后,由注入框架進行統一的實例創建。舉個例子,在正常的變一下,由三個 Feature A、B、C,A、B 被編譯在模塊 1 中,C 被編譯到模塊 2 中。在模塊 1 加載時,workbench 會進行一輪實例創建和初始化,此時,FeatureA 的初始化帶來了某個副作用。然后,模塊 2 加載了,workbench 再次進行一輪實力創建和初始化,此時 FeatureC 的初始化依賴了 FeatureA 的副作用,但是第一輪初始化已經結束,因此 C 順利實例化了。

當 transpileOnly 被開啟式,一切變了樣,由于無法區分導出,Feature A、B、C 被打包到同一個模塊了??上攵?,在 Feature C 初始化時,由于副作用尚未發生,C 的初始化就失敗了。

既然 transpileOnly 與依賴注入先天不兼容,那筆者就需要想辦法修復它。如果,筆者將 app 中的引用進行替換:

模塊導出的解析問題,是否就迎刃而解了?不過,這么多的代碼,改成這樣的引用,不但難看,反人類,工作量也很大。因此,讓筆者設計一個 plugin/loader 組合在編譯時來解決問題吧。在編譯的初始階段,筆者通過一個 plugin,對項目文件進行解析,將其中的 export 提取出來,找到每一個 export 和文件的對應關系,并儲存起來(此處,可能大家會擔心 IO 讀寫對性能的影響,考慮到現在開發人均都是高速 SSD,這點 IO 吞吐真的不算什么,實測這個 export 解析<1s),然后在編譯過程中,筆者再通過一個自定義的 loader 將對應的 import 語句進行替換,這樣,就可以實現在不影響正常寫代碼的情況下,保持 transpileOnly 解析的有效性了。

經過一番折騰,終于,成功的將騰訊文檔在高速編譯模式下運行了起來,達到了預定的編譯速度。

5.Webpack5 升級之路

5.1. 一些兼容問題處理

Webpack5 畢竟屬于一次非兼容的大升級,在騰訊文檔編譯系統重構的過程中,也遇到諸多問題。

5.1.1. SplitChunks 自定義 ChunkGroups 報錯

如果你也是 splitChunks 的重度用戶,在升級 webpack5 的過程中,你可能會遇到如下警告:

這個警告的說明并不是十分明確,用大白話來說,出現了這個提示,說明你的 chunkGroups 配置中,出現了 module 同時屬于 A、B 兩組(此處 A、B 是兩個 Entrypoint 或者兩個異步模塊),但是你明確指定了將模塊屬于 A 的情況。為何此時 Webpack5 會報出警告呢?因為從通常情況來說,module 分屬于兩個 Entrypoint 或者異步模塊,module 應該被提取為公共模塊的,如果 module 被歸屬于 A,那么 B 模塊如果單獨加載,就無法成功了。

不過,一般來說,出現這樣的指定,如果不是配置錯誤,那就是 A、B 之間已經有明確的加載順序。但是這個加載順序,Webpack 并不知道。對于 entrypoint,webpack5 中,允許通過 dependOn 屬性,指定 entry 之間的依賴關系。但是對于異步模塊,則沒有這么遍歷的設置。當然,筆者也可以通過自定義插件,在 optimize 之前,對已有的模塊依賴關系以及修改,保證 webpack 能夠知曉額外的信息:

5.1.2.plugin 依賴的 api 已刪除

Webpack5 發布后,各大主流 plugin 都已經相繼適配,大家只要將插件更新到最新版本即可。不過,也有一些插件因為諸多緣由,一些插件并沒有及時更新。(PS:目前,沒有匹配的插件大多已經比較小眾了。)總之,這個問題是比較無解的,不過可以適當等待,應該在近期,大部分插件都會適配 webpack5,事實上 webpack5 也是用了不少改名大法,部分接口進行轉移,調用方式發生了改變,倒也沒有全部翻天覆地的變化,所以,實在等不及的小插件不妨試試自己 fork 修改一下。

5.2.Module Federation 初體驗

通常,對于一個大型項目來說,筆者會抽取很多公共的組件來提高項目間的模塊共享,但是,這些模塊之間,難免會有一些共同依賴,比如 React、ReactDOM,JQuery 之類的基礎庫。這樣,就容易造成一個問題,公共組件抽取后,項目體積膨脹了。隨著公共組件的增多,項目體積的膨脹變得十分可怕。在傳統打包模型上,筆者摸索出了一套簡單有效的方法,對于公共組件,筆者使用 external,將這些公共部分摳出來,變成一個殘疾的組件。

但是,隨著組件的增多,共享組件的 Host 增多,這樣的方式帶來了一些問題:

  1. Component 需要為 Host 專門打包,它不是一個可以獨立運行的組件,每一個運行該 Component 的 Host 必須攜帶完整的運行時,否則 Component 就需要為不同的 Host 打出不同的殘疾包。

  2. Component 與 Component 之間如果存在較大的共享模塊,無法通過 external 解決。

這個時候,Module Federation 出現了,它是 Webpack 從靜態打包到完整運行時的一個轉變,Module Federation 中,提出了 Host 和 Remote 的概念。Remote 中的內容可以被 Host 消費,而在這個消費過程中,可以通過 webpack 的動態加載運行時,只加載其中需要的部分,對于已經存在的部分,則不作二次加載。(下圖中,由于 host 中已經包含了 jQuery、react 和 dui,Webpack 的運行時將只加載 Remote1 中的 Component1 和 Remote2 中的 Component2。)

也就是說,公共組件作為一個 Remote,它包含了完整的運行時,Host 無需知道需要準備什么樣的運行時才可以運行 Remote,但是 Webpack 的加載器保證了共享的代碼不作加載。如此一來,就避免了傳統 external 打包模式下的諸多問題。事實上,一個組件可以同時是 Host 和 Remote,也就是說,一個程序既可以作為主程運行,也可以作為一個在線的 sdk 倉庫。關于 Module Federation 的實現原理,此處不再贅述,大家感興趣可以參考探索 webpack5 新特性 Module federation 在騰訊文檔的應用中的解析,也可以多多參考 module-federation-examples 這個倉庫中的實例。

Webpack5 的 Module Federation 是依賴于其動態加載機制的,因此,在它的演示實例中,你都可以看到這樣的結構:

而 Webpack 的入口配置,都設置在了 index.js 上,這里,是因為所有的依賴都需要動態判定加載,如果不把入口變成一個異步的 chunk,那如何去保障依賴能夠按順序加載內?畢竟實現 Moudle Federation 的核心是 基于 webpack_require 的動態加載系統。由于 Module Federation 需要多個倉庫的聯動,它的推進必然是相對漫長的過程。那么筆者是否有必要將現有的項目直接改造為 index-bootstrap 結構呢?事實上,筆者依然可以利用 Webpack 的插件機制,動態實現這一個異步化過程:

在上述的 bootstrapEntry 方法中,筆者基于原本的 entrypoint 文件,創建一個虛擬文件,這個文件的內容就是:

再通過 webpack-virtual-modules 這個插件,在相同目錄生成一個虛擬文件,將原本的入口進行替換,就完成了 module-federation 的結構轉換。這樣,配合一些其他的相應配置,筆者就可以通過一個簡單參數開啟和關閉 module-federation,把項目變成一個 Webpack5 ready 的結構,當相關項目陸續適配成功,便可以一起歡樂的上線了。

6. 后記

對編譯的大重構是筆者蓄謀已久的事情,猶記得加入團隊之時,第一次接觸到編譯鏈路如此復雜的項目,深感自己的項目經歷太淺,接觸的編譯和打包都如此簡單,如今親自操刀才知道,這中間除了許多技術難題,大項目必有的祖傳配置也是阻礙項目進步的一大阻力。

Webpack 5 的 Beta 周期很長,所以在 Webpack5 發布之后,兼容問題還真不如預想的那么多,不過 Webpack5 的文檔有些坑,如果不是使用 NodeApi 的時候,有類型聲明,筆者絕對無法發現官方不少文檔的給的參數還是 webpack4 的舊數據,對不上號。于是不得不埋著頭調試源代碼,尋找正確的配置方式,不過也因此收獲良多。并且 Webpack5 還在持續迭代中,還存在一些 bug,比如 Module Federation 中使用了不恰當的配置,可能會導致奇怪的編譯結果,并且不會報錯。所以,遇到問題大家要大膽的提 issue。本次重構的經驗就暫時說到這兒,如有不當之處,歡迎斧正。

最后,騰訊文檔大量招人,如果你也想來研究這么有趣的技術,歡迎加入騰訊文檔的大家庭,歡迎聯系筆者 glendonzli@qq.com。

原創文章轉載請注明:

轉載自AlloyTeam:http://www.ecomenagepro.com/2020/12/14882/

  1. RandomYang 2020 年 12 月 16 日

    看了這篇文章學習到了很多,也很佩服你們的專研精神。不過有個疑問,為何你們的這個網站不進行一個簡單的 redesign,每次打開都有種年代感 [捂臉]。

發表評論