Cycle.js 狀態管理模型

分形(fractal)

當今前端領域,最流行的狀態管理模型毫無疑問是 redux,但遺憾的是,redux 並非一個分形架構。什麼是分形架構:javascript

若是子組件可以以一樣的結構,做爲一個應用使用,這樣的結構就是分形架構。html

在分形架構下,每一個應用都組成爲更大的應用使用,而在非分形架構下,應用每每依賴於一個統攬全局的協調器(orchestrators),各個組件並不能以一樣的結構當作應用使用,而是統一接收這個協調器協調。例如,redux 只是聚焦於狀態管理,而不涉及組件的視圖實現,沒法構成一個完整的應用閉環,所以 redux 不是一個分形架構,在 redux 中,協調器就是全局 Store前端

Redux diagram

咱們再看下 redux 靈感來源 —— Elm:java

Model-View-Update diagram

在 Elm 架構下,每一個組件都有一個完整的應用閉環:git

  • 一個 Model 類型
  • 一個 Model 的初始實例
  • 一個 View 函數
  • 一個 Action type 以及對應的更新函數

所以,Elm 就是分形架構的,每一個 Elm 組件也是一個 Elm 應用。github

Cycle.js

分形架構的好處顯而易見,就是複用容易,組合方便,Cycle.js 推崇的也是分形架構。其將應用抽象爲了一個純函數 main(sources),該函數接收一個 sources 參數,用來從外部環境得到諸如 DOM、HTTP 這樣的反作用,再輸出對應的 sinks 去影響外部環境。編程

img

基於這種簡單而直接的抽象,Cycle.js 容易作到分形,即每一個 Cycle.js 應用(每一個 main 函數)能夠組合爲更大的 Cycle.js 應用:redux

nested components
在分形體系下,經過 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 到外部世界。api

另外,因爲 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), initState),
  startWith(initState),
  shareReplay(1)
)
複製代碼

基於上述的前提,Cycle.sj 狀態管理模型的基礎設計也躍然紙上:

  • 將狀態源 state$ 放入 sources 中,輸入給 Cycle.js 應用
  • Cycle.js 應用則將 reducer$ 放入 sinks 中,輸出到外部世界

參看 @cycle/statewithState 的源碼,其響應式狀態管理模型實現亦大體如上。

在實際實現中,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$,
};
複製代碼

使用洋蔥模型傳遞狀態

實際項目中,應用老是由多個組件組成,而且組件間還會存在層級關係,所以,還須要思考:

  1. 怎麼傳遞狀態到組件
  2. 怎麼傳遞 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 操做,由內到外進行狀態更新:

Diagram

具體看一個例子,假定父組件得到以下的狀態:

{
  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.foostate.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 也是一個頗具我的魅力和表達能力的開發者,推薦你關注他的:

最後,不要盲目崇拜,只要瘋狂學習和探索。

參考資料

相關文章
相關標籤/搜索