在上一篇文章(Observable与RxJS)中提到RxJS
的 slogan 是 Think of RxJS as Lodash for events。利用RxJS
我们可以方便的处理用户交互、网络请求等异步操作(就像使用Lodash
一样)。
但是,RxJS
仅仅是Observable
和常用“运算符”的实现,它距离一个完整的前端框架还有些距离。在这篇文章中,我们将以RxJS
为基础,一步步实现一个完整的函数响应式前端框架——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:
React
是MV*
中的View
部分,通过JSX
实现了一套性能不错的Virtual DOM
机制和组件机制,但它通常需要搭配合适的数据流(如Flux
、Redux
)才能成为完整的前端框架;Redux
很好的通过“单向数据流”实现对state
的控制,但对于用户交互、数据模型等缺乏足够的抽象,同时大量switch
的存在也不是非常好的编程范式;而且由于React
和Redux
两者割裂,虽然有 react-redux 这样的封装,但依然需要具有四个参数的胶水函数——connect
,引入了不必要的学习成本。
Elm
是一个函数响应式框架的实现,Elm
使用自己的Elm-Lang
(它是Haskell
语言的子集),Elm
实现了多层级的Model-View-Action-Update
的数据流。但这种数据流比较复杂,加之其函数式的语法,导致其学习成本极高,少有工业界的应用。但Elm
的数据流思想非常有价值,直接启发了Cycle.js
和Redux
诞生。
Cycle.js
集百家之长,克服了上述技术的大部分问题,保持了简单、纯粹的编程范式。由于Cycle.js
采用了插件机制(类似于Koa
),其核心代码简单、高效,值得我们深入学习。
因此在这篇文章中我们将通过将一段Observable
代码(使用RxJS
实现)进行不断的改造,最终实现我们自己的Cycle.js
。当然在最后我们将使用Cycle.js
官方模块替代自己的代码,以证明它们是等价的。
首先我们从一个简单的例子开始。这个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)框架。
现在,我们希望控制台中与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
是一个函数,它接受两个参数:main
和effectsFns
。为了便于处理我们规定effectsFns
的字段需要与main
中返回的字段一致,这样在run
函数中就不需要额外的代码进行映射了。
在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
,但控制台将不会进行任何输出。
现在我们的程序实现了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}`)
}
}
但仅仅修改DOMDriver
和main
是不够的,我们还需要修改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);
}
下面这张图很好的解释了这种循环:
循环的两侧是main
和driver
,在App端(main
)编写逻辑,在框架端(driver
)实现副作用;两个方向分别对应了输入和输出,共同实现了代码与现实世界的交互。
循环是Cycle.js
的核心,也是其名字的由来。
接下来,就如同sinks
一样,我们将单一的DOMSource
改写为一组数据流sources
。而且sinks
与sources
的字段一一对应。完整的效果见: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
函数,它可以用于不同的main
和drivers
,实现了sinks
和sources
的循环。如果我们将它单独抽象出来,它就是Cycle.js
的@cycle/rxjs-run
包(链接)。
最后我们使用@cycle/rxjs-run
替代我们自己写的run
函数,代码依然运行良好:Demo。
Cycle.run(main, drivers);
Cycle.js
的核心非常简单,简单到只包含一个run
函数,但它却支撑起整个框架的运行。也许你会质疑它太过简单,那么不妨让我们继续实现一个Virtual DOM
。
我们成功的抽象出了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。
我们回顾整个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
现在只能渲染到className
为app
元素上,我们可以通过高阶函数进行简单的封装: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
函数是一个通用的标签生成函数,h1
和span
可以生成特定标签的DOM
描述对象。这样的DOM
描述对象看起来更类似于我们熟悉的HTML
标签了(事实上借助 babel-plugin-transform-react-jsx 和 snabbdom-jsx 我们可以使用JSX
编写它)。
最后,我们得到了一个较为通用的makeDOMDriver
和一些DOM
相关的函数(h
、h1
、span
),使用这些函数我们可以实现各种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/dom
、react-dom
等都是基于这个原理实现的。
由于频繁的DOM
操作会产生性能问题,各个框架在实现的时候通常都采样diff
的形式增量更新,而不是像我们的Demo中那样使用innerHTML
和append
。不过由于框架帮我们隐藏了这个细节,通常是不需要去关心的。
终于,通过一步步的修改,我们从一段简单的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
的工程化问题,敬请期待。