cycle.js介绍
下面的内容主要来自作者André Staltz的egghead.io
设计
cycle.js设计上有三个特点:
- 万物都是Stream(collections + 时间)
- Logic和Effect分离(借助
main
和drivers
) - app是纯数据流(data flow)
第一部分正如RxJS中介绍的一样,可以用Observable建模。
第二部分中,logic是数学相关的东西,是抽象的。effects则是影响实际世界的效应(如DOM、HTTP等),是实际的。将两者更好地分离开来是Cycle.js的设计初衷。而logic纯函数的特点优势也是很明显的(无副作用)。
上面的两部分在Cycle.js中,借助xstream, RxJS等Reactive Programming的库,以main和driver函数来实现。其中main
实现逻辑、生产数据,driver
订阅消费数据,交由cycle.js制造Effect。这里有一个浅显易懂的例子。
除了简单的DOM Effect外,还有HTTP请求等。交给不同的drivers完成就行了。这一点上和Elm设计很像。
对比流行的React、Angular、Vue,组件化的设计模式当然也涉及到。文档的Components部分讲得很清楚:
Any Cycle.js app can be reused as a component in a larger Cycle.js app.
即任何一个Cycle.js的应用都可以直接重用成更大应用的一个组件,无需额外的操作。原因很简单,任何一个logic都是在main
函数中完成的,这也是开发者唯一需要做的事。而这个函数接收的sources
以及返回给Cycle.js的sinks
都是一个包含DOM、HTTP等stream的对象,且键值对都一样,可以看下面这张图更好理解:
原理
完全可以在main中生产多种数据,作为对象返回,交给不同driver的得到不一样的effects。将main中数据交给driver的过程抽象在run函数中,完成数据生产和订阅的过程。这个过程并不复杂。下面是一个简陋的实现。
1 | function run(mainFn, drivers) { |
上面的实现手法只实现了单向的从逻辑到Effects的映射过程,要完成相反的从实际世界到逻辑的过程,需要读取外界的事件。要这么做,不仅main函数需要可以接受入参sources
,生产Effects的drivers也要能够返回main需要的sources
。类似地,sources的类型可以是DOM或者其他的合法input。同时,连接两者的run
函数会遇到循环的问题,driver的入参和出参正好是main的出参和入参,这也是Cycle一词的来源。解决办法是先fake一个的初始数据流,得到Effects后,再用Effects初始化main,最后用main替换fake的数据流即可。
继续改造run
函数,考虑到driver有多种类型,需要事先为所有driver都使用fakeSinks。在构造好drivers后,使用drivers的返回,构造main。最后用imitate
替代掉fakeSinks即可。这就是Cycle.js核心部分run的设计思路,实际上,run部分的代码也只有一百余行。
1 | function run(mainFn, drivers) { |
除了run,drivers也是Cycle.js设计的重要部分。它需要能够根据main逻辑的描述灵活地生成对应的Effects。如DOM,在main逻辑中声明所需的DOM结构,对应地,在domDriver中,根据结构生成实际的DOM元素(不论是使用createElement还是vDOM)。
不过仅仅在main逻辑中描述DOM结构是不够的,逻辑上还应该包括如何响应Effects的输入。类似地,这部分应该从driver中的hardcode抽离出来,由main声明,driver实现。类似下面这样:
1 | function main(sources) { |
上面的DOM结构可以进一步抽象函数,便于代码书写。另外,Cycle.js中使用makeDOMDriver的方式是为了显示声明DOM容器名,避免hardcode在driver中。
1 | function h(tagName, children) { |
使用
习惯了上面的思考方式后,可以考虑如何使用Cycle.js的问题了。通常情况下,一个空白的Cycle.js的脚手架像下面这样(使用UMD方案时):
1 | const { makeDOMDriver } = CycleDOM; |
结合HTML内容的声明和用户输入事件的读取,可以得到下面的结果:
1 | const { div, label, input, hr, h1, makeDOMDriver } = CycleDOM; |
其中要格外注意的是,name$
需要有startWith
才能有流的起始数据,从而初始化真实DOM。
结合灵活的流操作符,如merge
, fold
等,可以实现更加复杂点的应用,如官网给出的计数器
除了DOMDriver,HTTPDriver也是很常用的一种Driver,可以借助它实现HTTP的request和response的响应。如官网给的样例
MVI
MVI(Model-View-Intent)是Cycle.js提出的编程范式,可以将main的内容拆分成三部分,
- 第一部分Intent将Effects传递过来的事件转换成Model逻辑可以接受的类型,
- 第二部分Model实现具体逻辑,即state的改动
- 最终由View部分将逻辑转换成DOMDriver可以接受的数据流传递到最后的vdom。
使用这种方式拆分后的代码实际上类似view(model(intent(sources.DOM)))
。如文档中介绍的那样。
关于组件拆分和isolate,可以参看文档的Components部分。另外,在Cycle.js的状态管理工具cycle-onionify中也用到了isolate
,使用isolate
可以保证组件间的stream、状态相互独立。
xstream
万物皆Stream的概念是需要额外的库支持的。因此没有接触过RxJS的建议先学习一下这种思路。
Cycle.js允许使用RxJS等多种Reactive Programming库构造响应式的流结构,不过推荐针对Cycle.js定制的xstream。xstream学习成本简单,API仅有26个,此外文件体积小,速度适中。熟悉RxJS后,学习xstream就更简单了。
xstream的API分为Factories和Operators。前者通过Producer或合并等方式生产新的Stream,后者是Stream的相关方法。
学习xstream了解4个概念就足够了(如果你已经熟悉RxJS的思想后),Stream, Listener, Producer, MemoryStream。
- Stream,类似EventEmitter和RxJS中的Subject,一个Stream可以注册多个Listener,Stream上有event出现时,所有Listener都会收到通知。除此之外,Stream可以通过operators生产新的Stream,如
fold
,map
等。可以使用shamefullySend*
手动触发event,但是应避免使用这种方式 - Listener,和RxJS中的Observer类似,是有
next
,error
,complete
三种状态的对象,用来处理stream的三种event。通过addListener
和removeListener
和Stream建立联系。1
2
3
4
5
6
7
8
9
10
11var listener = {
next: (value) => {
console.log('The Stream gave me a value: ', value);
},
error: (err) => {
console.error('The Stream gave me an error: ', err);
},
complete: () => {
console.log('The Stream told me it is done.');
},
} - Producer,生产Stream所需的event。xstream使用
create(producer)
等方法生产Stream。一个Producer只能绑定一个Stream。Producer本身拥有start
和stop
便于在没有Listener监听时停止工作。1
2
3
4
5
6
7
8
9var producer = {
start: function (listener) {
this.id = setInterval(() => listener.next('yo'), 1000)
},
stop: function () {
clearInterval(this.id)
},
id: 0,
} - MemoryStream,和Stream不同的是会记录最后一次的event信息。类似RxJS里的BehaviorSubject。
生产Stream的函数有下面这些:
create(producer)
和createWithMemory(producer)
never()
生产不产生event的Streamempty()
生产立即结束的Streamerror(error)
生产立即错误的Streamfrom(input)
通过数组、Promise、Observable等生产Streamof(a1,a2,a3)
生产根据输入产生的一系列eventfromArray(array)
,fromPromise(promise)
,fromObservable(observable)
periodic(period)
周期性产生递增的数merge(s1, s2)
合并两个流combine(s1, s2)
合并两个流中的值
Stream的相关方法有:
addListener(listener)
和removeListener(listener)
subscribe(listener)
注册listener返回remove的函数map(project)
和mapTo(projectedValue)
映射event中的值filter(passes)
过滤take(amount)
限制Stream的event数目drop(amount)
忽略前amount
次的event数目last()
只释放最后一次eventstartWith(initialValue)
以给定值开始endWhen(other)
使用其他Stream决定是否完成当前Streamfold(accumulate, seed)
以给定值开始累加replaceError(replace)
取代一个流中的所有errorflatten()
将streams的Stream压缩为一个Stream,输出流中的数据只来自于当前Streamcompose(operator)
remember()
缓存最后一个值debug(labelOrSpy)
不修改流,便于debugimitate(target)
使用给定流替换原有StreamshamefullySendNext/Error/Complete
另外一些实用的Stream相关方法,在extra部分中引入,包括如下
buffer(separator)
缓存部分内容一同输出,输出时机由输入的Stream决定concat(s1, s2, ..., sn)
将Stream按照参数顺序从前到后连接起来debounce(period)
和throttle(period)
防抖和节流delay(period)
时延dropRepeats(isEquals)
丢掉邻接的重复数据dropUntil(other)
根据其他Stream决定该Stream的开始时机flattenConcurrently()
类似flatten()
,不过流中的数据根据时间merge,flattenSequentially()
类似,将Stream先后连接fromDiagram(diagram, options)
通过图表创建StreamfromEvent(element, eventName, useCapture)
通过DOM事件创建Streampairwise()
和上一个值成对组成event的值sampleCombine(streams)
source流和其他发生时间最近的流event相组合split(separator)
类似buffer
,将stream拆分为释放streams的Streamtween(config)
根据配置创建缓动函数,用于制作动画