Redux 学习总结 (React)

在 React 的学习和开发中,如果 state (状态)变得复杂时(例如一个状态需要能够在多个 view 中使用和更新),使用 Redux 可以有效地管理 state,使 state tree 结构清晰,方便状态的更新和使用。
当然,Redux 和 React 并没有什么关系。Redux 支持 React、Angular、Ember、jQuery 甚至纯 JavaScript。只是对我来说目前主要需要在 React 中使用,所以在这里和 React 联系起来便于理解记忆。

数据流

Redux 学习总结 (React)Redux 学习总结 (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() 方法(mapStateToPropsmapDispatchToProps

替代 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,
    });
  }
}

参考资料:

  1. Redux 中文文档
  2. React Native Training
  3. Redux 中文文档
  4. Stackflow 上关于 redux-saga 的一个回答

相关推荐