@bornkiller
2017-08-12T03:17:38.000000Z
字数 8972
阅读 3636
React
React 问世已久,笔者方才入坑。先于 React,笔者在实际项目中,引入了 redux,rxjs,所以目前实际开发中,大量使用 rxjs 践行 reactive programming,此文带来个人视角的组件状态管理。
React 本身作为 view 层,执行从数据到 DOM 的转换,对于开发来说,主要工作在于维护状态。redux 实现的集中式状态管理,可能算是 React 生态圈事实标准,笔者一直持保留态度,是否引入需考虑实际需求,谋定而后动。本文中,笔者使用 rxjs 处理组件级别的状态管理。
首先实现简单的时间组件,呈现当前格式化时间:
/*** @description - Count component implement* @author - huang.jian <hjj491229492@hotmail.com>*/// Externalimport format from 'dateformat';import React, { Component } from 'react';import { Alert, Card } from 'antd';import { Observable } from 'rxjs/Observable';import 'rxjs/add/observable/interval';import 'rxjs/add/operator/map';// Internal// Componentconst readableTimeFormat = 'yyyy-mm-dd HH:MM:ss:l';export class Counter extends Component {constructor(props) {super(props);this.state = {timestamp: Date.now()};}componentWillMount() {this.state$ = Observable.interval(1000).map(() => ({ timestamp: Date.now() }));}componentDidMount() {this.subscription = this.state$.subscribe((state) => {this.setState(state);});}componentWillUnmount() {this.subscription.unsubscribe();}render() {return (<Card title="React Count"><Alert message={`React Count: ${format(this.state.timestamp, readableTimeFormat)}`} type="success"/><Alert message={`React Count: ${format(this.state.timestamp, readableTimeFormat)}`} type="info"/><Alert message={`React Count: ${format(this.state.timestamp, readableTimeFormat)}`} type="warning"/></Card>);}}

笔者当前使用习惯,是利用 Component lifecycle 处理可观察对象声明,订阅,退订操作。在 componentWillMount 内部声明可观察对象,出于一致性考量,整个组件的状态归并为 state$,在 componentDidMount 内部订阅 state$,订阅名统一为 subscription,在 componentWillUnmount 内部退订可观察对象,订阅,退订降级为模板代码,核心将组件的状态变换为可观察对象。
// Declarationthis.state$ = Observable.interval(1000).map(() => ({ timestamp: Date.now() }));// Subscribethis.subscription = this.state$.subscribe((state) => {this.setState(state);});// Unsubscribethis.subscription.unsubscribe();
上述案例状态非常简单,不存在 Network, DOM 等存在 side effect 的操作,处理 side effect,redux 通过 middleware 来实现,笔者之前使用过 redux-thunk,redux-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 请求代码如下:
/*** @description - repo search request remote API* @author - huang.jian <hjj491229492@hotmail.com>*/// Externalimport pick from 'lodash/pick';import { message } from 'antd';import { Observable } from 'rxjs/Observable';/*** @description - search github repo** @param {string} keyword** @return {*}*/export function fromRepoSearch(keyword) {const url = `https://api.github.com/search/repositories?q=${keyword}`;const init = { mode: 'cors' };const fields = ['id', 'full_name', 'description', 'html_url', 'stargazers_count', 'language'];return Observable.create((observer) => {// Notify fetch startobserver.next({ repoSearchRequesting: true });fetch(url, init).then((res) => res.json()).then((res) => {const items = res.items.map((item) => pick(item, fields));message.info('search repo success');observer.next({ repos: items });}).catch((err) => {message.error('search repo failed');observer.error(err);}).then(() => {// Notify fetch completeobserver.next({ repoSearchRequesting: false });observer.complete();}, () => {observer.next({ repoSearchRequesting: false });observer.complete();});});}
代码异曲同工,类似于使用 observer.next 替换 store.dispatch 调用。redux-logger 通过扩展 Observable operator 来实现,简易代码如下:
/*** @description - Lite state output** @param {string} groupName** @return {any|Observable|Observable<T>|*}*/function logger(groupName) {return this.do((state) => {const timestamp = Date.now();const dynamicGroupNmae = `${groupName} @ ${format(timestamp, readableTimeFormat)}`;console.groupCollapsed(dynamicGroupNmae);console.log(state);console.groupEnd(dynamicGroupNmae);});}
组件实现如下:
/*** @description - github repo search* @author - huang.jian <hjj491229492@hotmail.com>*/// Externalimport React, { Component } from 'react';import { Icon, Input, Table } from 'antd';import { Subject } from 'rxjs/Subject';import { Observable } from 'rxjs/Observable';import 'rxjs/add/observable/merge';import 'rxjs/add/operator/scan';import 'rxjs/add/operator/debounceTime';import 'rxjs/add/operator/distinctUntilChanged';import 'rxjs/add/operator/switchMap';import 'rxjs/add/operator/pluck';import 'rxjs/add/operator/share';// Internalimport { fromRepoSearch } from './request';import { TimeMachine } from '../../hoc/time-machine';// Component scope@TimeMachine()export class RepoSearch extends Component {constructor(props) {super(props);this.state = {keyword: '',repos: [],repoSearchRequesting: false};this.columns = [{ key: 'full_name', dataIndex: 'full_name', title: 'Name' },{ key: 'description', dataIndex: 'description', title: 'Description', width: 400 },{key: 'html_url',dataIndex: 'html_url',title: 'URL',render: (url) => (<a href={url} target="_blank">{url}</a>)},{ key: 'stargazers_count', dataIndex: 'stargazers_count', title: 'Star' },{ key: 'language', dataIndex: 'language', title: 'Language' }];this.suffix = (<Icon type="search"/>);}componentWillMount() {this.keyword$ = Reflect.construct(Subject, []);this.search$ = this.keyword$.debounceTime(1000).pluck('keyword').distinctUntilChanged().switchMap((keyword) => fromRepoSearch(keyword)).share();// Subscribe several times for HOC TimeMachine// Entire state object is not necessary, which maybe cause diff performance issue// Initial state is also not necessary, which already declare in constructor blockthis.state$ = Observable.merge(this.keyword$, this.search$).share();}componentDidMount() {this.subscription = this.state$.subscribe((state) => {this.setState(state);});}componentWillUnmount() {this.subscription.unsubscribe();}handleKeywordSearch = (evt) => {this.keyword$.next({ keyword: evt.target.value });};render() {return (<section className="repo-search__box"><header className="repo-search__stdin"><Inputtype="text"size="large"placeholder="repo keyword"suffix={this.suffix}value={this.state.keyword}onInput={this.handleKeywordSearch}/></header><article className="repo-search__visualize"><TablerowKey="id"pagination={false}loading={this.state.repoSearchRequesting}columns={this.columns}dataSource={this.state.repos}/></article></section>);}}
问题频发于 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的套路:
const timeArrivalStationReducer = (size) => (buffer, state) => {let history = buffer.length <= size ? buffer.slice() : buffer.slice(1);let now = Date.now();let nextState = {time: format(now, readableTimeFormat),raw: omit(state, 'timeArrivalStations')};return [...history, nextState];};/*** @description - Backup history state** @param {number} size - Maximum buffer length** @return {Observable<R>}*/function timeMachine(size = 25) {return this.scan(timeArrivalStationReducer(size), []).map((timeArrivalStations) => ({ timeArrivalStations }));}
如何选择旅行节点,是当前实现的难点,没有 action type 这样明显的标记,笔者示例使用时间戳作为节点标记,使用 Select 展示可旅行节点,高阶组件实现如下:
/*** @description - time machine HOC* @author - huang.jian <hjj491229492@hotmail.com>*/// Externalimport React from 'react';import { Select } from 'antd';import { Observable } from 'rxjs/Observable';import { Subject } from 'rxjs/Subject';import 'rxjs/add/observable/merge';import 'rxjs/add/operator/startWith';import 'rxjs/add/operator/shareReplay';import 'rxjs/add/operator/withLatestFrom';import 'rxjs/add/operator/pluck';// Component scopeconst Option = Select.Option;const DefaultTimeMachineOptions = {size: 25};export const TimeMachine = (options) => (RawComponent) => {options = { ...DefaultTimeMachineOptions, options };return class extends RawComponent {static displayName = `HOC"${RawComponent.displayName || RawComponent.name}"`;constructor(props) {super(props);this.prevInitialState = { ...this.state };this.state = {...this.state,timeArrivalStations: []};}componentWillMount() {// Inherit parent lifecyclesuper.componentWillMount();// Create time machinethis.rollback$ = Reflect.construct(Subject, []);this.arrival$ = this.state$.startWith(this.prevInitialState).scan((acc, curr) => Object.assign({}, acc, curr)).logger('RepoSearch').timeMachine(options.size).shareReplay(1);this.jump$ = this.rollback$.withLatestFrom(this.arrival$, (point, arrivals) => arrivals.timeArrivalStations.find((arrival) => arrival.time === point)).pluck('raw');this.timeMachine$ = Observable.merge(this.arrival$, this.jump$);}componentDidMount() {// Inherit parent lifecyclesuper.componentDidMount();// Time machine lifecyclethis.timeMachineSubscription = this.timeMachine$.subscribe((state) => {this.setState(state);});}componentWillUnmount() {// Inherit parent lifecyclesuper.componentWillUnmount();// Time machine lifecyclethis.timeMachineSubscription.unsubscribe();}handleTimeJump = (point) => {this.rollback$.next(point);};render() {return (<section className="time-machine"><header className="time-machine__panel"><Select style={{ width: 200 }} onChange={this.handleTimeJump}>{this.state.timeArrivalStations.map((item) => (<Option key={item.time} value={item.time}>{item.time}</Option>))}</Select></header><article className="time-machine__slot">{super.render()}</article></section>);}};};
效果预览录屏繁琐,静态图将就:



Email: hjj491229492@hotmail.com
