Redux 学习总结 (React)
在 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, }); } }
参考资料: