複雜單頁應用的數據層設計 轉

複雜單頁應用的數據層設計

不少人看到這個標題的時候,會產生一些懷疑:前端

什麼是「數據層」?前端須要數據層嗎?git

能夠說,絕大部分場景下,前端是不須要數據層的,若是業務場景出現了一些特殊的需求,尤爲是爲了無刷新,極可能會催生這方面的須要。github

咱們來看幾個場景,再結合場景所產生的一些訴求,探討可行的實現方式。數據庫

視圖間的數據共享

所謂共享,指的是:編程

同一份數據被多處視圖使用,而且要保持必定程度的同步。後端

若是一個業務場景中,不存在視圖之間的數據複用,能夠考慮使用端到端組件。數組

什麼是端到端組件呢?瀏覽器

咱們看一個示例,在不少地方都會碰到選擇城市、地區的組件。這個組件對外的接口其實很簡單,就是選中的項。但這時候咱們會有一個問題:緩存

這個組件須要的省市區域數據,是由這個組件本身去查詢,仍是使用這個組件的業務去查好了傳給這個組件?前端框架

二者固然是各有利弊的,前一種,它把查詢邏輯封裝在本身內部,對使用者更加有利,調用方只需這麼寫:

<RegionSelector selected=「callback(region)」></RegionSelector>

外部只需實現一個響應取值事件的東西就能夠了,用起來很是簡便。這樣的一個組件,就被稱爲端到端組件,由於它獨自打通了從視圖到後端的整個通道。

這麼看來,端到端組件很是美好,由於它對使用者太便利了,咱們簡直應當擁抱它,放棄其餘全部。

端到端組件示意圖:

A | B | C
---------
 Server

惋惜並不是如此,選擇哪一種組件實現方式,是要看業務場景的。若是在一個高度集成的視圖中,剛纔這個組件同時出現了屢次,就有些尷尬了。

尷尬的地方在哪裏呢?首先是一樣的查詢請求被觸發了屢次,形成了冗餘請求,由於這些組件互相不知道對方的存在,固然有幾個就會查幾份數據。這實際上是個小事,但若是同時還存在修改這些數據的組件,就麻煩了。

好比說:在選擇某個實體的時候,發現以前漏了配置,因而點擊「馬上配置」,新增了一條,而後回來繼續原流程。

例如,買東西填地址的時候,發現想要的地址不在列表中,因而點擊彈出新增,在不打斷原流程的狀況下,插入了新數據,而且能夠選擇。

這個地方的麻煩之處在於:

組件A的多個實例都是純查詢的,查詢的是ModelA這樣的數據,而組件B對ModelA做修改,它固然能夠把本身的那塊界面更新到最新數據,可是這麼多A的實例怎麼辦,它們裏面都是老數據,誰來更新它們,怎麼更新?

這個問題爲何很值得說呢,由於若是沒有一個良好的數據層抽象,你要作這個事情,一個業務上的選擇和會有兩個技術上的選擇:

  • 引導用戶本身刷新界面
  • 在新增完成的地方,寫死一段邏輯,往查詢組件中加數據
  • 發一個自定義業務事件,讓查詢組件本身響應這個事件,更新數據

這三者都有缺點:

  • 引導用戶刷新界面這個,在技術上是比較偷懶的,可能體驗未必好。
  • 寫死邏輯這個,倒置了依賴順序,致使代碼產生了反向耦合,之後再來幾個要更新的地方,這裏代碼改得會很痛苦,並且,我一個配置的地方,爲何要管你後續增長的那些查詢界面?
  • 自定義業務事件這個,耦合是減小了,卻讓查詢組件本身的邏輯膨脹了很多,若是要監聽多種消息,而且合併數據,可能這裏更復雜,可否有一種比較簡化的方式?

因此,從這個角度看,咱們須要一層東西,墊在整個組件層下方,這一層須要可以把查詢和更新作好抽象,而且讓視圖組件使用起來儘量簡單。

另外,若是多個視圖組件之間的數據存在時序關係,不提取出來總體做控制的話,也很難去維護這樣的代碼。

添加了數據層以後的總體關係如圖:

A | B | C
------------
前端的數據層
------------
  Server

那麼,視圖訪問數據層的接口會是什麼樣?

咱們考慮耦合的問題。若是要減小耦合,很必然的就是這麼一種形式:

  • 變動的數據產生某種消息
  • 使用者訂閱這個消息,作一些後續處理

所以,數據層應當儘量對外提供相似訂閱方式的接口。

服務端推送

若是要引入服務端推送,怎麼調整?

考慮一個典型場景,WebIM,若是要在瀏覽器中實現這麼一個東西,一般會引入WebSocket做更新的推送。

對於一個聊天窗口而言,它的數據有幾個來源:

  • 初始查詢
  • 本機發起的更新(發送一條聊天數據)
  • 其餘人發起的更新,由WebSocket推送過來
視圖展現的數據 := 初始查詢的數據 + 本機發起的更新 + 推送的更新

這裏,至少有兩種編程方式。

查詢數據的時候,咱們使用相似Promise的方式:

getListData().then(data => {
  // 處理數據
})

而響應WebSocket的時候,用相似事件響應的方式:

ws.on(‘data’, data => {
  // 處理數據
})

這意味着,若是沒有比較好的統一,視圖組件裏至少須要經過這兩種方式來處理數據,添加到列表中。

若是這個場景再跟上一節提到的多視圖共享結合起來,就更復雜了,可能不少視圖裏都要同時寫這兩種處理。

因此,從這個角度看,咱們須要有一層東西,可以把拉取和推送統一封裝起來,屏蔽它們的差別。

緩存的使用

若是說咱們的業務裏,有一些數據是經過WebSocket把更新都同步過來,這些數據在前端就始終是可信的,在後續使用的時候,能夠做一些複用。

好比說:

在一個項目中,項目全部成員都已經查詢過,數據全在本地,並且變動有WebSocket推送來保證。這時候若是要新建一條任務,想要從項目成員中指派任務的執行人員,能夠沒必要再發起查詢,而是直接用以前的數據,這樣選擇界面就能夠更流暢地出現。

這時候,從視圖角度看,它須要解決一個問題:

  • 若是要獲取的數據未有緩存,它須要產生一個請求,這個調用過程就是異步的
  • 若是要獲取的數據已有緩存,它能夠直接從緩存中返回,這個調用過程就是同步的

若是咱們有一個數據層,咱們至少指望它可以把同步和異步的差別屏蔽掉,不然要使用兩種代碼來調用。一般,咱們是使用Promise來作這種差別封裝的:

function getDataP() : Promise<T> {
  if (data) {
    return Promise.resolve(data)
  } else {
    return fetch(url)
  }
}

這樣,使用者能夠用相同的編程方式去獲取數據,無需關心內部的差別。

數據的聚合

不少時候,視圖上須要的數據與數據庫存儲的形態並不徹底相同,在數據庫中,咱們老是傾向於儲存更原子化的數據,而且創建一些關聯,這樣,從這種數據想要變成視圖須要的格式,免不了須要一些聚合過程。

一般咱們指的聚合有這麼幾種:

  • 在服務端先聚合數據,而後再把這些數據與視圖模板聚合,造成HTML,總體輸出,這個過程也稱爲服務端渲染
  • 在服務端只聚合數據,而後把這些數據返回到前端,再生成界面
  • 服務端只提供原子化的數據接口,前端根據本身的須要,請求若干個接口得到數據,聚合成視圖須要的格式,再生成界面

大部分傳統應用在服務端聚合數據,經過數據庫的關聯,直接查詢出聚合數據,或者在Web服務接口的地方,聚合多個底層服務接口。

咱們須要考慮本身應用的特色來決定前端數據層的設計方案。有的狀況下,後端返回細粒度的接口會比聚合更合適,由於有的場景下,咱們須要細粒度的數據更新,前端須要知道數據之間的變動聯動關係。

因此,不少場景下,咱們能夠考慮在後端用GraphQL之類的方式來聚合數據,或者在前端用相似Linq的方式聚合數據。可是,注意到若是這種聚合關係要跟WebSocket推送產生關聯,就會比較複雜。

咱們拿一個場景來看,假設有一個界面,長得像新浪微博的Feed流。對於一條Feed而言,它可能來自幾個實體:

Feed消息自己

class Feed {
  content: string
  creator: UserId
  tags: TagId[]
}

Feed被打的標籤

class Tag {
  id: TagId
  content: string
}

人員

class User {
  id: UserId
  name: string
  avatar: string
}

若是咱們的需求跟微博同樣,確定仍是會選擇第一種聚合方式,也就是服務端渲染。可是,若是咱們的業務場景中,存在大量的細粒度更新,就比較有意思了。

好比說,若是咱們修改一個標籤的名稱,就要把關聯的Feed上的標籤也刷新,若是以前咱們把數據聚合成了這樣:

class ComposedFeed {
  content: string
  creator: User
  tags: Tag[]
}

就會致使沒法反向查找聚合後的結果,從中篩選出須要更新的東西。若是咱們可以保存這個變動路徑,就比較方便了。因此,在存在大量細粒度更新的狀況下,服務端API零散化,前端負責聚合數據就比較合適了。

固然這樣會帶來一個問題,那就是請求數量增長不少。對此,咱們能夠變通一下:

作物理聚合,不作邏輯聚合。

這段話怎麼理解呢?

咱們仍然能夠在一個接口中一次獲取所需的各類數據,只是這種數據格式多是:

{
  feed: Feed
  tags: Tags[]
  user: User
}

不作深度聚合,只是簡單地包裝一下。

在這個場景中,咱們對數據層的訴求是:創建數據之間的關聯關係。

綜合場景

以上,咱們述及四種典型的對前端數據層有訴求的場景,若是存在更復雜的狀況,兼有這些狀況,又當如何?

Teambition任務面板

Teambition的場景正是這麼一種狀況,它的產品特色以下:

  • 大部分交互都以對話框的形式展示,在視圖的不一樣位置,存在大量的共享數據,以任務信息爲例,一條任務數據對應渲染的視圖可能會有20個這樣的數量級。
  • 全業務都存在WebSocket推送,把相關用戶(好比處於同一項目中)的一切變動都發送到前端,並實時展現
  • 很強調無刷新,提供一種相似桌面軟件的交互體驗

好比說:

當一條任務變動的時候,不管你處於視圖的什麼狀態,須要把這20種可能的地方去作同步。

當任務的標籤變動的時候,須要把標籤信息也查找出來,進行實時變動。

甚至:

  • 若是某個用戶更改了本身的頭像,而他的頭像被處處使用了?
  • 若是當前用戶被移除了與所操做對象的關聯關係,致使權限變動,按鈕禁用狀態改變了?
  • 若是別人修改了當前用戶的身份,在管理員和普通成員之間做了變化,視圖怎麼自動變化?

固然這些問題都是能夠從產品角度權衡的,可是本文主要考慮的仍是若是產品角度不放棄對某些極致體驗的追求,從技術角度如何更容易地去作。

咱們來分析一下整個業務場景:

  • 存在全業務的細粒度變動推送 => 須要在前端聚合數據
  • 前端聚合 => 數據的組合鏈路長
  • 視圖大量共享數據 => 數據變動的分發路徑多

這就是咱們獲得的一個大體認識。

技術訴求

以上,咱們介紹了業務場景,分析了技術特色。假設咱們要爲這麼一種複雜場景設計數據層,它要提供怎樣的接口,才能讓視圖使用起來簡便呢?

從視圖角度出發,咱們有這樣的訴求:

  • 相似訂閱的使用方式(只被上層依賴,無反向鏈路)。這個來源於多視圖對同一業務數據的共享,若是不是相似訂閱的方式,職責就反轉了,對維護不利
  • 查詢和推送的統一。這個來源於WebSocket的使用。
  • 同步與異步的統一。這個來源於緩存的使用。
  • 靈活的可組合性。這個來源於細粒度數據的前端聚合。

根據這些,咱們可用的技術選型是什麼呢?

主流框架對數據層的考慮

一直以來,前端框架的側重點都是視圖部分,由於這塊是普適性很強的,但在數據層方面,通常都沒有很深刻的探索。

  • React, Vue 二者主要側重數據和視圖的同步,生態體系中有一些庫會在數據邏輯部分作一些事情
  • Angular,看似有Service這類能夠封裝數據邏輯的東西,實際上遠遠不夠,有形無實,在Service內部必須自行作一些事情
  • Backbone,作了一些業務模型實體和關聯關係的抽象,更早的ExtJS也作了一些事情

綜合以上,咱們能夠發現,幾乎全部現存方案都是不完整的,要麼只作實體和關係的抽象,要麼只作數據變化的封裝,而咱們須要的是實體的關係定義和數據變動鏈路的封裝,因此須要自行做一些定製。

那麼,咱們有怎樣的技術選型呢?

RxJS

遍觀流行的輔助庫,咱們會發現,基於數據流的一些方案會對咱們有較大幫助,好比RxJS,xstream等,它們的特色恰好知足了咱們的需求。

如下是這類庫的特色,恰好是迎合咱們以前的訴求。

  • Observable,基於訂閱模式
  • 相似Promise對同步和異步的統一
  • 查詢和推送可統一爲數據管道
  • 容易組合的數據管道
  • 形拉實推,兼顧編寫的便利性和執行的高效性
  • 懶執行,不被訂閱的數據流不執行

這些基於數據流理念的庫,提供了較高層次的抽象,好比下面這段代碼:

function getDataO(): Observable<T> {
  if (cache) {
    return Observable.of(cache)
  }
  else {
    return Observable.fromPromise(fetch(url))
  }
}

getDataO().subscribe(data => {
  // 處理數據
})

這段代碼實際上抽象程度很高,它至少包含了這麼一些含義:

  • 統一了同步與異步,兼容有無緩存的狀況
  • 統一了首次查詢與後續推送的響應,能夠把getDataO方法內部這個Observable也緩存起來,而後把推送信息合併進去

咱們再看另一段代碼:

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$數據流纔是「始終反映某用戶當前狀態」的數據流,咱們也就所以能夠用它與其它流組合,參與後續運算。

這麼一段代碼,其實就足以覆蓋以下需求:

  • 任務自己變化了(執行者、參與者改變,致使當前用戶權限不一樣)
  • 當前用戶自身的權限改變了

這二者致使後續操做權限的變化,都能實時根據須要計算出來。

其次,這是一個形拉實推的關係。這是什麼意思呢,通俗地說,若是存在以下關係:

c = a + b     // 無論a仍是b發生更新,c都不動,等到c被使用的時候,纔去從新根據a和b的當前值計算

若是咱們站在對c消費的角度,寫出這麼一個表達式,這就是一個拉取關係,每次獲取c的時候,咱們從新根據a和b當前的值來計算結果。

而若是站在a和b的角度,咱們會寫出這兩個表達式:

c = a1 + b     // a1是當a變動以後的新值
c = a + b1    // b1是當b變動以後的新值

這是一個推送關係,每當有a或者b的變動時,主動重算並設置c的新值。

若是咱們是c的消費者,顯然拉取的表達式寫起來更簡潔,尤爲是當表達式更復雜時,好比:

e = (a + b ) * c - d

若是用推的方式寫,要寫4個表達式。

因此,咱們寫訂閱表達式的時候,顯然是從使用者的角度去編寫,採用拉取的方式更直觀,但一般這種方式的執行效率都較低,每次拉取,不管結果是否變動,都要重算整個表達式,而推送的方式是比較高效精確的。

可是剛纔RxJS的這種表達式,讓咱們寫出了形似拉取,實際以推送執行的表達式,達到了編寫直觀、執行高效的結果。

看剛纔這個表達式,大體能夠看出:

permission$ := task$ + user$

這麼一個關係,而其中每一個東西的變動,都是經過訂閱機制精確發送的。

有些視圖庫中,也會在這方面做一些優化,好比說,一個計算屬性(computed property),是用拉的思路寫代碼,但可能會被框架分析依賴關係,在內部反轉爲推的模式,從而優化執行效率。

此外,這種數據流還有其它魔力,那就是懶執行。

什麼是懶執行呢?考慮以下代碼:

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是一種很便捷的操做,好比說,想要在某個對象屬性變動的時候,執行某些操做,就可使用它,大體代碼以下:

watch(‘a.b’, newVal => {
  // 處理新數據
})

這類監控機制,其內部實現無非幾種,好比自定義了setter,攔截數據的賦值,或者經過對比新舊數據的髒檢查方式,或者經過相似Proxy的機制代理了數據的變化過程。

從這些機制,咱們能夠獲得一些推論,好比說,它在對大數組或者複雜對象做監控的時候,監控效率都會下降。

有時候,咱們也會有監控多個數據,以合成另一個的需求,好比:

一條用於展現的任務數據 := 這條任務的原始數據 + 任務上的標籤信息 + 任務的執行者信息

若是不以數據流的方式編寫,這地方就須要爲每一個變量單獨編寫表達式或者批量監控多個變量,前者面臨的問題是代碼冗餘,跟前面咱們提到的推數據的方式相似;後者面臨的問題就比較有意思了。

監控的方式會比計算屬性強一些,緣由在於計算屬性處理不了異步的數據變動,而監控能夠。但若是監控條件進一步複雜化,好比說,要監控的數據之間存在競爭關係等等,都不是容易表達出來的。

另一個問題是,watch不適合作長鏈路的變動,好比:

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代碼也提到了,好比說:

用戶信息數據流 := 用戶信息的查詢 + 用戶信息的更新

這段東西就是按照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的用戶,也未必能說得出。

在不少場景下,這二者並沒有明顯分界,服務端返回的數據直接就適於在視圖上用,不多須要加工。可是在咱們這個方案中,仍是比較明顯的:

> ------ Fetch ------------->
 |                           |
View  <--  VM  <--  M  <--  RESTful
                    ^
                    |  <--  WebSocket

這個簡圖大體描述了數據的流轉關係。其中,M指代的是對原始數據的封裝,而VM則側重於面向視圖的數據組合,把來自M的數據流進行組合。

咱們須要根據業務場景考慮:是要連VM一塊兒跨端複用呢,仍是隻複用M?考慮清楚了這個問題以後,咱們才能肯定數據層的邊界所在。

除了在PC和移動版之間複用代碼,咱們還能夠考慮拿這塊代碼去作服務端渲染,甚至構建到一些Native方案中,畢竟這塊主要的代碼也是純邏輯。

4. 可拆解的WebSocket補丁

這個標題須要結合上面那個圖來理解。咱們怎麼理解WebSocket在整個方案中的意義呢?其實能夠總體視爲整個通用數據層的補丁包,所以,咱們就能夠用這個理念來實現它,把全部對WebSocket的處理部分,都獨立出去,若是須要,就異步加載到主應用來,若是在某些場景下,想把這塊拿掉,只需不引用它就好了,一行配置解決它的有無問題。

可是在具體實現的時候,須要注意:拆掉WebSocket以後的數據層,對應的緩存是不可信的,須要作相應考慮。

對技術選型的思考

到目前爲止,各類視圖方案是逐漸趨同的,它們最核心的兩個能力都是:

  • 組件化
  • MDV(模型驅動視圖)

缺乏這兩個特性的方案都很容易出局。

咱們會看到,無論哪一種方案,都出現了針對視圖以外部分的一些補充,總體稱爲某種「全家桶」。

全家桶方案的出現是必然的,由於爲了解決業務須要,必然會出現一些默認搭配,省去技術選型的煩惱。

可是咱們必須認識到,各類全家桶方案都是面向通用問題的,它能解決的都是很常見的問題,若是你的業務場景很不同凡響,還堅持用默認的全家桶,就比較危險了。

一般,這些全家桶方案的數據層部分都還比較薄弱,而有些特殊場景,其數據層複雜度遠非這些方案所能解決,必須做必定程度的自主設計和修正,我工做十餘年來,長期從事的都是複雜的toB場景,見過不少厚重的、集成度很高的產品,在這些產品中,前端數據和業務邏輯的佔比較高,有的很是複雜,但視圖部分也無非是組件化,一層套一層。

因此,真正會產生大的差別的地方,每每不是在視圖層,而是在水的下面。

願讀者在處理這類複雜場景的時候,慎重考慮。有個簡單的判斷標準是:視圖複用數據是否較多,整個產品是否很重視無刷新的交互體驗。若是這兩點都回答否,那放心用各類全家桶,基本不會有問題,不然就要三思了。

必須注意到,本文所說起的技術方案,是針對特定業務場景的,因此未必具備普適性。有時候,不少問題也能夠經過產品角度的權衡去避免,不過本文主要探討的仍是技術問題,指望可以在產品需求不讓步的狀況下,也能找到比較優雅、和諧的解決方案,在業務場景面前能攻能守,不至於進退失據。

即便咱們面對的業務場景沒有這麼複雜,使用相似RxJS的庫,依照數據流的理念對業務模型作適度抽象,也是會有一些意義的,由於它能夠用一條規則統一不少東西,好比同步和異步、過去和將來,而且提供了不少方便的時序操做。

後記

不久前,我寫過一篇總結,內容跟本文有很多重合之處,但爲何還要寫這篇呢?

上一篇,講問題的視角是從解決方案自己出發,闡述解決了哪些問題,可是對這些問題的前因後果講得並不清晰。不少讀者看完以後,仍然沒有獲得深入認識。

這一篇,我但願從場景出發,逐步展現整個方案的推導過程,每一步是怎樣的,要如何去解決,總體又該怎麼作,什麼方案能解決什麼問題,不能解決什麼問題。

上次我那篇講述在Teambition工做經歷的回答中,也有很多人產生了一些誤解,而且有反覆推薦某些全家桶方案,認爲可以包打天下的。平心而論,我對方案和技術選型的認識仍是比較慎重的,這類事情,事關技術方案的嚴謹性,關係到自身綜合水準的鑑定,不得不一辯到底。當時關注八卦,看熱鬧的人太多,對於探討技術自己倒沒有展示足夠的熱情,我的認爲比較惋惜,仍是但願你們可以多關注這樣一種有特點的技術場景。所以,此文非寫不可。

若是有關注我比較久的,可能會發現以前寫過很多關於視圖層方案技術細節,或者組件化相關的主題,但從15年年中開始,我的的關注點逐步過渡到了數據層,主要是由於上層的東西,如今研究的人已經多起來了,不勞我多說,而各類複雜方案的數據層場景,還須要做更艱難的探索。可預見的幾年內,我可能還會在這個領域做更多探索,前路漫漫,其修遠兮。

相關文章
相關標籤/搜索