初试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的组件化