cycle.js介绍

下面的内容主要来自作者André Staltz的egghead.io

cycle-flow

设计

cycle.js设计上有三个特点:

  • 万物都是Stream(collections + 时间
  • Logic和Effect分离(借助maindrivers
  • 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的对象,且键值对都一样,可以看下面这张图更好理解:

cycle.js component

原理

完全可以在main中生产多种数据,作为对象返回,交给不同driver的得到不一样的effects。将main中数据交给driver的过程抽象在run函数中,完成数据生产和订阅的过程。这个过程并不复杂。下面是一个简陋的实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
function run(mainFn, drivers) {
const sinks = mainFn();
Object.keys(drivers).forEach(key => {
if (sinks[key]) {
drivers[key](sinks[key]);
}
});
}
run(main, {
DOM: domDriver,
other: otherDriver
})

上面的实现手法只实现了单向的从逻辑到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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function run(mainFn, drivers) {
const fakeSinks = {};
Object.keys(drivers).forEach(key => {
fakeSinks[key] = xs.create();
})
const sources = {};
Object.keys(drivers).forEach(key => {
sources[key] = drivers[key](fakeSinks[key]);
});
const sinks = mainFn(sources);
Object.keys(sinks).forEach(key => {
fakeSinks[key].imitate(sinks[key];)
});
}

除了run,drivers也是Cycle.js设计的重要部分。它需要能够根据main逻辑的描述灵活地生成对应的Effects。如DOM,在main逻辑中声明所需的DOM结构,对应地,在domDriver中,根据结构生成实际的DOM元素(不论是使用createElement还是vDOM)。

不过仅仅在main逻辑中描述DOM结构是不够的,逻辑上还应该包括如何响应Effects的输入。类似地,这部分应该从driver中的hardcode抽离出来,由main声明,driver实现。类似下面这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function main(sources) {
const events$ = sources.DOM.selectEvents('span', 'click');
return {
DOM: events$.startWith(null).map(
xs.periodic(1000)
// ...
).flatten()
.map(i => ({
tagName: 'H1',
children: [
{
tagName: 'SPAN',
children:[`Seconds elapsed: ${i}`]
}
]
}))
// ...
}
}

上面的DOM结构可以进一步抽象函数,便于代码书写。另外,Cycle.js中使用makeDOMDriver的方式是为了显示声明DOM容器名,避免hardcode在driver中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
function h(tagName, children) {
return {
tagName,
children
}
}
function h1(children) {
return {
tagName: 'H1',
children
}
}
function span(children) {
return {
tagName: 'SPAN',
children
}
}
// ...
.map(
h1([
span([`Seconds elapsed: ${i}`])
])
)

使用

习惯了上面的思考方式后,可以考虑如何使用Cycle.js的问题了。通常情况下,一个空白的Cycle.js的脚手架像下面这样(使用UMD方案时):

1
2
3
4
5
6
7
8
9
10
11
12
const { makeDOMDriver } = CycleDOM;
function main(sources) {
// TODO
}
const drivers = {
DOM: makeDOMDriver('#app'),
// ...
}
Cycle.run(main, drivers);

结合HTML内容的声明和用户输入事件的读取,可以得到下面的结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
const { div, label, input, hr, h1, makeDOMDriver } = CycleDOM;
function main(sources) {
// ''--------------->
// div-------------->
const input$ = sources.DOM.select('.name').events('change');
const name$ = input$.map(ev => ev.target.value).startWidth('');
return {
DOM: name$.map(name =>
div([
label(['Name: ']),
input('.name', {attrs: {type: 'text'}}),
hr(),
h1(`Hello ${name}!`)
])
)
};
}
const drivers = {
DOM: makeDOMDriver('#app'),
// ...
}
Cycle.run(main, drivers);

其中要格外注意的是,name$需要有startWith才能有流的起始数据,从而初始化真实DOM。

结合灵活的流操作符,如merge, fold等,可以实现更加复杂点的应用,如官网给出的计数器

除了DOMDriver,HTTPDriver也是很常用的一种Driver,可以借助它实现HTTP的request和response的响应。如官网给的样例

MVI

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分为FactoriesOperators。前者通过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。通过addListenerremoveListener和Stream建立联系。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    var 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本身拥有startstop便于在没有Listener监听时停止工作。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    var 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的Stream
  • empty() 生产立即结束的Stream
  • error(error) 生产立即错误的Stream
  • from(input) 通过数组、Promise、Observable等生产Stream
  • of(a1,a2,a3) 生产根据输入产生的一系列event
  • fromArray(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() 只释放最后一次event
  • startWith(initialValue) 以给定值开始
  • endWhen(other) 使用其他Stream决定是否完成当前Stream
  • fold(accumulate, seed) 以给定值开始累加
  • replaceError(replace) 取代一个流中的所有error
  • flatten() 将streams的Stream压缩为一个Stream,输出流中的数据只来自于当前Stream
  • compose(operator)
  • remember() 缓存最后一个值
  • debug(labelOrSpy) 不修改流,便于debug
  • imitate(target) 使用给定流替换原有Stream
  • shamefullySendNext/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) 通过图表创建Stream
  • fromEvent(element, eventName, useCapture) 通过DOM事件创建Stream
  • pairwise() 和上一个值成对组成event的值
  • sampleCombine(streams) source流和其他发生时间最近的流event相组合
  • split(separator) 类似buffer,将stream拆分为释放streams的Stream
  • tween(config) 根据配置创建缓动函数,用于制作动画

参考