Cycle.js 狀態管理模型
分形(fractal)
當今前端領域,最流行的狀態管理模型毫無疑問是 redux,但遺憾的是,redux 並不是一個分形架構。什麼是分形架構:
如果子元件能夠以同樣的結構,作為一個應用使用,這樣的結構就是分形架構。
在分形架構下,每個應用都組成為更大的應用使用,而在非分形架構下,應用往往依賴於一個統攬全域性的協調器(orchestrators),各個元件並不能以同樣的結構當做應用使用,而是統一接收這個協調器協調。例如,redux 只是聚焦於狀態管理,而不涉及元件的檢視實現,無法構成一個完整的應用閉環,因此 redux 不是一個分形架構,在 redux 中,協調器就是全域性 Store
我們再看下 redux 靈感來源 —— Elm:
在 Elm 架構下,每個元件都有一個完整的應用閉環:
- 一個 Model 型別
- 一個 Model 的初始例項
- 一個 View 函式
- 一個 Action type 以及對應的更新函式
因此,Elm 就是分形架構的,每個 Elm 元件也是一個 Elm 應用。
Cycle.js
分形架構的好處顯而易見,就是複用容易,組合方便,Cycle.js 推崇的也是分形架構。其將應用抽象為了一個純函式 main(sources)
,該函式接收一個 sources
引數,用來從外部環境獲得諸如 DOM、HTTP 這樣的副作用,再輸出對應的 sinks
基於這種簡單而直接的抽象,Cycle.js 容易做到分形,即每個 Cycle.js 應用(每個 main
函式)可以組合為更大的 Cycle.js 應用:
run
API,能驅動任何 Cycle.js 應用執行,無論它是一個簡單的 Cycle.js 應用,還是一個巢狀複合的 Cycle.js 應用。
import {run} from '@cycle/run'
import {div, label, input, hr, h1, makeDOMDriver} from '@cycle/dom'
function main (sources) {
const input$ = sources.DOM.select('.field').events('input')
const name$ = input$.map(ev => ev.target.value).startWith('')
const vdom$ = name$.map(name =>
div([
label('Name:'),
input('.field', {attrs: {type: 'text'}}),
hr(),
h1('Hello ' + name),
])
)
return { DOM: vdom$ }
}
run(main, { DOM: makeDOMDriver('#app-container') })
複製程式碼
Cycle.js 的狀態管理
響應式
上面我們提到,Cycle.js 推崇的是分形應用結構,因此,redux 這樣的狀態管理器就不是 Cycle.js 願意使用的,它會讓全域性只有一個 redux 應用,而不是多個可拆卸的 Cycle.js 分形應用。基於此,若要引入狀態管理模型,其設計應當不改變 Cycle.js 應用的基本結構:從外部世界接收 sources
,輸出 sinks
到外部世界。
另外,由於 Cycle.js 是一個響應式前端框架,那麼狀態管理仍然保持是響應式的,即以 stream/observable 為基礎。如果你熟悉響應式程式設計,基於 Elm 的理念,以 RxJs 為例,我們可以很輕鬆的實現一個狀態管理模型:
const action$ = new Subject()
const incrReducer$ = action$.pipe(
filter(({type}) => type === 'INCR'),
mapTo(function incrReducer(state) {
return state + 1
})
)
const decrReducer$ = action$.pipe(
filter(({type}) => type === 'DECR'),
mapTo(function decrReducer(state) {
return state - 1
})
)
const reducer$ = merge(incrReducer$, decrReducer$)
const state$ = reducer$.pipe(
scan((state, reducer) => reducer(state)),
startWith(initState),
shareReplay(1)
)
複製程式碼
基於上述的前提,Cycle.sj 狀態管理模型的基礎設計也躍然紙上:
- 將狀態源
state$
放入sources
中,輸入給 Cycle.js 應用 - Cycle.js 應用則將 reducer$ 放入
sinks
中,輸出到外部世界
參看
@cycle/state
的withState
的原始碼,其響應式狀態管理模型實現亦大致如上。
在實際實現中,Cycle.js 通過 @cycle/state
暴露的 withState
來為 Cycle.js 注入狀態管理模型:
import {withState} from '@cycle/state'
function main(sources) {
const state$ = sources.state.stream
const vdom$ = state$.map(state => /*render virtual DOM*/)
const reducer$ = xs.periodic(1000)
.mapTo(function reducer(prevState) {
// return new state
})
const sinks = {
DOM: vdom$,
state: reducer$
}
return sinks
}
const wrappedMain = withState(main)
run(wrappedMain, drivers)
複製程式碼
在思考了如何讓 Cycle.js 引入狀態管理模型後仍然保持分形後,我們還要再狀態管理模型中解決下面這些問題:
- 如何宣告應用初始狀態
- 應用如何讀取以及修改某個狀態
初始化狀態
為了遵循響應式,我們可以宣告一個 initReducer$
,其預設發出一個 initReducer
,在這個 reducer 中,直接返回元件的初始狀態:
const initReducer$ = xs.of(function initReducer(prevState) {
return { count:0 }
})
const reducer$ = xs.merge(initReducer$, someOtherReducer$);
const sinks = {
state: reducer$,
};
複製程式碼
使用洋蔥模型傳遞狀態
實際專案中,應用總是由多個元件組成,並且元件間還會存在層級關係,因此,還需要思考:
- 怎麼傳遞狀態到元件
- 怎麼傳遞 reducer 到外部
假定我們的狀態樹是:
const state = {
visitors: {
count: 300
}
}
複製程式碼
假定我們的元件需要 count
狀態,就有兩種設計思路:
(1)在元件中直接宣告要摘取的狀態,如何處理子狀態變動:
function main(sources) {
const count$ = sources.state.visitors.count
const reducer$ = incrAction$.mapTo(function incr(prevState) {
return prevState + 1
})
return {
state: {
visitors: {
count: reducer$
}
}
}
}
複製程式碼
(2)保持元件的純淨,其獲得的 state$
,輸出的 reducer$
不用考慮當前狀態樹形態,二者都只相對於元件自己:
function main(sources) {
const state$ = sources.state
const reducer$ = incrAction$.mapTo(function incr(prevState) {
return prevState + 1
})
return {
state: reducer$
}
}
複製程式碼
兩種方式各有好處,第一種方式更加靈活,適合層級巢狀較深的場景。第二種則讓元件邏輯更加內聚,擁有更高的元件自治能力,在簡單場景下可能表現得更加直接。這裡我們首先探討第二種傳遞狀態方式。
在第二種狀態傳遞方式下,我們要將 count
傳遞給對應的元件,就需要從外到內逐層的剝開狀態,直到拿到元件需要的狀態:
stateA$ // Emits object `{visitors: {count: 300}}}`
stateB$ // Emits object `{count: 300}`
stateC$ // Emits object `300`
複製程式碼
而元件輸出 reducer 時,則需要由內到外進行 reduce:
reducerC$ // Emits function `count => count + 1`
reducerB$ // Emits function `visitors => ({count: reducerC(visitors.count)})`
reducerA$ // Emits function `appState => ({visitors: reducerB(appState.visitors)})`
複製程式碼
這形成了一個類似洋蔥(cycle state 的前身正是 cycle-onionify)的狀態管理模型:我們由外部世界開始,層層剝開外衣,拿到狀態;在逐層進行 reduce 操作,由內到外進行狀態更新:
具體看一個例子,假定父元件獲得如下的狀態:
{
foo: string,
bar: number,
child: {
count: number,
},
}
複製程式碼
其中,child
子狀態是其子元件需要的狀態,此時,洋蔥模型下就要考慮:
- 將
child
從狀態樹中剝離,傳遞給子元件 - 收集子元件輸出的
reducer$
,合併後繼續向外輸出
首先,我們需要使用 @cycle/isolate
隔離子元件,其暴露了一個 isolate(component, scope)
函式,該函式接受兩個引數:
component
:需要隔離的元件,即一個接受sources
並返回sinks
的函式scope
:元件被隔離到的 scope。scope 決定了 DOM,state 等外部環境如何劃分其資源到元件
該函式最終將返回隔離元件輸出的 sinks
。獲得了子元件的 reducer$
之後,還要與父元件的 reducer$
進行合併,繼續向外丟擲。
例如下面的程式碼中,isolate(Child, 'child')(sources)
將 Child
元件隔離到了名為 child
的 scope 下,因此, @cycle/state
能夠知道,要從狀態樹上選出名為 child
的狀態子樹給 Child
元件。
function Parent(sources) {
const state$ = sources.state.stream; // emits { foo, bar, child }
const childSinks = isolate(Child, 'child')(sources);
const parentReducer$ = xs.merge(initReducer$, someOtherReducer$);
const childReducer$ = childSinks.state;
const reducer$ = xs.merge(parentReducer$, childReducer$);
return {
state: reducer$
}
}
複製程式碼
另外,為了保證父元件不存在時,子元件能夠獨立執行的能力,需要在子元件中進行識別這種場景(prevState === undefined
),並返回對應狀態:
function Child(sources) {
const state$ = sources.state.stream; // emits { count }
const defaultReducer$ = xs.of(function defaultReducer(prevState) {
if (typeof prevState === 'undefined') {
return { count: 0}
} else {
return prevState
}
})
// 這裡,reducer 將處理 { count } state
const reducer$ = xs.merge(defaultReducer$, someOtherReducer$);
return {
state: reducer$
}
}
複製程式碼
好的習慣是,每個元件我們都宣告一個 defaultReducer$
,用來照顧其單獨使用時的場景,以及存在父元件時的場景。
關於元件隔離的來由,可以參看:Cycle.js Components 一節
使用 Lens 機制傳遞狀態
在洋蔥模型中,資料通過父元件傳遞到子元件,這裡父元件僅僅能夠從自身的狀態樹摘取一棵子樹給子元件,因此,這個模型在靈活性上受到了一些限制:
- 個數上:只能傳遞一個子狀態
- 規模上:不能傳遞整個狀態
- I/O 上:只能讀取,不能修改狀態
如果你有下面的需求,這種模式就難以勝任:
- 元件需要多個狀態,例如需要獲得
state.foo
及state.status
- 父子元件需要訪問同一部分狀態,例如父元件和子元件需要獲得
state.foo
- 當子元件的狀態變動後,需要聯動修改狀態樹,而不只是通過
reducer$
修改其自身狀態
為此,就需要考慮使用上文中我們提到的第一種狀態共享方式。我們給到的多少有些粗糙,Cycle.js 則是引入了 lens 機制來處理洋蔥模型無法照顧到的這些場景,顧名思義,這能讓元件擁有 洞察(讀取) 並且 更改(寫入) 狀態的能力。
簡單來說,lens 通過 getter/setter 定義了對某個資料的讀寫。
為了實現通過 lens 來讀寫狀態,Cycle.js 讓 isolate
在隔離元件例項時,接受元件自定義的 lens 作為 scope selector,以讓 @cycle/state
元件要如何讀取以及修改狀態。
const fooLens = {
get: state => state.foo,
set: (state, childState) => ({...state, foo: childState})
};
const fooSinks = isolate(Foo, {state: fooLens})(sources);
複製程式碼
上面程式碼中,通過自定義 lens,元件 Foo
能夠獲得狀態樹上的 foo
狀態,而當 Foo
修改了 foo
後,將聯動修改狀態樹上的 foo
狀態。
處理動態列表
渲染動態列表是前端最常見的需求之一,在 Cycle.js 引入狀態管理之前,這一直是 Cycle.js 做不好的一個點,甚至 André Staltz 還專門開了一篇 issue 來討論如何更在 Cycle.js 中更優雅的處理動態列表。
現在,基於上述的狀態管理模型,只需要一個 makeCollection
API,即可在 Cycle.js 中,建立一個動態列表:
function Parent(sources) {
const array$ = sources.state.stream;
const List = makeCollection({
item: Child,
itemKey: (childState, index) => String(index),
itemScope: key => key,
collectSinks: instances => {
return {
state: instances.pickMerge('state'),
DOM: instances.pickCombine('DOM')
.map(itemVNodes => ul(itemVNodes))
// ...
}
}
});
const listSinks = List(sources);
const reducer$ = xs.merge(listSinks.state, parentReducer$);
return {
state: reducer$
}
}
複製程式碼
看到上面的程式碼,基於 @cylce/state
建立一個動態列表,我們需要告訴 @cycle/state
:
-
列表元素是什麼
-
每個元素在狀態中的位置
-
每個元素的 scope
-
列表的
reducer$
:instances.pickMerge('state')
,其約等於:xs.merge(instances.map(sink => sink.state))
-
列表的
vdom$
:instances.pickCombine('DOM')
,其約等於:xs.combine(instances.map(sink => sink.DOM))
新增列表元素只需要在列表容器的 reducer$
中,為陣列新增一個元素即可:
const reducer$ = xs.periodic(1000).map(i => function reducer(prevArray) {
return prevArray.concat({count: i})
})
複製程式碼
刪除元素則需要子元件在刪除行為觸發時,將其狀態標識為 undefiend
,Cycle.js 內部會據此從列表陣列中刪除該狀態,進而刪除子元件及其輸出的 sinks:
function Child(sources) {
const deleteReducer$ = deleteAction$.mapTo(function deleteReducer(prevState) {
return undefined;
})
const reducer$ = xs.merge(deleteReducer$, someOtherReducer$)
return {
state: reducer$
}
}
複製程式碼
總結
Cycle.js 相比較於前端三大框架(Angular/React/Vue)來說,算是小眾的不能再小眾的框架,學習這樣的框架並不是為了標新立異,考慮到你的團隊,你也很難在大型工程中將它作為支援框架。但是,這不妨礙我們從 Cycle.js 的設計中獲得啟發和靈感,它多少能讓你感受到:
- 也許我們的應用就是一個和外部世界打交道的環
- 什麼是分形
- 響應式程式設計的魅力
- 什麼是 lens 機制?如何在 JavaScript 應用中使用 lens
- ...
另外,Cycle.js 的作者 André Staltz 也是一個頗具個人魅力和表達能力的開發者,推薦你關注他的:
- André Staltz 的部落格:staltz.com/
- André Staltz 在 egghead.io 上的 RxJs 和 Cycle.js 教程,你不僅能學到 API,還能學到框架設計思路:egghead.io/instructors…
- André Staltz 參加並演講的一系列會議:www.youtube.com/results?sea…
最後,不要盲目崇拜,只要瘋狂學習和探索。