@bornkiller
2017-08-12T11:17:38.000000Z
字数 8972
阅读 3330
React
React
问世已久,笔者方才入坑。先于 React
,笔者在实际项目中,引入了 redux
,rxjs
,所以目前实际开发中,大量使用 rxjs
践行 reactive programming
,此文带来个人视角的组件状态管理。
React
本身作为 view
层,执行从数据到 DOM
的转换,对于开发来说,主要工作在于维护状态。redux
实现的集中式状态管理,可能算是 React
生态圈事实标准,笔者一直持保留态度,是否引入需考虑实际需求,谋定而后动。本文中,笔者使用 rxjs
处理组件级别的状态管理。
首先实现简单的时间组件,呈现当前格式化时间:
/**
* @description - Count component implement
* @author - huang.jian <hjj491229492@hotmail.com>
*/
// External
import 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
// Component
const 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
内部退订可观察对象,订阅,退订降级为模板代码,核心将组件的状态变换为可观察对象。
// Declaration
this.state$ = Observable.interval(1000).map(() => ({ timestamp: Date.now() }));
// Subscribe
this.subscription = this.state$.subscribe((state) => {
this.setState(state);
});
// Unsubscribe
this.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>
*/
// External
import 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 start
observer.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 complete
observer.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>
*/
// External
import 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';
// Internal
import { 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 block
this.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">
<Input
type="text"
size="large"
placeholder="repo keyword"
suffix={this.suffix}
value={this.state.keyword}
onInput={this.handleKeywordSearch}
/>
</header>
<article className="repo-search__visualize">
<Table
rowKey="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>
*/
// External
import 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 scope
const 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 lifecycle
super.componentWillMount();
// Create time machine
this.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 lifecycle
super.componentDidMount();
// Time machine lifecycle
this.timeMachineSubscription = this.timeMachine$.subscribe((state) => {
this.setState(state);
});
}
componentWillUnmount() {
// Inherit parent lifecycle
super.componentWillUnmount();
// Time machine lifecycle
this.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