search
尋找貓咪~QQ 地點 桃園市桃園區 Taoyuan , Taoyuan

單頁應用的數據流方案探索

作者|徐飛4月18日,在QCon北京2017大會現場,來自螞蟻金服高級前端開發專家徐飛分享了主題為《單頁應用的數據流方案探索》的演講。獲取QCon北京2017相關PPT,請關注前端之巔公眾號併發送「QCon2017」。

大家好,現在是2017年4月。過去的3年裡,前端開發領域可謂風起雲湧,革故鼎新。除了開發語言的語法增強和工具體系的提升之外,大部分人開始習慣幾件事:

  • 組件化

  • MDV(Model Driven View)

所謂組件化,很容易理解,把視圖按照功能,切分為若干基本單元,所得的東西就可以稱為組件,而組件又可以一級一級組合而成複合組件,從而在整個應用的規模上,形成一棵倒置的組件樹。這種方法論歷史久遠,其實現方式或有瑜亮,理念則大同小異。

而MDV,則是對很多低級DOM操作的簡化,把對DOM的手動修改屏蔽了,通過從數據到視圖的一個映射關係,達到了只要操作數據,就能改變視圖的效果。

Model-Driven-View

給定一個數據模型,可以得到對應的的視圖,這一過程可以表達為:

其中的f就是從Model到View的映射關係,在不同的框架中,實現方式有差異,整體理念則是類似的。

當數據模型產生變化的時候,其對應的視圖也會隨之變化:

V + ΔV = f(M + ΔM)

另外一個方面,如果從變更的角度去解讀Model,數據模型不是無緣無故變化的,它是由某個操作引起的,我們也可以得出另外一個表達式:

ΔM = perform(action)

把每次的變更綜合起來,可以得到對整個應用狀態的表達:

state := actions.reduce(reducer, initState)

這個表達式的含義是:在初始狀態上,依次疊加後續的變更,所得的就是當前狀態。這就是當前最流行的數據流方案Redux的核心理念。

從整體來說,使用Redux,相當於把整個應用都實現為命令模式,一切變動都由命令驅動。

Reactive Programming 庫簡介

在傳統的編程實踐中,我們可以:

  • 復用一種數據

  • 復用一個函數

  • 復用一組數據和函數的集合

但是,很難做到:提供一種會持續變化的數據讓其他模塊復用。

而一些基於Reactive Programming的庫可以提供一種能力,把數據包裝成可持續變更、可觀測的類型,供後續使用,這種庫包括:RxJS,xstream,most.js等等。

對數據的包裝過程類似如下:

const a$ = xs.of(1) const arr$ = xs.from([1, 2, 3]) const interval$ = xs.periodic(1000)

這段代碼中的a$、arr$、interval$都是一種可觀測的數據包裝,如果對它們進行訂閱,就可以收到所有產生的變更。

interval$.subscribe(console.log)

我們可以把這種封裝結構視為數據管道,在這種管道上,可以添加統一的處理規則,這種規則會作用在管道中的每個數據上,並且形成新的管道:

const interval$ = xs.periodic(1000) const result$ = interval$ .filter(num => num % 3) .map(num => num * 2)

管道可被連續拼接,並形成新的管道。

需要注意的是:

  • 管道是懶執行的。一個拼接起來的數據管道,只有最末端被訂閱的時候,附加在管道上的所有邏輯才會被執行。

  • 一般情況下,管道的執行過程可以被共享,比如b$和c$兩個管道,都從a$變形得出,它們就共享了a$之前的所有執行過程。

也可以把多個管道組合在一起形成新的管道:

const priv$ = xs.combine(user$, article$) .map(arr => { const [user, article] = arr return user.isAdmin || article.creator === user.id })

從這個關係中可以看出,當user$或task$中的數據發生變更的時候,priv$都會自動計算出最新結果。

在業務開發的過程中,可以使用數據流的理念,把很多東西提高一個抽象等級:

const data$ = xs.fromPromise(service(params)) .map(data => ({ loading: false, data })) .replaceError(error => xs.of({ loading: false, error })) .startWith({ loading: true, error: null, })

比如上面這個例子,統一處理了一個普通請求過程中的三種狀態:請求前、成功、異常,並且把它們的數據:loading、正常數據、異常數據都統一成一種,視圖直接訂閱處理就行了。

高度抽象的數據來源

很多時候,我們進行業務開發,都是在一種比較低層次的抽象維度上,在低層抽象上,存在著太多的冗餘過程。如果能夠對數據的來源和去向做一些歸納會怎樣呢?

比如說,從實體的角度,很可能一份數據初始狀態有多個來源:

  • 應用的默認配置

  • HTTP請求

  • 本地存儲

  • ...等等

也很可能有多個事件都是在修改同一個東西:

  • 用戶從視圖發起的操作

  • 來自WebSocket的推送消息

  • 來自Worker的處理消息

  • 來自其它窗體的postMessage調用

  • 等等……

如果不做歸納,可能會寫出包含以上各種東西的邏輯組合。若干個類似的操作,在過濾掉額外信息之後,可能都是一樣的。從應用狀態的角度,我們不會需要關心一個數據究竟是從哪裡來的,也不會需要關心是通過什麼東西發起的修改。

用傳統的Redux寫法,可能會提取出一些公共方法:

const changeTodo = todo => { dispatch({type: 'updateTodo', payload: todo}) } const changefromDOMEvent = => { const todo = formState changeTodo(todo) } const changefromWebSocket = => { const todo = fromWS changeTodo(todo) }

基於方法調用的邏輯不能很好地展示一份數據的生命周期,它可能有哪些來源?可能被什麼修改?它是經過幾千年怎樣的辛苦修鍊之後才能夠化成人形,跟你坐在一張桌子上喝咖啡?

我們可以藉助RxJS或者xstream這樣的庫,以數據管道的理念,把這些東西更加直觀地組織在一起:

初始狀態來源

const fromInitState$ = xs.of(todo) const fromLocalStorage$ = xs.of(getTodoFromLS) // initState const init$ = xs .merge( fromInitState$, fromLocalStorage$ ) .filter(todo => !todo) .startWith({})

數據變更過程的統一

const changeFromHTTP$ = xs.fromPromise(getTodo) .map(result => result.data) const changeFromDOMEvent$ = xs .fromEvent($('.btn', 'click')) .map(evt => evt.data) const changeFromWebSocket$ = xs .fromEvent(ws, 'message') .map(evt => evt.data) // 合併所有變更來源 const changes$ = xs .merge( changeFromHTTP$, changeFromDOMEvent$, changeFromWebSocket$ )

在這樣的機制里,我們可以很清楚地看到一塊數據的來龍去脈,它最初是哪裡來的,後來可能會被誰修改過。所有這樣的數據都放置在管道中,除了指定的入口,不會有其他東西能夠修改這些數據,視圖可以很安全地訂閱他們。

基於Reactive理念的這些數據流庫,一般是沒有針對業務開發的強約束的,也以直接訂閱並設置組件狀態,也可以拿它按照Redux的理念來使用,豐儉由人。

簡單的使用

changes$.subscribe(({ payload }) => { xxx.setState({ todo: payload }) })

類似Redux的使用方式

const updateActions$ = changes$ .map(todo => ({type: 'updateTodo', payload: todo})) const todo$ = changeActions$ .fold((state, action) => { const { payload } = action return {...state, ...payload} }, initState)組件與外置狀態

我們前面提到,組件樹是一個樹形結構。理想中的組件化,是所有視圖狀態全部內置在組件中,一級一級傳遞。只有這樣,才能達到組件的最佳可復用狀態,並且,組件可以放心把自己該做的事情都做了。

但事實上,組件樹的層級可能很多,這會導致傳遞層級很多,很繁瑣,而且,存在一個經典問題,那就是兄弟組件,或者是位於組件樹的不同樹枝上的組件之間的通信很麻煩,必須通過共同的最近的祖先節點去轉發。

像Redux這樣的機制,把狀態的持有和更新外置,然後通過connect這樣的方法,去把特定組件所需的外部狀態從props設置進去,但它不僅僅是一個轉發器。

我們可以看到如下事實:

  • 轉發器在組件樹之外

  • 部分數據在組件樹之外

  • 對這部分數據的修改過程在組件樹之外

  • 修改完數據之後,通知組件樹更新

所以:

  • 組件可以通過中轉器修改其他組件的狀態

  • 組件可以通過中轉器修改自身的狀態

  • 組件可以通過中轉器修改全局的其他狀態

這樣看來,可以通過中轉器修改應用中的一切狀態。那麼,如果所有狀態都可以通過中轉器修改,是否意味著都應當通過它修改?

這個問題很大程度上等價於:

組件是否應當擁有自己的內部狀態?

我們可能會有如下的選擇:

  • 一切狀態外置,組件不管理自己狀態

  • 部分內置,由組件自己管理,另外一些由全局Store管理

這兩種方式,在傳統軟體開發領域分別稱為貧血組件、充血組件,它們的差別是:組件究竟是純展示,還是帶一些邏輯。

也可以拿蟻群和人群來形容這兩種組件實踐。單個螞蟻的智能程度很低,但它可以接受蟻王的指令去做某些事情,所有的麻煩事情都集中在上層,決策層的事務非常繁瑣。而人類則不同,每個人都有自己的思考和執行能力,一個管理有序的體系中,管理者只需決定他和自己直接下屬所需要做的事情就可以了。

在React體系中,純展示組件可被簡化為這樣的形式:

const ComponentA = (props) => { return (

{props.data}

) }

顯而易見,這種組件的優勢在於它的展示結果只跟輸入數據有關,所有狀態外置,因此,在熱替換等方面,可以做到極致。

然而,一旦這個組件複雜起來,自帶交互,可能就需要在事件、生命周期上做文章,免不了會需要一些中間狀態來表達組件自身的形態。

我們當然可以把這種狀態也外置,但這麼做有幾個問題:

  • 這樣的狀態只跟某組件自己有關,放出去到全局Store,會增加Store的不必要的複雜度

  • 組件的自身形態狀態被外置,將導致組件與狀態的距離變遠,從而對這些狀態的讀寫變得比原先繁瑣

  • 帶交互的組件,無法獨立、完整地描述自身的行為,必須藉助外部管理器

如果是一種單獨提供的組件庫,比如像Ant Design這樣的,卻要依賴一個外部的狀態管理器,這是很不合適的,它會導致組件庫帶有傾向性,從而對使用者造成困擾。

總的來說,狀態全外置,組件退化為貧血組件這種實踐,可以得到不少好處,但代價是比較大的。

You might not need Redux__這篇文章中,Redux的作者Dan Abramov提到:

因此,我們就可能會面臨一個尷尬的狀況,在大部分實踐中:

一個組件的狀態,可能一半在組件內管理,一半在全局的Store里

以React為例,大致是這樣一個狀況:

constructor(props) { super(props) this.state = { b: 1 } } render(props) { const a = this.state.b + props.c; return (

{a}

我們看到,在render裡面,需要合併state和props的數據,但是在這裡做這個事情,是破壞了render函數的純潔性的。可是,除了這裡,別的地方也不太適合做這種合併,怎麼辦呢?

所以,我們需要一種機制,能夠把本地狀態和props在render之外統一起來,這可能就是很多實踐者傾向於把本地狀態也外置的最重要原因。

在React + Redux的實踐中,通常會使用connect對視圖組件包裝一層,變成一種叫做容器組件的東西,這個connect所做的事情就是把全局狀態映射到組件的props中。

那麼,考慮如下代碼:

const mapStateToProps = (state: { a }) => { return { a } } // const localState = { b: 1 } // const mapLocalStateToProps = localState => localState const ComponentA = (props) => { const { a, b } = props const c = a + b return (

{ c }

) } return connect(mapStateToProps/*, mapLocalStateToProps*/)(ComponentA)

我們是否可以把一個組件的內部狀態外置到被註釋掉的這個位置,然後也connect進來呢?這段代碼其實是不起作用的,因為對localState的改變不會被檢測到,所以組件不會刷新。

我們先探索這種模式是否可行,然後再來考慮實現的問題。

MVI架構

Plug and Play All Your Observable Streams With Cycle.js__這篇文章中,我們可以看到一組理念:

  • 一切都是事件源

  • 使用Reactive的理念構建程序的骨架

  • 使用sink來定義應用的邏輯

  • 使用driver來隔離有副作用的行為(網路請求、DOM渲染)

基於這套理念,編寫代碼的方式可以變得很簡潔流暢:

  • 從driver中獲取action

  • 把action映射成數據流

  • 處理數據流,並且渲染成界面

  • 從界面的事件中,派發action去進行後續事項的處理

在CycleJS的理念中,這種模式叫做MVI(Model View Intent)。在這套理念中,我們的應用可以分為三個部分:

  • Intent,負責從外部的輸入中,提取出所需信息

  • Model,負責從Intent生成視圖展示所需的數據

  • View,負責根據視圖數據渲染視圖

整體結構可以這樣描述:

App := View(Model(Intent({ DOM, Http, WebSocket })))

對比Redux這樣的機制,它的差異在於:

  • Intent實際上做的是action執行過程的高級抽象,提取了必要的信息

  • Model做的是reducer的事情,把action的信息轉換之後合併為狀態對象

  • View跟其他框架沒什麼區別,從狀態對象渲染成視圖。

此外,在CycleJS中,View是純展示,連事件監聽也不做,這部分監聽的工作放在Intent中去做。

const model = (a$, b$) => { return xs.combine(a$, b$) } const view = (state$) => { return state$.map(({ a, b }) => { const c = a + b; return h2('c is ' + c) }) }

我們可以從中發掘這麼一些東西:

  • View還是純渲染,接受的唯一參數就是一個表達視圖狀態的數據流

  • Model的返回結果就是上面那個流,不分內外狀態,全部合併起來

  • Model所合併的東西的來源,是從Intent中來的

對我們來說,這裡面最大關鍵在於:所有東西的輸入輸出都是數據流,甚至連視圖接受的參數、還有它的渲染結果也是一個流!奧秘就在這裡。

因此,我們只需在把待傳入視圖的props與視圖的state以流的方式合併,直接把合併之後的流的結果傳入視圖組件,就能達到我們在上一節中提出的需求。

組件化與分形

我們之前提到過一點,在一個應用中,組件是形成倒置的樹形結構的。當組件樹上的某一塊越來越複雜,我們就把它再拆開,延伸出新的樹枝和葉子,這個過程,與分形__有異曲同工之妙。

然而,因為全局狀態和本地狀態的分離,導致每一次分形,我們都要兼顧本組件、下級組件、全局狀態、本地狀態,在它們之間作一些權衡,這是一個很麻煩的過程。在React的主流實踐中,一般可以利用connect這樣的高階函數,把全局狀態映射進組件的props,轉化為本地狀態。

上一節提及的MVI結構,不僅僅能夠描述一個應用的執行過程,還可以單獨描述一個組件的執行過程。

Component := View(Model(Intent({ DOM, Http, WebSocket })))

所以,從整體來理解我們的應用,就是這樣一個關係:

這樣一直分形下去,每一級組件都可以擁有自己的View、Model、Intent。

狀態的變更過程

在模型驅動視圖這個理念下,視圖始終會是調用鏈的最後一段,它的職責就是消費已經計算好的數據,渲染出來。所以,從這個角度看,我們的重點工作在於怎麼管理狀態,包括結構的定義和變更的流轉過程。

Redux提供了對狀態定義和變更過程的管理思路,但有不少值得探討的地方。

基於標準Flux/Redux的實踐有一個共同點:繁瑣。產生這種繁瑣的最主要原因是,它們都是以自定義事件為核心的,自定義事件本身就是繁瑣的。由於收發事件通常位於兩個以上不相同的模塊中,不得不以封裝的事件對象為通信載體,並且必須顯式定義事件的key,否則接收方無法指定自己的響應。

一旦整個應用都是以此為基石,其中的繁瑣程度可想而知,所以社區會存在一些簡化action創建,或者通過約定來減少action收發中間環節的Redux周邊。

如果不從根本上對事件這種機制進行抽象,就不可能徹底解決繁瑣的問題,基於Reactive理念的這幾個庫天然就是為了處理對事件機制的抽象而出現的,所以用在這種場景下有奇效,能把action的派發與處理過程描述得優雅精妙。

const updateActions$ = changes$ .map(todo => ({type: 'updateTodo', payload: todo})) const todo$ = updateActions$ .fold((state, action) => { const { payload } = action return {...state, ...payload} }, initState)

注意一個問題,既然我們之前得到一種思路,把全局狀態和本地狀態分開,然後合併注入組件,就需要考慮這樣的問題:如何管理本地狀態和全局狀態,使用相同的方式去管理嗎?

在Redux體系中,我們在修改全局狀態的時候,使用指定的action去修改狀態,原因是要區分那個哪個action修改state的什麼部分,怎樣修改。但是考慮本地狀態的情況,它反映的只是組件內部的數據變化,一般而言,其結構複雜程度遠遠低於全局狀態,繼續採用這種方式的話並不划算。

Redux這類東西出現的初衷只是為了提供一種單向數據流的思路,防止狀態修改的混亂。但是在基於數據管道的這些庫中,數據天然就是單向流動的。在剛才那段代碼里,其實action的type是沒有意義的,一直就沒有用到。

實際上,這個代碼中的updateActions$自身就表達了updateTodo的含義,而它後續的fold操作,實際上就是直接在reduce。理解了這一點之後,我們就可以寫出反映若干種數據變更的合集了,這個時候,可以根據不同的action去選擇不同的reducer操作:

// 我們可以先把這些action全部merge之後再fold,跟Redux的理念類似 const actions = xs.merge( addActions$, updateActions$, deleteActions$ ) const localState$ = actions.fold((state, action) => { switch(action.type) { case 'addTodo': return addTodo(state, action) case 'updateTodo': return updateTodo(state, action) case 'deleteTodo': return deleteTodo(state, action) } }, initState)

我們注意到,這裡是把所有action全部merge了之後再fold的,這是符合Redux方式的做法。有沒有可能各自fold之後再merge呢?

其實是有可能的,我們只要能夠確保action導致的reducer粒度足夠小,比如只修改state的同一個部分,是可以按照這種維度去組織action的。

const a$ = actionsA$.fold(reducerA, initA) const b$ = actionsB$.fold(reducerB, initB) const c$ = actionsC$.fold(reducerC, initC) const state$ = xs.combine(a$, b$, c$) .map(([a, b, c]) => ({a, b, c}))

如果我們一個組件的內部狀態足夠簡單,甚至連action的類型都可以不需要,直接從操作映射到狀態結果。

const state$ = xs.fromEvent($('.btn'), click) .map(e => e.data)

這樣,我們可以在組件內運行這種簡化版的Redux機制,而在全局狀態上運行比較完善的。這兩種都是基於數據管道的,然後在容器組件中可以把它們合併,傳入視圖組件。

整個流程如圖所示:

狀態的分組與管理

基於redux-saga的封裝庫dva提供了一種分類機制,可以把一類業務的東西進行分組:

export const project = { namespace: 'project', state: {}, reducers: {}, effects: {}, subscriptions: {} }

從這個結構可以看出,這個在dva中被稱為model的東西,定義了:

  • 它是面向的什麼業務模型

  • 需要在全局存儲什麼樣的數據結構

  • 經過哪些操作去變更數據

面向同一種業務實體的數據結構、業務邏輯可以組織到一起,這樣,對業務代碼的維護是比較有利的。對一個大型應用來說,可以根據業務來劃分model。Vue技術棧的Vuex也是用類似的結構來進行業務歸類的,它們都是受elm的啟發而創建,因此會有類似結構。

回想到上一節,我們提到,如果若干個reducer修改的是state的不同位置,可以分別收斂之後,再進行合併。如果我們把狀態結構按照上面這種業務模型的方式進行管理,就可以採用這種機制來分別收斂。這樣,單個model內部就形成了一個閉環,能夠比較清晰的描述自身所代表的業務含義,也便於做測試等等。

MobX的Store就是類似這樣的一個組織形式:

class TodoStore { authorStore @observable todos = @observable isLoading = true constructor(authorStore) { this.authorStore = authorStore this.loadTodos } loadTodos {} updateTodoFromServer(json) {} createTodo {} removeTodo(todo) {} }

依照之前的思路,我們所謂的model其實就是一個合併之後生成state結構的數據管道,因為我們的管道是可以組合的,所以沒有特別的必要去按照上面那種結構定義。

那麼,在整個應用的最上層,是否還有必要去做combineReducer這種操作呢?

我們之前提到一個表達式:

整個React-Redux體系,都是傾向於讓使用者儘可能去從整體的角度關注變化,比如說,Redux的輸入輸出結果是整個應用變更前後的完整狀態,React接受的是整個組件的完整狀態,然後,內部再去做diff。

我們需要注意到,為什麼不是直接把Redux接在React上,而是通過一個叫做react-redux的庫呢?因為它需要藉助這個庫,去從整體的state結構上檢出變化的部分,拿給對應的組件去重繪。

所以,我們發現如下事實:

  • 在觸發reducer的時候,我們是精確知道要修改state的什麼位置的

  • 合併完reducer之後,輸出結果是個完整state對象,已經不知道state的什麼位置被修改過了

  • 視圖組件必須精確地拿到變更的部分,才能排除無效的渲染

整個過程,是經歷了變更信息的擁有——丟失——重新擁有過程的。如果我們的數據流是按照業務模型去分別建立的,我們可以不需要去做這個全合併的操作,而是根據需要,選擇合併其中一部分去進行運算。

這樣的話,整個變更過程都是精確的,減少了不必要的diff和緩存。

如果為了使用redux-tool的話,可以全部合併起來,往redux-tool裡面寫入每次的全局狀態變更信息,供調試使用,而因為數據管道是懶執行的,我們可以做到開發階段訂閱整個state,而運行時不訂閱,以減少不必要的合併開銷。

Model的結構

我們從宏觀上對業務模型作了分類的組織,接下來就需要關注每種業務模型的數據管道上,數據格式應當如何管理了。

在Redux,Vuex這樣的實踐中,很多人都會有這樣的糾結:

在store中,應當以什麼樣的形式存放數據?

通常,會有兩種選擇:

  • 打平了的數據,儘可能以id這樣的key去索引

  • 貼近視圖的數據,比如樹形結構

前者有利於查詢和更新,而後者能夠直接給視圖使用。我們需要思考一個問題:

將處理過後的視圖狀態存放在store中是否合理?

我認為不應當存太偏向視圖結構的數據,理由如下:

某一種業務數據,很可能被不同的視圖使用,它們的結構未必一致,如果按照視圖的格式存儲,就要在store中存放不同形式的多份,它們之間的同步是個大問題,也會導致store嚴重膨脹,隨著應用規模的擴大,這個問題更加嚴重。

既然這樣,那就要解決從這種數據到視圖所需數據的關聯關係,這個處理過程放在哪裡合適呢?

在Redux和Vuex中,為了數據的變更受控,應當在reducer或者mutation中去做狀態變更,但這兩者修改的又是store,這又繞回去了:為了視圖渲染方便而計算出來的數據,如果在reducer或者mutation中做,還是得放在store里。

所以,就有了一個結論:從原始數據到視圖數據的處理過程不應當放在reducer或mutation中,那很顯然就應當放在視圖組件的內部去做。

[ View <-- VM ] <-- State ↓ ↑ Action --> Reducer

這個圖中,方括弧的部分是視圖組件,它內部包含了從原始state到view所需數據的變動,以React為例,用代碼表示:

render(props) { const { flatternData } = props const viewData = formatData(flatternData) // ...render viewData }

經過這樣的拆分之後,store中的結構更加簡單清晰,reducer的職責也更少了,視圖有更大的自主權,去從原始數據組裝成自己要的樣子。

在大型業務開發的過程中,store的結構應當儘早穩定無爭議,避免因為視圖的變化而不停調整,因此,存放相對原始一些的數據是更合理的,這樣也會避免視圖組件在理解數據上的歧義。多個視圖很可能以不同的業務含義去看待狀態樹上的同一個分支,這會造成很多麻煩。

我們期望在store中存儲更偏向於更扁平化的原始數據。即使是對於從後端返回的層級數據,也可以藉助normalizr這樣的輔助庫去展開。

展開前:

[{ id: 1, title: 'Some Article', author: { id: 1, name: 'Dan' } }, { id: 2, title: 'Other Article', author: { id: 1, name: 'Dan' } }]

展開后:

{ result: [1, 2], entities: { articles: { 1: { id: 1, title: 'Some Article', author: 1 }, 2: { id: 2, title: 'Other Article', author: 1 } }, users: { 1: { id: 1, name: 'Dan' } } } }

很明顯,這樣的結構對我們的後續操作是比較便利的。因為我們手裡有數據管道這樣的利器,所以不擔心數據是比較原始的、離散的,因為對它們作聚合處理是比較容易的,所以可以放心地把這些數據打成比較原始的形態。

前端的數據建模

之前我們提到過store裡面存放的是扁平化的原始數據,但是需要注意到,同樣是扁平化,可能有像map那樣基於id作索引的,也可能有基於數組形式存放的,很多時候,我們是兩種都要的。

在更複雜的情況下,還會需要有對象關係的關聯,一對一,一對多,多對多,這就導致視圖在需要使用store中的數據進行組合的時候,不管是store的結構定義還是組合操作都比較麻煩。

如果前端是單一業務模型,那我們按照前一節的方案,已經可以做到當數據變更的時候,把當前狀態推送給訂閱它的組件,但實際情況下,都會比這個複雜,業務模型之間會存在關聯關係,在一個模型變更的時候,可能需要自動觸發所關聯到的模型的更新。

如果複雜度較低,我們可以手動處理這種關聯,如果聯動關係非常複雜,可以考慮對數據按照實體、關係進行建模,甚至加入一個迷你版的類似ORM的庫來定義這種關係。

  • 組織可以有下層組織

  • 組織下可以有人員

  • 組織和人員是一對多的關係

如果一個數據流訂閱了某個組織的基本信息,它可能只反映這個組織自身實體上的變更,而另外一個數據流訂閱了該組織的全部信息,用於形成一個實時更新的組織全視圖,則需要聚合該組織和可能的下級組織、人員的變動匯總。

上層視圖可以根據自己的需要,選擇從不同的數據流訂閱不同複雜度的信息。在這種情況下,可以把整個ORM模塊整體視為一個外部的數據源。

整個流程如下:

這裡面有幾個需要注意的地方:

  • 一個action實際上還是對應到一個reducer,然後發起對state的更改,但因為state已經不是簡單結構了,所以我們不能直接改,而是通過這層類似ORM的關係去改。

  • 對ORM的一次修改,可能會產生對state的若干處改動,比如說,改了一個數據,可能會推導出業務上與之有關係的一塊關聯數據的變更。

  • 如果是基於react-redux這樣基於diff的機制,同時修改state的多個位置是可以的,但在我們這套機制里,因為沒有了先合併修改再diff的過程,所以很可能多個位置的修改需要通過ORM的關聯,延伸出不同的管道來。

  • 視圖訂閱的state變更,只能組合運算,不應當再干別的事情了。

在這麼一種體系下,實際上前端存在著一個類似資料庫的機制,我們可以把每種數據的變動原子化,一次提交只更新單一類型的實體。這樣,我們相當於在前端部分做了一個讀寫分離,讀取的部分是被實時更新的,可以包含一種類似游標的機制,供視圖組件訂閱。

下面是Redux-ORM的簡單示例,是不是很像在操作資料庫?

class Todo extends Model {} Todo.modelName = 'Todo'; Todo.fields = { user: fk('User', 'todos'), tags: many('Tag', 'todos'), }; class Tag extends Model {} Tag.modelName = 'Tag'; Tag.backend = { idAttribute: 'name'; }; class User extends Model {} User.modelName = 'User';小 結

文章最開始,我們提到最理想的組件化開發方式是依託組件樹的結構,每個組件完成自己內部事務的處理。當組件之間出現通信需求的時候,不得不藉助於Redux之類的庫來做轉發。

但是Redux的理念,又不僅僅是只定位於做轉發,它更是期望能管理整個應用的狀態,這反過來對組件的實現,甚至應用的整體架構造成了較大的影響。

我們仍然會期望有一種機制,能夠像分形那樣進行開發,但又希望能夠避免狀態管理的混亂,因此,MVI這樣的模式某種程度上能夠滿足這種需求,並且達到邏輯上的自洽。

如果以MVI的理念來進行開發,它的一個組件其實是:數據模型、動作、視圖三者的集合,這麼一個MVI組件相當於React-Redux體系中,connect了store之後的高階組件。

因此,我們只需把傳統的組件作一些處理:

這樣,組件就是自洽的一個東西,它不關注外面是不是Redux,有沒有全局的store,每個組件自己內部運行著一個類似Redux的東西,這樣的一個組件可以更加容易與其他組件進行配合。

與Redux相比,這套機制的特點是:

  • 不需要顯式定義整個應用的state結構

  • 全局狀態和本地狀態可以良好地統一起來

  • 可以存在非顯式的action,並且action可以不集中解析,而是分散執行

  • 可以存在非顯式的reducer,它附著在數據管道的運算中

  • 非同步操作先映射為數據,然後通過單向聯動關係組合計算出視圖狀態

回顧整個操作過程:

  • 數據的寫入部分,都是通過類似Redux的action去做

  • 數據的讀取部分,都是通過數據管道的組合訂閱去做

藉助RxJS或者xstream這樣的數據管道的理念,我們可以直觀地表達出數據的整個變更過程,也可以把多個數據流進行便捷的組合。如果使用Redux,正常情況下,需要引入至少一種非同步中間件,而RxJS因為自身就是為處理非同步操作而設計的,所以,只需用它控制好從非同步操作到同步的收斂,就可以達到Redux一樣的數據單向流動。如果想要在數據管道中接入一段承擔中間件職責的東西,也是非常容易的。

而RxJS、xstream所提供的數據流組合功能非常強大,天然提供了一切非同步操作的統一抽象,這一點是其他非同步方案很難相比的。

所以,這些庫,因為擁有下面這些特性,很適合做數據流控制:

  • 對事件的高度抽象

  • 同步和非同步的統一化處理

  • 數據變更的持續訂閱(訂閱模式)

  • 數據的連續變更(管道拼接)

  • 數據變更的的組合運算(管道組合)

  • 懶執行(無訂閱者,則不執行)

  • 緩存的中間結果

  • 可重放的歷史記錄

等等……

今日薦文

前端每周清單第9期:向重度jQuery介紹Vue;React Studio 1.0.2、ECharts GL 1.0 alpha、Prettier 1.0發布

視野拓展

InfoQ主辦的移動和前端開發領域的精品大會【GMTC 2017】將於6月9~10日在北京舉行,作為首屆以「大前端」為主題的大會,GMTC涉及移動、前端、跨平台、AI應用等多個技術領域,幫助你方方面面提高技術水平。掃描下圖二維碼或戳閱讀原文,前往官網了解詳細信息!

前端之巔

「前端之巔」是InfoQ旗下關注前端技術的垂直社群,加入前端之巔學習群請關注「前端之巔」公眾號后回復「加群」。推薦分享或投稿請發郵件到[email protected],註明「前端之巔投稿」。



熱門推薦

本文由 yidianzixun 提供 原文連結

寵物協尋 相信 終究能找到回家的路
寫了7763篇文章,獲得2次喜歡
留言回覆
回覆
精彩推薦