Cycle.js的组件化
cycle.js的组件定义是(s:source)=>sink,我们来尝试来组件化构建一个BMI计算器。
0x01 单体BMI
这回我们来做一个BMI计算器,其由两个slider和一个显示计算结果的label组成。
因为习惯性先想到视图,就先弄视图部分了:
function view(state$) {
return state$.map(state => {
return div([
div(".height-slider", [
label(`Height: ${state.height} cm`),
input(".height", {
attrs: { type: "range", min: 140, max: 220, value: 150 }
})
]),
div(".weight-slider", [
label(`Weight: ${state.weight} kg`),
input(".weight", {
attrs: { type: "range", min: 40, max: 100, value: 50 }
})
]),
p([label(`BMI: ${state.bmi}`)])
]);
});
}
然后是转换视图中事件的intent部分,读取两个slider的变化,映射成值:
function intent(sources) {
const domSource = sources.DOM;
const height$ = domSource
.select(".height")
.events("input")
.map(ev => ev.target.value);
const weigth$ = domSource
.select(".weight")
.events("input")
.map(ev => ev.target.value);
return {
height: height$,
weight: weigth$
};
}
接着是处理数值的model部分,bmi = weight / height2:
function model(intents) {
const height= intents.height.startWith(165);
const weight= intents.weight.startWith(58);
const state$ = xs.combine(height, weight).map(([h, w]) => {
const hm = h * 0.01;
const bmi = Math.round(w / (hm * hm));
return {
height: h,
weight: w,
bmi
};
});
return state$;
}
最后老样子,几部分连接起来。
function main(sources) {
const actions = intent(sources);
const state$ = model(actions);
const vdom$ = view(state$);
return { DOM: vdom$ };
}
const drivers = {
DOM: makeDOMDriver("#app")
};
Cycle.run(main, drivers);
0x02 组件Slider
我们回看代码,至少关于slider的部分我们重复了,所以slider能独立成组件出来。
照旧,先弄视图:
function view(state$) {
return state$.map(state => {
return div(".labeled-slider", [
label(".label", `${state.label}: ${state.value} ${state.unit}`),
input(".slider", {
attrs: {
type: "range",
min: state.min,
max: state.max,
value: state.value
}
})
]);
});
}
然后intent:
function intent(sources) {
const domSource = sources.DOM;
const value$ = domSource
.select(".slider")
.events("input")
.map(ev => ev.target.value);
return { value: value$ };
}
接着model,除了转换intent中的数值外,我们还需要构建初始值、最大值、最小值、标签和符号等:
function model(intents, props) {
const { value } = intents;
return props
.map(props =>
value.startWith(props.init).map(value => ({ value, ...props }))
)
.flatten();
}
最后连接在一起,我们构件一个fakeDriver来提供props
;其中main
这个名字并没有什么特殊,所以我们重命名为Slider
:
function Slider(sources) {
const actions = intent(sources);
const state$ = model(actions, sources.props);
const vdom$ = view(state$);
return { DOM: vdom$ };
}
const drivers = {
DOM: makeDOMDriver("#app"),
props: () =>
xs.of({
label: "Weight",
min: 40,
max: 100,
init: 58,
unit: "kg"
})
};
Cycle.run(Slider, drivers);
0x03 组合BMI
main
这回我们请回main
函数,用他调用Slider
function main(sources) {
const weightProps = xs.of({
label: "Weight",
min: 40,
max: 100,
init: 58,
unit: "kg"
});
const sinks = Slider({ ...sources, props: weightProps });
return sinks;
}
还记得最初对cycle.js组件的定义么?(s:sources)=>sinks
!。
这里Slider
是一个组件,main
本身也是一个组件,于是就成了cycle in cycle。
组合两个Slider
现在我们组合两个Slider,把main
里面Slider
调用相关的代码复制一遍,再把两个DOM
给combine
下就行:
function main(sources) {
const weightProps = xs.of({
label: "Weight",
min: 40,
max: 100,
init: 58,
unit: "kg"
});
const weightSinks = Slider({ ...sources, props: weightProps });
const heightProps = xs.of({
label: "Height",
min: 140,
max: 220,
init: 165,
unit: "cm"
});
const heightSinks = Slider({ ...sources, props: heightProps });
const vdom$ = xs
.combine(weightSinks.DOM, heightSinks.DOM)
.map(([weightVDOM, heightVDOM]) => div([weightVDOM, heightVDOM]));
return { DOM: vdom$ };
}
然后貌似有点问题,拨动一个slider会让另一个也动起来,原因显而易见,是intent函数的selector把两个slider都选中了。
分离两个Slider的事件
我们可以通过给Slider加前后置DOM处理来解决:
function main(sources) {
const weightProps = xs.of({
label: "Weight",
min: 40,
max: 100,
init: 58,
unit: "kg"
});
const weightDOMSource = sources.DOM.select(".weight");
const weightSinks = Slider({
...sources,
DOM: weightDOMSource,
props: weightProps
});
const weightVDOM$ = weightSinks.DOM.map(vdom => {
vdom.sel += ".weight";
return vdom;
});
const heightProps = xs.of({
label: "Height",
min: 140,
max: 220,
init: 165,
unit: "cm"
});
const heightDOMSource = sources.DOM.select(".height");
const heightSinks = Slider({
...sources,
DOM: heightDOMSource,
props: heightProps
});
const heightVDOM$ = heightSinks.DOM.map(vdom => {
vdom.sel += ".height";
return vdom;
});
const vdom$ = xs
.combine(weightVDOM$, heightVDOM$)
.map(([weightVDOM, heightVDOM]) => div([weightVDOM, heightVDOM]));
return { DOM: vdom$ };
}
然后main
就变得超长一个,其中还出现了重复的模式了,又得抽离出来了。
抽离前置和后置处理
@cycle/dom
其实内置了我们的前后置套路,稍微改造一下。
function main(sources) {
const { isolateSource, isolateSink } = sources.DOM;
const weightProps = xs.of({
label: "Weight",
min: 40,
max: 100,
init: 58,
unit: "kg"
});
const weightDOMSource = isolateSource(sources.DOM, ".weight");
const weightSinks = Slider({
...sources,
DOM: weightDOMSource,
props: weightProps
});
const weightVDOM$ = isolateSink(weightSinks.DOM, ".weight");
const heightProps = xs.of({
label: "Height",
min: 140,
max: 220,
init: 165,
unit: "cm"
});
const heightDOMSource = isolateSource(sources.DOM, ".height");
const heightSinks = Slider({
...sources,
DOM: heightDOMSource,
props: heightProps
});
const heightVDOM$ = isolateSink(heightSinks.DOM, ".height");
const vdom$ = xs
.combine(weightVDOM$, heightVDOM$)
.map(([weightVDOM, heightVDOM]) => div([weightVDOM, heightVDOM]));
return { DOM: vdom$ };
}
看上去稍稍少了一丢丢。
使用@cycle/isolate
鉴于上面这种琐碎的写法,cycle.js也提炼了一个isolate
库,功能跟上面代码差不太多。
再来使用isolate
改造一下main
函数:
function main(sources) {
const weightProps = xs.of({
label: "Weight",
min: 40,
max: 100,
init: 58,
unit: "kg"
});
const weightSlider = isolate(Slider, ".weight");
const weightSinks = weightSlider({
...sources,
props: weightProps
});
const heightProps = xs.of({
label: "Height",
min: 140,
max: 220,
init: 165,
unit: "cm"
});
const heightSlider = isolate(Slider, ".height");
const heightSinks = heightSlider({
...sources,
props: heightProps
});
const vdom$ = xs
.combine(weightSinks.DOM, heightSinks.DOM)
.map(([weightVDOM, heightVDOM]) => div([weightVDOM, heightVDOM]));
return { DOM: vdom$ };
}
看上去,isolate
像是React
中的高阶组件。
组合BMI
计算BMI需要两个slider的值,所以先把值暴露出来。
function Slider(sources) {
const actions = intent(sources);
const state$ = model(actions, sources.props);
const vdom$ = view(state$);
return {
DOM: vdom$,
value: state$.map(state => state.value)
};
}
然后在main
里面计算bmi并渲染出来
function main(sources) {
const weightProps = xs.of({
label: "Weight",
min: 40,
max: 100,
init: 58,
unit: "kg"
});
const weightSlider = isolate(Slider, ".weight");
const weightSinks = weightSlider({
...sources,
props: weightProps
});
const heightProps = xs.of({
label: "Height",
min: 140,
max: 220,
init: 165,
unit: "cm"
});
const heightSlider = isolate(Slider, ".height");
const heightSinks = heightSlider({
...sources,
props: heightProps
});
const bmi$ = xs
.combine(weightSinks.value, heightSinks.value)
.map(([weight, height]) => {
const hm = height * 0.01;
const bmi = Math.round(weight / (hm * hm));
return bmi;
});
const vdom$ = xs
.combine(weightSinks.DOM, heightSinks.DOM, bmi$)
.map(([weightVDOM, heightVDOM, bmi]) =>
div([weightVDOM, heightVDOM, p(label(`BMI: ${bmi}`))])
);
return { DOM: vdom$ };
}
至此,组组合BMI完成。
0x04 More
基本使用和组件都会弄了,下面做一个常见的demo:TodoMVC。