初试Cycle.js
Cycle.js最基础的设施是stream,其中钦定了xstream。不妨从stream开始,看看cycle.js的原理。然后我们再用Cycle.js构建俩个简单应用试试。
0x00 Before Cycle
Cycle.js的最基础设施是stream,如果你用过Rx.js那你就应该比较熟悉Reactive Programming,其中就有stream这个概念。前文说过stream是一个持续发出0或者多个事件的事件流,可能结束,也可能无限;如果它结束了,它将会放出error或者特别的complete事件
。
Cycle.js支持Rx.js作为stream库,不过其钦定的还是xstream。
使用stream来探索cycle.js原理
计时应用
一把梭
来使用xstream写个最基本的界面
按照惯例先建一个dom节点
<div id="app"></div>
然后使用stream计数当前逝去的秒数,接着将其显示在页面上
window.xs = xstream.default;
// Logic
xs
.periodic(1000)
.fold(prev => prev + 1, 0)
.map(i => `Seconds elapsed: ${i}`)
// Effects
.subscribe({
next: str => {
const elem = document.querySelector("#app");
elem.textContent = str;
}
});
于是这样一个最基本的stream的应用就完成了
Logic与Effects分离
之前是逻辑与副作用一把梭不分离的写法,现在我们把逻辑和副作用分离。
逻辑部分分离到main函数里面,dom操作部分分离到domDriver函数里面。
window.xs = xstream.default;
function main() {
return xs
.periodic(1000)
.fold(prev => prev + 1, 0)
.map(i => `Seconds elapsed: ${i}`);
}
function domDriver(text$) {
text$.subscribe({
next: str => {
const elem = document.querySelector("#app");
elem.textContent = str;
}
});
}
const sink = main();
domDriver(sink);
这样拆分出来后,如果你想加入其他副作用处理,那么再添加一个driver就行。
function logDriver(msg$) {
msg$.subscribe({
next: str => {
console.log(str);
}
});
}
logDriver(sink);
Nice Work,分离成功,添加新的也很方便,但是最后的sink处理好麻烦,如果是多个stream加上多个driver那写起来就太啰嗦了。
run
如果有多个stream的话,现在的main函数返回值只有一个,不能满足要求,于是就改造成返回对象。
function main() {
return {
DOM: xs
.periodic(1000)
.fold(prev => prev + 1, 0)
.map(i => `Seconds elapsed: ${i}`),
log: xs.periodic(2000).fold(prev => prev + 1, 0)
};
}
调用的部分,自己取属于自己那块儿的属性就行。
const sinks = main();
domDriver(sinks.DOM);
logDriver(sinks.log);
不过这样写起来还是硬编码,那么调用这块儿也改造一下。
function run(mainFn, drivers) {
const sinks = mainFn();
Object.keys(drivers).forEach(key => {
if (sinks[key]) {
drivers[key](sinks[key]);
}
});
}
run(main, {
DOM: domDriver,
log: logDriver
});
看上去,逻辑与副作用的分离已经完成,对他们的调用也抽象出来了。
获取外部的副作用
我们目前为止都是生产副作用,没有读取外部副作用。web交互不是单向的,所以我们来做一个读取点击事件的功能吧。然后这里我们就会遇到问题:
const source = main(sink);
const sink = drivers(source);
循环依赖了。这也是cycle.js的名字来源:循环,cyclic。
解决方法来源于于stream。
const fakeSink = xs.create();
const source = main(fakeSink);
const sink = driver(source);
fakeSinke.imitate(sink);
略微改造一下domDriver,用以接收dom的click事件
function domDriver(text$) {
text$.subscribe({
next: str => {
const elem = document.querySelector("#app");
elem.textContent = str;
}
});
const domSource = xs.fromEvent(document, "click");
return domSource;
}
再改造一下下main函数,用于获取click事件流
function main(sources) {
const click$ = sources.DOM;
return {
DOM: click$
.startWith(null)
.map(() => xs.periodic(1000).fold(prev => prev + 1, 0))
.flatten()
.map(i => `Seconds elapsed: ${i}`),
log: xs.periodic(2000).fold(prev => prev + 1, 0)
};
}
按照xstream imitate的模式改造一下run就成了
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]);
});
}
就目前而言,还没有引入cycle.js,那么该怎么使用cycle.js呢?
0x01 Let's Cycle
使用Cycle.js
很简单,引入@cycle/run
这个包,然后用Cycle.run替换我们自己写的run就行。
Cycle.run(main, {
DOM: domDriver,
log: logDriver
});
Cycle.js的run和我们自己写的run差不太多,就是corner case和error checking做得更好。
domDriver理应更加灵活
现在我们已经不是用玩具版run了,但是domDriver却还是很原始。
事实上driver比run更加重要,因为它负责和外部交互。
所以我们从硬编码改得更灵活吧。
提取函数
首先我们构建一个创建dom节点的函数
function createElement(obj) {
const elem = document.createElement(obj.tagName);
elem.textContent = obj.children[0];
return elem;
}
然而这样没法创建树形的节点,所以加上递归功能
function createElement(obj) {
const elem = document.createElement(obj.tagName);
obj.children.forEach(child => {
if (typeof child === "object") {
elem.appendChild(createElement(child));
} else {
elem.textContent = child;
}
});
return elem;
}
在我们的domDriver中还有fromEvent(document,"click")
这样的硬编码,我们还需要监听某个节点上的其他的事件。譬如这样:
const click$ = sources.DOM.selectEvents("span", "mouseover");
那就我们来改造一下domDriver:
function domDriver(text$) {
text$.subscribe({
next: obj => {
const app = document.querySelector("#app");
app.textContent = "";
const elem = createElement(obj);
app.appendChild(elem);
}
});
const domSource = {
selectEvents: function(tagName, eventType) {
return xs
.fromEvent(document, eventType)
.filter(ev => ev.target.tagName === tagName.toUpperCase());
}
};
return domSource;
}
然而createElement函数的参数编写还是很琐碎,所以我们提取一个函数出来
function h(tagName, children) {
return {
tagName,
children
};
}
这样写结构的时候就需要
h("h1",
[h("span", [
`Seconds elapsed: ${i}`
])
]);
还可以更进一步,将h1和span等要用的tag都抽成一个函数,从而代码上有更明显的树形结构。
function tagFactory(tagName) {
return function(children) {
return {
tagName,
children
};
};
}
const h1 = tagFactory("h1");
const span = tagFactory("span");
接着改一下main函数里面的参数编写
function main(sources) {
const click$ = sources.DOM.selectEvents("span", "click");
return {
DOM: click$
.startWith(null)
.map(() => xs.periodic(1000).fold(prev => prev + 1, 0))
.flatten()
.map(i => {
return h("h1", [
h("span", [
`Seconds elapsed: ${i}`
])
]);
}),
log: xs.periodic(2000).fold(prev => prev + 1, 0)
};
}
这时候看上去跟pug
这类模板的语法很像了。
接着我们的domDriver里面的下一个硬编码,
const app = document.querySelector("#app");
就像上面的tagFactory
一样改造一下
function makeDOMDriver(selector) {
return function domDriver(text$) {
text$.subscribe({
next: obj => {
const app = document.querySelector(selector);
app.textContent = "";
const elem = createElement(obj);
app.appendChild(elem);
}
});
const domSource = {
selectEvents: function(tagName, eventType) {
return xs
.fromEvent(document, eventType)
.filter(ev => ev.target.tagName === tagName.toUpperCase());
}
};
return domSource;
}
}
调用的地方微微改一下就行
Cycle.run(main, {
DOM: makeDOMDriver("#app"),
log: logDriver
});
现在这样,就有足够的灵活性了。然而我们现在每次都会刷新整个dom,这是一个很致命的性能问题;其外我们现在的事件使用tagName
选择节点,通常的做法都是用selector
。
所以现在我们不妨替换掉我们的玩具domDriver
,使用@cycle/dom
。
const { h1, span, makeDOMDriver } = CycleDOM;
我们的玩具就可以删掉了。
0x02 More Interactive
这次我们换个能输入的应用,惯例的hello world
。
Hello World
这个应用很简单,读取input
中的值,然后渲染在界面上。
视图的部分:
div([
label(["Name: "]),
input(".field", { attrs: { type: "text" } }),
hr(),
h1(`hello ${value}!`)
])
取值:
$value = sources.DOM
.select("input.field")
.events("input")
.map(ev => ev.target.value)
.startWith("");
整合起来就是
const { div, label, input, hr, h1, makeDOMDriver } = CycleDOM;
function main(sources) {
const inputEv$ = sources.DOM.select("input.field").events("input");
const name$ = inputEv$.map(ev => ev.target.value).startWith("");
const domSink = name$.map(value =>
div([
label(["Name: "]),
input(".field", { attrs: { type: "text" } }),
hr(),
h1(`hello ${value}!`)
])
);
const sinks = { DOM: domSink };
return sinks;
}
const drivers = {
DOM: makeDOMDriver("#app")
};
Cycle.run(main, drivers);
Counter
接着我们做一个计数器应用,有两个按钮,分别代表增加和减少,点击一下后改变计数器的值。
到目前为止我们的玩具都是一整个main函数,这次我们使用mvi
把逻辑分开。
view,当然是渲染视图:
function view(state$) {
return state$.map(state => {
return div([
button(".inc", "Increment"),
button(".dec", "Decrement"),
p([label(`Count: ${state}`)])
]);
});
}
intent,将外部的副作用转换成值,作用上类似于redux
中的action
:
function intent(sources) {
const domSource = sources.DOM;
const inc$ = domSource
.select(".inc")
.events("click")
.mapTo(+1);
const dec$ = domSource
.select(".dec")
.events("click")
.mapTo(-1);
return {
inc: inc$,
dec: dec$
};
}
model,数值计算逻辑:
function model(intents) {
const { inc, dec } = intents;
const delta$ = xs.merge(inc, dec);
const count$ = delta$.fold((prev, delta) => prev + delta, 0);
return count$;
}
接着在main函数中将他们连接起来:
function main(sources) {
const actions = intent(sources);
const state$ = model(actions);
const vdom$ = view(state$);
return { DOM: vdom$ };
}
这样一个Counter
就完成了,
0x03 More
Okay,至此基本的用法就介绍完了。下一篇我们介绍一下Cycle.js的组件化。