單頁應用的數據流方案探索
大家好,現在是2017年4月。過去的3年里,前端開發領域可謂風起雲湧,革故鼎新。除了開發語言的語法增強和工具體系的提升之外,大部分人開始習慣幾件事:
- 組件化
- MDV(Model Driven View)
所謂組件化,很容易理解,把視圖按照功能,切分為若干基本單元,所得的東西就可以稱為組件,而組件又可以一級一級組合而成複合組件,從而在整個應用的規模上,形成一棵倒置的組件樹。這種方法論歷史久遠,其實現方式或有瑜亮,理念則大同小異。
而MDV,則是對很多低級DOM操作的簡化,把對DOM的手動修改屏蔽了,通過從數據到視圖的一個映射關係,達到了只要操作數據,就能改變視圖的效果。
Model-Driven-View
給定一個數據模型,可以得到對應的的視圖,這一過程可以表達為:
V = f(M)
其中的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())// initStateconst 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 (<div>{props.data}</div>)}
顯而易見,這種組件的優勢在於它的展示結果只跟輸入數據有關,所有狀態外置,因此,在熱替換等方面,可以做到極致。
然而,一旦這個組件複雜起來,自帶交互,可能就需要在事件、生命周期上做文章,免不了會需要一些中間狀態來表達組件自身的形態。
我們當然可以把這種狀態也外置,但這麼做有幾個問題:
- 這樣的狀態只跟某組件自己有關,放出去到全局Store,會增加Store的不必要的複雜度
- 組件的自身形態狀態被外置,將導致組件與狀態的距離變遠,從而對這些狀態的讀寫變得比原先繁瑣
- 帶交互的組件,無法獨立、完整地描述自身的行為,必須藉助外部管理器
如果是一種單獨提供的組件庫,比如像Ant Design這樣的,卻要依賴一個外部的狀態管理器,這是很不合適的,它會導致組件庫帶有傾向性,從而對使用者造成困擾。
總的來說,狀態全外置,組件退化為貧血組件這種實踐,可以得到不少好處,但代價是比較大的。
在You might not need Redux這篇文章中,Redux的作者Dan Abramov提到:
Local State is Fine.
因此,我們就可能會面臨一個尷尬的狀況,在大部分實踐中:
一個組件的狀態,可能一半在組件內管理,一半在全局的Store里
以React為例,大致是這樣一個狀況:
constructor(props) { super(props) this.state = { b: 1 }}render(props) { const a = this.state.b + props.c; return (<div>{a}</div>)}
我們看到,在render裡面,需要合併state和props的數據,但是在這裡做這個事情,是破壞了render函數的純潔性的。可是,除了這裡,別的地方也不太適合做這種合併,怎麼辦呢?
所以,我們需要一種機制,能夠把本地狀態和props在render之外統一起來,這可能就是很多實踐者傾向於把本地狀態也外置的最重要原因。
在React + Redux的實踐中,通常會使用connect對視圖組件包裝一層,變成一種叫做容器組件的東西,這個connect所做的事情就是把全局狀態映射到組件的props中。
那麼,考慮如下代碼:
const mapStateToProps = (state: { a }) => { return { a }}// const localState = { b: 1 }// const mapLocalStateToProps = localState => localStateconst ComponentA = (props) => { const { a, b } = props const c = a + b return (<div>{ c }</div>)}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 })))
所以,從整體來理解我們的應用,就是這樣一個關係:
APP [ View <-- Model <-- Intent ] | ------------------------------------------------ | |ComponentA [ ViewA <-- ModelA <-- IntentA ] ComponentB
這樣一直分形下去,每一級組件都可以擁有自己的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機制,而在全局狀態上運行比較完善的。這兩種都是基於數據管道的,然後在容器組件中可以把它們合併,傳入視圖組件。
整個流程如圖所示:
--------------------- ↑ ↓ |-- LocalState View <-- | |-- GlobalState ↓ ↑Action --> Reducer
狀態的分組與管理
基於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這種操作呢?
我們之前提到一個表達式:
View = f(Model)
整個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模塊整體視為一個外部的數據源。
整個流程如下:
[ View <-- VM ] <-- [State <-- ORM] ↓ ↑Action --> Reducer
這裡面有幾個需要注意的地方:
- 一個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之後的高階組件。
因此,我們只需把傳統的組件作一些處理:
- 視圖隔離,純化為展示組件
- 內部狀態的定義清晰化
- 描述出內部狀態的來源關係:state := actions.reduce(reducer, initState)
- 將內部的動作以action的方式輸出到上面那個表達式關係中
這樣,組件就是自洽的一個東西,它不關注外面是不是Redux,有沒有全局的store,每個組件自己內部運行著一個類似Redux的東西,這樣的一個組件可以更加容易與其他組件進行配合。
與Redux相比,這套機制的特點是:
- 不需要顯式定義整個應用的state結構
- 全局狀態和本地狀態可以良好地統一起來
- 可以存在非顯式的action,並且action可以不集中解析,而是分散執行
- 可以存在非顯式的reducer,它附著在數據管道的運算中
- 非同步操作先映射為數據,然後通過單向聯動關係組合計算出視圖狀態
回顧整個操作過程:
- 數據的寫入部分,都是通過類似Redux的action去做
- 數據的讀取部分,都是通過數據管道的組合訂閱去做
藉助RxJS或者xstream這樣的數據管道的理念,我們可以直觀地表達出數據的整個變更過程,也可以把多個數據流進行便捷的組合。如果使用Redux,正常情況下,需要引入至少一種非同步中間件,而RxJS因為自身就是為處理非同步操作而設計的,所以,只需用它控制好從非同步操作到同步的收斂,就可以達到Redux一樣的數據單向流動。如果想要在數據管道中接入一段承擔中間件職責的東西,也是非常容易的。
而RxJS、xstream所提供的數據流組合功能非常強大,天然提供了一切非同步操作的統一抽象,這一點是其他非同步方案很難相比的。
所以,這些庫,因為擁有下面這些特性,很適合做數據流控制:
- 對事件的高度抽象
- 同步和非同步的統一化處理
- 數據變更的持續訂閱(訂閱模式)
- 數據的連續變更(管道拼接)
- 數據變更的的組合運算(管道組合)
- 懶執行(無訂閱者,則不執行)
- 緩存的中間結果
- 可重放的歷史記錄……等等
推薦閱讀:
※嗶哩嗶哩(B站)的前端之路
※你為什麼喜歡 React?
※組件化必殺技:styled-components 簡明教程【附視頻下載】
※React源碼(一) setState
※收藏指數滿格!幫你打包前端之巔一整年好文!
TAG:前端框架 |