大型前端項(xiàng)目架構(gòu)優(yōu)化探索之路-騰訊文檔表格
騰訊文檔表格是一個(gè)非常復(fù)雜的業(yè)務(wù),它實(shí)現(xiàn)了傳統(tǒng) excel 的大部分核心功能,包括函數(shù)計(jì)算、條件格式、圖表、智能分列等;除此之外還支持高效的多人協(xié)同編輯;它的代碼量百萬級(jí)別,啟動(dòng)也流程多達(dá)十幾步。在前端領(lǐng)域,能達(dá)到這種規(guī)模的項(xiàng)目應(yīng)該還是比較少的。
騰訊文檔表格業(yè)務(wù)不僅代碼規(guī)模龐大,模塊間依賴關(guān)系也很復(fù)雜;由于業(yè)務(wù)的特殊性導(dǎo)致模塊間天然有較強(qiáng)的依賴關(guān)系;比如改動(dòng)一個(gè)單元格的內(nèi)容,可能觸發(fā)包括函數(shù)計(jì)算、圖表渲染等多個(gè)模塊的邏輯執(zhí)行。
由于大部分前端項(xiàng)目相對(duì)較小,我們?nèi)粘i_發(fā)中可能意識(shí)不到大量模塊耦合帶來的問題;往往是直接在代碼里硬編碼依賴關(guān)系;比如我使用 A、B、C 三個(gè)模塊完成一個(gè)功能,只需要手動(dòng) new 三個(gè)模塊就行了,看起來很簡單。
logic(){const a = new A();const b = new B();const c = new C();a.do();b.do();c.do();}
項(xiàng)目的規(guī)模比較小,代碼量很少時(shí),我們的代碼或許整體上看起來還很干凈。就像一個(gè)小型機(jī)房,只有幾交換機(jī),每臺(tái)交換機(jī)用網(wǎng)線互相連接;即便我們不對(duì)它們進(jìn)行額外的管理,也不會(huì)顯得混亂。

但如果我們管理的是一個(gè)大型數(shù)據(jù)中心呢,還是不對(duì)機(jī)器之間的連接進(jìn)行額外的治理,會(huì)是怎么樣?對(duì)于大型的項(xiàng)目而言,隨著功能模塊的增加,整個(gè)代碼庫看起來就像這個(gè)數(shù)據(jù)中心一樣:

針對(duì)這種混亂的局面,如果讓我們排查兩個(gè)機(jī)器之間的連接問題是非常困難的。對(duì)于大型項(xiàng)目,我們往往會(huì)進(jìn)行一些初步的梳理。
1. 初步梳理
大型項(xiàng)目的模塊過多時(shí),我們往往會(huì)考慮根據(jù)功能進(jìn)行分層;就比如在線表格項(xiàng)目中,我們把模塊分為渲染層、數(shù)據(jù)層、網(wǎng)絡(luò)層、以及 feature 層,當(dāng)然還包括 worker 里的模塊:

然后對(duì)每一個(gè)層級(jí),都新建一個(gè)單獨(dú)的目錄:
+ src+ dataLayersubModule...+ netLayer+ renderLayer+ feature+ worker
對(duì)模塊進(jìn)行分層后,再引入全局變量 globalApp,將各個(gè)模塊分層次掛載后,
const globalApp = {dataLayer: { subModule... },netLayer: { subModule... },renderLayer: { subModule... },feature: { subModule... },worker: { subModule... }}
就可以直接通過全局變量引用模塊,就像 node 里的 global超級(jí)對(duì)象一樣:
logic(){// 調(diào)用數(shù)據(jù)層子模塊globalApp.dataLayer.a.doSomething();// 調(diào)用網(wǎng)絡(luò)層子模塊globalApp.netLayer.b.doSomething();// 調(diào)用渲染層子模塊globalApp.renderLayer.c.doSomething();}
這樣看,模塊的組織貌似還算有條理,很多大型項(xiàng)目都止步于此;在線表格業(yè)務(wù)在很長一段時(shí)間也是這么做的。
但是通過全局 globalApp引用子模塊,隱藏了實(shí)際的依賴關(guān)系,本來應(yīng)該直接依賴 subModule,現(xiàn)在變成間接通過 globalApp 依賴了 subModule 了;在不了解系統(tǒng)的全貌的情況下,在改模塊代碼、寫模塊單測(cè)時(shí)都變得更困難了。我們必須認(rèn)真的讀代碼邏輯,才知道這段代碼具體依賴于哪個(gè)子模塊。除此之外,有些模塊是異步模塊,通過全局 APP 調(diào)用異步模塊時(shí),異步模塊可能還沒初始化好,很容易導(dǎo)致時(shí)序問題。
2. 依賴注入/控制反轉(zhuǎn)
為了解決這些問題,我們引入了依賴注入框架來管理依賴。依賴注入的思想在軟件設(shè)計(jì)領(lǐng)域已經(jīng)非常成熟,但受限于前端項(xiàng)目規(guī)模,可能不少前端開發(fā)還沒有在前端項(xiàng)目中實(shí)際使用過。
可以這樣理解依賴注入/控制反轉(zhuǎn):一個(gè)模塊本來需要接受各種參數(shù)來構(gòu)造一個(gè)對(duì)象,現(xiàn)在只接受一個(gè)參數(shù)——已經(jīng)實(shí)例化的對(duì)象。對(duì)模塊來說對(duì)對(duì)象的『依賴』是注入進(jìn)來的,而和它的構(gòu)造方式解耦了。而“構(gòu)造它”這個(gè)『控制』操作也交給了第三方框架,也就是控制反轉(zhuǎn)。
引入依賴注入框架后,模塊間沒有直接聯(lián)系。模塊就像一個(gè)個(gè)獨(dú)立的零部件,放在一個(gè)容器里等待裝配。一段代碼聲明了它需要哪些類型(接口)的模塊。然后由框架將模塊裝配起來實(shí)現(xiàn)功能。可以將我們聲明的依賴關(guān)系理解為一份配置,模塊容器根據(jù)這份配置,為我們裝配出各個(gè)模塊的實(shí)際關(guān)系,形成一個(gè)系統(tǒng)功能。

通過這種思路,我們即避免了模塊直接強(qiáng)耦合,也解決了手動(dòng)管理復(fù)雜依賴的困境。為了便于理解架構(gòu)的演進(jìn),接下來我們來簡要了解一下依賴注入框架的技術(shù)細(xì)節(jié)。
(1) 依賴聲明與解析
首先我們將所有的模塊放入一個(gè)列表中(借鑒至 vscode),每個(gè)模塊都有一個(gè) ID 和構(gòu)造器:
模塊列表 = [{ IA, A },{ IB, B },];
然后使用 Typescript 的參數(shù)裝飾器聲明依賴的關(guān)系:
class X{construction( a, b){}}
參數(shù)裝飾器為構(gòu)造函數(shù)添加一個(gè)元數(shù)據(jù)屬性,用來保存依賴關(guān)系:
X.$$DEPS =通過構(gòu)造函數(shù)元數(shù)據(jù)中保存的依賴關(guān)系,查找模塊列表就能解析出一個(gè)模塊的依賴關(guān)系圖:

根據(jù)模塊間聲明的依賴關(guān)系,進(jìn)行多次查表,我們就能解析出整個(gè)系統(tǒng)的依賴關(guān)系圖。而依賴圖解析完成后,按深度優(yōu)先遍歷的順序?qū)嵗湍鼙WC初始化時(shí)序的正確性。
通過聲明式依賴,保證了依賴關(guān)系的清晰;框架負(fù)責(zé)注入依賴實(shí)例,保證了模塊的解耦,提高可測(cè)性和可維護(hù)性;框架按依賴圖初始化,避免了時(shí)序問題。
(2) 延遲初始化模塊實(shí)例
當(dāng)模塊所依賴的對(duì)象實(shí)例都是由框架注入,我們還可以做一些有趣的優(yōu)化:延遲初始化。對(duì)于騰訊文檔在線表格業(yè)務(wù)來說,無論有無編輯權(quán)限,大部用戶打開表格都是為了查看表格,而不會(huì)進(jìn)行編輯。如果大部分用戶不編輯表格,我們可以利用依賴注入框架將與編輯功能有關(guān)的模塊,統(tǒng)一延遲到真正使用時(shí)再初始化。比如在線表格業(yè)務(wù)中的 undoredo 棧,在用戶沒編輯時(shí)就不會(huì)初始化,從而達(dá)到一定的內(nèi)存治理的效果。

延遲初始化是如何實(shí)現(xiàn)的呢?undoredo 模塊的實(shí)例是由依賴注入框架提供的,我們可以先注入一個(gè) Proxy 占位:

然后通過 Proxy 攔截實(shí)例的屬性查找、函數(shù)調(diào)用,這樣就能做到在真正獲取屬性實(shí)例或調(diào)用實(shí)例的方法時(shí)再初始化實(shí)例。
const undeoProxy = new Proxy(Object.create(null), {get(target, key) {// 沒有實(shí)例時(shí),先創(chuàng)建實(shí)例if (!this.instance) {this.instance = di.create(Ctor);}// 返回已經(jīng)緩存的實(shí)例return this.instance[key];},});
這套延遲初始化方案,對(duì)模塊本身是透明的,完全由依賴注入框架通過參數(shù)配置有選擇的開啟。
(3) 異步依賴管理
在線表格代碼量在百萬級(jí)別,模塊有上千個(gè);而要在單個(gè)頁面中加載一百萬行代碼,我們必須對(duì)模塊進(jìn)行異步分批加載。上文提到的依賴注入思路只能支持同步模塊,顯然無法滿足我們的業(yè)務(wù)需求。舉一個(gè)我們業(yè)務(wù)場(chǎng)景中實(shí)際的例子:頁面在執(zhí)行到某個(gè)生命周期階段時(shí),需要加載插件系統(tǒng),而插件系統(tǒng)會(huì)加載 workbench 模塊,workbench 上的文件導(dǎo)出模塊可能在用戶正在點(diǎn)擊時(shí)才觸發(fā)加載。
針對(duì)這種多層嵌套的異步依賴關(guān)系,我們?cè)撊绾斡行У墓芾砟兀繛榇丝蚣芴峁┝艘粋€(gè)通用 loader,通過 loader 就能將一個(gè)同步模塊包裝成異步模塊。loader 負(fù)責(zé)兩件事:
加載異步模塊
解析異步模塊的依賴并初始化
假設(shè)一個(gè)系統(tǒng)中,C 模塊異步的依賴于 D 模塊,那我們只需要聲明 C 對(duì) dLoader 的依賴:
class C {constructor( d: dLoader){} //C模塊聲明對(duì)dLoader的依賴test(){const d = dLoader.getInstance(); //加載并初始化D模塊}}
則依賴關(guān)系圖如下:

在調(diào)用 dLoader.getInstance時(shí)會(huì)觸發(fā)對(duì) D 模塊的加載,同時(shí)解析 D 模塊本身的依賴關(guān)系,然后再初始化 D 模塊。假設(shè) D 模塊本身又依賴于 E 模塊,則解析出的 D 模塊依賴關(guān)系圖如下:

以 X 為根節(jié)點(diǎn)的依賴圖包含了 dLoader,但對(duì) D 的依賴圖無感知;只有 dLoader 知道 D 模塊的依賴圖;可以通過 dLoader 作為橋梁,將兩部分依賴圖鏈接起來,形成包含同步模塊和異步模塊的復(fù)雜的依賴關(guān)系圖:

通過引入 Loader,我們屏蔽了同步和異步模塊的實(shí)現(xiàn)細(xì)節(jié);一個(gè)模塊是同步還是異步,只需要在聲明依賴和注冊(cè)模塊時(shí)稍有差異。多個(gè) Loader 就可以組裝成任意多層嵌套的異步依賴關(guān)系圖;通過支持同步和異步模塊的隨意組合,我們就能駕馭真實(shí)的復(fù)雜場(chǎng)景,而如果要硬編碼維護(hù)這樣復(fù)雜的多層嵌套異步依賴關(guān)系,是非常容易出問題的。
至此,我們已經(jīng)探索出一條有效的模塊依賴關(guān)系治理之路。但架構(gòu)的優(yōu)化不會(huì)止步于此,業(yè)務(wù)對(duì)架構(gòu)又提出了新的訴求。
3. 模塊分層
什么是模塊分層呢?對(duì)在線表格來說,我們將系統(tǒng)分為幾個(gè)層級(jí):
Core 層提供核心的能力;只讀層調(diào)用 Core 層的能力實(shí)現(xiàn)只讀功能;相應(yīng)的,可編輯層調(diào)用只讀層和 Core 層的能力,實(shí)現(xiàn)可編輯功能

分層的好處是:理想情況下,我們可以隨意的替換掉外層,來提供不同的產(chǎn)品能力;而要隨意的替換外層,必須保證外層模塊不影響內(nèi)層模塊的功能,這就需要做到:
外層模塊單向依賴于內(nèi)層模塊,內(nèi)層模塊不能依賴外層模塊
因?yàn)閮?nèi)層模塊如果依賴于外層模塊,替換外層就會(huì)破壞內(nèi)層的功能。保證模塊分層單向依賴后,假設(shè)一個(gè)第三方的 APP 只需要在線表格的只讀功能,那么我們只需要拿掉可編輯層,封裝一個(gè) SDK 提供給第三方 APP 就行了。

為了應(yīng)對(duì)這種訴求,框架也支持多層容器,可以將模塊放在不同層級(jí)的容器中。比如模塊 C 放在可編輯層容器,模塊 B 放在只讀層容器,模塊 A 放在 core 層容器。

外層容器通過 parent 指向內(nèi)層容器。
editableCollection.parent = readonlyCollection;readonlyCollection.parent = coreCollection;
而在依賴解析時(shí),遞歸查找父容器
function inCollection(collection, id) {if (colleciton.has(id)) {return true;} else if (colleciton.parent) {//遞歸的查詢?nèi)肴萜?/span>return inCollection(colleciton.parent);}return false;}
通過這種單向的遞歸依賴解析,我們就做到了層級(jí)間模塊單向可見:
inCollection(editableCollection, A) === true;inCollection(coreCollection, B) === false;

預(yù)先將模塊放入不同層級(jí)的容器中,對(duì)于違背單向依賴規(guī)則的模塊則拋出異常
比如我們的 A 模塊是負(fù)責(zé)圖表繪制的核心模塊,而 B 模塊是負(fù)責(zé)錯(cuò)誤上報(bào)的非核心模塊(與具體的日志平臺(tái)耦合)。理論上來說,為了保證 A 模塊的高可用性,A 模塊不應(yīng)該依賴于特殊的業(yè)務(wù)模塊,即錯(cuò)誤上報(bào)模塊 B。而在一個(gè)代碼庫中的模塊,如何防止這種情況出現(xiàn)呢?傳統(tǒng)的方法是 CodeReview,而最好的辦法就是利用容器分層單向依賴關(guān)系,在運(yùn)行時(shí)拋出異常,強(qiáng)制業(yè)務(wù)開發(fā)不能這么使用。
4. 模塊生命周期管理
模塊的生命周期包括創(chuàng)建、銷毀、清理。大部分其他架構(gòu)只考慮根據(jù)依賴關(guān)系創(chuàng)建模塊,極少考慮到模塊的清理、銷毀、以及銷毀后的重建。接下來我們繼續(xù)探索一下我們的框架是如何管理模塊的銷毀和清理的。

為什么需要管理模塊的生命周期呢?對(duì)應(yīng)我們的要業(yè)務(wù)來說:比如刪除子表時(shí),對(duì)應(yīng)子表的實(shí)例就應(yīng)該可靠的銷毀,否則會(huì)有內(nèi)存泄露的問題。

(1)模塊實(shí)例的銷毀
模塊的銷毀就是直接刪除模塊的實(shí)例。我們首先考慮模塊的銷毀難在哪里?假設(shè)一個(gè)模塊依賴圖,包括了復(fù)雜的同步、異步依賴組成的關(guān)系網(wǎng)。

我們要保證銷毀部分模塊后,還能重建整個(gè)依賴關(guān)系網(wǎng),系統(tǒng)功能還是正常的。比如標(biāo)紅的 D 和 K 模塊,它們都是要被銷毀的模塊,但是它們都被其他模塊引用了。模塊被引用,如何可靠的銷毀呢?
一種解決思路是:對(duì)于需要銷毀的模塊,我們生成一個(gè) Scoped Wrapper,其他模塊通過 Wrapper 引用該模塊,保證模塊的真實(shí)實(shí)例沒有被外部直接引用,這樣就能安全的銷毀掉模塊實(shí)例。銷毀后,其他模塊通過 Wrapper 調(diào)用該模塊的方法時(shí),會(huì)觸發(fā)通過 DI 框架重新初始化該模塊,從而達(dá)到安全的重新初始化。

這樣就做到了:
只銷毀掉依賴關(guān)系圖中的部分模塊的實(shí)例,同時(shí)保留了其他模塊的實(shí)例。
(2)模塊實(shí)例的清理
模塊實(shí)例的清理往往是為了清理實(shí)例的狀態(tài),這些狀態(tài)包括:設(shè)置的 timer、pending 狀態(tài)的 Promise、插入的 DOM 節(jié)點(diǎn)、自定義的事件等。
假設(shè)我們有 a、b、c、d、e、f、g 六個(gè)模塊實(shí)例需要清理。
一種常見方法是:各個(gè)模塊是平級(jí)關(guān)系,全局拋出一個(gè)事件(dispose),各個(gè)模塊都監(jiān)聽這個(gè)事件,執(zhí)行自己的清理工作,各人自掃門前雪。
onDispose(()=>{a.dispose();});...onDispose(()=>{f.dispose();});
這種方式對(duì)業(yè)務(wù)模塊入侵較大,每個(gè)實(shí)現(xiàn)模塊的都必須監(jiān)聽一個(gè)全局事件;此外,清理工作分散在各個(gè)模塊中,很容易因?yàn)槭韬鰧?dǎo)致泄露。
另外一種方案是:各個(gè)模塊實(shí)現(xiàn)統(tǒng)一的清理接口,負(fù)責(zé)自身狀態(tài)的清理;同時(shí)把自己的清理接口的調(diào)用委托給父節(jié)點(diǎn),形成一個(gè)清理樹:

這樣只需要根節(jié)點(diǎn)調(diào)用一次清理方法,就能完成整個(gè)子樹的清理工作。這種方案使得清理工作更加結(jié)構(gòu)化,但需要我們手動(dòng)編碼維護(hù)這種清理樹;框架提供了基類,只需要簡單的調(diào)用就能維護(hù)這棵清樹。
無論框架提供多么高的便利性,還是需要我們手動(dòng)編碼組織起這塊清理樹。如果某些節(jié)點(diǎn)沒有將自身清理工作委托給父節(jié)點(diǎn),就會(huì)導(dǎo)致子樹的泄露。

為此,框架提供了一種簡單的開發(fā)階段的泄露檢查機(jī)制,判斷發(fā)生泄露時(shí),會(huì)觸發(fā)異常;進(jìn)而提醒開發(fā)關(guān)注,而通過異常堆棧就可以快速定位到具體那里的代碼存在泄露問題。結(jié)構(gòu)化清理機(jī)制支持 trace 日志,可以清晰的看出銷毀樹的層級(jí),方便開發(fā)調(diào)試。
dispose IInstantiationServicedispose -> ICdispose -> IInstantiationServicedispose -> IBdispose -> IAdispose -> Ainstance
(3)模塊重用的綜合效果
我們希望在切換不同的表格時(shí),能夠復(fù)用 webview 和已經(jīng)加載的模塊,而不是每次都重新加載整個(gè)頁面。可以將頁面理解為一個(gè)容器,同一個(gè)容器,清理狀態(tài),就能承載不同的表格數(shù)據(jù)。 容器化重用的核心就是銷毀和清理模塊。

清理 container 的狀態(tài)就是指:清理模塊實(shí)例的狀態(tài)、銷毀模塊實(shí)例、并重用模塊
通過架構(gòu)對(duì)模塊全生命周期的管理,我們可以對(duì)模塊進(jìn)行可靠的復(fù)用,從而達(dá)到非常好的優(yōu)化效果,在模塊復(fù)用的情況下進(jìn)行切換表格,可以快速展示表格內(nèi)容。
總結(jié)一下業(yè)務(wù)驅(qū)動(dòng)的架構(gòu)探索之路:
管理復(fù)雜的依賴關(guān)系
支持異步依賴關(guān)系管理
支持模塊分層單向依賴
支持模塊全生命周期管理
架構(gòu)的探索最終都反應(yīng)在依賴注入框架的演進(jìn)上。我們也已經(jīng)將這套依賴注入框架部分核心能力從業(yè)務(wù)中抽離出來,形成 npm 包,目前團(tuán)隊(duì)中的其他項(xiàng)目也在嘗試接入中。
關(guān)于AlloyTeam
AlloyTeam 是國內(nèi)影響力最大的前端團(tuán)隊(duì)之一,核心成員來自前 WebQQ 前端團(tuán)隊(duì)。 AlloyTeam負(fù)責(zé)過WebQQ、QQ群、興趣部落、騰訊文檔等大型Web項(xiàng)目,積累了許多豐富寶貴的Web開發(fā)經(jīng)驗(yàn)。 這里技術(shù)氛圍好,領(lǐng)導(dǎo)nice、錢景好,無論你是身經(jīng)百戰(zhàn)的資深工程師,還是即將從學(xué)校步入社會(huì)的新人,只要你熱愛挑戰(zhàn),希望前端技術(shù)和我們飛速提高,這里將是最適合你的地方。 加入我們,請(qǐng)將簡歷發(fā)送至 [email protected],或直接在公眾號(hào)留言~ 期待您的回復(fù)??
最后
面試題交流群持續(xù)開放,已經(jīng)分享了近 許多 個(gè)面經(jīng)。
加我微信: DayDay2021,備注面試,拉你進(jìn)群~
我是 TianTianUp,我們下篇見~
往期推薦
