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调用相关的代码复制一遍,再把两个DOMcombine下就行:

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