阅读 946

Cycle.js 状态管理模型

分形(fractal)

当今前端领域,最流行的状态管理模型毫无疑问是 redux,但遗憾的是,redux 并不是一个分形架构。什么是分形架构:

如果子组件能够以同样的结构,作为一个应用使用,这样的结构就是分形架构。

在分形架构下,每个应用都组成为更大的应用使用,而在非分形架构下,应用往往依赖于一个统揽全局的协调器(orchestrators),各个组件并不能以同样的结构当做应用使用,而是统一接收这个协调器协调。例如,redux 只是聚焦于状态管理,而不涉及组件的视图实现,无法构成一个完整的应用闭环,因此 redux 不是一个分形架构,在 redux 中,协调器就是全局 Store

Redux diagram

我们再看下 redux 灵感来源 —— Elm:

Model-View-Update diagram

在 Elm 架构下,每个组件都有一个完整的应用闭环:

  • 一个 Model 类型
  • 一个 Model 的初始实例
  • 一个 View 函数
  • 一个 Action type 以及对应的更新函数

因此,Elm 就是分形架构的,每个 Elm 组件也是一个 Elm 应用。

Cycle.js

分形架构的好处显而易见,就是复用容易,组合方便,Cycle.js 推崇的也是分形架构。其将应用抽象为了一个纯函数 main(sources),该函数接收一个 sources 参数,用来从外部环境获得诸如 DOM、HTTP 这样的副作用,再输出对应的 sinks 去影响外部环境。

img

基于这种简单而直接的抽象,Cycle.js 容易做到分形,即每个 Cycle.js 应用(每个 main 函数)可以组合为更大的 Cycle.js 应用:

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 到外部世界。

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

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

参考资料

关注下面的标签,发现更多相似文章
评论