[关闭]
@bornkiller 2017-08-12T11:17:38.000000Z 字数 8972 阅读 3313

Reactive 视角审视 React 组件

React


前言

React 问世已久,笔者方才入坑。先于 React,笔者在实际项目中,引入了 reduxrxjs,所以目前实际开发中,大量使用 rxjs 践行 reactive programming,此文带来个人视角的组件状态管理。

状态管理

React 本身作为 view 层,执行从数据到 DOM 的转换,对于开发来说,主要工作在于维护状态。redux 实现的集中式状态管理,可能算是 React 生态圈事实标准,笔者一直持保留态度,是否引入需考虑实际需求,谋定而后动。本文中,笔者使用 rxjs 处理组件级别的状态管理。

首先实现简单的时间组件,呈现当前格式化时间:

  1. /**
  2. * @description - Count component implement
  3. * @author - huang.jian <hjj491229492@hotmail.com>
  4. */
  5. // External
  6. import format from 'dateformat';
  7. import React, { Component } from 'react';
  8. import { Alert, Card } from 'antd';
  9. import { Observable } from 'rxjs/Observable';
  10. import 'rxjs/add/observable/interval';
  11. import 'rxjs/add/operator/map';
  12. // Internal
  13. // Component
  14. const readableTimeFormat = 'yyyy-mm-dd HH:MM:ss:l';
  15. export class Counter extends Component {
  16. constructor(props) {
  17. super(props);
  18. this.state = {
  19. timestamp: Date.now()
  20. };
  21. }
  22. componentWillMount() {
  23. this.state$ = Observable.interval(1000).map(() => ({ timestamp: Date.now() }));
  24. }
  25. componentDidMount() {
  26. this.subscription = this.state$.subscribe((state) => {
  27. this.setState(state);
  28. });
  29. }
  30. componentWillUnmount() {
  31. this.subscription.unsubscribe();
  32. }
  33. render() {
  34. return (
  35. <Card title="React Count">
  36. <Alert message={`React Count: ${format(this.state.timestamp, readableTimeFormat)}`} type="success"/>
  37. <Alert message={`React Count: ${format(this.state.timestamp, readableTimeFormat)}`} type="info"/>
  38. <Alert message={`React Count: ${format(this.state.timestamp, readableTimeFormat)}`} type="warning"/>
  39. </Card>
  40. );
  41. }
  42. }

image.png-19.8kB

笔者当前使用习惯,是利用 Component lifecycle 处理可观察对象声明,订阅,退订操作。在 componentWillMount 内部声明可观察对象,出于一致性考量,整个组件的状态归并为 state$,在 componentDidMount 内部订阅 state$,订阅名统一为 subscription,在 componentWillUnmount 内部退订可观察对象,订阅,退订降级为模板代码,核心将组件的状态变换为可观察对象。

  1. // Declaration
  2. this.state$ = Observable.interval(1000).map(() => ({ timestamp: Date.now() }));
  3. // Subscribe
  4. this.subscription = this.state$.subscribe((state) => {
  5. this.setState(state);
  6. });
  7. // Unsubscribe
  8. this.subscription.unsubscribe();

上述案例状态非常简单,不存在 Network, DOM 等存在 side effect 的操作,处理 side effectredux 通过 middleware 来实现,笔者之前使用过 redux-thunkredux-promise,自定义项目中使用的 redux-http 等,基本理念相同,触发 complex action,中间件针对特定 action type,转化为 pure action,即 reducer 直接操作的 action,保证状态的纯净。使用 rxjs 来处理理念依旧相同,但 rxjs 异步控制相比 redux-saga 更为简单可控。

接下来实现一个 instant search 组件,通过关键词搜索 github repo,然后使用表格呈现,只处理首页数据,目标类似于 https://blog.thoughtram.io/angular/2016/01/06/taking-advantage-of-observables-in-angular2.html

处理 HTTP 请求代码如下:

  1. /**
  2. * @description - repo search request remote API
  3. * @author - huang.jian <hjj491229492@hotmail.com>
  4. */
  5. // External
  6. import pick from 'lodash/pick';
  7. import { message } from 'antd';
  8. import { Observable } from 'rxjs/Observable';
  9. /**
  10. * @description - search github repo
  11. *
  12. * @param {string} keyword
  13. *
  14. * @return {*}
  15. */
  16. export function fromRepoSearch(keyword) {
  17. const url = `https://api.github.com/search/repositories?q=${keyword}`;
  18. const init = { mode: 'cors' };
  19. const fields = ['id', 'full_name', 'description', 'html_url', 'stargazers_count', 'language'];
  20. return Observable.create((observer) => {
  21. // Notify fetch start
  22. observer.next({ repoSearchRequesting: true });
  23. fetch(url, init)
  24. .then((res) => res.json())
  25. .then((res) => {
  26. const items = res.items.map((item) => pick(item, fields));
  27. message.info('search repo success');
  28. observer.next({ repos: items });
  29. })
  30. .catch((err) => {
  31. message.error('search repo failed');
  32. observer.error(err);
  33. })
  34. .then(() => {
  35. // Notify fetch complete
  36. observer.next({ repoSearchRequesting: false });
  37. observer.complete();
  38. }, () => {
  39. observer.next({ repoSearchRequesting: false });
  40. observer.complete();
  41. });
  42. });
  43. }

代码异曲同工,类似于使用 observer.next 替换 store.dispatch 调用。redux-logger 通过扩展 Observable operator 来实现,简易代码如下:

  1. /**
  2. * @description - Lite state output
  3. *
  4. * @param {string} groupName
  5. *
  6. * @return {any|Observable|Observable<T>|*}
  7. */
  8. function logger(groupName) {
  9. return this.do((state) => {
  10. const timestamp = Date.now();
  11. const dynamicGroupNmae = `${groupName} @ ${format(timestamp, readableTimeFormat)}`;
  12. console.groupCollapsed(dynamicGroupNmae);
  13. console.log(state);
  14. console.groupEnd(dynamicGroupNmae);
  15. });
  16. }

组件实现如下:

  1. /**
  2. * @description - github repo search
  3. * @author - huang.jian <hjj491229492@hotmail.com>
  4. */
  5. // External
  6. import React, { Component } from 'react';
  7. import { Icon, Input, Table } from 'antd';
  8. import { Subject } from 'rxjs/Subject';
  9. import { Observable } from 'rxjs/Observable';
  10. import 'rxjs/add/observable/merge';
  11. import 'rxjs/add/operator/scan';
  12. import 'rxjs/add/operator/debounceTime';
  13. import 'rxjs/add/operator/distinctUntilChanged';
  14. import 'rxjs/add/operator/switchMap';
  15. import 'rxjs/add/operator/pluck';
  16. import 'rxjs/add/operator/share';
  17. // Internal
  18. import { fromRepoSearch } from './request';
  19. import { TimeMachine } from '../../hoc/time-machine';
  20. // Component scope
  21. @TimeMachine()
  22. export class RepoSearch extends Component {
  23. constructor(props) {
  24. super(props);
  25. this.state = {
  26. keyword: '',
  27. repos: [],
  28. repoSearchRequesting: false
  29. };
  30. this.columns = [
  31. { key: 'full_name', dataIndex: 'full_name', title: 'Name' },
  32. { key: 'description', dataIndex: 'description', title: 'Description', width: 400 },
  33. {
  34. key: 'html_url',
  35. dataIndex: 'html_url',
  36. title: 'URL',
  37. render: (url) => (<a href={url} target="_blank">{url}</a>)
  38. },
  39. { key: 'stargazers_count', dataIndex: 'stargazers_count', title: 'Star' },
  40. { key: 'language', dataIndex: 'language', title: 'Language' }
  41. ];
  42. this.suffix = (<Icon type="search"/>);
  43. }
  44. componentWillMount() {
  45. this.keyword$ = Reflect.construct(Subject, []);
  46. this.search$ = this.keyword$.debounceTime(1000)
  47. .pluck('keyword')
  48. .distinctUntilChanged()
  49. .switchMap((keyword) => fromRepoSearch(keyword))
  50. .share();
  51. // Subscribe several times for HOC TimeMachine
  52. // Entire state object is not necessary, which maybe cause diff performance issue
  53. // Initial state is also not necessary, which already declare in constructor block
  54. this.state$ = Observable.merge(this.keyword$, this.search$).share();
  55. }
  56. componentDidMount() {
  57. this.subscription = this.state$.subscribe((state) => {
  58. this.setState(state);
  59. });
  60. }
  61. componentWillUnmount() {
  62. this.subscription.unsubscribe();
  63. }
  64. handleKeywordSearch = (evt) => {
  65. this.keyword$.next({ keyword: evt.target.value });
  66. };
  67. render() {
  68. return (
  69. <section className="repo-search__box">
  70. <header className="repo-search__stdin">
  71. <Input
  72. type="text"
  73. size="large"
  74. placeholder="repo keyword"
  75. suffix={this.suffix}
  76. value={this.state.keyword}
  77. onInput={this.handleKeywordSearch}
  78. />
  79. </header>
  80. <article className="repo-search__visualize">
  81. <Table
  82. rowKey="id"
  83. pagination={false}
  84. loading={this.state.repoSearchRequesting}
  85. columns={this.columns}
  86. dataSource={this.state.repos}
  87. />
  88. </article>
  89. </section>
  90. );
  91. }
  92. }

问题频发于 this.state$ 归并操作,cold observable 会重复订阅,需要特别小心。从行为上来讲,combineLatest operator 最为合适,多数据流合并为完整的 Component state object,但官方实现不支持多元可观察对象的合并,且状态变更,即 this.setState 调用传入完整对象笔者认为并无必要。

细心的读者可能发现还有个 TimeMachine 的关键字,此处为高阶组件,使用 esnext decorator 方式,目的在于实现简易的 Time Travel Debug。如果仔细斟酌,会发现时光旅行调试并不是 redux 独此一家,笔者仅做抛砖引玉,不被工具链所束缚,做到看山不是山,看水亦是水。

做到时光旅行调试,笔者通过建立 state snapshot 的方式来进行,暂用 merge + scan 方式组合状态。redux 如何实现并未仔细研究,请不吝赐教。具体代码维度,依旧 extend Observable operator的套路:

  1. const timeArrivalStationReducer = (size) => (buffer, state) => {
  2. let history = buffer.length <= size ? buffer.slice() : buffer.slice(1);
  3. let now = Date.now();
  4. let nextState = {
  5. time: format(now, readableTimeFormat),
  6. raw: omit(state, 'timeArrivalStations')
  7. };
  8. return [...history, nextState];
  9. };
  10. /**
  11. * @description - Backup history state
  12. *
  13. * @param {number} size - Maximum buffer length
  14. *
  15. * @return {Observable<R>}
  16. */
  17. function timeMachine(size = 25) {
  18. return this.scan(timeArrivalStationReducer(size), []).map((timeArrivalStations) => ({ timeArrivalStations }));
  19. }

如何选择旅行节点,是当前实现的难点,没有 action type 这样明显的标记,笔者示例使用时间戳作为节点标记,使用 Select 展示可旅行节点,高阶组件实现如下:

  1. /**
  2. * @description - time machine HOC
  3. * @author - huang.jian <hjj491229492@hotmail.com>
  4. */
  5. // External
  6. import React from 'react';
  7. import { Select } from 'antd';
  8. import { Observable } from 'rxjs/Observable';
  9. import { Subject } from 'rxjs/Subject';
  10. import 'rxjs/add/observable/merge';
  11. import 'rxjs/add/operator/startWith';
  12. import 'rxjs/add/operator/shareReplay';
  13. import 'rxjs/add/operator/withLatestFrom';
  14. import 'rxjs/add/operator/pluck';
  15. // Component scope
  16. const Option = Select.Option;
  17. const DefaultTimeMachineOptions = {
  18. size: 25
  19. };
  20. export const TimeMachine = (options) => (RawComponent) => {
  21. options = { ...DefaultTimeMachineOptions, options };
  22. return class extends RawComponent {
  23. static displayName = `HOC"${RawComponent.displayName || RawComponent.name}"`;
  24. constructor(props) {
  25. super(props);
  26. this.prevInitialState = { ...this.state };
  27. this.state = {
  28. ...this.state,
  29. timeArrivalStations: []
  30. };
  31. }
  32. componentWillMount() {
  33. // Inherit parent lifecycle
  34. super.componentWillMount();
  35. // Create time machine
  36. this.rollback$ = Reflect.construct(Subject, []);
  37. this.arrival$ = this.state$
  38. .startWith(this.prevInitialState)
  39. .scan((acc, curr) => Object.assign({}, acc, curr))
  40. .logger('RepoSearch')
  41. .timeMachine(options.size)
  42. .shareReplay(1);
  43. this.jump$ = this.rollback$
  44. .withLatestFrom(this.arrival$, (point, arrivals) => arrivals.timeArrivalStations.find((arrival) => arrival.time === point))
  45. .pluck('raw');
  46. this.timeMachine$ = Observable.merge(this.arrival$, this.jump$);
  47. }
  48. componentDidMount() {
  49. // Inherit parent lifecycle
  50. super.componentDidMount();
  51. // Time machine lifecycle
  52. this.timeMachineSubscription = this.timeMachine$.subscribe((state) => {
  53. this.setState(state);
  54. });
  55. }
  56. componentWillUnmount() {
  57. // Inherit parent lifecycle
  58. super.componentWillUnmount();
  59. // Time machine lifecycle
  60. this.timeMachineSubscription.unsubscribe();
  61. }
  62. handleTimeJump = (point) => {
  63. this.rollback$.next(point);
  64. };
  65. render() {
  66. return (
  67. <section className="time-machine">
  68. <header className="time-machine__panel">
  69. <Select style={{ width: 200 }} onChange={this.handleTimeJump}>
  70. {
  71. this.state.timeArrivalStations.map((item) => (
  72. <Option key={item.time} value={item.time}>
  73. {item.time}
  74. </Option>)
  75. )
  76. }
  77. </Select>
  78. </header>
  79. <article className="time-machine__slot">
  80. {super.render()}
  81. </article>
  82. </section>
  83. );
  84. }
  85. };
  86. };

效果预览录屏繁琐,静态图将就:

QQ20170806-3.png-376.2kB

QQ20170806-4.png-363.8kB

QQ20170806-5.png-379.3kB

Contact

Email: hjj491229492@hotmail.com

qrcode_for_gh_d8efb59259e2_344.jpg-8.7kB

添加新批注
在作者公开此批注前,只有你和作者可见。
回复批注