65.9K
CodeProject 正在变化。 阅读更多。
Home

反应式自主状态

starIconstarIconstarIconstarIconstarIcon

5.00/5 (3投票s)

2015 年 11 月 27 日

CDDL

7分钟阅读

viewsIcon

15878

一个实用示例,展示如何利用响应式扩展 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 构造和概念,供您参考:

借助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() 之前将保持空闲状态。当完成数据流后,我们可以disposesubscribe() 调用返回的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 的新计算值。当我们合并这两个流(upKeysdownKeys)到一个流中时,它允许观察者处理 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(mouseClicksescapeKeys)时,我们需要关闭菜单并停止处理;而当 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 组件内的自治状态管理变得轻而易举。

© . All rights reserved.