Skip to content

Latest commit

 

History

History
475 lines (359 loc) · 17.6 KB

02-from-rxjs-to-cyclejs.md

File metadata and controls

475 lines (359 loc) · 17.6 KB

从RxJS到Cycle.js

在上一篇文章(Observable与RxJS)中提到RxJS的 slogan 是 Think of RxJS as Lodash for events。利用RxJS我们可以方便的处理用户交互、网络请求等异步操作(就像使用Lodash一样)。

但是,RxJS仅仅是Observable和常用“运算符”的实现,它距离一个完整的前端框架还有些距离。在这篇文章中,我们将以RxJS为基础,一步步实现一个完整的函数响应式前端框架——Cycle.js

Cycle.js

A functional and reactive JavaScript framework for cleaner code.

Cycle.js 官网:https://cycle.js.org/

Cycle.js Github 地址:https://github.com/cyclejs/cyclejs

背景

Cycle.js是一个基于Observable的“函数响应式前端框架”。

作为一个现代的前端框架,它具有如下特性:

  • Virtual DOM
  • JSX
  • 服务端渲染
  • 组件化
  • MV*框架(Model-View-Intent)
  • 路由
  • 数据流
  • 时间旅行(撤销、回放)
  • 热加载
  • Native
  • 插件化

Cycle.js比较类似于 React + Redux 或者 Elm

ReactMV*中的View部分,通过JSX实现了一套性能不错的Virtual DOM机制和组件机制,但它通常需要搭配合适的数据流(如FluxRedux)才能成为完整的前端框架;Redux很好的通过“单向数据流”实现对state的控制,但对于用户交互、数据模型等缺乏足够的抽象,同时大量switch的存在也不是非常好的编程范式;而且由于ReactRedux两者割裂,虽然有 react-redux 这样的封装,但依然需要具有四个参数的胶水函数——connect,引入了不必要的学习成本。

Elm是一个函数响应式框架的实现,Elm使用自己的Elm-Lang(它是Haskell语言的子集),Elm实现了多层级的Model-View-Action-Update的数据流。但这种数据流比较复杂,加之其函数式的语法,导致其学习成本极高,少有工业界的应用。但Elm的数据流思想非常有价值,直接启发了Cycle.jsRedux诞生。

Cycle.js集百家之长,克服了上述技术的大部分问题,保持了简单、纯粹的编程范式。由于Cycle.js采用了插件机制(类似于Koa),其核心代码简单、高效,值得我们深入学习。

因此在这篇文章中我们将通过将一段Observable代码(使用RxJS实现)进行不断的改造,最终实现我们自己的Cycle.js。当然在最后我们将使用Cycle.js官方模块替代自己的代码,以证明它们是等价的。

main

首先我们从一个简单的例子开始。这个RxJS程序的功能很简单,它在DOM中显示了当前时间流逝了多少秒:Demo

Rx.Observable.timer(0, 1000)
  .map(i => `Seconds elapsed ${i}`)
  .subscribe({
    next: text => {
      const container = document.querySelector('.app');
      container.innerHTML = text;
    }
  });

接下来我们将逻辑(logic)与副作用(effcet)进行分离,并将数据流中的数据打印到控制台:Demo

function main() {
  return Rx.Observable.timer(0, 1000)
    .map(i => `Seconds elapsed ${i}`);
}

function DOMEffect(text$) {
  // render DOM with text$
}

function consoleLogEffect(text$) {
  // log text
}

const sink = main();
DOMEffect(sink);
consoleLogEffect(sink);

main函数负责实现计算逻辑,在这里我们使用了sink,它是main函数的返回值,用于传递给不同的带有副作用的函数。

接下来,我们将以“分离逻辑和副作用”为目的不断修改代码,最终获得一个只包含逻辑、没有副作用的纯函数(pure function)框架。

run

现在,我们希望控制台中与DOM显示不一样的数据,这意味着我们需要在main函数中返回两条数据流:Demo

function main() {
  return {
    DOM: Rx.Observable.timer(0, 1000)
      .map(i => `Seconds elapsed ${i}`),
    Log: Rx.Observable.timer(0, 2000)
      .map(i => i * 2)
  }
}
// ...
const sinks = main();
DOMEffect(sinks.DOM);
consoleLogEffect(sinks.Log);

sinks对象中,不同的字段对应着不同的数据流,只要将它们合理的分配给不同effect函数即可。接下来我们对这一步进行封装:Demo

// ...
function run(mainFn, effects) {
  const sinks = mainFn();
  Object.keys(effects).forEach(key => {
    effects[key](sinks[key]);
  });
}

const effectsFns = {
  DOM: DOMEffect,
  Log: consoleLogEffect
}

run(main, effectsFns);

run是一个函数,它接受两个参数:maineffectsFns。为了便于处理我们规定effectsFns的字段需要与main中返回的字段一致,这样在run函数中就不需要额外的代码进行映射了。

drivers

Cycle.js中,run的第二个参数被称为drivers

在操作系统中,“驱动程序”是程序与硬件之间的接口;而在Cycle.js中,drivers则是“逻辑”与“副作用”之间的接口。

修改后的代码如下:Demo

function run(mainFn, drivers) {
  const sinks = mainFn();
  Object.keys(drivers).forEach(key => {
    drivers[key](sinks[key]);
  });
}

const drivers = {
  DOM: DOMDriver,
//   Log: consoleLogDriver
}

run(main, drivers);

如果在2016款Mac Book上安装Windows系统,由于缺少驱动程序Touch Bar可能无法正常工作;同样的如果注释掉drivers中的部分字段,对应的副作用也就无法发生了。

因此上面这段代码中,因为注释掉了drivers.Log,即使main函数返回了sinks.Log,但控制台将不会进行任何输出。

cycle

现在我们的程序实现了DOM渲染、控制台输出,并且分离了逻辑和副作用,但它还没有接收任何输入信息。

接下来我们需要实现这样一个功能,每次点击网页时重置页面的计时器,完整的效果见:Demo

首先修改DOMDriver函数的返回值,它将返回页面点击的数据流DOMSource。利用Observable.fromEvent可以获取点击事件:

function DOMDriver(text$) {
  text$
    .subscribe({
      next: text => {
        const container = document.querySelector('.app');
        container.innerHTML = text;
      }
    });
  return Rx.Observable.fromEvent(document, 'click');
}

然后修改main函数,它接受一个参数DOMSource,也就是DOMDriver的返回值。使用switchMapTo运算符可以实现点击重置的效果:

function main(DOMSource) {
  const click$ = DOMSource;
  return {
    DOM: click$
      .startWith(null)
      .switchMapTo(Rx.Observable.timer(0, 1000))
      .map(i => `Seconds elapsed ${i}`)
  }
}

但仅仅修改DOMDrivermain是不够的,我们还需要修改run函数实现DOMSource的传递。但显然下面这段代码是行不通的:

function run(mainFn, drivers) {
  const sinks = mainFn(DOMSource);
  const DOMSource = drivers.DOM(sinks.DOM);
}

在这里发生了“循环依赖”,简化一下代码可以更明显的看到问题所在:

a = f(b);
b = g(a);

为了解决循环依赖,需要使用上篇文章中介绍过的Subject链接)。通过Subject引入一个代理数据流实现循环依赖:

bProxy = new Rx.Subject();
a = f(bProxy);
b = g(a);
b.subscribe(bProxy);

最后修改run函数如下,代码即可成功运行:Demo

function run(mainFn, drivers) {
  const proxyDOMSource = new Rx.Subject();
  const sinks = mainFn(proxyDOMSource);
  const DOMSource = drivers.DOM(sinks.DOM);
  DOMSource.subscribe(proxyDOMSource);
}

下面这张图很好的解释了这种循环:

cycle

循环的两侧是maindriver,在App端(main)编写逻辑,在框架端(driver)实现副作用;两个方向分别对应了输入和输出,共同实现了代码与现实世界的交互。

循环是Cycle.js的核心,也是其名字的由来。

rxjs-run

接下来,就如同sinks一样,我们将单一的DOMSource改写为一组数据流sources。而且sinkssources的字段一一对应。完整的效果见:Demo

首先是main函数:

function main(sources) {
  const click$ = sources.DOM;
  // ...
}

然后是run函数。为了解决“循环”问题,需要创建一组Subject,它们保存在proxySources中,并分别订阅各个driver返回的source

function run(mainFn, drivers) {
  const proxySources = {};
  Object.keys(drivers).forEach(key => {
    proxySources[key] = new Rx.Subject();
  });
  const sinks = mainFn(proxySources);
  Object.keys(drivers).forEach(key => {
    const source = drivers[key](sinks[key]);
    source.subscribe(proxySources[key]);
  });
}

我们获得了一个非常通用的run函数,它可以用于不同的maindrivers,实现了sinkssources的循环。如果我们将它单独抽象出来,它就是Cycle.js@cycle/rxjs-run包(链接)。

最后我们使用@cycle/rxjs-run替代我们自己写的run函数,代码依然运行良好:Demo

Cycle.run(main, drivers);

Cycle.js的核心非常简单,简单到只包含一个run函数,但它却支撑起整个框架的运行。也许你会质疑它太过简单,那么不妨让我们继续实现一个Virtual DOM

DOM Object

我们成功的抽象出了run函数,现在还有main函数和drivers对象(包含一个DOMDriver),接下来我们将抽象drivers

现在我们的main函数看起来是这个样子:

function main(sources) {
  const click$ = sources.DOM;
  return {
    DOM: click$
      .startWith(null)
      .switchMapTo(Rx.Observable.timer(0, 1000))
      .map(i => `Seconds elapsed ${i}`)
  }
}

我们需要拓展main函数,使其能返回更加复杂的数据渲染到DOM结点上。我们将使用一个对象来描述DOM

click$
  // ...
  .map(i => ({
    tagName: 'H1',
    children: [`Seconds elapsed ${i}`]
  }))

其中tagName表示DOM的标签名;children是该DOM的内容,现在它的值是包含一个字符串的数组。同样的,我们需要编写一个createElement函数,接受DOM描述对象,并产生真正的DOM对象:

function createElement(obj) {
  const element = document.createElement(obj.tagName);
  obj.children.forEach(child => {
    element.innerHTML += child;
  });
  return element;
}

然后修改DOMDriver,将接受到的DOM描述对象通过createElement获取真实DOM对象,并通过container.append渲染到页面上(由于使用了.append,所以需要额外清空已有DOM):Demo

function DOMDriver(obj$) {
  obj$
    .subscribe({
      next: obj => {
        const container = document.querySelector('.app');
        container.innerHTML = '';
        container.append(createElement(obj));
      }
    });
  return Rx.Observable.fromEvent(document, 'click');
}

代码运行良好,成功的在页面上渲染了一个h1结点。

接下来我们需要完善下代码,使其能渲染带嵌套的DOM结构,比如h1标签中嵌套span标签:

click$
  // ...
  .map(i => ({
    tagName: 'H1',
    children: [{
      tagName: 'SPAN',
      children: [`Seconds elapsed ${i}`]
    }]
  }))

需要修改createElement支持递归,以实现嵌套:

  function createElement(obj) {
    const element = document.createElement(obj.tagName);
    obj.children.forEach(child => {
      if (typeof child === 'string') {
        element.innerHTML += child;
      } else {
        element.append(createElement(child));
      }
    });
    return element;
  }

完整的代码见Demo

Virtual DOM

我们回顾整个DOMDriver,它已经足够通用,除了三个细节。

第一,DOMDriver只能返回点击事件:

function DOMDriver(obj$) {
  // ...
  return Rx.Observable.fromEvent(document, 'click');
}

可以通过简单的修改使DOMDriver支持任意事件绑定,这样就可以在main函数中通过链式调用实现事件绑定:

function main(sources) {
  const click$ = sources.DOM.select('span').events('mouseover');
  // ...
}

function DOMDriver(obj$) {
  // ...
  return {
    select: tagName => ({
      events: eventType => {
        return Rx.Observable.fromEvent(document, eventType)
          .filter(ev => ev.target.tagName === tagName.toUpperCase())
      }
    })
  };
}

在这里我们使用了事件代理,这样就不比每次更新DOM结点时都重新绑定事件。需要注意的是在HTML中,所有的标签名都是大写的(REC-DOM-Level-1-19981001)。

第二,DOMDriver现在只能渲染到classNameapp元素上,我们可以通过高阶函数进行简单的封装:Demo

function makeDOMDriver(mountSelector) {
  return function DOMDriver(obj$) {
    // ...
    const container = document.querySelector(mountSelector);
    // ...
  }
}

const drivers = {
  DOM: makeDOMDriver('.app')
}

第三,DOM描述对象看起来还是有些简单,我们将使用函数嵌套来实现DOM嵌套:

const h = (tagName, children) => ({ tagName, children });
const h1 = children => h('H1', children);
const span = children => h('SPAN', children);

function main(sources) {
  click$
    // ..
    .map(i =>
      h1([
        span([`Seconds elapsed ${i}`])
      ])
    )
}

h函数是一个通用的标签生成函数,h1span可以生成特定标签的DOM描述对象。这样的DOM描述对象看起来更类似于我们熟悉的HTML标签了(事实上借助 babel-plugin-transform-react-jsxsnabbdom-jsx 我们可以使用JSX编写它)。

最后,我们得到了一个较为通用的makeDOMDriver和一些DOM相关的函数(hh1span),使用这些函数我们可以实现各种DOM结构的渲染。

现在,让我们使用真正的Cycle.js替代这些函数。在@cycle/dom包中(链接)封装了对这些函数的实现,因此是时候使用@cycle/dom替代我们自己的函数了:Demo

const { makeDOMDriver, h1, span } = CycleDOM;
const { startWith, switchMapTo, map } = Rx.Observable.prototype;

function main(sources) {
  const click$ = sources.DOM.select('span').events('mouseover');
  return {
    DOM: click$
      ::startWith(null)
      ::switchMapTo(Rx.Observable.timer(0, 1000))
      ::map(i =>
        h1([
          span([`Seconds elapsed ${i}`])
        ])
      )
  }
}

const drivers = {
  DOM: makeDOMDriver('.app')
}

Cycle.run(main, drivers);

由于最新版Cycle.js的默认数据流从RxJS切换到XStream,因此即使使用cycle/rxjs-run也需要引入xstream

至此,我们了解了Virtual DOM的原理:我们返回一个包含嵌套的DOM描述对象,内部保存了绘制DOM所需的信息,前端框架根据这个对象操作真实的DOM元素。@cycle/domreact-dom等都是基于这个原理实现的。

由于频繁的DOM操作会产生性能问题,各个框架在实现的时候通常都采样diff的形式增量更新,而不是像我们的Demo中那样使用innerHTMLappend。不过由于框架帮我们隐藏了这个细节,通常是不需要去关心的。

总结

终于,通过一步步的修改,我们从一段简单的RxJS改造出了Cycle.js框架,并证明了它们的等价性。

正如本文开头所说,实现逻辑与副作用的分离是我们的目的。显然在Cycle.js中,我们只需要编写main函数,它一般是纯函数,只包含计算逻辑;而副作用都隐藏在了drivers中,在实际开发中通常我们不需要自己实现drivers,因为Cycle.js提供了大量的drivers实现,所以可以认为副作用都隐藏在了框架之中。

在本文我们实现了与@cycle/dom类似的Virtual DOM,通过这个例子可以发现Cycle.js插件机制的强大,只要通过引入不同的drivers即可实现各种副作用:DOM操作、网络请求、访问存储,甚至可以在Native上渲染(@cycle/react-native)。

正如本文开头所说,Cycle.js作为完整的前端框架还拥有很多的特性,篇幅所限不能在此一一列举。在下一篇文章中我们将对Cycle.js的设计和使用做更详细的介绍,并探讨Cycle.js的工程化问题,敬请期待。