React中使用Redux, React-Redux, Redux Thunk(Middleware)處理Request基礎實作 + 觀念釐清

之前學了udemy這堂Stephen Grider的react初階課程,最近由於有更新內容,於是回鍋複習了一下並稍微紀錄一點之前不甚熟悉且抽象的基礎觀念,希望能清楚表達。

安裝Package

基本要安裝項目的如下:

$ npm i --save redux react-redux axios redux-thunk

react-redux提供了Provider跟connect這兩個函數(component),設定元件的state與props,並跟redux中的createStore函數配合,按照使用者的行為改變存在store中的state。

axios是用來處理request api的函式庫,react-thunk作為Middleware則是處理redux中action函數裡request非同步的問題。

以圖簡介Redux Cycle

第一步一定要先了解整個redux的處理週期:

首先是會有一個actionCreator,負責創造action物件,這個action物件一定要有一個type屬性,好讓後續的函數能透過這個type來分辨應該如何處理,剩下空間的就是拿來放state。

帶著state的action物件透過dispatch函數分配給不同的Reducer函數處理,回傳應該回傳的東西,最後再更新state。

但redux也要能和react溝通,所以才要安裝react-redux,並利用其中的 Providerconnect 這兩個函數作為redux跟react之間溝通state的方法。

從下圖便能清楚看出整個專案更新狀態的過程:

以呈現一個SongList為例
為何要用Redux?有比較好?

首先要對"沒有"redux的處理有基礎理解。

以往沒有Redux的情況下,會有network request一定是使用者要得到一些存在後端的資訊,比如說某時刻的天氣狀況、某個關鍵字搜尋的影片或圖片等等。這些"資訊"會透過父元件設定state作為props傳給子元件,把資料的流動控制成"由上而下",子元件只註冊用戶的事件(onClick、onSubmit等等),透過這個事件函數去執行父元件props中的函數,而這個父元件裡的函數就有一個callback function去做network request,結果回傳後更新父元件中的state,並同樣作為props傳給子元件,並觸發畫面的重繪。

這樣做並沒有錯,但使用react的初衷是以CDD(component driven develop)的方式開發,盡量做到:

  1. 減少不必要的DOM操作,以避免瀏覽器不停重繪浪費效能。
  2. 使元件能重複使用。

從這兩個結果來檢視,透過元件與JSX的virtual DOM已經能做到讓畫面只重繪改變的部分,但像剛剛的寫法,A專案中a元件如果是負責搜尋(searchBar),當中一定寫入了各種註冊事件後要執行由props帶來的函數,這樣在元件的重複使用上並沒有達到太好的效果。(也就是父元件與子元件的耦合在一起)

所以使用redux的關鍵思維是:

能不能把元件們再拆成:"只"負責畫面顯示&"只"負責資料處理這兩種呢?

這樣之後畫面顯示部分的可以重複使用,處理資料的每個專案都不一樣就再重寫即可。

剛剛提到的概念,就出現了以下兩種元件名稱: Presentational Components and Container Components。

前者就是一般的做法,但在引入redux之後,它實現了這個 Container Components的角色功能:負責處理資料

這是個非常重要的觀念,可參考以下這些文章,解釋的非常清楚。

來自redux跟reacte-react-app的共同作者之一的文章。

上面那位作者推薦的文章

追蹤作者

追蹤好部落格

有了Redux,怎麼用?Network Request擺哪?

這裡就不提引入redux之後專案的資料夾引入以及順序,如果到這裡都看懂肯定也對redux有粗淺的認識以及使用。

這裡引入一張課程中專案的流程圖,看起來很繁雜但其實很簡單:

Stephen Grider很用心地把這流程圖分成三部分,如果已經熟悉元件生命週期以及繪製順序的基礎,可以很快地看出,原來引入redux後是透過 ActionCreator函數來做network request,並把回傳的東西擺進action物件中,透過 dispatch 函數把action物件傳給 reducer 函數處理,最後更新state。

好了,那就把api的部分寫入actionCreator裡並透過action物件回傳。

錯誤訊息警告!

在課程中,要呼叫 jsonplaceholder中的假資料,這樣寫:

import jsonPlacehoder from '../apis/jsonPlacehoder.js';
// this is an actionCreator function
export const fetchPosts = async () => {
const res = await jsonPlaceholder.get('posts');
return {
type: 'FETCH_POSTS',
payload: res
};
};

當然如果寫promise來處理也可(事實上async / await 當中也是回傳promise物件):

import jsonPlacehoder from '../apis/jsonPlacehoder.js';
// this is an actionCreator function
export const fetchPosts = () => {
jsonPlaceholder.get('posts')
.then(res =>(
{ type: 'FETCH_POSTS',
payload: res
}
)
)

.catch(err => {
console.log(err);...
}
)

};

這個示範專案是要顯示假資料中的id以及留言內容,畫出一個小小留言版。因此新增元件PostList.js,並引入剛剛寫的 actionCreator 函數:

import React from 'react';
import { connect } from 'react-redux';
import { fetchPosts } from '../actions';
class PostList extends React.Component{
componentDidMount(){
this.props.fetchPosts();
};
  render(){
return (
<div className="">PostList</div>
);
}
};
export default connect(null, {fetchPosts})(PostList);

結果竟然出現錯誤:

從訊息上來看,action物件只能是一個plain object(不知道這個詞的意思請看連結文章),所以要使用一個叫 Middleware的東西來處理async function。不過為何會出現這種現象呢?

原因解釋

首先示範一個做法,到action檔案改寫:

export const fetchPosts = () => {
const promise = jsonPlaceholder.get('posts');
return {
type: 'FETCH_POSTS',
payload: promise
};

錯誤訊息消失了?但事實上action物件並沒有收到API之後的data,怎麼看出呢?

如果在 PostList.js的componentDidMount()中加上一個 console.log

import React from 'react';
import { connect } from 'react-redux';
import { fetchPosts } from '../actions';
class PostList extends React.Component{
componentDidMount(){
console.log(fetchPosts());
this.props.fetchPosts();
};
  render(){
return (
<div className="">PostList</div>
);
}
};
export default connect(null, {fetchPosts})(PostList);

可以看出來action物件根本沒有接收到,Promise物件的狀態是pending,表示等待中。

但很奇怪,如果打開dev tool中的network,卻會看到回傳成功的訊息:

這兩種情型究竟是怎麼回事呢?

先說明plain object的問題,如果將 actionCreator 的code複製到 Babel來轉譯,會看到以下的畫面:

可以去 Babel 的try out來試試。

為何要轉譯呢?因為瀏覽器尚未支援這個ES7語法,因此事實上瀏覽器執行的是轉譯過的code,從這段code中看到:

return regeneratorRuntime.wrap(function _callee$(_context) {
while (1) {
switch (_context.prev = _context.next) {
case 0:
_context.next = 2;
return jsonPlaceholder.get('posts');
                    case 2:
res = _context.sent;
return _context.abrupt('return', {
type: 'FETCH_POSTS',
payload: res
});
....
..
.

表示其實在一開始執行 actionCreator 後的結果,傳給react-redux中的dispatch函數並不是預想回傳的物件{type: “FETCH_POST”, …},而是這個 jsonPlacehoder.get('posts') request object。因此 store 在接收到這個 request object之後,判斷說:不是 plain object,所以就跳出這個錯誤訊息。

流程圖

第二個問題,為何把 actionCreator 函數改寫後:

export const fetchPosts = () => {
const promise = jsonPlaceholder.get('posts');
return {
type: 'FETCH_POSTS',
payload: promise
};

錯誤訊息消失,action object卻仍然沒有在第一時間接收到data呢?

這就牽涉到一個執行時間上的問題:

無論如何,當要JS執行network request,一定會產生非同步的問題,因為當JS引擎發出http request,即使在很短的時間內傳回來,回傳的東西也會存在event queue,等到所有在執行堆(execution stack)的函數執行完畢,才會去event queue處理。(詳情可參考這篇文章)

另一個角度,下圖顯示出整個redux的執行流程:

actionCreator 創造出 action 、傳給dispatch函數再到Reducer函數,這個過程非常快速,尤其此時相對的是非同步的http request,因此會造成以下圖的結果:

即便request再怎麼快,也會放在JS引擎中的event loop,redux流程跑完後才去處理回傳的東西,因此無論如何redux在第一時間都無法正確得到該有的data。

那該怎麼辦呢?

Middleware in Redux

邏輯很簡單,「既然這個 actionCreator 立刻就執行,那能不能創造一個也是"非同步"的 actionCreator 呢?」

之前撰寫的都是上圖中左邊的 Synchronous action creator,現在要撰寫非同步的,就要用到一直有提到的 "Middleware"。

ok,那什麼是"Middleware"呢?先來看看這張圖:

跟之前提到的不同,在dipatch後多了一個Middleware,而不是直接傳給Reducer。定義如下圖:

簡單來說也是一個函數,一個在每個action經過dispatch之後都會被呼叫的函數,可以阻止action繼續傳遞下去。

Redux-Thunk

當然,理論上這個middleware也可以自己寫出來,不過如果有別人已經寫好、以更簡潔有效的方法會更好。這裡就要使用redux-thunk這個package。

原本的 actionCreator 會是這樣:

但加入了redux-thunk,就會變成:

這就是安裝redux-thunk之後可以做到的事情:讓 actionCreator 可以回傳action物件,也可以回傳一個函數

整個處理流程如下:

當把action物件傳入dispatch函數後,這個action物件也被送入了redux-thunk這個middleware,此時就會進行判斷:

是函數嗎?如果不是函數而是action物件(plain object),那就分配給reducers函數。

如果回傳的是函數,那就會立即執行這個函數,並將dispatch跟getState這兩個函數都當作參數傳入函數中,這個函數會先處理其他事情(這裡是http request),等待非同步(http request)執行結束,接著再利用dispatch函數"手動"將action物件回傳、再次透過redux-thunk來檢查。

當然這個函數既然傳入了dispatch跟getState,也就可以透過這兩者來改變、新增或使用所有存在reduxStore中的data。

最重要的部分就是在http request結束後,必須手動再次將action物件重新dispatch,這也就是為何能掌握讓action物件不會立即傳到reducers的部分。

事實上確實不複雜,尤其是可以去看redux-thunk的source code,連結

只有短短的十多行code,看了便能了解其中的用途。

改code

找到index.js,改寫:

import React from 'react';
import ReactDOM from 'react-dom';
import { Provider } from 'react-redux';
import { createStore, applyMiddleware } from 'redux';
import thunk from 'redux-thunk';
import App from './components/App';
import reducers from './reducers';
const store = createStore(reducers, applyMiddleware(thunk));
ReactDOM.render(
  <Provider store={store}>
<App />
</Provider>,
document.getElementById('root')
);

粗體就是新增的部分,把安裝好的thunk引入,並透過傳入applyMiddleware(thunk)函數作為createStore的參數,成功把redux-thunk引入專案。

回到action檔案,改寫為:

import jsonPlaceholder from '../apis/jsonPlacehoder.js';
export const fetchPosts = () => async (dispatch, getState) => {
const res = await jsonPlaceholder.get('/posts');

dispatch({ type: "FETCH_POSTS", payload: res });
};

透過es6寫法簡化過了,其實就是寫一個函數,這個函數回傳了一個新的函數,這個新函數接受dispatch跟getState兩個函數作為參數,並且可利用這個新函數做http request,由於action物件被控制著,所以不用擔心過早被傳到reducers。

或是用Promise寫法也可:

import jsonPlaceholder from '../apis/jsonPlacehoder.js';
export const fetchPosts = () => (dispatch, getState) => {
jsonPlaceholder.get('/posts')
.then(res => {
dispatch({ type: "FETCH_POSTS", payload: res });
})
.catch(err => {
console.log(err);
})
};

以上。下一篇會記錄學習reducer的部分。