开源技术 * IBM 微讲堂:Kubeflow 系列(观看回放 | 下载讲义) 了解详情

结合使用 Redux 和 Angular

在本系列的前面两期中,了解了如何使用和实现 Redux 中间件来执行异步操作。还了解了如何实现时间旅行 — 在应用程序以前的状态之间移动的能力。

在这个 使用 Redux 管理状态系列的最后一期中,您将:

  • 了解如何完成图书搜索应用程序的 React 版本
  • 学习如何使用 Redux 和 Angular 实现图书搜索应用程序
  • 了解 Redux DevTools 和 Redux 生态系统

完成 React 版本

从前面几期,已经了解了控件和状态查看器组件的实现,如图 1 所示。

图 1. 图书搜索应用程序

图书搜索应用程序的 React 版本的屏幕截图

控件显示在应用程序顶部,左侧是 Topic 文本字段,右侧是箭头按钮。状态查看器组件在页面底部显示当前应用程序状态。现在我将解释 Books 组件,它将图书显示为文本或缩略图链接。

回想一下 结合使用 Redux 和 React ,将 React 组件连接到 Redux 存储的一种方式是将它们实现为两个组件:一个容器包含另一个组件。容器连接到 Redux 存储,在状态更改时将属性导出到包含的组件中。包含的组件创建这些属性的可视表示。 Books 组件也不例外。清单 1 给出了 Books 容器的代码。

清单 1. books 容器 (containers/book.js)
import { connect } from 'react-redux';
import Books from '../components/books';

const mapStateToProps = state => ({
  books:         state.books,
  currentStatus: state.currentStatus,
  displayMode:   state.displayMode,
});

export default connect(
  mapStateToProps,
  null
)(Books);

Books 容器将状态映射到所包含的 Books 组件的属性。Books 的用途只是提供它们的存在状态,所以没有要指派的操作,这就解释了 connect() 函数的 null 参数。

清单 2 给出了 Books 容器中包含的 Books 组件的代码。

清单 2. books 组件 (components/books.js)
import React from 'react';
import Book from './book.js';

let ReactCSSTransitionGroup =
      require('react-addons-css-transition-group');

const Books = ({
  books,
  displayMode,
  currentStatus,
}) => {
  const styles = {
    container: {
      width: '100%',
    },

    spinner: {
      textAlign: 'center',

      width: '100%',
    },
  };

  const Spinner = () => (
    <div style={styles.spinner}>
      <img src="./images/spinner.gif"
        role="presentation" />
    </div>
  );

  const bookMarkup = () => {
    let components = null;
    let bookItems = (<span>No items!</span>);

    if (books.length > 0) {
      components = books.map(item => {
        if (item.volumeInfo.imageLinks) {
          // Need different keys for different display modes
          // to trigger <ReactCSSTransitionGroup> animations

          const key = displayMode === 'THUMBNAIL' ?
                                       item.id + 1 :
                                       item.id;
          bookItems = (
            <Book item={item}
              displayMode={displayMode}
              key={key} />);
        }
        return bookItems;
      });
    }
    return components;
  }

  return (
    <div>
      { currentStatus !== 'Fetching...' ?  null : <Spinner /> }

      <div style={styles.container}>
        <ReactCSSTransitionGroup transitionName="books"
          transitionLeaveTimeout={1}
          transitionEnterTimeout={1000}>
          {bookMarkup()}
        </ReactCSSTransitionGroup>
      </div>
    </div>
  );
};

Books.propTypes = {
  books:       React.PropTypes.array.isRequired,
  currentStatus: React.PropTypes.string.isRequired,
  displayMode: React.PropTypes.string.isRequired,
};

export default Books;

在应用程序抓取下一批图书时, Books 组件会显示一个下拉列表部件;否则该组件会显示存储在应用程序的状态中的图书。 Books 组件使用 ReactCSSTransitionGroup 组件让显示的图书淡出。

从 Redux 角度讲,清单 2 中最重要的部分是传递给 Books 组件的 3 个属性:图书列表、显示模式和当前抓取状态。这些属性之所以可用,离不开 清单 1 中对 connect() 函数的调用。回想一下, connect() 函数未包含在 Redux 中,而是由 react-redux 绑定提供。

清单 2 中, bookMarkup 函数使用 Book 元素创建标记。 Book 组件的代码如清单 3 所示。

清单 3. book 组件 (components/book.js)
import React from 'react';

const Book = ({
  item,
  displayMode,
}) => {
  const styles = {
    links: {
      marginTop: '20px',
    },

    link: {
      padding: '25px',
    },

    image: {
      boxShadow: '3px 3px 3px #686868',
      marginBottom: '15px',
    },
  };

  const linkMarkup = (currentItem, link) => (
    <div style={styles.links}>
      <a href={link} style={styles.link}>
        {currentItem.volumeInfo.title}
      </a>
    </div>
  );

  const thumbnailMarkup = (currentItem, link) => (
    <a href={link} style={styles.link}>
      <img src={currentItem.volumeInfo.imageLinks.thumbnail}
        style={styles.image}
        role="presentation" />
    </a>
  );

  const link = item.volumeInfo.canonicalVolumeLink;

  return displayMode === 'THUMBNAIL' ?
           thumbnailMarkup(item, link) :
           linkMarkup     (item, link);
};

Book.propTypes = {
  item:        React.PropTypes.object.isRequired,
  displayMode: React.PropTypes.string.isRequired,
};

export default Book;

Book 组件将图书显示为链接或缩略图。 itemdisplayMode 属性来自 Books 组件,如 books 组件 (components/books.js) 所示。

此组件完善了使用 React 和 Redux 的图书搜索应用程序的实现。React 不是您用于 Redux 的组件框架的唯一选择。接下来,将会了解如何结合使用 Redux 和 Angular 创建同一个应用程序。(如果仅使用 React,可以跳到 Redux DevTools 部分。)

结合使用 Redux 和 Angular

图 2 给出了图书搜索应用程序的 Angular 2(下文简称 Angular)版本。

图 2. 图书搜索应用程序的 Angular 版本

图书搜索应用程序的 Angular 版本的屏幕截图

像 React 一样(但不同于 Angular 1),Angular 是一个基于组件的框架。因此从理论上讲,我应该能有效地将 React 组件替换为 Angular 组件,并重用 React 版本的 reducer、操作和存储。事实证明该理论切实可行。

重要说明: 图书搜索应用程序的代码 不适用于 RC5 之前的任何 Angular 版本。

设置

图书搜索应用程序的 Angular 版本的 HTML 如清单 4 所示。

清单 4. 该应用程序的 HTML (index.html)
<html>
  <head>
    <title>Book Search</title>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <link rel="stylesheet" href="styles.css">
  </head>

  <body>
    <app>Loading...</app>
  </body>

  <!-- Polyfill(s) for older browsers -->
  <script src="node_modules/core-js/client/shim.min.js"></script>
  <script src="node_modules/zone.js/dist/zone.js"></script>
  <script src="node_modules/reflect-metadata/Reflect.js"></script>
  <script src="node_modules/systemjs/dist/system.src.js"></script>

  <script src="dist/bundle.js"></script>
</html>

清单 4 中的 HTML 拉入了一些填充信息和该应用程序的 TypeScript。清单 5 给出了应用程序的模块。

清单 5. 该应用程序的模块 (app/app.module.ts)
import { NgModule }      from '@angular/core';
import { BrowserModule } from '@angular/platform-browser';
import { FormsModule } from '@angular/forms';

import App from './components/app.component';
import Book from './components/book.component';
import Books from './components/books.component';
import Controls from './components/controls.component';
import DisplayMode from './components/displayMode.component';
import History from './components/history.component';
import StateViewer from './components/stateviewer.component';
import TopicSelector from './components/topicselector.component';

@NgModule({
  bootstrap: [ App ],

  declarations: [
    App,
    Book,
    Books,
    Controls,
    DisplayMode,
    History,
    StateViewer,
    TopicSelector,
  ],

  imports: [
    BrowserModule,
    FormsModule,
  ],
})

export class AppModule { }

清单 5 的代码定义了一个 Angular 模块。 @NgModule 是一个 TypeScript 注释。(Angular 的 TypeScript 版本中大量使用了注释。) @NgModule 注释是 Angular RC5 中的一项新功能。

在实现 Angular 应用程序时,必须声明模块中的所有组件。清单 5 的代码导入了浏览器模块,浏览器中运行的所有 Angular 应用程序都会导入该模块。该模块也是 @NgModule 注释的来源。

我没有在图书搜索应用程序中使用表单,但显然,我必须导入表单模块 (FormsModule);如果不导入,则会获得以下错误消息:

Can't bind to _ngModel_ since it isn't a known property of _input_.

该错误消息没有提供任何线索来表明需要导入表单模块;堆栈溢出为我提供了解决方案。

样板的最后一部分是应用程序的入口点 (app/main.ts):

import { platformBrowserDynamic } from '@angular/platform-browser-dynamic';
import { AppModule } from './app.module';

platformBrowserDynamic().bootstrapModule(AppModule);

main.ts 代码启动 Angular 应用程序。

它负责运行图书搜索应用程序的 Angular 版本的样板。现在我将展示该应用程序的 Angular 组件。

Angular 组件

您可以将原始版本中的 React 组件替换为它们的 Angular 等效组件 — 并迅速得到一个 Angular 图书搜索应用程序。Angular 版本中的 reducer、操作、存储和状态历史都与 React 版本相同。

与 React 一样,借助 Angular,您可以实现彼此嵌套的组件。清单 6 给出了 App 组件,它包含应用程序中的其他所有组件。

清单 6. App 组件 (app/components/app.component.ts)
import { Component } from '@angular/core';

import Books from './books.component';
import Controls from './controls.component';
import StateViewer from './stateviewer.component';

import store from '../../store';
import { fetchBooks, setTopic } from '../../actions';

@Component({
  selector: 'app',
  template: `
    <div style='text-align: center; font-size: 1.5em'>
     {{app.title}}
    </div>

    <hr/>

    <controls></controls>
    <books></books>
    <state-viewer></state-viewer>`
})

export default class {
  ngOnInit() {
    store.dispatch(setTopic('Border collies'));
    store.dispatch(fetchBooks());
  }

  app = {
    title: 'Book Search (Angular version)',
  };
}

App 组件模板包含 3 个组件: controlsbooksstate-viewer 。该组件的 TypeScript 在初始化 App 组件时向 Redux 存储分派操作。

controls 组件如清单 7 所示。

清单 7. controls 组件 (app/components/controls.component.ts)
import { Component } from '@angular/core';

@Component({
  selector: 'controls',
  template: `
    <topic-selector></topic-selector>
    <display-mode-container></display-mode-container>
    <history></history>
  `
})

export default class Controls {
}

controls 组件不执行任何操作,仅包含其他组件,所以它的关联类的实现是空的(但仍然必不可少)。

topic-selector 组件如清单 8 所示。

清单 8. TopicSelector 组件 (app/components/topicselector.component.ts)
import { Component } from '@angular/core';
import { fetchBooks, setTopic } from '../../actions';
import store from '../../store';

@Component({
  selector: 'topic-selector',
  template: `
    <label for='topic'>Topic</label>

    <input #topicInput
      [(ngModel)]='topic'
      (input)='setTopic(topicInput.value)'
      (keyup.enter)='fetchTopic(topicInput.value)'
      autofocus/>
  `
})

export default class TopicSelector {
  private topic: string;
  private unsubscribe: any;

  constructor() {
    store.subscribe(() => {
      this.topic = store.getState().topic;
    });
  }

  setTopic(newTopic) {
    store.dispatch(setTopic(newTopic));
  }

  fetchTopic(newTopic) {
    store.dispatch(fetchBooks());
  }

  ngOnDestroy() {

    this.unsubscribe();
  }
}

主题选择器包含一个标签和一个输入用于指定新主题。输入的 keyup.enter 事件对应于该组件的 fetchTopic() 方法, input 事件对应于该组件的 setTopic() 方法。因此,每次击键都会更新该主题,而且在用户按 Enter 键时会发起一次新的抓取。

[(ngModel)] 结构是 Angular 的特殊的双向绑定语法。在这个示例中,Angular 将文本字段中的值传递给组件的 topic 属性,它还将对属性的更改传递回文本字段。

从 Redux 角度讲, TopicSelector 组件 (app/components/topicselector.component.ts) 的最有趣之处在于,事实上该组件将会订阅 Redux 存储。当存储更改时,该组件会更新它的 topic 属性。您会看到,图书搜索应用程序中的所有 Angular 组件均以一种类似方式订阅 Redux 存储。

显示模式组件如清单 9 所示。

清单 9. DisplayMode 组件 (app/components/displayMode.component.ts)
import { Component } from '@angular/core';
import store from '../../store';
import { setDisplayMode } from '../../actions';

@Component({
  selector: 'display-mode',
  template: `
    <span>
      <label for='thumbnailRadio'>Thumbnail</label>

      <input id="thumbnailRadio" style="cursor: pointer"
        type="radio"
        name="display_mode"
        value="Thumbnail"
        [checked]='displayMode === "THUMBNAIL"'
        (change)="setMode('THUMBNAIL')"/>

      <label for='listRadio'>List</label>

      <input id="listRadio" style="cursor: pointer"
        type="radio"
        name="display_mode"
        value="List"
        [checked]='displayMode === "LIST"'
        (change)="setMode('LIST')"/>
    </span>
    `
})

export default class DisplayMode {
  private displayMode: string;
  private unsubscribe: any;

  constructor() {
    this.unsubscribe = store.subscribe(() => {
      this.displayMode = store.getState().displayMode;
    });
  }

  setMode(value) {
    store.dispatch(setDisplayMode(value));
  }

  ngOnDestroy() {
    this.unsubscribe();
  }
}

显示模式组件类似于状态查看器。两个组件都将输入事件映射到组件方法。两个组件都订阅 Redux 存储,以便在应用程序状态发生更改时更新一个属性。区别在于显示模式组件有两个输入,它们都是单选按钮。这些单选按钮链接到应用程序的状态中存储的显示模式值。

历史组件的代码很长,所以我将它分为两个清单。该组件的模板如清单 10 所示。

清单 10. History 组件的模板 (app/components/history.component.ts)
import { Component } from '@angular/core';

@Component({
  selector: 'history',
  template: `
  <input type='range' #range
    style='cursor: pointer'
    min={1}
    (input)='setState(range.value)'
    [max]='maximum'
    [value]='value'/>

  <a href='#' style='text-decoration: none'
    (click)='previousState()'
    [innerHTML] = 'leftArrow'>
  </a>

  <a href='#' style='text-decoration: none'
    (click)='nextState()'
    [innerHTML] = 'rightArrow'>
  </a>
  `
})

历史组件包含滑块和箭头按钮,它们控制应用程序的当前状态。同样地,模板标记将事件(确切地讲,是 clickinput 事件)映射到组件方法。这些方法如清单 11 所示,其中包含历史组件的 TypeScript。

清单 11. History 组件的 TypeScript (app/components/history.component.ts)
import { gotoState, redo, undo } from '../../actions';
import stateHistory from '../../statehistory';
import store from '../../store';

export default class History {
  private leftArrow: string;
  private rightArrow: string;
  private stateHistory: any;
  private topic: string;
  private unsubscribe: any;
  private value: number;
  private maximum: number;

  constructor() {
    this.leftArrow = '←';
    this.rightArrow = '→';

    this.stateHistory = stateHistory;

    this.unsubscribe = store.subscribe(() => {
      this.topic = store.getState().topic;
      this.maximum = this.max();
      this.value = this.val();
    });
  }

  setState(stateIndex) {
    store.dispatch(gotoState(stateIndex));
  }

  previousState() {
    store.dispatch(undo());
  }

  nextState() {
    store.dispatch(redo());
  }

  val() {
    return this.stateHistory.past ? this.stateHistory.past.length : 0;
  }

  max() {
    return (this.stateHistory.past    ? this.stateHistory.past.length   : 0) +
           (this.stateHistory.present ? 1 : 0)             +
           (this.stateHistory.future  ? this.stateHistory.future.length : 0) - 1;
  }

  ngOnDestroy() {
    this.unsubscribe();
  }
}

像我目前讨论的其他 Angular 组件一样,历史组件将会订阅 Redux 存储,并在状态更改时更新其 topicmaximumvalue 属性。

历史组件使用 stateHistory 对象配置它的滑块。像 reducer、操作和存储一样, stateHistory 对象与应用程序的 React 版本相同。请参阅 “使用 Redux 实现时间旅行 ” 了解 stateHistory 对象的实现。

Books 组件如清单 12 所示。

清单 12. Books 组件 (app/components/books.component.ts)
import { Component } from '@angular/core';
import { trigger, state, style, transition, animate } from '@angular/core';
import store from '../../store';
import Book from './book.component';

@Component({
  selector: 'books',
  template: `
    <div *ngIf='status === "Fetching..."'
      style='width: 100%; padding: 20px; text-align: center'>
      <img src="../images/spinner.gif" role="presentation" />
    </div>

    <div *ngIf='displayMode === "LIST"' style='width: 100%; padding: 20px;'>
      <ul>
        <li *ngFor='let book of books'>
          <book [item]='book'
                [displayMode]='displayMode'></book>
        </li>
      </ul>
    </div>

    <div *ngIf='displayMode === "THUMBNAIL"' style='padding: 20px;'>
      <book *ngFor='let book of books'
        [item]='book'
        [displayMode]='displayMode'>
      </book>
    </div>
  `
})

export default class Books {
  private books: Array<Book>;
  private displayMode: string;
  private status: string;
  private unsubscribe: any;

  constructor() {
    this.unsubscribe = store.subscribe(() => {
      const state = store.getState();

      this.books = state.books;
      this.displayMode = state.displayMode;
      this.status = state.currentStatus;
    });
  }

  ngOnDestroy() {
    this.unsubscribe();
  }
}

如果应用程序的当前状态为 Fetching…, Books 组件将显示一个下拉列表部件。该组件也会迭代图书列表,创建 book 元素。 Books 组件通过订阅 Redux 存储来同步其表示状态的属性。

Book 组件如清单 13 所示。

清单 13. Book 组件 (app/components/book.component.ts)
import { Component, Input } from '@angular/core';
import store from '../../store';

@Component({
  selector: 'book',
  template: `
    <span *ngIf='displayMode === "THUMBNAIL"' style='padding: 10px'>
      <a href={{item.volumeInfo.canonicalVolumeLink}}>
        <img src={{item.volumeInfo.imageLinks.thumbnail}}/>
      </a>
    </span>

    <span *ngIf='displayMode === "LIST"'>
      <a href={{item.volumeInfo.canonicalVolumeLink}}>
        {{item.volumeInfo.title}}
      </a>
    </span>
  `
})

export default class Book {
  @Input() item: any;
  @Input() displayMode: string;
}

Book 组件的标记将图书呈现为文本链接或缩略图。该组件的 TypeScript 声明了两个输入属性: itemdisplayMode 。这些属性的值由 Books 组件指定,如 Books 组件 (app/components/books.component.ts) 所示。

最后一个组件是 StateViewer ,如清单 14 所示。

清单 14. StateViewer 组件 (app/components/stateviewer.component.ts)
import { Component } from '@angular/core';
import store from '../../store';
import stateHistory from '../../statehistory';

@Component({
  selector: 'state-viewer',
  template: `
  <hr/>

  <span style='font-style: tahoma; font-size: 1.5em'>
    Application State
  </span>

  <div style='padding-top: 10px'>
    Topic: {{state.topic}}<br />
    Display mode: {{state.displayMode}}<br />
    Books displayed:   {{ state.books.length }}<br />
    Actions processed: {{ stateHistory.past.length +
                          stateHistory.future.length + 1 }}<br />
    Current action:    {{ stateHistory.past.length + 1 }}
  </div>
  `
})

export default class StateViewer {
  private state: any;
  private stateHistory: any;
  private unsubscribe: any;

  constructor(
  ){
    this.state = store.getState();
    this.stateHistory = stateHistory;

    this.unsubscribe = store.subscribe(() => {
      this.state = store.getState();
    });
  }

  ngOnDestroy() {
    this.unsubscribe();
  }
}

StateViewer 组件使用应用程序状态和状态历史来显示当前状态。

图书搜索应用程序的 Angular 版本现在已完成。

Redux DevTools

图书搜索应用程序的一些功能(用于在应用程序以前的状态中移动的历史滑块和箭头按钮,以及当前状态的显示视图)不是图书搜索所独有的。您可以提取这些特性的代码,将它们用在其他应用程序中。您可能会怀疑,某人(Redux 的实现者)通过实现 Redux DevTools 完成了类似的工作。

图 3 显示了 Redux DevTools Chrome 扩展 ,该扩展是在 Redux DevTools 上构建的。

图 3. Redux DevTools Chrome 扩展

Redux DevTools Chrome 扩展的屏幕截图

图 3 显示了日志监视器窗格,默认情况下,它显示了状态历史,还提供了一个滑块和箭头按钮。目前为止,您应该已经很好地理解了这些特性是如何实现的。

图 4 显示了 Chart 窗格,它提供了应用程序状态的图形表示。将鼠标悬停在状态中的单个数据上时,就可以在一个弹出窗口中看到该数据的表示,在图 4 中也可以看到该数据表示。

图 4. 使用 Redux DevTools Chrome 扩展了解状态

使用 Redux Devottols Chrome 扩展查看状态的屏幕截图

Redux DevTools Chrome 扩展的使用很简单。根据该扩展的 文档 中的介绍,安装该扩展后,必须对应用程序的代码执行一处更改。对于图书搜索应用程序,它使用了自定义中间件,所以您只需修改对 Redux 的 createStore() 函数的调用,如清单 15 所示。

清单 15. book 组件 (store.js)
import { createStore, applyMiddleware, compose } from 'redux';
import reducers from './reducers';
import { logger, thunk } from './middleware';

export default createStore(reducers, compose(applyMiddleware(logger, thunk),
      window.devToolsExtension ? window.devToolsExtension() : f => f));

随后,当您在 Chrome 中打开开发人员工具时,您会看到一个 Redux 选项卡。单击该选项卡就会显示该扩展。

Redux DevTools 通过提供一组强大的调试工具,使 Redux 变得更加强大。但这不是 Redux 提供的全部优势。Redux 还催生了一个繁荣的生态系统。

Redux 生态系统概览

Redux 生态系统包含以下存储库:

Redux 生态系统中的一些库(比如 redux-form )是特定于 React 的;其他库(比如 redux-logic )仅需要 Redux。

众所周知,Redux 使用操作和 reducer 来维护状态。给定当前状态和一个操作,操作描述状态更改,reducer 根据当前生成一个全新的状态。对于同步操作,这个简单模型就够了,但正如您在 “使用 Redux 实现异步操作 ” 中看到的,它没有考虑异步操作。

异步操作是一种特殊的 类的副作用 ,之前这些与 Redux 相关的存储库( redux-form 除外,它使用 Redux 存储 React 表单状态)都用于管理副作用。这些库有助于实现一些特性,比如 去除抖动 ,图书搜索应用程序没有实现该特性(但根据 redux-logic 的作者在对 “使用 Redux 实现异步操作 ” 的评价中所指出的,应该实现该特性)。有了 redux-saga ,您可以熟悉 Elm 效果;而有了 redux-observable ,您可以了解如何将观察结果与 Redux 相集成。对 Redux 生态系统的进一步讨论不属于本系列的讨论范围。

结束语

尽管不是一个吸引人的主题,但状态管理是任何应用程序的最重要方面之一。在编写本文时,Redux 在 GitHub 上拥有 22,000 颗星和 3,500 个分支,是最流行的 JavaScript 存储库之一。

Redux 如此流行是因为,它使状态管理变得可预测,显著减少了应用程序中的错误数量。Redux 也是一个可用于任何 UI 框架的简单 JavaScript 库。在本系列中,您看到了如何使用 Redux 维护同步和异步操作,还看到了可同时用于 React 和 Angular 的相同的 Redux 代码。Redux DevTools 和 Redux 生态系统使 Redux 变得更加振奋人心。

本文翻译自:Using Redux with Angular(2016-09-28)