反应式自主状态





5.00/5 (3投票s)
一个实用示例,展示如何利用响应式扩展 RxJS 来处理 React 组件中的自治状态,从而实现更具表现力和简洁的代码。
引言
在 Web 应用程序中,响应式扩展和RxJS 可以被视为“异步集合的 lodash”,它提供了强大的函数式结构和操作符,以减少 Web 应用程序随着异步性质的扩展而带来的复杂性。它还将编程模型从“拉取”转变为“推送”,在概念上统一了异步数组和事件之间的数据模型和行为,通过数据流实现了有效的数据转换,最终以更清晰的方法简化了应用程序状态管理逻辑。
异步集合的范围不仅包括数据(主要是数组和对象),还包括事件(键盘、鼠标、手势等)、Promise(例如 Ajax 调用)以及基于时间的间隔数据流等。尽管RxJS 也可以在 Node.js 上运行,但本文将重点介绍其在用户界面中使用React.JS 组件的事件流。
在处理异步事件时,RxJS 提供了更强的表现力和简洁性;它强大而丰富的操作符可以在事件“在流中流动”时转换、翻译、过滤、聚合和重构事件数据。这就是“响应式”工作的本质:Observable 异步数据集合充当数据源,通过操作符在流中转换数据,而Observer 订阅 Observable 源的更新,将结果传递给React 作为组件状态。React 将在组件状态更新时通过高效的虚拟 DOM 重新渲染组件。
本文也是对“基于组件的 Web 应用程序”的后续。在我们选择React 作为组件模型后,我们一直在寻找使组件内部状态更新更有效率和简洁的方法。我们将把组件间交互(通过RxJS 实现Flux)留给另一篇讨论,只关注组件本身。
组件内部状态是自治的;它指的是封装在组件内部的状态,而不是作为不可变属性从 Controller(Container 或 Mediator)组件传递下来。相反,自治状态是可变的,由组件本身管理,并且通常对用户输入做出响应。正如我们将在下面的示例中看到的,当组件具有需要管理的流式(连续更新)和自包含的状态数据时,RxJS 的表现力和简洁性非常有帮助。
代码示例是实现一个可重用的下拉菜单,它有一个可移动的高亮菜单项,可以通过上下箭头键进行选择。
概述
在下拉菜单用例中,需求如下:
- 当下拉菜单折叠时:启用上下箭头键来高亮显示菜单项,按 Enter 键执行菜单命令并关闭下拉菜单。
- 任何鼠标点击、Escape 键或 Tab 键都会关闭下拉菜单:应停止键盘和鼠标事件的处理。
我们当然可以通过经典的 DOM addEventListener / removeEventListener 来实现,或者使用 React.JS 的SyntheticEvent。使用RxJS,代码可以更简洁、更具表现力。
以下是一些基本的RxJS 构造和概念,供您参考:
- 将键盘/鼠标事件视为Observable 流:Rx.Observable.fromEvent API 有助于将 DOM 事件转换为 Observable 序列。
- 将事件数据转换为与组件状态匹配:像map、filter、reduce、debounce、flatmap、concat、merge 这样的操作符是最常用的,用于转换数据,即使是来自不同源的数据。
- 订阅/取消订阅:要开始/停止处理 Observable 数据流,它们是RxJS 世界中的 add/remove 事件监听器的等价物。
借助RxJS,这是我们的整体解决方案:
- 下拉菜单组件将有一个自治状态:hightLightIdx,默认值为 -1,表示需要高亮的菜单项的索引,可由用户输入修改。
- 菜单项数据将从 Controller/Container 组件作为属性传入,保持不变。
- 一个用于高亮的 Rx 流将按键转换为 hightLightIdx,可以是向上或向下。
- 另一个用于用户输入的 Rx 流将捕获所有关闭下拉菜单的触发器:鼠标点击、Tab 键和 Escape 键。
我们将首先设置我们的组件,初始化两个流,然后在组件生命周期回调中附加/分离观察者,以使 RxJS 与 React 一起工作。让我们看一些代码。
组件设置
这是我们的下拉菜单 React 组件的骨架,默认属性显示了它期望的数据结构,默认状态仅包含 hightLightIdx
。
'use strict';
import React from 'react';
import Rx from 'rx';
const MENU_STYLES = "dropdown-menu-section";
let MenuBarDropDown = React.createClass({
getDefaultProps() {
return {
active: "",
menuData: {
label: "",
items: [
{
label: "", icon: "", action: ""
}
]
},
onMenuItemAction: () => {}
};
},
getInitialState() {
return {
highLightIdx: -1
};
},
render() {
let dropDownStyle = this.props.active ? MENU_STYLES + " active" : MENU_STYLES;
return (
<ul className={dropDownStyle} role="menu">
{this.renderMenuItems()}
</ul>
);
},
renderMenuItems() {
return this.props.menuData.items.map( (item, idx) => {
if (item.role === 'separator') {
return (<li key={item.role + idx} role="separator" className="divider"></li>);
}
else {
let itemStyle = this.state.highLightIdx === idx ? 'highlight' : '';
if (item.disabled) {
itemStyle = "disabled";
}
return (
<li key={item.action} role="presentation" className={itemStyle}>
<a href={item.label} onClick={this.onMenuItemAction.bind(this, idx)}>
<i className={"icon " + item.icon}></i>{item.label}
</a>
</li>
);
}
});
}
});
module.exports = MenuBarDropDown;
请注意 active
属性,它允许控制器/容器组件控制下拉菜单的折叠或关闭。结合 active
CSS 类,它使得下拉菜单组件可以重用于菜单栏容器(具有多个下拉菜单实例)或作为上下文菜单(单个实例)。
highLightIdx
状态仅在 renderMenuItems()
方法中被引用,该方法从 render()
调用,它添加或移除 highlight
的 CSS 类。
尽管我们还没有添加任何Rx 代码,但该组件已经通过 React 的重新渲染机制“响应”其状态和 props 更新。高阶控制器/容器组件(菜单栏或上下文菜单)对哪个菜单项被高亮显示、高亮项如何上下移动不感兴趣,它只关心执行哪个命令。这种组件边界之间的关注点分离使得 highLightIdx
成为一个自治状态,完全封装并由下拉菜单组件自身管理。
此组件和响应式模式的一个优点是:当扩展组件以增加高亮显示行为时(我们将在下一步进行),所有上述渲染代码都保持不变,无需修改代码即可适应新的行为,可以轻松遵循开放/封闭原则。
现在让我们使用RxJS 来扩展组件的行为。
流设置
我们可以在设置 Observable 流时编写所有数据转换代码,这些 Observable 在调用subscribe() 之前将保持空闲状态。当完成数据流后,我们可以dispose 从subscribe() 调用返回的subscription。
以下是Rx 高亮显示流的初始化方式:
initStream() {
//base key press stream, relies on event bubbling to the document
let keyPresses = Rx.Observable.fromEvent(document, 'keyup').map( e => e.keyCode || e.which);
//both keyUp and keyDown are doing the same: calculate new highLightIdx,
let upKeys = keyPresses.filter( k => k === 38 ).
map( (k) => this.getIdxToHighLight('up', this.state.highLightIdx) );
let downKeys = keyPresses.filter( k => k === 40 ).
map( (k) => this.getIdxToHighLight('down', this.state.highLightIdx) );
//so merge them
this.highLightStream = Rx.Observable.merge(upKeys, downKeys).debounce(100 /* ms */);
}
highlightStream
源自 document.onkeyup
,转换为键码,并进一步转换为 highLightIdx
的新计算值。当我们合并这两个流(upKeys
和 downKeys
)到一个流中时,它允许观察者处理 highLightIdx
的新值,而不是按键事件。
同样,我们可以合并另外两个会关闭下拉菜单的流:鼠标点击以及 Tab/Escape 键:(以下代码是 initStream 函数的一部分)
//base click stream, relies on event bubbling to the document
let mouseClicks = Rx.Observable.fromEvent(document, 'click').map( evt => -1 );
//both escapeKey/tabKey stream and click anywhere also doing the same thing: trigger to close the dropdown
let escapeKeys = keyPresses.filter( k => k === 27 || k === 9).map( k => -1 ); // escape or tab key
let enterKeys = keyPresses.filter(k => k === 13).map(k => this.state.highLightIdx); // enter key
//so, merge them
this.userInputStream = Rx.Observable.merge(mouseClicks, escapeKeys, enterKeys).debounce(100 /* ms */);
当 this.userInputStream
更新为 -1(mouseClicks
、escapeKeys
)时,我们需要关闭菜单并停止处理;而当 enterKeys
更新流为当前高亮项索引时,我们需要先执行由索引标识的命令,然后关闭菜单。
上述 initStream()
和两个合并的 Rx 流已准备就绪,我们可以在组件插入 DOM 时调用它。
componentDidMount() {
this.initStream();
}
Observable 流的设置已完成,现在让我们看看观察者。
响应式观察者
componentDidMount()
和 componentDidUpdate()
都是 React 的组件生命周期回调,前者调用 initStream()
来准备事件流和数据转换,后者是触发 subscribe()
和 dispose()
调用的钩子:
componentDidUpdate(prevProps, prevState) {
if (prevProps.active !== this.props.active) {
this.handleMenuState(this.props.active === 'active');
}
}
如前所述,active
属性由控制器组件传递,用于折叠/关闭下拉菜单。handleMenuState()
所做的就是启动/停止流处理。
handleMenuState(shown) {
if (shown) {
this.highLightSub = this.highLightStream.subscribe((hIdx) => this.setState({highLightIdx: hIdx }));
this.userInputSub = this.userInputStream.subscribe((hIdx) => this.onMenuItemAction(this.state.highLightIdx));
}
else {
this.highLightSub.dispose();
this.userInputSub.dispose();
}
}
现在我们有了一个功能齐全的下拉菜单,可以通过箭头键高亮显示项目,可以通过点击或 Enter 键执行命令,点击外部或按 Escape/Tab 键将关闭下拉菜单。除了一个额外的情况,我们几乎完成了:我们需要在菜单下拉时捕获所有鼠标点击。
如果某些 DOM 元素“吞噬了点击事件”(例如 iFrame
,或者调用 stopPropagation()
API 取消了事件传播),我们的 mouseClicks
流将不会被更新,因为它附加在 document
上冒泡的点击事件上。我们需要确保捕获所有点击事件,以便点击 iframe 内部也能关闭折叠的菜单。
优化
捕获鼠标点击有不同的方法,我们采用的方法是动态插入一个固定定位的全屏“背景层”在下拉菜单下方。CSS 规则是:
.dropdown-backdrop {
position: fixed;
top: 0;
right: 0;
bottom: 0;
left: 0;
z-index: 999;
background-color: transparent;
}
将它的 z-index 设置为 999 是为了确保背景元素正好位于我们的下拉菜单下方,该菜单的 z-index 为 1000。
.dropdown-menu-section {
position: absolute;
top: 100%;
left: 0;
z-index: 1000;
display: none;
float: left;
min-width: 160px;
padding: 5px 0;
margin: 2px 0 0;
list-style: none;
font-size: 13px;
text-align: left;
background-color: $color-darker-grey;
border: 1px solid rgba(0, 0, 0, 0.15);
border-radius: 3px;
-webkit-box-shadow: 0 6px 12px rgba(0, 0, 0, 0.175);
box-shadow: 0 6px 12px rgba(0, 0, 0, 0.175);
background-clip: padding-box;
&.active {
display: block;
}
> li {
&.highlight {
background-color: $color-accent;
}
&.disabled {
a {
color: $color-dark-grey;
cursor: default;
&:hover {
background-color: $color-darker-grey;
color: $color-dark-grey;
}
}
}
&.divider {
height: 1px;
margin: 6px 0;
overflow: hidden;
background-color: $color-dark-grey;
}
i {
width: 20px;
display: inline-block;
}
> a {
outline: none;
text-decoration: none;
display: block;
padding: 3px 20px;
clear: both;
font-weight: normal;
line-height: 1.42857;
color: #fff;
white-space: nowrap;
&:hover {
background-color: $color-dark-blue;
color: $color-lighter;
}
}
}
}
以上是我们下拉菜单和不同类型菜单项(高亮、禁用和分隔线)的所有 CSS 规则。
要创建背景元素,我们可以扩展 componentDidMount() 回调。
componentDidMount() {
this.initStream();
this.clickBackDrop = document.createElement('div');
this.clickBackDrop.className = 'dropdown-backdrop';
},
现在背景元素已准备就绪,我们可以进一步扩展 handleMenuState()
来在菜单下拉或关闭时插入/移除背景。
handleMenuState(shown) {
document.activeElement.blur();
if (shown) {
document.body.appendChild(this.clickBackDrop);
this.highLightSub = this.highLightStream.subscribe((hIdx) => this.setState({highLightIdx: hIdx }));
this.userInputSub = this.userInputStream.subscribe((hIdx) => this.onMenuItemAction(this.state.highLightIdx));
}
else {
this.highLightSub.dispose();
this.userInputSub.dispose();
document.body.removeChild(this.clickBackDrop);
}
}
请注意,我们没有将事件处理器附加到背景元素上,我们只在需要鼠标捕获时(菜单下拉)插入它,并在需要停止流处理时(菜单关闭)移除它。
就这样。
总结
通过下拉菜单示例,我们发现使用 Rx 进行状态管理代码非常简洁明了。事件处理程序中没有混乱的事件数据操作,渲染组件时也没有命令式的 DOM 操作。数据流源自 DOM 事件,有效地转换为组件状态,代码变得更短、更清晰、易于阅读和维护。
RxJS 提供了一种处理异步事件和数据的替代方法,在概念上用流统一了事件和数组/对象。与经典的 DOM 事件处理相比,响应式扩展和RxJS 具有更高的抽象级别和表现力,真正使ReactJS 组件内的自治状态管理变得轻而易举。