当今前端领域,最流行的状态管理模型毫无疑问是 redux,但遗憾的是,redux 并非一个分形架构。什么是分形架构:javascript
若是子组件可以以一样的结构,做为一个应用使用,这样的结构就是分形架构。html
在分形架构下,每一个应用都组成为更大的应用使用,而在非分形架构下,应用每每依赖于一个统揽全局的协调器(orchestrators),各个组件并不能以一样的结构当作应用使用,而是统一接收这个协调器协调。例如,redux 只是聚焦于状态管理,而不涉及组件的视图实现,没法构成一个完整的应用闭环,所以 redux 不是一个分形架构,在 redux 中,协调器就是全局 Store
。前端
咱们再看下 redux 灵感来源 —— Elm:java
在 Elm 架构下,每一个组件都有一个完整的应用闭环:git
所以,Elm 就是分形架构的,每一个 Elm 组件也是一个 Elm 应用。github
分形架构的好处显而易见,就是复用容易,组合方便,Cycle.js 推崇的也是分形架构。其将应用抽象为了一个纯函数 main(sources)
,该函数接收一个 sources
参数,用来从外部环境得到诸如 DOM、HTTP 这样的反作用,再输出对应的 sinks
去影响外部环境。编程
基于这种简单而直接的抽象,Cycle.js 容易作到分形,即每一个 Cycle.js 应用(每一个 main
函数)能够组合为更大的 Cycle.js 应用:redux
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 推崇的是分形应用结构,所以,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 应用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$,
};
复制代码
实际项目中,应用老是由多个组件组成,而且组件间还会存在层级关系,所以,还须要思考:
假定咱们的状态树是:
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 一节
在洋葱模型中,数据经过父组件传递到子组件,这里父组件仅仅可以从自身的状态树摘取一棵子树给子组件,所以,这个模型在灵活性上受到了一些限制:
若是你有下面的需求,这种模式就难以胜任:
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 的设计中得到启发和灵感,它多少能让你感觉到:
另外,Cycle.js 的做者 André Staltz 也是一个颇具我的魅力和表达能力的开发者,推荐你关注他的:
最后,不要盲目崇拜,只要疯狂学习和探索。