Tubes響應性數據系統的設計與原理
Tubes是一套面向C端搭建場景,支持靈活擴展、極致性能和高穩定性的終端渲染解決方案,目前廣泛運用在淘寶、天貓,包括:雙11、618會場、淘寶新人版首頁等業務場景。
介紹
響應性數據系統指的是程序在使用系統提供的數據的同時會自動訂閱自己所使用的數據,被訂閱的數據發生變化時使用了(訂閱了)這個數據的程序會對數據的變化做出響應。
響應性是 Vue.js 最具特色的特性(之前叫響應式,Vue3給翻譯成響應性,我認為響應性這個詞整挺好),不過 Tubes 的響應性原理和 Vue.js 略有不同。
響應性數據系統在Tubes中發揮的作用
Tubes 為什么需要響應性數據系統,這來源于一個重要的業務問題:“如何解決會場分屏渲染問題”,站在技術視角要解決的問題是:“Tube多次執行的設計問題”。
正在讀文章的你可能不了解Tubes體系,不清楚什么是Tube,沒關系,你可以把問題想象成:如何解決 Express.js 的中間件多次執行、如何解決 Webpack 的Loader多次執行。
分屏渲染指的是在搭建體系中,產出的頁面會先渲染首屏的模塊,再渲染次屏模塊。在Tubes體系中,從第一個Tube開始執行,執行到最后一個Tube則完成一次渲染(這與 Express.js 的中間件機制有點類似,一個請求進來從第一個 Middleware 開始執行,執行到最后一個Middleware結束),如下圖所示:
從第一個Tube執行到最后一個Tube則完成一次模塊的渲染。在會場分屏渲染的場景下,通常第一輪執行完畢表示完成了首屏模塊的渲染。那么想要渲染次屏模塊,只需要再執行一輪Tube就可以將次屏模塊渲染出來。那么問題來了,Tube多次執行的機制如何設計比較優雅?
我們設計了兩種方案:“方案一:基于循環系統實現”、“方案二:基于響應性數據系統實現”。下面我們對比下兩個方案的區別:
? 方案一:基于循環系統實現
基于循環系統,Tube可以一圈一圈執行。第一圈渲染時,Tube開發者可以在內部調用API通知 Tubes-Engine 本輪執行完后還需要再執行一輪。通過這樣的機制,實現第一圈渲染首屏模塊,第二圈渲染次屏模塊。
這里的問題是由于并不是所有Tube都需要執行多次,因此 Tubes-Engine 需要為Tube提供一些屬性和方法(例如:當前執行的圈數、once方法等)方便 Tube 根據 “圈數” 做一些邏輯、或者使用 once 聲明自己只需要在第一輪執行一次,后面的其他輪次不參與執行。
其實只有和“首屏”、“次屏”渲染邏輯強相關的Tube需要多次執行(舉個例子:env tube的邏輯是初始化一些環境信息,比如是否是Android或者iOS,PC還是Mobile,這個邏輯只需要在第一輪執行時執行一次就可以),在基于循環系統的實現下,不需要多次執行的Tube就需要開發者明確聲明這個Tube只需要執行一次(就是說,如果開發者判斷自己的Tube只需要執行一次,那么他需要為這個Tube做一些額外的處理)。
現在我們總結下這個方案的優缺點:
優點: Tubes-Engine內部實現簡單(意味著不容易出錯)
缺點:
1.Tube開發者需深入理解Tube運行機制(輪圈制)以及背后為什么設計成“輪圈制”
2.每個Tube,都需要處理輪圈制相關的邏輯,例如:
只需要執行一次的Tube:需要使用once聲明自己只需要執行一次
需要執行多次的Tube:需要根據圈數來判斷本輪做什么事情,以及是否需要再來下一輪并調用API通知 Tubes-Engine 再執行一輪。
這個方案很直觀,可以解決問題。但也可以看出,該方案對Tube開發者來說使用成本很高,無論Tube是否需要多次執行,開發者都需要深入理解背后的輪圈原理并對自己開發的Tube做相應的判斷(是否需要多次執行)和處理。
? 方案二:基于響應性數據系統實現
設計之初,Tube的本質就是一個具備 “冪等性” 的執行單元(函數),它的一面是輸入,另一面是輸出,前一個Tube的輸出是下一個Tube的輸入,多個Tube組合在一起完成頁面渲染。
Tube是用于處理渲染的執行單元(函數),Tubes 的設計理念是利用若干簡單的執行單元(Tube)讓計算結果不斷漸進,逐層推導復雜的運算,而不是設計一個復雜的執行過程?!?Tubes官網
具備 “冪等性” 意味著,輸入相同的情況下,輸出一定相同。也就是說輸入不變的情況下,輸出也沒有變化,因此沒有必要讓其重新執行。只有輸入變了,才意味著輸出會變,這時候就需要重新執行Tube使其重新計算并輸出新的結果。
方案的關鍵思路是“根據輸入輸出的變化來決定哪個Tube應該再次執行”,而實現這個能力的關鍵技術就是“響應性數據系統”。
因此,基于響應性數據系統指的是:只有某個Tube所使用的數據發生“變化”時,才重新“按序”執行Tube?!鞍葱颉眻绦惺侵溉绻鄠€Tube都使用了同一個數據,且該數據發生了變化,那么這些 Tube 按照Tube的編排順序執行。
例如:渲染了首屏后,“數據Tube”請求了次屏數據并將次屏數據也放入了Store中,那么這個放入數據的操作,就會觸發一個數據的 update,那么此時使用了該數據的Tube會再次執行,且只有使用了該數據的Tube會再次執行。再次執行的Tube或許又會修改數據,依賴這個數據的后續Tube又會加入到“執行隊列”中再次執行,以此實現一條 “基于依賴的執行鏈” 即:前一個 Tube 修改了數據并寫入響應性數據系統,后面讀取了這份數據的 Tube 會被再次執行并將重新計算后的新數據寫入響應性數據系統,而這個寫入行為又會再次觸發后面的其他 Tube 執行,直到完成最終渲染。
現在我們總結下這個方案的優缺點:
- 優點:
沒有對Tube增加額外負擔(無新增方法、無新增屬性、無需Tube內部配合Tubes-Engine實現流程控制)
Tube對首屏次屏場景下的多次執行無感知
只執行需要執行的Tube(而不是先執行,再由中間件內部判斷是否跳過)
- 缺點: 內部實現復雜(意味著容易出錯)
基于響應性數據系統,可以很智能的“挑選”出哪些Tube應該被執行,該方案對Tube開發者沒有任何額外負擔,而且它只執行需要執行的Tube,執行效率上也更好。最終,我們選擇了使用響應性數據系統。
響應性數據系統的設計與原理
歷史上,為了將性能優化到極致,Tubes 的響應性數據系統的實現原理共設計了三個版本,由于第二個版本是第一個版本的改良版,因此本節為您詳細介紹第二個版本和第三個版本的實現原理。
? 經典實現
當我們研究響應性數據系統的原理時,本質上我們在研究三件事:響應性數據、收集依賴、觸發依賴。
- 響應性數據
響應性數據最關鍵的兩個核心是:監聽器、依賴存儲。
監聽器
所謂的監聽器指的是我們采用什么樣的方式攔截用戶對數據的操作,主流的實現方式有以下幾種:
- Getter / Setter
- Proxy
- 自定義 Set/Get API
Tubes 采用第三種方式實現,原因是 Tube 開發者希望可以在原始數據中修改數據且不觸發響應性,例如:
const data = this.store.get('data'); // 希望只在這里綁定依賴關系
const name = data.name; // 希望這個操作不觸發Getter
data.name = name + '!'; // 希望這個操作不觸發Setter
this.store.set('data', data); // 希望執行到這行代碼時再統一觸發響應性
Tube 開發者希望自由控制什么時候觸發響應性,在這種場景下 Tubes 采用了自定義API的方式攔截用戶對數據的操作。
依賴存儲
當監聽器攔截到用戶對某個數據進行操作后,需要找到這個數據對應的“依賴”,如何找到數據對應的“依賴”主要看“依賴”如何存儲,實現方式有很多種,傳統的實現方式比如Vue2是直接將“依賴”存在數據身上,找到了數據也就找到了這個數據對應的“依賴”,不得不說這很方便,Tubes最初也是這樣實現的,如下圖所示:
如上圖所示,依賴都保存在 __dep__ 這個屬性中,而這個屬性則直接保存在數據中。
- 收集依賴
關于依賴收集,本質上我們要解決的問題是“什么時候收集”、“收集誰”、“如何收集”、“收集到哪里”等問題。
什么時候收集
什么時候收集?答案是:“讀取數據的時候收集依賴”。當監聽器攔截到讀取數據時,就可以開始進行依賴收集了。
收集誰
收集誰?回答這個問題似乎不那么簡單,事實上這個問題沒有標準答案。如果用發布&訂閱者模式來理解,那么我們收集的是“訂閱者”,當數據發生變化時我們要通知給訂閱了這個數據的“訂閱者”,但訂閱者是誰?這取決于我們具體的需求,也就是我們希望使用響應性數據系統做什么事。
在 Tubes 中,我們希望響應性數據系統能幫助我們將需要再次執行的 Tube 挑選出來,那么我們的“訂閱者”就是Tube,一旦數據發生變化,就能找到這個數據的依賴(也就是 Tube),隨后就可以將這些 Tube 發送到調度系統中去執行。
在 Vue.js 中,希望響應性數據系統能幫助將需要重新渲染的組件挑選出來,那么這個時候訂閱者就是組件,數據發生變化后,能找到數據的依賴(也就是組件),隨后用新數據重新渲染組件。
實際上,Tubes 中的訂閱者并不是 Tube,因為 Tube 需要支持并行執行,如果并行執行的 Tube 同時使用了某個數據,那么在收集他們的時候需要保持好并行Tube的數據結構,因此在 Tubes 內部,并行執行的 Tube 為一組,訂閱者其實是這個小組。也就是先把 Tube 添加到這個小組,然后再把這個小組添加到依賴列表中。假設并行執行的 Tube 只有其中一個使用了某個數據,那么這個數據的訂閱者就是一個小組,然后這個小組里面只有一個 Tube,如果并行執行的 Tube 有多個使用了某個數據,那么這個小組里面就有多個 Tube。
如何收集
現在我們已經知道了“什么時候收集”、“收集誰”以及“收集到哪里”,下面詳細介紹一下怎么收集,我們用一個例子來說明:
const data = ctx.store.get('a.d');
在 Tubes 中,由于同一時間只有一個 Tube 在執行,而且不存在嵌套關系,因此定位哪個 Tube 中執行了 ctx.store.get 并不困難,偽代碼如下:
function get(keypath) {
this.ctx.tb.store.effect = effect;
this.ctx.tb.store.tube = tube;
const res = this.ctx.tb.store.get(keypath);
this.ctx.tb.store.tube = null;
this.ctx.tb.store.effect = null;
return res;
}
上面偽代碼展示了當用戶調用 this.store.get 時,如何定位當前正在執行的 Tube 和 Tube 所屬的那個組(effect)。
收集到哪里
前面講響應性數據的時候我們講依賴是直接存在數據上的,因為這樣方便修改數據的時候找到該數據對應的依賴,但只將依賴保存在被修改的數據身上是不夠的。
試想一個場景,Tube A 使用了數據 a.d,這時候相當于訂閱了 a.d 這個數據,現在另一個 Tube 修改了數據 a,或者數據 a.d.x 那么此時 Tube A要不要響應?答案是:要!
因此,我們得到一個結論:依賴不只是保存到目標數據身上,還要保存到目標數據的所有父級和所有子級中,如下圖所示:
上圖紅色圈表示需要被訂閱的數據,綠色圈表示依賴保存的位置,右側表示當 Tube 讀取 a.d 時,需要同時訂閱 a、a.d、a.d.e和a.d.f。
這里有一個細節,圖中綠色圈比我們要訂閱的數據高一個層級,這是因為數據的最底部會出現基本類型的數據,而基本類型的數據是無法掛載額外屬性的,因此我們選擇將每個數據的依賴都保存在父級節點中。
- 讀取依賴
觸發依賴的時機是 “修改數據的時候” ,當監聽器攔截到用戶修改數據時,將對應數據的依賴找到就可以了。在 Tubes 中,依賴被找到后會發送給調度系統執行。
? 基于哈希表的響應性數據系統
這種實現方式的原理和核心思想與傳統方式沒有太大區別,本質上我們仍在研究三件事:響應性數據、收集依賴、觸發依賴。
顧名思義,這種實現方式最大的特點是使用哈希表來存儲依賴,如下圖所示:
- 為什么使用哈希表存儲依賴
使用哈希表來存儲依賴和將依賴直接保存在數據身上的主要區別在于操作依賴的 “速度”。
前面我們講收集依賴的時候除了要將依賴保存在當前讀取的那個數據之外還需要將依賴保存在所有父級和所有子級中。將依賴保存在所有父級中并不怎么影響性能,但將依賴保存到所有子級,這需要遍歷到每一個子級和子級的子級,如果數據非常大,那么這個遍歷的過程將非常耗時,在低端機尤其明顯。
使用哈希表來存儲依賴,操作依賴的速度則不受數據大小的影響,存儲依賴不再需要遍歷數據的每個子級,無論是保存依賴還是讀取依賴都不受數據量大小的影響,可以在一瞬間完成。
下面我們詳細介紹使用哈希表如何存儲和讀取依賴。
- 存儲依賴
使用哈希表存儲依賴非常簡單,只需要使用 keypath 作為 Key,值是一個列表,將依賴追加到列表中即可。這里需要注意去重的邏輯,避免將同一個依賴重復添加到列表中。一圖勝千言:
如上圖所示,當用戶執行 ctx.store.get('a.d') 時,直接使用 a.d 作為哈希表中的KEY將依賴追加到列表中,邏輯簡單且執行效率高。
值得注意的是哈希表中還使用一個列表保存了依賴對應的ID,這用于避免將重復的依賴追加到列表中。
- 讀取依賴
使用哈希表存儲依賴時,如何讀取某個數據對應的依賴?
這是個好問題,當依賴保存在數據上面時,讀取數據時只要找到了數據,就找到了數據對應的依賴,邏輯非常簡單高效。而使用哈希表存儲依賴由于依賴并不保存在數據身上,所以找到了數據并不等于找到了依賴,因此我們需要一段額外的邏輯來拿到數據對應的依賴。
我們先回顧下前面的邏輯,前面我們講當 Tube 讀取 a.d 時,需要同時訂閱 a、a.d、a.d.e和a.d.f。
也就是說,當我們修改了某個數據時,除了該數據外,該數據的所有父級以及所有子級都需要做出響應,現在這個邏輯仍然適用。因此,不難得出,當我們修改 a.d 時,我們需要在哈希表中將 a、a.d、a.d.* 的依賴取出來,其中 a、為父級,a.d為數據本身,a.d.*為所有子級,如下圖所示:
值得注意的是,同一個KEY下面的依賴會去重, 但不同KEY之間有可能有重復的依賴,為了避免重復響應,取出來的數據需要做一個去重處理。
- 性能提升
經過驗證,相比于傳統實現,使用哈希表來存儲依賴在低端機的性能提升尤其明顯。在維持寫入速度不變的情況下,提升讀取數據的速度 “1,349.8” 倍,在業務中的表現是會場的首屏速度提升452.6ms,以下是詳細數據:
- 讀取速度:提升了1,349.8倍,Android低端機由64.79ms(10次平均值)降低為0.048ms(10次平均值)。
- 寫入速度:維持現有速度不變
- 時間復雜度:舊算法會隨著數據量大小而“增加寫入時長”,而新算法寫入速度始終保持在 0ms ~ 0.2ms 區間
- 舊算法:讀取數據為O(N)
- 新算法:讀取數據為O(1)
- 對會場首屏速度的影響 (新舊算法渲染10次取平均數):提升452.6ms
- 舊算法:2,924.7ms
- 新算法:1,917.5ms
- 測試環境:低端機
- 設備:紅米7
- 系統:Android 9
- 頁面:活動中心頁面
小結
基于哈希表的響應性數據系統和傳統實現在本質上沒有區別,區別在于存儲依賴的方式,不同的存儲依賴的方式決定了存儲和讀取依賴的算法不同,不同的算法決定了執行效率。
總結
本文詳細介紹了響應性數據系統在 Tubes 中的運用,以及響應性數據系統的三種不同設計與原理,遺憾的是,該項目目前只針對阿里內部開源,但一些技術思路仍可以供您學習與參考。