Redux 學習總結 (React)

在 React 的學習和開發中,若是 state (狀態)變得複雜時(例如一個狀態須要可以在多個 view 中使用和更新),使用 Redux 能夠有效地管理 state,使 state tree 結構清晰,方便狀態的更新和使用。
固然,Redux 和 React 並無什麼關係。Redux 支持 React、Angular、Ember、jQuery 甚至純 JavaScript。只是對我來講目前主要須要在 React 中使用,因此在這裏和 React 聯繫起來便於理解記憶。react

數據流

圖片描述圖片描述

Action

只是描述 state (狀態)更新的動做,即「發生了什麼」,並不更新 state。git

const ADD_TODO = 'ADD_TODO'

{
  type: ADD_TODO,
  text: 'Build my first Redux app'
}
  • type:必填,表示將要執行的動做,一般會被定義成字符串常量,尤爲是大型項目。
  • 除了 type 外的其餘字段:可選,自定義,一般可傳相關參數。例如上面例子中的 text。

Action 建立函數

簡單返回一個 Action:github

function addTodo(text) {
  return {
    type: ADD_TODO,
    text
  }
}

dispatch Action:json

dispatch(addTodo(text))
// 或者建立一個 被綁定的 action 建立函數 來自動 dispatch
const boundAddTodo = text => dispatch(addTodo(text))
boundAddTodo(text)

幫助生成 Action 建立函數的庫(對減小樣板代碼有幫助):redux

redux-actions

createAction(s)handleAction(s)combineActionsreact-native

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,api

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。session

  • 維持應用的 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 的一個回答
相關文章
相關標籤/搜索