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 也是一个颇具我的魅力和表达能力的开发者,推荐你关注他的:

最后,不要盲目崇拜,只要疯狂学习和探索。

参考资料

相关文章
相关标签/搜索