在 React 的学习和开发中,如果 state (状态)变得复杂时(例如一个状态需要能够在多个 view 中使用和更新),使用 Redux 可以有效地管理 state,使 state tree 结构清晰,方便状态的更新和使用。
当然,Redux 和 React 并没有什么关系。Redux 支持 React、Angular、Ember、jQuery 甚至纯 JavaScript。只是对我来说目前主要需要在 React 中使用,所以在这里和 React 联系起来便于理解记忆。
数据流
Action
只是描述 state (状态)更新的动作,即“发生了什么”,并不更新 state。
const ADD_TODO = 'ADD_TODO'
{
type: ADD_TODO,
text: 'Build my first Redux app'
}
- type:必填,表示将要执行的动作,通常会被定义成字符串常量,尤其是大型项目。
- 除了 type 外的其他字段:可选,自定义,通常可传相关参数。例如上面例子中的 text。
Action 创建函数
简单返回一个 Action:
function addTodo(text) {
return {
type: ADD_TODO,
text
}
}
dispatch Action:
dispatch(addTodo(text))
// 或者创建一个 被绑定的 action 创建函数 来自动 dispatch
const boundAddTodo = text => dispatch(addTodo(text))
boundAddTodo(text)
帮助生成 Action 创建函数的库(对减少样板代码有帮助):
redux-actions
createAction(s)
、handleAction(s)
、combineActions
createAction(
type,
payloadCreator = Identity, // function/undefined/null,默认使用 lodash 的 Identity; 如果传入 Error,则不会调用 payloadCreator 处理 Error,而是设置 action.error 为 true
?metaCreator // 用来保存 payload 以外的其他数据
)
const addTodo = createAction(
'ADD_TODO',
text => ({text: text.trim(), created_at: new Date().getTime()}),
() => ({ admin: true })
);
expect(addTodo('New Todo')).to.deep.equal({
type: 'ADD_TODO',
payload: {
text: 'New Todo',
created_at: 1551322911779
},
meta: { admin: true }
});
const error = new TypeError('error');
expect(addTodo(error)).to.deep.equal({
type: 'ADD_TODO',
payload: error,
error: true
});
createActions(
actionMap, // {type => payloadCreator / [payloadCreator, metaCreator] / actionMap}
?...identityActions, // 字符串类型的参数列表,表示一组使用 Identity payloadCreator 的 actions
?options // 定义 type 前缀:{ prefix, namespace } prefix 前缀字符串,namespace 前缀和 type 之间的分隔符(默认为 /)
)
const actionCreators = createActions(
{
TODO: {
ADD: todo => ({ todo }), // payload creator
REMOVE: [
todo => ({ todo }), // payload creator
(todo, warn) => ({ todo, warn }) // meta creator
]
},
COUNTER: {
INCREMENT: [amount => ({ amount }), amount => ({ key: 'value', amount })],
DECREMENT: amount => ({ amount: -amount }),
SET: undefined // given undefined, the identity function will be used
}
},
'UPDATE_SETTINGS',
{
prefix: 'app',
namespace: '-'
}
);
expect(actionCreators.todo.remove('Todo 1', 'warn: xxx')).to.deep.equal({
type: 'app-TODO-REMOVE',
payload: { todo: 'Todo 1' },
meta: { todo: 'Todo 1', warn: 'warn: xxx' }
});
expect(actionCreators.updateSettings({ theme: 'blue' })).to.deep.equal({
type: 'app-UPDATE_SETTINGS',
payload: { theme: 'blue' }
})
redux-actions 也能帮助生成 reducer,
handleAction(
type,
reducer | reducerMap = Identity,
defaultState,
)
handleAction(
'ADD_TODO',
(state, action) => ({
...state,
{
text: action.payload.text,
completed: false
}
}),
{ text: '--', completed: false },
);
const reducer = handleAction('INCREMENT', {
next: (state, { payload: { amount } }) => ({ ...state, counter: state.counter + amount }),
throw: state => ({ ...state, counter: 0 }),
}, { counter: 10 });
expect(reducer(undefined, increment(1)).to.deep.equal({ counter: 11 });
expect(reducer({ counter: 5 }, increment(1)).to.deep.equal({ counter: 6 });
expect(reducer({ counter: 5 }, increment(new Error)).to.deep.equal({ counter: 0 });
handleActions(reducerMap, defaultState[, options])
handleActions(
{
INCREMENT: (state, action) => ({
counter: state.counter + action.payload
}),
DECREMENT: (state, action) => ({
counter: state.counter - action.payload
})
},
{ counter: 0 }
);
// Map
const INCREMENT = 'INCREMENT';
const DECREMENT = 'DECREMENT';
handleActions(
new Map([
[
INCREMENT,
(state, action) => ({
counter: state.counter + action.payload
})
],
[
DECREMENT,
(state, action) => ({
counter: state.counter - action.payload
})
]
]),
{ counter: 0 }
);
const increment = createAction(INCREMENT);
const decrement = createAction(DECREMENT);
const reducer = handleActions(
new Map([
[
increment,
(state, action) => ({
counter: state.counter + action.payload
})
],
[
decrement,
(state, action) => ({
counter: state.counter - action.payload
})
]
]),
{ counter: 0 }
);
当多个 action 有相同的 reducer 时,可以使用 combineActions,
combineActions(...types) // types: strings, symbols, or action creators
const { increment, decrement } = createActions({
INCREMENT: amount => ({ amount }),
DECREMENT: amount => ({ amount: -amount })
});
const reducer = handleActions(
{
[combineActions(increment, decrement)]: (
state,
{ payload: { amount } }
) => {
return { ...state, counter: state.counter + amount };
}
},
{ counter: 10 }
);
Reducer
说明在发起 action 后 state 应该如何更新。
是一个纯函数:只要传入参数相同,返回计算得到的下一个 state 就一定相同。(previousState, action) => newState
注意,不能在 reducer 中执行的操作:
- 修改传入的参数
- 执行有副作用的操作,如 API 请求和路由跳转
- 调用非纯函数,如 Date.now() 或 Math.random()
import { combineReducers } from 'redux'
import {
ADD_TODO,
TOGGLE_TODO,
SET_VISIBILITY_FILTER,
VisibilityFilters
} from './actions'
const { SHOW_ALL } = VisibilityFilters
function visibilityFilter(state = SHOW_ALL, action) {
switch (action.type) {
case SET_VISIBILITY_FILTER:
return action.filter
default:
return state
}
}
function todos(state = [], action) {
switch (action.type) {
case ADD_TODO:
return {
...state,
{
text: action.text,
completed: false
}
}
case TOGGLE_TODO:
return state.map((todo, index) => {
if (index === action.index) {
return Object.assign({}, todo, {
completed: !todo.completed
})
}
return todo
})
default:
return state
}
}
const todoApp = combineReducers({
visibilityFilter,
todos
})
export default todoApp
Store
Redux 应用只有一个单一的 store。
- 维持应用的 state;
- 提供 getState() 方法获取 state;
- 提供 dispatch(action) 方法更新 state;
- 通过 subscribe(listener) 注册监听器;
- 通过 subscribe(listener) 返回的函数注销监听器。
import { createStore } from 'redux'
import todoApp from './reducers'
let store = createStore(
todoApp,
[preloadedState], // 可选,state 初始状态
enhancer
)
import { createStore, combineReducers, applyMiddleware, compose } from 'redux'
import thunk from 'redux-thunk'
import DevTools from './containers/DevTools'
import reducer from '../reducers/index'
export default function configureStore() {
const store = createStore(
reducer,
compose(
applyMiddleware(thunk),
DevTools.instrument()
)
);
return store;
}
react-redux
connect()
方法(mapStateToProps
、mapDispatchToProps
)
替代 store.subscribe()
,从 Redux state 树中读取部分数据,并通过 props 提供给要渲染的组件。
import { bindActionCreators } from 'redux';
import { connect } from 'react-redux';
import * as actions from './actions';
class App extends Component {
handleAddTodo = () => {
const { actions } = this.props;
actions.addTodo('Create a new todo');
}
render() {
const { todos } = this.props;
return (
<div>
<Button onClick={this.handleAddTodo}>+</Button>
<ul>
{todos.map(todo => (
<Todo key={todo.id} {...todo} />
))}
</ul>
</div>
);
}
}
function mapStateToProps(state) {
return {
todos: state.todos
};
}
function mapDispatchToProps(dispatch) {
return {
actions: bindActionCreators({
addTodo: actions.addTodo
}, dispatch)
}
}
export default connect(
mapStateToProps,
mapDispatchToProps
)(App);
Provider
组件
import React from 'react'
import { render } from 'react-dom'
import { Provider } from 'react-redux'
import configureStore from './store/configureStore'
import App from './components/App'
render(
<Provider store={configureStore()}>
<App />
</Provider>,
document.getElementById('root')
API 请求
一般情况下,每个 API 请求都需要 dispatch 至少三种 action:
- 通知 reducer 请求开始的 action
{ type: 'FETCH_POSTS_REQUEST' }
reducer 可能会{...state, isFetching: true}
- 一种通知 reducer 请求成功的 action
{ type: 'FETCH_POSTS_SUCCESS', response: { ... } }
reducer 可能会{...state, isFetching: false, data: action.response}
- 一种通知 reducer 请求失败的 action
{ type: 'FETCH_POSTS_FAILURE', error: 'Oops' }
reducer 可能会{...state, isFetching: false, error: action.error}
使用 middleware 中间件实现网络请求:
redux-thunk
通过使用指定的 middleware,action 创建函数除了返回 action 对象外还可以返回函数。这时,这个 action 创建函数就成为了 thunk。
function shouldFetchPosts(state) {
if (state.posts.isFetching) {
return false;
}
return true;
}
export function fetchPosts() {
return (dispatch, getState) => {
if (!shouldFetchPosts(getState())) {
return Promise.resolve();
}
dispatch({ type: 'FETCH_POSTS_REQUEST' });
return fetch(postApi).then(response => {
const data = response.json();
return dispatch({type: 'FETCH_POSTS_SUCCESS', data});
});
}
}
...
actions.fetchPosts().then(() => console.log(this.props.posts))
...
function mapStateToProps(state) {
return {
posts: state.posts
};
}
function mapDispatchToProps(dispatch) {
return {
actions: bindActionCreators({
fetchPosts
}, dispatch)
}
}
...
redux-saga
声明式 vs 命令式:
- DOM: jQuery / React
- Redux effects: redux-thunk / redux-saga
实现获取用户信息的两种方式对比:
-
redux-thunk
<div onClick={e => dispatch(actions.loadUserProfile(123)}>Robert</div> function loadUserProfile(userId) { return dispatch => fetch(`http://data.com/${userId}`) .then(res => res.json()) .then( data => dispatch({ type: 'USER_PROFILE_LOADED', data }), err => dispatch({ type: 'USER_PROFILE_LOAD_FAILED', err }) ); }
-
redux-saga
<div onClick={e => dispatch({ type: 'USER_NAME_CLICKED', payload: 123 })}>Robert</div> function* loadUserProfileOnNameClick() { yield* takeLatest("USER_NAME_CLICKED", fetchUser); } function* fetchUser(action) { try { const userProfile = yield fetch(`http://data.com/${action.payload.userId }`) yield put({ type: 'USER_PROFILE_LOADED', userProfile }) } catch(err) { yield put({ type: 'USER_PROFILE_LOAD_FAILED', err }) } }
比较看来,使用 redux-saga 的代码更干净清晰,方便测试。
redux-saga 使用了 ES6 的 Generator 功能,让异步的流程更易于读取,写入和测试。
class UserComponent extends React.Component {
...
onSomeButtonClicked() {
const { userId, dispatch } = this.props
dispatch({type: 'USER_FETCH_REQUESTED', payload: {userId}})
}
...
}
sagas.js
import { call, put, takeEvery, takeLatest } from 'redux-saga/effects'
import Api from '...'
// worker Saga: will be fired on USER_FETCH_REQUESTED actions
function* fetchUser(action) {
try {
const user = yield call(Api.fetchUser, action.payload.userId);
yield put({type: "USER_FETCH_SUCCEEDED", user: user});
} catch (e) {
yield put({type: "USER_FETCH_FAILED", message: e.message});
}
}
// or
function fetchUserApi(userId) {
return Api.fetchUser(userId)
.then(response => ({ response }))
.catch(error => ({ error }))
}
function* fetchUser(action) {
const { response, error } = yield call(fetchUserApi, action.payload.userId);
if (response) {
yield put({type: "USER_FETCH_SUCCEEDED", user: user});
} else {
yield put({type: "USER_FETCH_FAILED", message: e.message});
}
}
/*
Starts fetchUser on each dispatched `USER_FETCH_REQUESTED` action.
Allows concurrent fetches of user.
*/
function* mySaga() {
yield takeEvery("USER_FETCH_REQUESTED", fetchUser);
}
/*
Alternatively you may use takeLatest.
Does not allow concurrent fetches of user. If "USER_FETCH_REQUESTED" gets
dispatched while a fetch is already pending, that pending fetch is cancelled
and only the latest one will be run.
*/
function* mySaga() {
yield takeLatest("USER_FETCH_REQUESTED", fetchUser);
}
export default mySaga;
/**** 测试: ****/
const iterator = fetchUser({ payload: {userId: 123} })
// 期望一个 call 指令
assert.deepEqual(
iterator.next().value,
call(Api.fetchUser, 123),
"fetchProducts should yield an Effect call(Api.fetchUser, 123)"
)
// 创建一个假的响应对象
const user = {}
// 期望一个 dispatch 指令
assert.deepEqual(
iterator.next(user).value,
put({ type: 'USER_FETCH_SUCCEEDED', user }),
"fetchProducts should yield an Effect put({ type: 'USER_FETCH_SUCCEEDED', user })"
)
// 创建一个模拟的 error 对象
const error = {}
// 期望一个 dispatch 指令
assert.deepEqual(
iterator.throw(error).value,
put({ type: 'USER_FETCH_FAILED', error }),
"fetchProducts should yield an Effect put({ type: 'USER_FETCH_FAILED', error })"
)
main.js
import { createStore, applyMiddleware } from 'redux'
import createSagaMiddleware from 'redux-saga'
import reducer from './reducers'
import mySaga from './sagas'
// create the saga middleware
const sagaMiddleware = createSagaMiddleware()
// mount it on the Store
const store = createStore(
reducer,
applyMiddleware(sagaMiddleware)
)
// then run the saga
sagaMiddleware.run(mySaga)
// render the application
路由跳转
一般使用 react-router,与 redux 无关。如果想要使用 redux 管理 route 状态,可以使用 connect-react-router (history -> store -> router -> components)
dva 框架
dva 首先是一个基于 redux 和 redux-saga 的数据流方案,然后为了简化开发体验,dva 还额外内置了 react-router 和 fetch,所以也可以理解为一个轻量级的应用框架。
通过 reducers, effects 和 subscriptions 组织 model:
User Dashboard 的 model 配置,
import * as usersService from '../services/users';
export default {
namespace: 'users',
state: {
list: [],
total: null,
page: null,
},
reducers: {
save(state, { payload: { data: list, total, page } }) {
return { ...state, list, total, page };
},
},
effects: {
*fetch({ payload: { page = 1 } }, { call, put }) {
const { data, headers } = yield call(usersService.fetch, { page });
yield put({
type: 'save',
payload: {
data,
total: parseInt(headers['x-total-count'], 10),
page: parseInt(page, 10),
},
});
},
*remove({ payload: id }, { call, put }) {
yield call(usersService.remove, id);
yield put({ type: 'reload' });
},
*patch({ payload: { id, values } }, { call, put }) {
yield call(usersService.patch, id, values);
yield put({ type: 'reload' });
},
*create({ payload: values }, { call, put }) {
yield call(usersService.create, values);
yield put({ type: 'reload' });
},
*reload(action, { put, select }) {
const page = yield select(state => state.users.page);
yield put({ type: 'fetch', payload: { page } });
},
},
subscriptions: {
setup({ dispatch, history }) {
return history.listen(({ pathname, query }) => {
if (pathname === '/users') {
dispatch({ type: 'fetch', payload: query });
}
});
},
},
};
action 添加前缀 prefix,
function prefix(obj, namespace, type) {
return Object.keys(obj).reduce((memo, key) => {
const newKey = `${namespace}${NAMESPACE_SEP}${key}`;
memo[newKey] = obj[key];
return memo;
}, {});
}
function prefixNamespace(model) {
const {
namespace,
reducers,
effects,
} = model;
if (reducers) {
if (isArray(reducers)) {
model.reducers[0] = prefix(reducers[0], namespace, 'reducer');
} else {
model.reducers = prefix(reducers, namespace, 'reducer');
}
}
if (effects) {
model.effects = prefix(effects, namespace, 'effect');
}
return model;
}
reducer 处理,
function getReducer(reducers, state, handleActions) {
// Support reducer enhancer
// e.g. reducers: [realReducers, enhancer]
if (Array.isArray(reducers)) {
return reducers[1](
(handleActions || defaultHandleActions)(reducers[0], state)
);
} else {
return (handleActions || defaultHandleActions)(reducers || {}, state);
}
}
saga,
import * as sagaEffects from 'redux-saga/lib/effects';
import {
takeEveryHelper as takeEvery,
takeLatestHelper as takeLatest,
throttleHelper as throttle,
} from 'redux-saga/lib/internal/sagaHelpers';
import { NAMESPACE_SEP } from './constants';
function getSaga(effects, model, onError, onEffect) {
return function*() {
for (const key in effects) {
if (Object.prototype.hasOwnProperty.call(effects, key)) {
const watcher = getWatcher(key, effects[key], model, onError, onEffect);
const task = yield sagaEffects.fork(watcher);
yield sagaEffects.fork(function*() {
yield sagaEffects.cancel(task);
});
}
}
};
}
function getWatcher(resolve, reject, key, _effect, model, onError, onEffect) {
let effect = _effect;
let type = 'takeEvery';
let ms;
if (Array.isArray(_effect)) {
// effect 是数组而不是函数的情况下暂不考虑
}
function *sagaWithCatch(...args) {
try {
yield sagaEffects.put({ type: `${key}${NAMESPACE_SEP}@@start` });
const ret = yield effect(...args.concat(createEffects(model)));
yield sagaEffects.put({ type: `${key}${NAMESPACE_SEP}@@end` });
resolve(key, ret);
} catch (e) {
onError(e);
if (!e._dontReject) {
reject(key, e);
}
}
}
const sagaWithOnEffect = applyOnEffect(onEffect, sagaWithCatch, model, key);
switch (type) {
case 'watcher':
return sagaWithCatch;
case 'takeLatest':
return function*() {
yield takeLatest(key, sagaWithOnEffect);
};
case 'throttle':
return function*() {
yield throttle(ms, key, sagaWithOnEffect);
};
default:
return function*() {
yield takeEvery(key, sagaWithOnEffect);
};
}
}
function createEffects(model) {
// createEffects(model) 的逻辑
}
function applyOnEffect(fns, effect, model, key) {
for (const fn of fns) {
effect = fn(effect, sagaEffects, model, key);
}
return effect;
}
import { handleActions } from 'redux-actions';
import createSagaMiddleware from 'redux-saga/lib/internal/middleware';
const prefixedModel = models.map(m => {
return prefixNamespace({...m});
});
const reducers = {}, sagas = [];
for (const m of prefixedModel) {
reducers[m.namespace] = getReducer(
m.reducers,
m.state,
handleActions
);
if (m.effects)
sagas.push(getSaga(m.effects, m, onError, onEffect));
}
const sagaMiddleware = createSagaMiddleware();
sagas.forEach(sagaMiddleware.run)
react-coat
在掘金上看到一篇文章与DvaJS风云对话,是DvaJS挑战者?还是又一轮子?,发现了另一个 react 状态与数据流管理框架 react-coat,以下是代码示例:
// 仅需一个类,搞定 action、dispatch、reducer、effect、loading
class ModuleHandlers extends BaseModuleHandlers {
@reducer
protected putCurUser(curUser: CurUser): State {
return {...this.state, curUser};
}
@reducer
public putShowLoginPop(showLoginPop: boolean): State {
return {...this.state, showLoginPop};
}
@effect("login") // 使用自定义loading状态
public async login(payload: {username: string; password: string}) {
const loginResult = await sessionService.api.login(payload);
if (!loginResult.error) {
// this.updateState()是this.dispatch(this.actions.updateState(...))的快捷
this.updateState({curUser: loginResult.data});
Toast.success("欢迎您回来!");
} else {
Toast.fail(loginResult.error.message);
}
}
// uncatched错误会触发@@framework/ERROR,监听并发送给后台
@effect(null) // 不需要loading,设置为null
protected async ["@@framework/ERROR"](error: CustomError) {
if (error.code === "401") {
// dispatch Action:putShowLoginPop
this.dispatch(this.actions.putShowLoginPop(true));
} else if (error.code === "301" || error.code === "302") {
// dispatch Action:路由跳转
this.dispatch(this.routerActions.replace(error.detail));
} else {
Toast.fail(error.message);
await settingsService.api.reportError(error);
}
}
// 监听自已的INIT Action,做一些异步数据请求
@effect()
protected async ["app/INIT"]() {
const [projectConfig, curUser] = await Promise.all([
settingsService.api.getSettings(),
sessionService.api.getCurUser()
]);
// this.updateState()是this.dispatch(this.actions.updateState(...))的快捷
this.updateState({
projectConfig,
curUser,
});
}
}
参考资料:
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。