redux摘要2

weixin_34236497發表於2018-09-26

Action 發出以後,Reducer 立即算出 State,這叫做同步;Action 發出以後,過一段時間再執行 Reducer,這就是非同步
怎麼才能 Reducer 在非同步操作結束後自動執行呢?這就要用到新的工具:中介軟體(middleware)

中介軟體的概念

為了理解中介軟體,讓我們站在框架作者的角度思考問題:如果要新增功能,你會在哪個環節新增?

(1)Reducer:純函式,只承擔計算 State 的功能,不合適承擔其他功能,也承擔不了,因為理論上,純函式不能進行讀寫操作。

(2)View:與 State 一一對應,可以看作 State 的視覺層,也不合適承擔其他功能。

(3)Action:存放資料的物件,即訊息的載體,只能被別人操作,自己不能進行任何操作。

想來想去,只有傳送 Action 的這個步驟,即store.dispatch()方法,可以新增功能。舉例來說,要新增日誌功能,把 Action 和 State 列印出來,可以對store.dispatch進行如下改造。

let next = store.dispatch;
store.dispatch = function dispatchAndLog(action) {
  console.log('dispatching', action);
  next(action);
  console.log('next state', store.getState());
}

上面程式碼中,對store.dispatch進行了重定義,在傳送 Action 前後新增了列印功能。這就是中介軟體的雛形。

中介軟體就是一個函式,對store.dispatch方法進行了改造,在發出 Action 和執行 Reducer 這兩步之間,新增了其他功能。

中介軟體的用法

本教程不涉及如何編寫中介軟體,因為常用的中介軟體都有現成的,只要引用別人寫好的模組即可。比如,上一節的日誌中介軟體,就有現成的redux-logger模組。這裡只介紹怎麼使用中介軟體

import { applyMiddleware, createStore } from 'redux';
import createLogger from 'redux-logger';
const logger = createLogger();

const store = createStore(
  reducer,
  applyMiddleware(logger)
);

上面程式碼中,redux-logger提供一個生成器createLogger,可以生成日誌中介軟體logger。然後,將它放在applyMiddleware方法之中,傳入createStore方法,就完成了store.dispatch()的功能增強。

這裡有兩點需要注意:
(1)createStore方法可以接受整個應用的初始狀態作為引數,那樣的話,applyMiddleware就是第三個引數了。

const store = createStore(
  reducer,
  initial_state,
  applyMiddleware(logger)
);

(2)中介軟體的次序有講究。

const store = createStore(
  reducer,
  applyMiddleware(thunk, promise, logger)
);

上面程式碼中,applyMiddleware方法的三個引數,就是三個中介軟體。有的中介軟體有次序要求,使用前要查一下文件。比如,logger就一定要放在最後,否則輸出結果會不正確

applyMiddlewares()

看到這裡,你可能會問,applyMiddlewares這個方法到底是幹什麼的?

它是 Redux 的原生方法,作用是將所有中介軟體組成一個陣列,依次執行。下面是它的原始碼

export default function applyMiddleware(...middlewares) {
  return (createStore) => (reducer, preloadedState, enhancer) => {
    var store = createStore(reducer, preloadedState, enhancer);
    var dispatch = store.dispatch;
    var chain = [];

    var middlewareAPI = {
      getState: store.getState,
      dispatch: (action) => dispatch(action)
    };
    chain = middlewares.map(middleware => middleware(middlewareAPI));
    dispatch = compose(...chain)(store.dispatch);

    return {...store, dispatch}
  }
}

上面程式碼中,所有中介軟體被放進了一個陣列chain,然後巢狀執行,最後執行store.dispatch。可以看到,中介軟體內部(middlewareAPI)可以拿到getState和dispatch這兩個方法。

非同步操作的基本思路

理解了中介軟體以後,就可以處理非同步操作了。

同步操作只要發出一種 Action 即可,非同步操作的差別是它要發出三種 Action。

操作發起時的 Action
操作成功時的 Action
操作失敗時的 Action

以向伺服器取出資料為例,三種 Action 可以有兩種不同的寫法。


// 寫法一:名稱相同,引數不同
{ type: 'FETCH_POSTS' }
{ type: 'FETCH_POSTS', status: 'error', error: 'Oops' }
{ type: 'FETCH_POSTS', status: 'success', response: { ... } }

// 寫法二:名稱不同
{ type: 'FETCH_POSTS_REQUEST' }
{ type: 'FETCH_POSTS_FAILURE', error: 'Oops' }
{ type: 'FETCH_POSTS_SUCCESS', response: { ... } }

除了 Action 種類不同,非同步操作的 State 也要進行改造,反映不同的操作狀態。下面是 State 的一個例子

let state = {
  // ... 
  isFetching: true,
  didInvalidate: true,
  lastUpdated: 'xxxxxxx'
};

上面程式碼中,State 的屬性isFetching表示是否在抓取資料。didInvalidate表示資料是否過時,lastUpdated表示上一次更新時間。

現在,整個非同步操作的思路就很清楚了。

操作開始時,送出一個 Action,觸發 State 更新為"正在操作"狀態,View 重新渲染
操作結束後,再送出一個 Action,觸發 State 更新為"操作結束"狀態,View 再一次重新渲染

redux-thunk 中介軟體

非同步操作至少要送出兩個 Action:使用者觸發第一個 Action,這個跟同步操作一樣,沒有問題;如何才能在操作結束時,系統自動送出第二個 Action 呢?

奧妙就在 Action Creator 之中。

class AsyncApp extends Component {
  componentDidMount() {
    const { dispatch, selectedPost } = this.props
    dispatch(fetchPosts(selectedPost))
  }

上面程式碼是一個非同步元件的例子。載入成功後(componentDidMount方法),它送出了(dispatch方法)一個 Action,向伺服器要求資料 fetchPosts(selectedSubreddit)。這裡的fetchPosts就是 Action Creator。

下面就是fetchPosts的程式碼,關鍵之處就在裡面。


1935872-f40636eb2f459278.png
const fetchPosts = postTitle => (dispatch, getState) => {
  dispatch(requestPosts(postTitle));
  return fetch(`/some/API/${postTitle}.json`)
    .then(response => response.json())
    .then(json => dispatch(receivePosts(postTitle, json)));
  };
};

// 使用方法一
store.dispatch(fetchPosts('reactjs'));
// 使用方法二
store.dispatch(fetchPosts('reactjs')).then(() =>
  console.log(store.getState())
);

上面程式碼中,fetchPosts是一個Action Creator(動作生成器),返回一個函式。這個函式執行後,先發出一個Action(requestPosts(postTitle)),然後進行非同步操作。拿到結果後,先將結果轉成 JSON 格式,然後再發出一個 Action( receivePosts(postTitle, json))。

上面程式碼中,有幾個地方需要注意。

(1)fetchPosts返回了一個函式,而普通的 Action Creator 預設返回一個物件。

(2)返回的函式的引數是dispatch和getState這兩個 Redux 方法,普通的 Action Creator 的引數是 Action 的內容。

(3)在返回的函式之中,先發出一個 Action(requestPosts(postTitle)),表示操作開始。

(4)非同步操作結束之後,再發出一個 Action(receivePosts(postTitle, json)),表示操作結束。

這樣的處理,就解決了自動傳送第二個 Action 的問題。但是,又帶來了一個新的問題,Action 是由store.dispatch方法傳送的。而store.dispatch方法正常情況下,引數只能是物件,不能是函式。

這時,就要使用中介軟體redux-thunk

import { createStore, applyMiddleware } from 'redux';
import thunk from 'redux-thunk';
import reducer from './reducers';

// Note: this API requires redux@>=3.1.0
const store = createStore(
  reducer,
  applyMiddleware(thunk)
);

上面程式碼使用redux-thunk中介軟體,改造store.dispatch,使得後者可以接受函式作為引數。

因此,非同步操作的第一種解決方案就是,寫出一個返回函式的 Action Creator,然後使用redux-thunk中介軟體改造store.dispatch。

redux-promise 中介軟體

既然 Action Creator 可以返回函式,當然也可以返回其他值。另一種非同步操作的解決方案,就是讓 Action Creator 返回一個 Promise 物件。

這就需要使用redux-promise中介軟體

import { createStore, applyMiddleware } from 'redux';
import promiseMiddleware from 'redux-promise';
import reducer from './reducers';

const store = createStore(
  reducer,
  applyMiddleware(promiseMiddleware)
); 

這個中介軟體使得store.dispatch方法可以接受 Promise 物件作為引數。這時,Action Creator 有兩種寫法。寫法一,返回值是一個 Promise 物件。

const fetchPosts = 
  (dispatch, postTitle) => new Promise(function (resolve, reject) {
     dispatch(requestPosts(postTitle));
     return fetch(`/some/API/${postTitle}.json`)
       .then(response => {
         type: 'FETCH_POSTS',
         payload: response.json()
       });
});

寫法二,Action 物件的payload屬性是一個 Promise 物件。這需要從redux-actions模組引入createAction方法,並且寫法也要變成下面這樣。

import { createAction } from 'redux-actions';
class AsyncApp extends Component {
  componentDidMount() {
    const { dispatch, selectedPost } = this.props
    // 發出同步 Action
    dispatch(requestPosts(selectedPost));
    // 發出非同步 Action
    dispatch(createAction(
      'FETCH_POSTS', 
      fetch(`/some/API/${postTitle}.json`)
        .then(response => response.json())
    ));
  }

上面程式碼中,第二個dispatch方法發出的是非同步 Action,只有等到操作結束,這個 Action 才會實際發出。注意,createAction的第二個引數必須是一個 Promise 物件。

React-Redux 的用法

為了方便使用,Redux 的作者封裝了一個 React 專用的庫 React-Redux,本文主要介紹它。

這個庫是可以選用的。實際專案中,你應該權衡一下,是直接使用 Redux,還是使用 React-Redux。後者雖然提供了便利,但是需要掌握額外的 API,並且要遵守它的元件拆分規範。

UI 元件

React-Redux 將所有元件分成兩大類:UI 元件(presentational component)和容器元件(container component)
UI 元件有以下幾個特徵。

只負責 UI 的呈現,不帶有任何業務邏輯
沒有狀態(即不使用this.state這個變數)
所有資料都由引數(this.props)提供
不使用任何 Redux 的 API

下面就是一個 UI 元件的例子。

const Title =
  value => <h1>{value}</h1>;

因為不含有狀態,UI 元件又稱為"純元件",即它純函式一樣,純粹由引數決定它的值。

二、容器元件

容器元件的特徵恰恰相反。

負責管理資料和業務邏輯,不負責 UI 的呈現
帶有內部狀態
使用 Redux 的 API

總之,只要記住一句話就可以了:UI 元件負責 UI 的呈現,容器元件負責管理資料和邏輯。

你可能會問,如果一個元件既有 UI 又有業務邏輯,那怎麼辦?回答是,將它拆分成下面的結構:外面是一個容器元件,裡面包了一個UI 元件。前者負責與外部的通訊,將資料傳給後者,由後者渲染出檢視。

React-Redux 規定,所有的 UI 元件都由使用者提供,容器元件則是由 React-Redux 自動生成。也就是說,使用者負責視覺層,狀態管理則是全部交給它。

connect()

React-Redux 提供connect方法,用於從 UI 元件生成容器元件。connect的意思,就是將這兩種元件連起來。

import { connect } from 'react-redux'
const VisibleTodoList = connect()(TodoList);

上面程式碼中,TodoList是 UI 元件,VisibleTodoList就是由 React-Redux 通過connect方法自動生成的容器元件。

但是,因為沒有定義業務邏輯,上面這個容器元件毫無意義,只是 UI 元件的一個單純的包裝層。為了定義業務邏輯,需要給出下面兩方面的資訊。

(1)輸入邏輯:外部的資料(即state物件)如何轉換為 UI 元件的引數

(2)輸出邏輯:使用者發出的動作如何變為 Action 物件,從 UI 元件傳出去。

因此,connect方法的完整 API 如下。

import { connect } from 'react-redux'

const VisibleTodoList = connect(
  mapStateToProps,
  mapDispatchToProps
)(TodoList)

上面程式碼中,connect方法接受兩個引數:mapStateToProps和mapDispatchToProps。它們定義了 UI 元件的業務邏輯。前者負責輸入邏輯,即將state對映到 UI 元件的引數(props),後者負責輸出邏輯,即將使用者對 UI 元件的操作對映成 Action。

mapStateToProps()

mapStateToProps是一個函式。它的作用就是像它的名字那樣,建立一個從(外部的)state物件到(UI 元件的)props物件的對映關係。

作為函式,mapStateToProps執行後應該返回一個物件,裡面的每一個鍵值對就是一個對映。請看下面的例子。
const mapStateToProps = (state) => {
return {
todos: getVisibleTodos(state.todos, state.visibilityFilter)
}
}
上面程式碼中,mapStateToProps是一個函式,它接受state作為引數,返回一個物件。這個物件有一個todos屬性,代表 UI 元件的同名引數,後面的getVisibleTodos也是一個函式,可以從state算出 todos 的值。

下面就是getVisibleTodos的一個例子,用來算出todos。


const getVisibleTodos = (todos, filter) => {
  switch (filter) {
    case 'SHOW_ALL':
      return todos
    case 'SHOW_COMPLETED':
      return todos.filter(t => t.completed)
    case 'SHOW_ACTIVE':
      return todos.filter(t => !t.completed)
    default:
      throw new Error('Unknown filter: ' + filter)
  }
}

mapStateToProps會訂閱 Store,每當state更新的時候,就會自動執行,重新計算 UI 元件的引數,從而觸發 UI 元件的重新渲染。

mapStateToProps的第一個引數總是state物件,還可以使用第二個引數,代表容器元件的props物件。

// 容器元件的程式碼
//    <FilterLink filter="SHOW_ALL">
//      All
//    </FilterLink>

const mapStateToProps = (state, ownProps) => {
  return {
    active: ownProps.filter === state.visibilityFilter
  }
}

使用ownProps作為引數後,如果容器元件的引數發生變化,也會引發 UI 元件重新渲染。

connect方法可以省略mapStateToProps引數,那樣的話,UI 元件就不會訂閱Store,就是說 Store 的更新不會引起 UI 元件的更新。

mapDispatchToProps()

mapDispatchToProps是connect函式的第二個引數,用來建立 UI 元件的引數到store.dispatch方法的對映。也就是說,它定義了哪些使用者的操作應該當作 Action,傳給 Store。它可以是一個函式,也可以是一個物件。

如果mapDispatchToProps是一個函式,會得到dispatch和ownProps(容器元件的props物件)兩個引數。

const mapDispatchToProps = (
  dispatch,
  ownProps
) => {
  return {
    onClick: () => {
      dispatch({
        type: 'SET_VISIBILITY_FILTER',
        filter: ownProps.filter
      });
    }
  };
}

從上面程式碼可以看到,mapDispatchToProps作為函式,應該返回一個物件,該物件的每個鍵值對都是一個對映,定義了 UI 元件的引數怎樣發出 Action。

如果mapDispatchToProps是一個物件,它的每個鍵名也是對應 UI 元件的同名引數,鍵值應該是一個函式,會被當作 Action creator ,返回的 Action 會由 Redux 自動發出。舉例來說,上面的mapDispatchToProps寫成物件就是下面這樣。

const mapDispatchToProps = {
  onClick: (filter) => {
    type: 'SET_VISIBILITY_FILTER',
    filter: filter
  };
}

<Provider> 元件

connect方法生成容器元件以後,需要讓容器元件拿到state物件,才能生成 UI 元件的引數。

一種解決方法是將state物件作為引數,傳入容器元件。但是,這樣做比較麻煩,尤其是容器元件可能在很深的層級,一級級將state傳下去就很麻煩。

React-Redux 提供Provider元件,可以讓容器元件拿到state。

import { Provider } from 'react-redux'
import { createStore } from 'redux'
import todoApp from './reducers'
import App from './components/App'

let store = createStore(todoApp);

render(
  <Provider store={store}>
    <App />
  </Provider>,
  document.getElementById('root')
)

上面程式碼中,Provider在根元件外面包了一層,這樣一來,App的所有子元件就預設都可以拿到state了。

它的原理是React元件的context屬性,請看原始碼。

例項:計數器

我們來看一個例項。下面是一個計數器元件,它是一個純的 UI 元件。

class Counter extends Component {
  render() {
    const { value, onIncreaseClick } = this.props
    return (
      <div>
        <span>{value}</span>
        <button onClick={onIncreaseClick}>Increase</button>
      </div>
    )
  }
}

上面程式碼中,這個 UI 元件有兩個引數:value和onIncreaseClick。前者需要從state計算得到,後者需要向外發出 Action。

接著,定義value到state的對映,以及onIncreaseClick到dispatch的對映。

function mapStateToProps(state) {
  return {
    value: state.count
  }
}

function mapDispatchToProps(dispatch) {
  return {
    onIncreaseClick: () => dispatch(increaseAction)
  }
}

// Action Creator
const increaseAction = { type: 'increase' }

然後,使用connect方法生成容器元件。

const App = connect(
  mapStateToProps,
  mapDispatchToProps
)(Counter)

然後,定義這個元件的 Reducer。

// Reducer
function counter(state = { count: 0 }, action) {
  const count = state.count
  switch (action.type) {
    case 'increase':
      return { count: count + 1 }
    default:
      return state
  }
}

最後,生成store物件,並使用Provider在根元件外面包一層。

import React, { Component } from 'react'
import PropTypes from 'prop-types'
import ReactDOM from 'react-dom'
import { createStore } from 'redux'
import { Provider, connect } from 'react-redux'

// React component
class Counter extends Component {
  render() {
    const { value, onIncreaseClick } = this.props
    return (
      <div>
        <span>{value}</span>
        <button onClick={onIncreaseClick}>Increase</button>
      </div>
    )
  }
}

Counter.propTypes = {
  value: PropTypes.number.isRequired,
  onIncreaseClick: PropTypes.func.isRequired
}

// Action
const increaseAction = { type: 'increase' }

// Reducer
function counter(state = { count: 0 }, action) {
  const count = state.count
  switch (action.type) {
    case 'increase':
      return { count: count + 1 }
    default:
      return state
  }
}

// Store
const store = createStore(counter)

// Map Redux state to component props
function mapStateToProps(state) {
  return {
    value: state.count
  }
}

// Map Redux actions to component props
function mapDispatchToProps(dispatch) {
  return {
    onIncreaseClick: () => dispatch(increaseAction)
  }
}

// Connected Component
const App = connect(
  mapStateToProps,
  mapDispatchToProps
)(Counter)

ReactDOM.render(
  <Provider store={store}>
    <App />
  </Provider>,
  document.getElementById('root')
)

React-Router 路由庫

使用React-Router的專案,與其他專案沒有不同之處,也是使用Provider在Router外面包一層,畢竟Provider的唯一功能就是傳入store物件。

const Root = ({ store }) => (
  <Provider store={store}>
    <Router>
      <Route path="/" component={App} />
    </Router>
  </Provider>
);

相關文章