很多人看到這個標題的時候,會產生一些懷疑:
什麼是“資料層”?前端需要資料層嗎?
可以說,絕大部分場景下,前端是不需要資料層的,如果業務場景出現了一些特殊的需求,尤其是為了無重新整理,很可能會催生這方面的需要。
我們來看幾個場景,再結合場景所產生的一些訴求,探討可行的實現方式。
檢視間的資料共享
所謂共享,指的是:
同一份資料被多處檢視使用,並且要保持一定程度的同步。
如果一個業務場景中,不存在檢視之間的資料複用,可以考慮使用端到端元件。
什麼是端到端元件呢?
我們看一個示例,在很多地方都會碰到選擇城市、地區的元件。這個元件對外的介面其實很簡單,就是選中的項。但這時候我們會有一個問題:
這個元件需要的省市區域資料,是由這個元件自己去查詢,還是使用這個元件的業務去查好了傳給這個元件?
兩者當然是各有利弊的,前一種,它把查詢邏輯封裝在自己內部,對使用者更加有利,呼叫方只需這麼寫:
1 |
<RegionSelector selected=“callback(region)”></RegionSelector> |
外部只需實現一個響應取值事件的東西就可以了,用起來非常簡便。這樣的一個元件,就被稱為端到端元件,因為它獨自打通了從檢視到後端的整個通道。
這麼看來,端到端元件非常美好,因為它對使用者太便利了,我們簡直應當擁抱它,放棄其他所有。
端到端元件示意圖:
1 2 3 |
A | B | C --------- Server |
可惜並非如此,選擇哪種元件實現方式,是要看業務場景的。如果在一個高度整合的檢視中,剛才這個元件同時出現了多次,就有些尷尬了。
尷尬的地方在哪裡呢?首先是同樣的查詢請求被觸發了多次,造成了冗餘請求,因為這些元件互相不知道對方的存在,當然有幾個就會查幾份資料。這其實是個小事,但如果同時還存在修改這些資料的元件,就麻煩了。
比如說:在選擇某個實體的時候,發現之前漏了配置,於是點選“立刻配置”,新增了一條,然後回來繼續原流程。
例如,買東西填地址的時候,發現想要的地址不在列表中,於是點選彈出新增,在不打斷原流程的情況下,插入了新資料,並且可以選擇。
這個地方的麻煩之處在於:
元件A的多個例項都是純查詢的,查詢的是ModelA這樣的資料,而元件B對ModelA作修改,它當然可以把自己的那塊介面更新到最新資料,但是這麼多A的例項怎麼辦,它們裡面都是老資料,誰來更新它們,怎麼更新?
這個問題為什麼很值得說呢,因為如果沒有一個良好的資料層抽象,你要做這個事情,一個業務上的選擇和會有兩個技術上的選擇:
- 引導使用者自己重新整理介面
- 在新增完成的地方,寫死一段邏輯,往查詢元件中加資料
- 發一個自定義業務事件,讓查詢元件自己響應這個事件,更新資料
這三者都有缺點:
- 引導使用者重新整理介面這個,在技術上是比較偷懶的,可能體驗未必好。
- 寫死邏輯這個,倒置了依賴順序,導致程式碼產生了反向耦合,以後再來幾個要更新的地方,這裡程式碼改得會很痛苦,而且,我一個配置的地方,為什麼要管你後續增加的那些查詢介面?
- 自定義業務事件這個,耦合是減少了,卻讓查詢元件自己的邏輯膨脹了不少,如果要監聽多種訊息,並且合併資料,可能這裡更復雜,能否有一種比較簡化的方式?
所以,從這個角度看,我們需要一層東西,墊在整個元件層下方,這一層需要能夠把查詢和更新做好抽象,並且讓檢視元件使用起來儘可能簡單。
另外,如果多個檢視元件之間的資料存在時序關係,不提取出來整體作控制的話,也很難去維護這樣的程式碼。
新增了資料層之後的整體關係如圖:
1 2 3 4 5 |
A | B | C ------------ 前端的資料層 ------------ Server |
那麼,檢視訪問資料層的介面會是什麼樣?
我們考慮耦合的問題。如果要減少耦合,很必然的就是這麼一種形式:
- 變更的資料產生某種訊息
- 使用者訂閱這個訊息,做一些後續處理
因此,資料層應當儘可能對外提供類似訂閱方式的介面。
服務端推送
如果要引入服務端推送,怎麼調整?
考慮一個典型場景,WebIM,如果要在瀏覽器中實現這麼一個東西,通常會引入WebSocket作更新的推送。
對於一個聊天視窗而言,它的資料有幾個來源:
- 初始查詢
- 本機發起的更新(傳送一條聊天資料)
- 其他人發起的更新,由WebSocket推送過來
1檢視展示的資料 := 初始查詢的資料 + 本機發起的更新 + 推送的更新
這裡,至少有兩種程式設計方式。
查詢資料的時候,我們使用類似Promise的方式:
1 2 3 |
getListData().then(data => { // 處理資料 }) |
而響應WebSocket的時候,用類似事件響應的方式:
1 2 3 |
ws.on(‘data’, data => { // 處理資料 }) |
這意味著,如果沒有比較好的統一,檢視元件裡至少需要通過這兩種方式來處理資料,新增到列表中。
如果這個場景再跟上一節提到的多檢視共享結合起來,就更復雜了,可能很多檢視裡都要同時寫這兩種處理。
所以,從這個角度看,我們需要有一層東西,能夠把拉取和推送統一封裝起來,遮蔽它們的差異。
快取的使用
如果說我們的業務裡,有一些資料是通過WebSocket把更新都同步過來,這些資料在前端就始終是可信的,在後續使用的時候,可以作一些複用。
比如說:
在一個專案中,專案所有成員都已經查詢過,資料全在本地,而且變更有WebSocket推送來保證。這時候如果要新建一條任務,想要從專案成員中指派任務的執行人員,可以不必再發起查詢,而是直接用之前的資料,這樣選擇介面就可以更流暢地出現。
這時候,從檢視角度看,它需要解決一個問題:
- 如果要獲取的資料未有快取,它需要產生一個請求,這個呼叫過程就是非同步的
- 如果要獲取的資料已有快取,它可以直接從快取中返回,這個呼叫過程就是同步的
如果我們有一個資料層,我們至少期望它能夠把同步和非同步的差異遮蔽掉,否則要使用兩種程式碼來呼叫。通常,我們是使用Promise來做這種差異封裝的:
1 2 3 4 5 6 7 |
function getDataP() : Promise<T> { if (data) { return Promise.resolve(data) } else { return fetch(url) } } |
這樣,使用者可以用相同的程式設計方式去獲取資料,無需關心內部的差異。
資料的聚合
很多時候,檢視上需要的資料與資料庫儲存的形態並不完全相同,在資料庫中,我們總是傾向於儲存更原子化的資料,並且建立一些關聯,這樣,從這種資料想要變成檢視需要的格式,免不了需要一些聚合過程。
通常我們指的聚合有這麼幾種:
- 在服務端先聚合資料,然後再把這些資料與檢視模板聚合,形成HTML,整體輸出,這個過程也稱為服務端渲染
- 在服務端只聚合資料,然後把這些資料返回到前端,再生成介面
- 服務端只提供原子化的資料介面,前端根據自己的需要,請求若干個介面獲得資料,聚合成檢視需要的格式,再生成介面
大部分傳統應用在服務端聚合資料,通過資料庫的關聯,直接查詢出聚合資料,或者在Web服務介面的地方,聚合多個底層服務介面。
我們需要考慮自己應用的特點來決定前端資料層的設計方案。有的情況下,後端返回細粒度的介面會比聚合更合適,因為有的場景下,我們需要細粒度的資料更新,前端需要知道資料之間的變更聯動關係。
所以,很多場景下,我們可以考慮在後端用GraphQL之類的方式來聚合資料,或者在前端用類似Linq的方式聚合資料。但是,注意到如果這種聚合關係要跟WebSocket推送產生關聯,就會比較複雜。
我們拿一個場景來看,假設有一個介面,長得像新浪微博的Feed流。對於一條Feed而言,它可能來自幾個實體:
Feed訊息本身
1 2 3 4 5 |
class Feed { content: string creator: UserId tags: TagId[] } |
Feed被打的標籤
1 2 3 4 |
class Tag { id: TagId content: string } |
人員
1 2 3 4 5 |
class User { id: UserId name: string avatar: string } |
如果我們的需求跟微博一樣,肯定還是會選擇第一種聚合方式,也就是服務端渲染。但是,如果我們的業務場景中,存在大量的細粒度更新,就比較有意思了。
比如說,如果我們修改一個標籤的名稱,就要把關聯的Feed上的標籤也重新整理,如果之前我們把資料聚合成了這樣:
1 2 3 4 5 |
class ComposedFeed { content: string creator: User tags: Tag[] } |
就會導致無法反向查詢聚合後的結果,從中篩選出需要更新的東西。如果我們能夠儲存這個變更路徑,就比較方便了。所以,在存在大量細粒度更新的情況下,服務端API零散化,前端負責聚合資料就比較合適了。
當然這樣會帶來一個問題,那就是請求數量增加很多。對此,我們可以變通一下:
做物理聚合,不做邏輯聚合。
這段話怎麼理解呢?
我們仍然可以在一個介面中一次獲取所需的各種資料,只是這種資料格式可能是:
1 2 3 4 5 |
{ feed: Feed tags: Tags[] user: User } |
不做深度聚合,只是簡單地包裝一下。
在這個場景中,我們對資料層的訴求是:建立資料之間的關聯關係。
綜合場景
以上,我們述及四種典型的對前端資料層有訴求的場景,如果存在更復雜的情況,兼有這些情況,又當如何?
Teambition的場景正是這麼一種情況,它的產品特點如下:
- 大部分互動都以對話方塊的形式展現,在檢視的不同位置,存在大量的共享資料,以任務資訊為例,一條任務資料對應渲染的檢視可能會有20個這樣的數量級。
- 全業務都存在WebSocket推送,把相關使用者(比如處於同一專案中)的一切變更都傳送到前端,並實時展示
- 很強調無重新整理,提供一種類似桌面軟體的互動體驗
比如說:
當一條任務變更的時候,無論你處於檢視的什麼狀態,需要把這20種可能的地方去做同步。
當任務的標籤變更的時候,需要把標籤資訊也查詢出來,進行實時變更。
甚至:
- 如果某個使用者更改了自己的頭像,而他的頭像被到處使用了?
- 如果當前使用者被移除了與所操作物件的關聯關係,導致許可權變更,按鈕禁用狀態改變了?
- 如果別人修改了當前使用者的身份,在管理員和普通成員之間作了變化,檢視怎麼自動變化?
當然這些問題都是可以從產品角度權衡的,但是本文主要考慮的還是如果產品角度不放棄對某些極致體驗的追求,從技術角度如何更容易地去做。
我們來分析一下整個業務場景:
- 存在全業務的細粒度變更推送 => 需要在前端聚合資料
- 前端聚合 => 資料的組合鏈路長
- 檢視大量共享資料 => 資料變更的分發路徑多
這就是我們得到的一個大致認識。
技術訴求
以上,我們介紹了業務場景,分析了技術特點。假設我們要為這麼一種複雜場景設計資料層,它要提供怎樣的介面,才能讓檢視使用起來簡便呢?
從檢視角度出發,我們有這樣的訴求:
- 類似訂閱的使用方式(只被上層依賴,無反向鏈路)。這個來源於多檢視對同一業務資料的共享,如果不是類似訂閱的方式,職責就反轉了,對維護不利
- 查詢和推送的統一。這個來源於WebSocket的使用。
- 同步與非同步的統一。這個來源於快取的使用。
- 靈活的可組合性。這個來源於細粒度資料的前端聚合。
根據這些,我們可用的技術選型是什麼呢?
主流框架對資料層的考慮
一直以來,前端框架的側重點都是檢視部分,因為這塊是普適性很強的,但在資料層方面,一般都沒有很深入的探索。
- React, Vue 兩者主要側重資料和檢視的同步,生態體系中有一些庫會在資料邏輯部分做一些事情
- Angular,看似有Service這類可以封裝資料邏輯的東西,實際上遠遠不夠,有形無實,在Service內部必須自行做一些事情
- Backbone,做了一些業務模型實體和關聯關係的抽象,更早的ExtJS也做了一些事情
綜合以上,我們可以發現,幾乎所有現存方案都是不完整的,要麼只做實體和關係的抽象,要麼只做資料變化的封裝,而我們需要的是實體的關係定義和資料變更鏈路的封裝,所以需要自行作一些定製。
那麼,我們有怎樣的技術選型呢?
RxJS
遍觀流行的輔助庫,我們會發現,基於資料流的一些方案會對我們有較大幫助,比如RxJS,xstream等,它們的特點剛好滿足了我們的需求。
以下是這類庫的特點,剛好是迎合我們之前的訴求。
- Observable,基於訂閱模式
- 類似Promise對同步和非同步的統一
- 查詢和推送可統一為資料管道
- 容易組合的資料管道
- 形拉實推,兼顧編寫的便利性和執行的高效性
- 懶執行,不被訂閱的資料流不執行
這些基於資料流理念的庫,提供了較高層次的抽象,比如下面這段程式碼:
1 2 3 4 5 6 7 8 9 10 11 12 |
function getDataO(): Observable<T> { if (cache) { return Observable.of(cache) } else { return Observable.fromPromise(fetch(url)) } } getDataO().subscribe(data => { // 處理資料 }) |
這段程式碼實際上抽象程度很高,它至少包含了這麼一些含義:
- 統一了同步與非同步,相容有無快取的情況
- 統一了首次查詢與後續推送的響應,可以把getDataO方法內部這個Observable也快取起來,然後把推送資訊合併進去
我們再看另外一段程式碼:
1 2 3 4 5 6 |
const permission$: Observable<boolean> = Observable .combineLatest(task$, user$) .map(data => { let [task, user] = data return user.isAdmin || task.creatorId === user.id }) |
這段程式碼的意思是,根據當前的任務和使用者,計算是否擁有這條任務的操作許可權,這段程式碼其實也包含了很多含義:
首先,它把兩個資料流task$和user$合併,並且計算得出了另外一個表示當前許可權狀態的資料流permission$。像RxJS這類資料流庫,提供了非常多的操作符,可用於非常簡便地按照需求把不同的資料流合併起來。
我們這裡展示的是把兩個對等的資料流合併,實際上,還可以進一步細化,比如說,這裡的user$,我們如果再追蹤它的來源,可以這麼看待:
某使用者的資料流user$ := 對該使用者的查詢 + 後續對該使用者的變更(包括從本機發起的,還有其他地方更改的推送)
如果說,這其中每個因子都是一個資料流,它們的疊加關係就不是對等的,而是這麼一種東西:
- 每當有主動查詢,就會重置整個user$流,恢復一次初始狀態
- user$等於初始狀態疊加後續變更,注意這是一個reduce操作,也就是把後續的變更往初始狀態上合併,然後得到下一個狀態
這樣,這個user$資料流才是“始終反映某使用者當前狀態”的資料流,我們也就因此可以用它與其它流組合,參與後續運算。
這麼一段程式碼,其實就足以覆蓋如下需求:
- 任務本身變化了(執行者、參與者改變,導致當前使用者許可權不同)
- 當前使用者自身的許可權改變了
這兩者導致後續操作許可權的變化,都能實時根據需要計算出來。
其次,這是一個形拉實推的關係。這是什麼意思呢,通俗地說,如果存在如下關係:
1 |
c = a + b // 不管a還是b發生更新,c都不動,等到c被使用的時候,才去重新根據a和b的當前值計算 |
如果我們站在對c消費的角度,寫出這麼一個表示式,這就是一個拉取關係,每次獲取c的時候,我們重新根據a和b當前的值來計算結果。
而如果站在a和b的角度,我們會寫出這兩個表示式:
1 2 |
c = a1 + b // a1是當a變更之後的新值 c = a + b1 // b1是當b變更之後的新值 |
這是一個推送關係,每當有a或者b的變更時,主動重算並設定c的新值。
如果我們是c的消費者,顯然拉取的表示式寫起來更簡潔,尤其是當表示式更復雜時,比如:
1 |
e = (a + b ) * c - d |
如果用推的方式寫,要寫4個表示式。
所以,我們寫訂閱表示式的時候,顯然是從使用者的角度去編寫,採用拉取的方式更直觀,但通常這種方式的執行效率都較低,每次拉取,無論結果是否變更,都要重算整個表示式,而推送的方式是比較高效精確的。
但是剛才RxJS的這種表示式,讓我們寫出了形似拉取,實際以推送執行的表示式,達到了編寫直觀、執行高效的結果。
看剛才這個表示式,大致可以看出:
permission$ := task$ + user$
這麼一個關係,而其中每個東西的變更,都是通過訂閱機制精確傳送的。
有些檢視庫中,也會在這方面作一些優化,比如說,一個計算屬性(computed property),是用拉的思路寫程式碼,但可能會被框架分析依賴關係,在內部反轉為推的模式,從而優化執行效率。
此外,這種資料流還有其它魔力,那就是懶執行。
什麼是懶執行呢?考慮如下程式碼:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
const a$: Subject<number> = new Subject<number>() const b$: Subject<number> = new Subject<number>() const c$: Observable<number> = Observable.combineLatest(a$, b$) .map(arr => { let [a, b] = arr return a + b }) const d$: Observable<number> = c$.map(num => { console.log('here') return num + 1 }) c$.subscribe(data => console.log(`c: ${data}`)) a$.next(2) b$.next(3) setTimeout(() => { a$.next(4) }, 1000) |
注意這裡的d$,如果a$或者b$中產生變更,它裡面那個here會被列印出來嗎?大家可以執行一下這段程式碼,並沒有。為什麼呢?
因為在RxJS中,只有被訂閱的資料流才會執行。
主題所限,本文不深究內部細節,只想探討一下這個特點對我們業務場景的意義。
想象一下最初我們想要解決的問題,是同一份資料被若干個檢視使用,而檢視側的變化是我們不可預期的,可能在某個時刻,只有這些訂閱者的一個子集存在,其它推送分支如果也執行,就是一種浪費,RxJS的這個特性剛好能讓我們只精確執行向確實存在的檢視的資料流推送。
RxJS與其它方案的對比
1. 與watch機制的對比
不少檢視層方案,比如Angular和Vue中,存在watch這麼一種機制。在很多場景下,watch是一種很便捷的操作,比如說,想要在某個物件屬性變更的時候,執行某些操作,就可以使用它,大致程式碼如下:
1 2 3 |
watch(‘a.b’, newVal => { // 處理新資料 }) |
這類監控機制,其內部實現無非幾種,比如自定義了setter,攔截資料的賦值,或者通過對比新舊資料的髒檢查方式,或者通過類似Proxy的機制代理了資料的變化過程。
從這些機制,我們可以得到一些推論,比如說,它在對大陣列或者複雜物件作監控的時候,監控效率都會降低。
有時候,我們也會有監控多個資料,以合成另外一個的需求,比如:
一條用於展示的任務資料 := 這條任務的原始資料 + 任務上的標籤資訊 + 任務的執行者資訊
如果不以資料流的方式編寫,這地方就需要為每個變數單獨編寫表示式或者批量監控多個變數,前者面臨的問題是程式碼冗餘,跟前面我們提到的推資料的方式類似;後者面臨的問題就比較有意思了。
監控的方式會比計算屬性強一些,原因在於計算屬性處理不了非同步的資料變更,而監控可以。但如果監控條件進一步複雜化,比如說,要監控的資料之間存在競爭關係等等,都不是容易表達出來的。
另外一個問題是,watch不適合做長鏈路的變更,比如:
1 2 3 4 |
c := a + b d := c + 1 e := a * c f := d * e |
這種型別,如果要用監控表示式寫,會非常囉嗦。
2. 跟Redux的對比
Rx和Redux其實沒有什麼關係。在表達資料變更的時候,從邏輯上講,這兩種技術是等價的,一種方式能表達出的東西,另外一種也都能夠。
比如說,同樣是表達資料a到b這麼一個轉換,兩者所關注的點可能是不一樣的:
- Redux:定義一個action叫做AtoB,在其實現中,把a轉換成b
- Rx:定義兩個資料流A和B,B是從A經過一次map轉換得到的,map的表示式是把a轉成b
由於Redux更多地是一種理念,它的庫功能並不複雜,而Rx是一種強大的庫,所以兩者直接對比並不合適,比如說,可以用Rx依照Redux的理念作實現,但反之不行。
在資料變更的鏈路較長時,Rx是具有很大優勢的,它可以很簡便地做多級狀態變更的連線,也可以做資料變更鏈路的複用(比如存在a -> b -> c,又存在a -> b -> d,可以把a -> b這個過程拿出來複用),還天生能處理好包括競態在內的各種非同步的情況,Redux可能要藉助saga等理念才能更好地組織程式碼。
我們之前有些demo程式碼也提到了,比如說:
1 |
使用者資訊資料流 := 使用者資訊的查詢 + 使用者資訊的更新 |
這段東西就是按照reducer的理念去寫的,跟Redux類似,我們把變更操作放到一個資料流中,然後用它去累積在初始狀態上,就能得到始終反映某個實體當前狀態的資料流。
在Redux方案中,中介軟體是一種比較好的東西,能夠對業務產生一定的約束,如果我們用RxJS實現,可以把變更過程中間接入一個統一的資料流來完成同樣的事情。
具體方案
以上我們談了以RxJS為代表的資料流庫的這麼多好處,彷佛有了它,就像有了民主,人民就自動吃飽穿暖,物質文化生活就自動豐富了,其實不然。任何一個框架和庫,它都不是來直接解決我們的業務問題的,而是來增強某方面的能力的,它剛好可以為我們所用,作為整套解決方案的一部分。
至此,我們的資料層方案還缺失什麼東西嗎?
考慮如下場景:
某個任務的一條子任務產生了變更,我們會讓哪條資料流產生變更推送?
分析子任務的資料流,可以大致得出它的來源:
subtask$ = subtaskQuery$ + subtaskUpdate$
看這句虛擬碼,加上我們之前的解釋(這是一個reduce操作),我們得到的結論是,這條任務對應的subtask$資料流會產生變更推送,讓檢視作後續更新。
僅僅這樣就可以了嗎?並沒有這麼簡單。
從檢視角度看,我們還存在這樣的對子任務的使用:那就是任務的詳情介面。但這個介面訂閱的是這條子任務的所屬任務資料流,在其中任務資料包含的子任務列表中,含有這條子任務。所以,它訂閱的並不是subtask$,而是task$。這麼一來,我們必須使task$也產生更新,以此推動任務詳情介面的重新整理。
那麼,怎麼做到在subtask的資料流變更的時候,也推動所屬task的資料流變更呢?這個事情並非RxJS本身能做的,也不是它應該做的。我們之前用RxJS來封裝的部分,都只是資料的變更鏈條,記得之前我們是怎麼描述資料層解決方案的嗎?
實體的關係定義和資料變更鏈路的封裝
我們前面關注的都是後面一半,前面這一半,還完全沒做呢!
實體的變更關係如何做呢,辦法其實很多,可以用類似Backbone的Model和Collection那樣做,也可以用更加專業的方案,引入一個ORM機制來做。這裡面的實現就不細說了,那是個相對成熟的領域,而且說起來篇幅太大,有疑問的可以自行了解。
需要注意的是,我們在這個裡面需要考慮好與快取的結合,前端的快取很簡單,基本就是一種精簡的k-v資料庫,在做它的儲存的時候,需要做到兩件事:
- 以集合形式獲取的資料,需要拆分放入快取,比如Task[],應當以每個Task的TaskId為索引,分別單獨儲存
- 有時候後端返回的資料可能是不完整的,或者格式有差異,需要在儲存之間作正規化(normalize)
總結以上,我們的思路是:
- 快取 => 基於記憶體的微型k-v資料庫
- 關聯變更 => 使用ORM的方式抽象業務實體和變更關係
- 細粒度推送 => 某個實體的查詢與變更先合併為資料流
- 從實體的變更關係,引出資料流,並且所屬實體的流
- 業務上層使用這些原始資料流以組裝後續變更
更深入的探索
如果說我們針對這樣的複雜場景,實現了這麼一套複雜的資料層方案,還可以有什麼有意思的事情做呢?
這裡我開幾個腦洞:
- 用Worker隔離計算邏輯
- 用ServiceWorker實現本地共享
- 與本地持久快取結合
- 前後端狀態共享
- 視覺化配置
我們一個一個看,好玩的地方在哪裡。
第一個,之前提到,整個方案的核心是一種類似ORM的機制,外加各種資料流,這裡面必然涉及資料的組合、計算之類,那麼我們能否把它們隔離到渲染執行緒之外,讓整個檢視變得更流暢?
第二個,很可能我們會碰到同時開多個瀏覽器選項卡的客戶,但是每個選項卡展現的介面狀態可能不同。正常情況下,我們的整個資料層會在每個選項卡中各存在一份,並且獨立執行,但其實這是沒有必要的,因為我們有訂閱機制來保證可以擴散到每個檢視。那麼,是否可以用過ServiceWorker之類的東西,實現跨選項卡的資料層共享?這樣就可以減少很多計算的負擔。
對這兩條來說,讓資料流跨越執行緒,可能會存在一些障礙待解決。
第三個,我們之前提到的快取,全部是在記憶體中,屬於易失性快取,只要使用者關掉瀏覽器,就全部丟了,可能有的情況下,我們需要做持久快取,比如把不太變動的東西,比如企業通訊錄的人員名單存起來,這時候可以考慮在資料層中加一些非同步的與本地儲存通訊的機制,不但可以存localStorage之類的key-value儲存,還可以考慮存本地的關係型資料庫。
第四個,在業務和互動體驗複雜到一定程度的時候,服務端未必還是無狀態的,想要在兩者之間做好狀態共享,有一定的挑戰。基於這麼一套機制,可以考慮在前後端之間打通一個類似meteor的通道,實現狀態共享。
第五個,這個話題其實跟本文的業務場景無關,只是從第四個話題引發。很多時候我們期望能做到視覺化配置業務系統,但一般最多也就做到配置檢視,所以,要麼做到的是一個配置運營頁面的東西,要麼是能生成一個腳手架,供後續開發使用,但是一旦開始寫程式碼,就沒法合併回來。究其原因,是因為配不出元件的資料來源和業務邏輯,找不到合理的抽象機制。如果有第四條那麼一種鋪墊,也許是可以做得比較好的,用資料流作資料來源,還是挺合適的,更何況,資料流的組合關係能夠視覺化描述啊。
獨立資料層的優勢
回顧我們整個資料層方案,它的特點是很獨立,從頭到尾,做掉了很長的資料變更鏈路,也因此帶來幾個優勢:
1. 檢視的極度輕量化。
我們可以看到,如果檢視所消費的資料都是來源於從核心模型延伸並組合而成的各種資料流,那檢視層的職責就非常單一,無非就是根據訂閱的資料渲染介面,所以這就使得整個檢視層非常薄。而且,檢視之間是不太需要打交道的,元件之間的通訊很少,大家都會去跟資料層互動,這意味著幾件事:
- 檢視的變更難度大幅降低了
- 檢視的框架遷移難度大幅降低了
- 甚至同一個專案中,在必要的情況下,還可以混用若干種檢視層方案(比如剛好需要某個元件)
我們採用了一種相對中立的底層方案,以抵抗整個應用架構在前端領域日新月異的情況下的變更趨勢。
2. 增強了整個應用的可測試性。
因為資料層的佔比較高,並且相對集中,所以可以更容易對資料層做測試。此外,由於檢視非常薄,甚至可以脫離檢視打造這個應用的命令列版本,並且把這個版本與e2e測試合為一體,進行覆蓋全業務的自動化測試。
3. 跨端複用程式碼。
以前我們經常會考慮做響應式佈局,目的是能夠減少開發的工作量,儘量讓一份程式碼在PC端和移動端複用。但是現在,越來越少的人這麼做,原因是這樣並不一定降低開發的難度,而且對互動體驗的設計是一個巨大考驗。那麼,我們能不能退而求其次,複用儘量多的資料和業務邏輯,而開發兩套檢視層?
在這裡,可能我們需要做一些取捨。
回憶一下MVVM這個詞,很多人對它的理解流於形式,最關鍵的點在於,M和VM的差異是什麼?即使是多數MVVM庫比如Vue的使用者,也未必能說得出。
在很多場景下,這兩者並無明顯分界,服務端返回的資料直接就適於在檢視上用,很少需要加工。但是在我們這個方案中,還是比較明顯的:
1 2 3 4 5 |
> ------ Fetch -------------> | | View <-- VM <-- M <-- RESTful ^ | <-- WebSocket |
這個簡圖大致描述了資料的流轉關係。其中,M指代的是對原始資料的封裝,而VM則側重於面向檢視的資料組合,把來自M的資料流進行組合。
我們需要根據業務場景考慮:是要連VM一起跨端複用呢,還是隻複用M?考慮清楚了這個問題之後,我們才能確定資料層的邊界所在。
除了在PC和移動版之間複用程式碼,我們還可以考慮拿這塊程式碼去做服務端渲染,甚至構建到一些Native方案中,畢竟這塊主要的程式碼也是純邏輯。
4. 可拆解的WebSocket補丁
這個標題需要結合上面那個圖來理解。我們怎麼理解WebSocket在整個方案中的意義呢?其實可以整體視為整個通用資料層的補丁包,因此,我們就可以用這個理念來實現它,把所有對WebSocket的處理部分,都獨立出去,如果需要,就非同步載入到主應用來,如果在某些場景下,想把這塊拿掉,只需不引用它就行了,一行配置解決它的有無問題。
但是在具體實現的時候,需要注意:拆掉WebSocket之後的資料層,對應的快取是不可信的,需要做相應考慮。
對技術選型的思考
到目前為止,各種檢視方案是逐漸趨同的,它們最核心的兩個能力都是:
- 元件化
- MDV(模型驅動檢視)
缺少這兩個特性的方案都很容易出局。
我們會看到,不管哪種方案,都出現了針對檢視之外部分的一些補充,整體稱為某種“全家桶”。
全家桶方案的出現是必然的,因為為了解決業務需要,必然會出現一些預設搭配,省去技術選型的煩惱。
但是我們必須認識到,各種全家桶方案都是面向通用問題的,它能解決的都是很常見的問題,如果你的業務場景很與眾不同,還堅持用預設的全家桶,就比較危險了。
通常,這些全家桶方案的資料層部分都還比較薄弱,而有些特殊場景,其資料層複雜度遠非這些方案所能解決,必須作一定程度的自主設計和修正,我工作十餘年來,長期從事的都是複雜的toB場景,見過很多厚重的、整合度很高的產品,在這些產品中,前端資料和業務邏輯的佔比較高,有的非常複雜,但檢視部分也無非是元件化,一層套一層。
所以,真正會產生大的差異的地方,往往不是在檢視層,而是在水的下面。
願讀者在處理這類複雜場景的時候,慎重考慮。有個簡單的判斷標準是:檢視複用資料是否較多,整個產品是否很重視無重新整理的互動體驗。如果這兩點都回答否,那放心用各種全家桶,基本不會有問題,否則就要三思了。
必須注意到,本文所提及的技術方案,是針對特定業務場景的,所以未必具有普適性。有時候,很多問題也可以通過產品角度的權衡去避免,不過本文主要探討的還是技術問題,期望能夠在產品需求不讓步的情況下,也能找到比較優雅、和諧的解決方案,在業務場景面前能攻能守,不至於進退失據。
即使我們面對的業務場景沒有這麼複雜,使用類似RxJS的庫,依照資料流的理念對業務模型做適度抽象,也是會有一些意義的,因為它可以用一條規則統一很多東西,比如同步和非同步、過去和未來,並且提供了很多方便的時序操作。
後記
不久前,我寫過一篇總結,內容跟本文有不少重合之處,但為什麼還要寫這篇呢?
上一篇,講問題的視角是從解決方案本身出發,闡述解決了哪些問題,但是對這些問題的來龍去脈講得並不清晰。很多讀者看完之後,仍然沒有得到深刻認識。
這一篇,我希望從場景出發,逐步展示整個方案的推導過程,每一步是怎樣的,要如何去解決,整體又該怎麼做,什麼方案能解決什麼問題,不能解決什麼問題。
上次我那篇講述在Teambition工作經歷的回答中,也有不少人產生了一些誤解,並且有反覆推薦某些全家桶方案,認為能夠包打天下的。平心而論,我對方案和技術選型的認識還是比較慎重的,這類事情,事關技術方案的嚴謹性,關係到自身綜合水準的鑑定,不得不一辯到底。當時關注八卦,看熱鬧的人太多,對於探討技術本身倒沒有展現足夠的熱情,個人認為比較可惜,還是希望大家能夠多關注這樣一種有特色的技術場景。因此,此文非寫不可。
如果有關注我比較久的,可能會發現之前寫過不少關於檢視層方案技術細節,或者元件化相關的主題,但從15年年中開始,個人的關注點逐步過渡到了資料層,主要是因為上層的東西,現在研究的人已經多起來了,不勞我多說,而各種複雜方案的資料層場景,還需要作更艱難的探索。可預見的幾年內,我可能還會在這個領域作更多探索,前路漫漫,其修遠兮。
(整個這篇寫起來還是比較順利的,因為之前思路都是完整的。上週在北京閒逛一週,本來是比較隨意交流的,鑑於有些公司的朋友發了比較正式的分享郵件,花了些時間寫了幻燈片,在百度、去哪兒網、58到家等公司作了比較正式的分享,回來之後,花了一整天時間整理出了本文,與大家分享一下,歡迎探討。)