1. 程式人生 > >大型前端應用中,Redux與伺服器非同步通訊過程全解析(文末有彩蛋!)

大型前端應用中,Redux與伺服器非同步通訊過程全解析(文末有彩蛋!)

本文節選自程墨撰寫的《深入淺出React和Redux》一書,由機械工業出版社。
作者:程墨,資深架構師,曾任職於摩托羅拉、雅虎和微軟,雲鳥配送平臺聯合創始人,目前服務於美國視訊服務公司Hulu。知乎專欄《進擊的React》作者,《深入淺出React和Redux》一書作者。
程墨,也是“前端開發創新實踐”線上峰會的特邀講師。他將帶大家全面剖析Redux,及其在管理大型前端應用資料上的最佳實踐。該峰會採用線上直播形式,將於7月8日召開,票價5折熱銷中。
大家注意啦,文末有彩蛋噢!!

無論Redux還是React,工作方式都是靠資料驅動,到目前為止,本文例子中的資料都是通過使用者輸入產生,但現實中,應用的資料往往儲存在資料庫中,通過一個API伺服器暴露出來,網頁應用要獲得資料,就需要與伺服器進行通訊。

本文中,我們會介紹:

  • React元件訪問伺服器的方式;
  • Redux架構下訪問伺服器的方式。
  • React元件訪問伺服器的方式適用於簡單的網頁應用;對於複雜的網頁應用,自然會採用Redux來管理資料,所以在Redux環境中訪問伺服器會是我們介紹的重點。

React元件訪問伺服器

我們先考慮最直接最簡單的場景,在一個極其簡單的網頁應用中,有可能只需要單獨使用React庫,而不使用Redux之類的資料管理框架,這時候React元件自身也可以擔當起和伺服器通訊的責任。

訪問伺服器本身可以使用任何一種支援網路訪問的JavaScript庫,最傳統的當然是jQuery的$.ajax函式,但是我們都用上了React了,那也就沒有必要使用jQuery,實在沒有理由再為了一個$.ajax

函式引入一個jQuery庫到網頁裡面來。

一個趨勢是在React應用中使用瀏覽器原生支援的fetch函式來訪問網路資源,fetch函式返回的結果是一個Promise物件,Promise模式能夠讓需要非同步處理的程式碼簡潔清晰,這也是fetch函式讓大家廣為接受的原因。

對於不支援fetch的瀏覽器版本,也可以通過fetch的polyfill來增加對fetch的支援。接下來的例子都用fetch來訪問伺服器資料資源。

提示:polyfill指的是“用於實現瀏覽器不支援原生功能的程式碼”,比如,現代瀏覽器應該支援fetch函式,對於不支援的瀏覽器,網頁中引入對應fetch的polyfill後,這個polyfill就給全域性的window物件上增加一個fetch函式,讓這個網頁中的JavaScript可以直接使用fetch函數了,就好像瀏覽器本來就支援fetch一樣。在這個連結上

https://github.com/github/fetch 可以找到fetch polyfill的一個實現。

我們用一個具體例項來說明如何讓React訪問伺服器。

如果要做成一個完整的網站應用,那麼應該把待辦事項資料儲存在伺服器端,這樣待辦事項能夠持久化儲存下來,而且從任何一個瀏覽器訪問都能夠看到待辦事項,當然伺服器端如何儲存不是本書要討論的範圍,我們的重點是開發一個新的應用來展示React訪問伺服器的方式。

近年來,天氣問題成了大家都關注的問題,我們做一個能夠展示某個城市天氣的React元件,這樣很有實際意義。

我們不想浪費篇幅去介紹如何構建一個伺服器端API,而是利用網際網路上現成的API來支援我們的應用。 中國天氣網提供了RESTful API用於訪問某個城市的天氣資訊,在例子中我們會利用這個API來獲得天氣資料。

代理功能訪問API

我們利用create-react-app建立一個新的應用,名叫weather_react,讀者可以在本書對應Github程式碼庫目錄下找到完整程式碼。

首先我們要確定訪問什麼樣的API能夠獲得天氣資訊,中國天氣網提供的RESTful API中有訪問一個城市當前天氣情況的API,規格如表1所示。

圖片描述

表1 中國天氣網獲取城市天氣API規格

{
  "weatherinfo":{
    "city":"北京",
    "cityid":"101010100",
    "temp1":"-2℃",
    "temp2":"16℃",
    "weather":"晴",
    "img1":"n0.gif",
    "img2":"d0.gif",
    "ptime":"18:00"
  }
}

但是,我們的網頁應用中不能夠直接訪問中國天氣網的這個API,因為從我們本地的網頁訪問 weather.com.cn域名下的網路資源屬於跨域訪問,而中國天氣網的API並不支援跨域訪問,所以在我們的應用中如果直接像下面那樣使用fetch訪問這個API資源,肯定無法獲得我們預期的JSON格式結果:

fetch(http://www.weather.com.cn/data/cityinfo/101010100.html)

解決跨域訪問API的一個方式就是通過代理(Proxy),讓我們的網頁應用訪問所屬域名下的一個伺服器API介面,這個伺服器介面做的工作就是把這個請求轉發給另一個域名下的API,拿到結果之後再轉交給發起請求的瀏覽器網頁應用,只是一個“代理”工作。

因為對於跨域訪問API的限制是針對瀏覽器的行為,伺服器對任何域名下的API的訪問不受限制,所以這樣的代理工作可以成功實現對跨域資源的訪問。

在本地開發的時候,網頁應用的域名是localhost,對應的伺服器域名也是localhost,所以要在localhost伺服器上建立一個代理。好在create-react-app創造的應用已經具備了代理功能,所以並不用花費時間來開發一個代理服務。

在weather_react應用的根目錄package.json中新增如下一行:

"proxy": "http://www.weather.com.cn/",

這一行配置告訴weather_react應用,當接收到不是要求本地資源的HTTP請求時,這個HTTP請求的協議和域名部分替換為http://www.weather.com.cn/ 轉手發出去,並將收到的結果返還給瀏覽器,這樣就實現了代理功能。

至此,我們就準備好了一個API。

提示:create-react-app生成應用的proxy功能只是方便開發,在實際的生產環境中,使用這個proxy功能就不合適了,應該要開發出自己的代理伺服器來滿足生產環境的需要。

React元件訪問伺服器的生命週期

在weather_react應用中,我們創造一個名為Weather的元件,這個元件可以在本書的Github程式碼庫目錄下找到。

這個Weather元件將要顯示指定城市的當前天氣情況,這個元件封裝了兩個功能:

  • 通過伺服器API獲得天氣情況資料;
  • 展示天氣情況資料。

現在面臨的首要問題是如何關聯非同步的網路請求和同步的React元件渲染。

訪問伺服器API是一個非同步操作。因為JavaScript是單執行緒的語言,不可能讓唯一的執行緒一直等待網路請求的結果,所以所有對伺服器的資料請求必定是非同步請求。

但是,React元件的渲染又是同步的,當開始渲染過程之後,不可能讓Weather元件一邊渲染一邊等待伺服器的返回結果。

總之,當Weather元件進入裝載過程的時候,即使此時Weather立刻通過fetch函式發起對伺服器的請求,也沒法等待伺服器的返回資料來進行渲染。因為React元件的裝載過程和更新過程中生命週期函式是同步執行的,沒有任何機會等待一個非同步操作。

所以,可行的方法只能是這樣,分兩個步驟完成:

步驟1,在裝載過程中,因為Weather元件並沒有獲得伺服器結果,就不顯示結果。或者顯示一個“正在裝載”之類的提示資訊,但Weather元件這時候要發出對伺服器的請求。

步驟2,當對伺服器的請求終於獲得結果的時候,要引發Weather元件的一次更新過程,讓Weather重新繪製自己的內容,這時候就可以根據API返回結果繪製天氣資訊了。

從上面的過程可以看得出來,為了顯示天氣資訊,必須要經歷裝載過程和更新過程,至少要渲染Weather元件兩次。

還有一個關鍵問題,在裝載過程中,在什麼時機發出對伺服器的請求呢?

通常我們在元件的componentDidMount函式中做請求伺服器的事情,因為當生命週期函式componentDidMount被呼叫的時候,表明裝載過程已經完成,元件需要渲染的內容已經在DOM樹上出現,對伺服器的請求可能依賴於已經渲染的內容,在component-DidMount函式中傳送對伺服器請求是一個合適的時機。

另外,componentDidMount函式只在瀏覽器中執行。當React元件在伺服器端渲染時,肯定不希望它發出無意義的請求,所以componentDidMount是最佳的獲取初始化元件內容請求的時機。

萬事俱備,我們來看一看定義Weather元件的weather.js檔案內容,首先是建構函式,程式碼如下:

constructor() {
  super(...arguments);
  this.state = {weather: null};
}

因為Weather元件要自我驅動更新過程,所以Weather必定是一個有狀態的元件,狀態中包含天氣情況資訊,在狀態上有一個weather欄位,這個欄位的值是一個物件,格式和伺服器API返回的JSON資料中的weatherinfo欄位一致。

在render函式中,所要做的是渲染this.state上的內容,程式碼如下:

render() {
  if (!this.state.weather) {
    return <div>暫無資料</div>;
  }

  const {city, weather, temp1, temp2} = this.state.weather;
  return (
    <div>
      {city} {weather} 最低氣溫 {temp1} 最高氣溫 {temp2}
    </div>
  )
}

在建構函式中,我們將元件狀態上的weather欄位初始化為null。這樣,在裝載過程引發的第一次render函式呼叫時,就會直接渲染一個“暫無資料”的文字;但是當state上包含weather資訊時,就可以渲染出實際的天氣資訊。

通過API獲得資料的工作交給componentDidMount,程式碼如下:

componentDidMount() {
  const apiUrl = `/data/cityinfo/${cityCode}.html`;
  fetch(apiUrl).then((response) => {
    if (response.status !== 200) {
      throw new Error('Fail to get response with status ' + response.status);
    }
    response.json().then((responseJson) => {
      this.setState({weather: responseJson.weatherinfo});
    }).catch((error) => {
      this.setState({weather: null});
    });
  }).catch((error) => {
      this.setState({weather: null});
  });
}

fetch函式執行會立刻返回,返回一個Promise型別的物件,所以後面會跟上一大串then和catch的語句。每個Promise成功的時候,對應的then中的回撥函式會被呼叫;如果失敗,對應catch中的回撥函式也被呼叫。

值得注意的是,fetch的引數apiUrl中只有URL的路徑部分,沒有協議和域名部分,程式碼如下:

const apiUrl = `/data/cityinfo/${cityCode}.html`;

遺憾的是,中國天氣網沒有提供根據訪問者IP對映到城市的功能。在這個例子中我們硬編碼了北京市的城市程式碼,讀者在實際操作的時候,也可以把weather.js中的模組級變數cityCode改為你所在的城市,在 http://www.weather.com.cn/ 頁面上搜索城市名,網頁跳轉後URL上的數字就是城市對應的城市程式碼。

componentDidMount中這段程式碼看起來相當繁雜。不過沒有辦法,輸入輸出操作就是這樣,因為fetch的過程是和另一個計算機實體通訊,而且通訊的介質也是一個無法保證絕對可靠的網際網路,在這個通訊過程中,各種異常情況都可能發生,伺服器可能崩潰沒有響應,或者伺服器有響應但是返回的不是一個狀態碼為200的結果,再或者伺服器返回的是一個狀態碼為200的結果,結果的實際內容可能並不是一個合法的JSON資料。正因為每一個環節都可能出問題,所以每一個環節都需要判斷是不是成功。

雖然被fetch廣為接受,大有取代其他網路訪問方式的架勢,但是它有一個特性一直被人詬病,那就是fetch認為只要伺服器返回一個合法的HTTP響應就算成功,就會呼叫then提供的回撥函式,即使這個HTTP響應的狀態碼是表示出錯了的400或者500。正因為fetch的這個特點,所以我們在then中,要做的第一件事就是檢查傳入引數response的status欄位,只有status是代表成功的200的時候才繼續,否則以錯誤處理。

當response.status為200時,也不能直接讀取response中的內容,因為fetch在接收到HTTP響應的報頭部分就會呼叫then,不會等到整個HTTP響應完成。所以這時候也不保準能讀到整個HTTP報文的JSON格式資料。所以,response.body函式執行並不是返回JSON內容,而是返回一個新的Promise,又要接著用then和catch來處理成功和失敗的情況。如果返回HTTP報文內容是一個完整的JSON格式資料就會成功,如果返回結果不是一個JSON格式,比如是一堆HTML程式碼,那就會失敗。

當歷經各種檢查最後終於獲得了JSON格式的結果時,我們通過Weather元件的this.setState函式把weatherinfo欄位賦值到weather狀態上去,如果失敗,就把weather設為null。

處理輸入輸出看起來的確很麻煩,但是必須要遵照套路把所有可能出錯的情況都考慮到,對任何輸入輸出操作只要記住一點:不要相信任何返回結果

至此,Weather功能完成了,我們開啟網頁重新整理察看最終效果,可以看到網頁最開始顯示“暫無資料”,這是裝載過程的渲染結果,過了一會,當通過代理呼叫中國天氣網遠端API返回的時候,網頁上就會顯示北京市的天氣情況,這是API返回資料驅動的Weather元件更新過程的渲染結果,顯示介面如圖1所示。

圖片描述

圖1 元件Weather的介面

React元件訪問伺服器的優缺點

通過上面的例子,我們可以感受到讓React元件自己負責訪問伺服器的操作非常直接簡單,容易理解。對於像Weather這樣的簡單元件,程式碼也非常清晰。

但是,把狀態存放在元件中其實並不是一個很好的選擇,尤其是當元件變得龐大複雜了之後。

Redux是用來幫助管理應用狀態的,應該儘量把狀態存放在Redux Store的狀態中,而不是放在React元件中。同樣,訪問伺服器的操作應該經由Redux來完成。

接下來,我們就看一看用Redux來訪問伺服器如何做到。

Redux訪問伺服器

為了展示更豐富的功能,我們擴充套件前面的展示天氣資訊應用的功能,讓使用者可以在若干個城市之中選擇,選中某個城市,就顯示某個城市的天氣情況,這次我們用Redux來管理訪問伺服器的操作。

對應的程式碼可以在本書Github程式碼庫的chapter-07/weather_redux目錄下找到。

我們還是用create-react-app創造一個新的應用,叫weather_redux,這個應用建立Store的部分將使用一個叫redux-thunk的Redux中介軟體。

redux-thunk中介軟體

使用Redux訪問伺服器,同樣要解決的是非同步問題。

Redux的單向資料流是同步操作,驅動Redux流程的是action物件,每一個action物件被派發到Store上之後,同步地被分配給所有的reducer函式,每個reducer都是純函式,純函式不產生任何副作用,自然是完成資料操作之後立刻同步返回,reducer返回的結果又被同步地拿去更新Store上的狀態資料,更新狀態資料的操作會立刻被同步給監聽Store狀態改變的函式,從而引發作為檢視的React元件更新過程。

這個過程從頭到尾,Redux馬不停蹄地一路同步執行,根本沒有執行非同步操作的機會,那應該在哪裡插入訪問伺服器的非同步操作呢?

Redux創立之初就意識到了這種問題,所以提供了thunk這種解決方法,但是thunk並沒有作為Redux的一部分一起釋出,而是存在一個獨立的redux-thunk釋出包中,我們需要安裝對應的npm包:

npm install --save redux-thunk

實際上,redux-thunk的實現極其簡單,只有幾行程式碼,將它作為一個獨立的npm釋出而不是放在Redux框架中,更多的只是為了保持Redux框架的中立性,因為redux-thunk只是Redux中非同步操作的解決方法之一,還有很多其他的方法,具體使用哪種方法開發人員可以自行決定,在後面章節會介紹Redux其他支援非同步操作的方法。

讀者可能想問thunk這個命名是什麼含義,thunk是一個計算機程式設計的術語,表示輔助呼叫另一個子程式的子程式,聽起來有點繞,不過看看下面的例子就會體會到其中的含義。
假如有一個JavaScript函式f如下定義:

const f = (x) => {
  return x() + 5;
}

f把輸入引數x當做一個子程式來執行,結果加上5就是f的執行結果,那麼我們試著呼叫一次f:

const g = () => {
  return 3 + 4;
}

f(g); //結果是 (3+4)*5 = 37

上面程式碼中函式g就是一個thunk,這樣使用看起來有點奇怪,但有個好處就是g的執行只有在f實際執行時才執行,可以起到延遲執行的作用,我們繼續看redux-thunk的用法來理解其意義。

按照redux-thunk的想法,在Redux的單向資料流中,在action物件被reducer函式處理之前,是插入非同步功能的時機。

在Redux架構下,一個action物件在通過store.dispatch派發,在呼叫reducer函式之前,會先經過一箇中間件的環節,這就是產生非同步操作的機會,實際上redux-thunk提供的就是一個Redux中介軟體,我們需要在建立Store時用上這個中介軟體。如圖2所示。

圖片描述

圖2 Redux的action處理流程

redux-immutable-state-invariant這個中介軟體幫助開發者發現reducer裡不應該出現的錯誤,現在我們要再加一個redux-thunk中介軟體來支援非同步action物件。

我們建立的Store.js檔案基本和Todo應用中基本一致,區別就是引入了redux-thunk,程式碼如下:

import thunkMiddleware from 'redux-thunk';

const middlewares = [thunkMiddleware];

之前我們用一個名為middlewares的陣列來儲存所有中介軟體,現在只要往這個數組裡加一個元素就可以了,之後,如果需要用到更多的中介軟體,只需要匯入中介軟體放在middlewares陣列中就可以。

非同步action物件

當我們想要讓Redux幫忙處理一個非同步操作的時候,程式碼一樣也要派發一個action物件,畢竟Redux單向資料流就是由action物件驅動的。但是這個引發非同步操作的action物件比較特殊,我們叫它們“非同步action物件”。

前面例子中的action建構函式返回的都是一個普通的物件,這個物件包含若干欄位,其中必不可少的欄位是type,但是“非同步action物件”不是一個普通JavaScript物件,而是一個函式。

如果沒有redux-thunk中介軟體的存在,這樣一個函式型別的action物件被派發出來會一路傳送到各個reducer函式,reducer函式從這些實際上是函式的action物件上是無法獲得type欄位的,所以也做不了什麼實質的處理。

不過,有了redux-thunk中介軟體之後,這些action物件根本沒有機會觸及到reducer函式,在中介軟體一層就被redux-thunk截獲。

redux-thunk的工作是檢查action物件是不是函式,如果不是函式就放行,完成普通action物件的生命週期,而如果發現action物件是函式,那就執行這個函式,並把Store的dispatch函式和getState函式作為引數傳遞到函式中去,處理過程到此為止,不會讓這個非同步action物件繼續往前派發到reducer函式。

舉一個並不涉及網路API訪問的非同步操作例子,在Counter元件中存在一個普通的同步增加計數的action建構函式increment,程式碼如下:

const increment = () => ({
  type: ActionTypes.INCREMENT,
});

派發increment執行返回的action物件,Redux會同步更新Store狀態和檢視,但是我們現在想要創造一個功能,能夠發出一個“讓Counter元件在1秒之後計數加一”的指令,這就需要定義一個新的非同步action建構函式,程式碼如下:

const incrementAsync = () => {
  return (dispatch) => {
    setTimeout(() => {
      dispatch(increment());
    }, 1000);
  };
};

非同步action建構函式incrementAsync返回的是一個新的函式,這樣一個函式被dispatch函式派發之後,會被redux-thunk中介軟體執行,於是setTimeout函式就會發生作用,在1秒之後利用引數dispatch函式派發出同步action建構函式increment的結果。

這就是非同步action的工作機理,這個例子雖然簡單,但是可以看得出來,非同步action最終還是要產生同步action派發才能對Redux系統產生影響。

redux-thunk要做的工作也就不過如此,但因為引入了一次函式執行,而且這個函式還能夠訪問到dispatch和getState,就給非同步操作帶來了可能。

action物件函式中完全可以通過fetch發起一個對伺服器的非同步請求,當得到伺服器結果之後,通過引數dispatch,把成功或者失敗的結果當做action物件再派發出去。這一次派發的是普通的action物件,就不會被redux-thunk截獲,而是直接被派發到reducer,最終驅動Store上狀態的改變。

非同步操作的模式

有了redux-thunk的幫助,我們可以用非同步action物件來完成非同步的訪問伺服器功能了,但是在此之前,我們先想一想如何設計action型別和檢視。

一個訪問伺服器的action,至少要涉及三個action型別:

  • 表示非同步操作已經開始的action型別,在這個例子裡,表示一個請求天氣資訊的API請求已經發送給伺服器的狀態;
  • 表示非同步操作成功的action型別,請求天氣資訊的API呼叫獲得了正確結果,就會引發這種型別的action;
  • 表示非同步操作失敗的action型別,請求天氣資訊的API呼叫任何一個環節出了錯誤,無論是網路錯誤、本地代理伺服器錯誤或者是遠端伺服器返回的結果錯誤,都會引發這個型別的action。

當這三種類型的action物件被派發時,會讓React元件進入各自不同的三種狀態,如下所示。

  • 非同步操作正在進行中;
  • 非同步操作已經成功完成;
  • 非同步操作已經失敗。

不管網路的傳輸速度有多快,也不管遠端伺服器響應有多快,我們都不能認為“非同步操作正在進行中”狀態會瞬間轉換為“非同步操作已經成功完成”或者“非同步操作已經失敗”狀態。前面說過,網路和遠端伺服器都是外部實體,是靠不住的。在開發環境下可能速度很快,所以感知不到狀態轉換,但在其他環境下可能會明顯感覺存在延遲,所以有必要在檢視上體現三種狀態的區別。

作為一種模式,我們需要定義三種action型別,還要定義三種對應的狀態型別。

相關程式碼在本書的Github程式碼庫的chapter- 07/weather_redux目錄下可以找到。

和以往的習慣一樣,我們為Weather元件建立一個放置所有程式碼的目錄weather,對外介面的檔案是src/weather/index.js,把這個功能模組的內容匯出,程式碼如下:

import * as actions from './actions.js';
import reducer from './reducer.js';
import view from './view.js';

export {actions, reducer, view};

在src/weather/actionTypes.js中定義非同步操作需要的三種action型別:

export const FETCH_STARTED = 'WEATHER/FETCH_STARTED';
export const FETCH_SUCCESS = 'WEATHER/FETCH_SUCCESS';
export const FETCH_FAILURE = 'WEATHER/FETCH_FAILURE';

在src/weather/status.js檔案中定義對應的三種非同步操作狀態:

export const LOADING = 'loading';
export const SUCCESS = 'success';
export const FAILURE = 'failure';

讀者可能覺得actionTypes.js和status.js內容重複了,因為三個action型別和三個狀態是一一對應的。雖然看起來程式碼重複,但是從語義上說,action型別只能用於action物件中,狀態則是用來表示檢視。為了語義清晰,還是把兩者分開定義。

接下來我們看src/weather/actions.js中action建構函式如何定義,首先是三個普通的action建構函式,程式碼如下:

import {FETCH_STARTED, FETCH_SUCCESS, FETCH_FAILURE} from './actionTypes.js';

export const fetchWeatherStarted = () => ({
  type: FETCH_STARTED
});
export const fetchWeatherSuccess = (result) => ({
  type: FETCH_SUCCESS,
  result
})
export const fetchWeatherFailure = (error) => ({
  type: FETCH_FAILURE,
  error
})

三個普通的action建構函式fetchWeatherStarted、fetchWeatherSuccess和fetchWeather-Failure沒有什麼特別之處,只是各自返回一個有特定type欄位的普通物件,它們的作用是驅動reducer函式去改變Redux Store上weather欄位的狀態。

關鍵是隨後的非同步action建構函式fetchWeather,程式碼如下:

export const fetchWeather = (cityCode) => {
  return (dispatch) => {
    const apiUrl = `/data/cityinfo/${cityCode}.html`;

    dispatch(fetchWeatherStarted())

    fetch(apiUrl).then((response) => {
      if (response.status !== 200) {
        throw new Error('Fail to get response with status ' + response.status);
      }
      response.json().then((responseJson) => {
        dispatch(fetchWeatherSuccess(responseJson.weatherinfo));
      }).catch((error) => {
        throw new Error('Invalid json response: ' + error)
      });
    }).catch((error) => {
      dispatch(fetchWeatherFailure(error));
    })
  };
}

非同步action建構函式的模式就是函式體內返回一個新的函式,這個新的函式可以有兩個引數dispatch和getState,分別代表Redux唯一的Store上的成員函式dispatch和getState。這兩個引數的傳入是redux-thunk中介軟體的工作,至於redux-thunk如何實現這個功能,大家可以在《深入淺出React和Redux》一書中關於中介軟體的章節中檢視。

在這裡,我們只要知道非同步action建構函式的程式碼基本上都是這樣的套路,程式碼如下:

export const sampleAsyncAction = () => {
  return (dispatch, getState) => {
    //在這個函式裡可以呼叫非同步函式,自行決定在合適的時機通過dispatch引數
    //派發出新的action物件。
  }
}

在我們的例子中,非同步action物件返回的新函式首先派發由fetchWeatherStarted的產生的action物件。這個action物件是一個普通action物件,所以會同步地走完單向資料流,一直走到reducer函式中,引發檢視的改變。同步派發這個action物件的目的是將檢視置於“有非同步action還未結束”的狀態,完成這個提示之後,接下來才開始真正的非同步操作。

這裡使用fetch來做訪問伺服器的操作,和前面介紹的weather_react應用中的程式碼幾乎一樣,區別只是this.setState改變元件狀態的語句不見了,取而代之的是通過dispatch來派發普通的action物件。也就是說,訪問伺服器的非同步action,最後無論成敗,都要通過派發action物件改變Redux Store上的狀態完結。

在fetch引發的非同步操作完成之前,Redux正常工作,不會停留在fetch函式執行上,如果有其他任何action物件被派發,Redux照常處理。

我們來看一看src/weather/reducer.js中的reducer函式,程式碼如下:

export default (state = {status: Status.LOADING}, action) => {
  switch(action.type) {
    case FETCH_STARTED: {
      return {status: Status.LOADING};
    }
    case FETCH_SUCCESS: {
      return {...state, status: Status.SUCCESS, ...action.result};
    }
    case FETCH_FAILURE: {
      return {status: Status.FAILURE};
    }
    default: {
      return state;
    }
  }
}

在reducer函式中,完成了上面提到的三種action型別到三種狀態型別的對映,增加一個status欄位,代表的就是檢視三種狀態之一。

這裡沒有任何處理非同步action物件的邏輯,因為非同步action物件在中介軟體層就被redux-thunk攔截住了,根本沒有機會走到reducer函式中來。

最後來看看src/weather/view.js中的檢視,也就是React元件部分,首先是無狀態元件函式,程式碼如下:

const Weather = ({status, cityName, weather, lowestTemp, highestTemp}) => {
  switch (status) {
    case Status.LOADING: {
      return <div>天氣資訊請求中...</div>;
    }
    case Status.SUCCESS: {
      return (
        <div>
          {cityName} {weather} 最低氣溫 {lowestTemp} 最高氣溫 {highestTemp}
        </div>
      )
    }
    case Status.FAILURE: {
      return <div>天氣資訊裝載失敗</div> 
    }
    default: {
      throw new Error('unexpected status ' + status);
    }
  }
}

和weather_react中的例子不同,因為現在狀態都是儲存在Redux Store上,所以這裡Weather是一個無狀態元件,所有的props都是通過Redux Store狀態獲得。

在渲染函式中,根據三種不同的狀態,顯示出來的內容也不一樣。當檢視狀態為LOADING時,表示一個對天氣資訊的API請求剛剛發出,還沒有結果返回,這時候介面上就顯示一個“天氣資訊請求中…”字樣;當檢視狀態為SUCCESS時,根據狀態顯示對應的城市和天氣資訊;當檢視狀態為FAILURE時,顯示“天氣資訊裝載失敗”。

這個元件對應的mapStateToProps函式程式碼如下:

const mapStateTopProps = (state) => {
  const weatherData = state.weather;

  return {
    status: weatherData.status,
    cityName: weatherData.city,
    weather: weatherData.weather,
    lowestTemp: weatherData.temp1,
    highestTemp: weatherData.temp2
  };
}

為了驅動Weather元件的action,我們另外建立一個城市選擇器控制元件CitySelector,CitySelector很簡單,也不是這個應用功能的重點,我們只需要它提供一個作為檢視的React元件就可以。

在CitySelector元件中,定義了四個城市的程式碼,程式碼如下:

const CITY_CODES = {
  '北京': 101010100,
  '上海': 101020100,
  '廣州': 101280101,
  '深圳': 101280601
};

CitySelector元件的render函式根據CITY_CODES的定義畫出四個城市的選擇器,程式碼如下:

  render() {
    return (
      <select onChange={this.onChange}>
        {
          Object.keys(CITY_CODES).map(
            cityName => <option key={cityName} value={CITY_CODES[cityName]}> {cityName}</option>
          )
        }
      </select>
    );
  }
}

其中使用到的onChange函式使用onSelectCity來派發出action,程式碼如下:

onChange(ev) {
  const cityCode = ev.target.value;
  this.props.onSelectCity(cityCode)
}

為了讓網頁初始化的時候就能夠獲得天氣資訊,在componentDidMount中派發了對應第一個城市的fetchWeatheraction物件,程式碼如下:

componentDidMount() {
  const defaultCity = Object.keys(CITY_CODES)[0];
  this.props.onSelectCity(CITY_CODES[defaultCity]);
}
CitySelector的mapDispatchToProps函式提供了名為onSelectCity的函式型別prop,程式碼如下:
const mapDispatchToProps = (dispatch) => {
  return {
    onSelectCity: (cityCode) => {
      dispatch(weatherActions.fetchWeather(cityCode));
    }
  }
};

這個city_selector提供的檢視匯入了weather功能元件匯出的actions,顯示出北京、上海、廣州、深圳四個城市的選擇器,當用戶選中某個城市的時候,就會派發fetchWeather構造器產生的action物件,讓Weather元件去伺服器獲取對應城市的天氣資訊。

完成全部程式碼之後,我們在網頁中就可以看到最終效果,如圖3所示。

圖3 天氣應用最終效果

當我們選擇另一個城市之後,可以看到會有短暫的顯示“天氣資訊請求中…”,然後才顯示出對應城市的天氣,因為訪問伺服器總是會有時間延遲。

我們也可以試著關閉命令列上中斷npm start這個命令,等於是關閉了代理伺服器,這樣對API的訪問必然失敗,然後切換城市,可以看到“天氣資訊裝載失敗”。

非同步操作的中止

對於訪問伺服器這樣的非同步操作,從發起操作到操作結束,都會有段時間延遲,在這段延遲時間中,使用者可能希望中止非同步操作。

從執行一個fetch語句發出請求,到獲得伺服器返回的結果,可能很快只有幾十毫秒,也可能要花上好幾秒甚至十幾秒,如果沒有超時限制的話,就算是等上幾分鐘也完全是可能的。也就是說,從一個請求發出到獲得響應這個過程中,使用者可能等不及了,或者改變主意想要執行另一個操作,使用者就會進行一些操作引發新的請求發往伺服器,而這就是我們開發者需要考慮的問題。

在weather_redux這個應用中,如果當前城市是“北京”,使用者選擇了“上海”之後,不等伺服器返回,立刻又選擇“廣州”,那麼,最後顯示出來的天氣是什麼呢?

結果是難以預料的,使用者的兩次選擇城市操作,引發了兩次API請求,最後結果就看哪個請求先返回結果。假如是關於“上海”的請求先返回結果,介面上就會先顯示上海的天氣資訊,然後關於“廣州”的請求返回,介面上又自動更新為廣州的天氣資訊。假如是關於“廣州”的請求先返回,關於“上海”的請求後返回,那麼結果就正相反,最後顯示的是上海的天氣資訊。此時介面上會出現嚴重的資訊不一致,城市選擇器上顯示的是“廣州”,但是天氣資訊部分卻是“上海”。

兩次API的請求順序是“上海”“廣州”,有可能返回的順序是“廣州”“上海”嗎?完全可能,訪問伺服器這樣的輸入輸出操作,複雜就複雜在返回的結果和時間都是不可靠的,即使是訪問同樣一個伺服器,也完全可能先發出的請求後收到結果。

要解決這種介面上顯示不一致的問題,一種方法是在檢視上做文章,比如當一個API請求發出去,立刻將城市選擇器鎖住,設為不可改變,直到API請求返回結果才解鎖。這種方式雖然可行,但是給使用者的體驗可能並不好,使用者希望隨時能夠選擇城市,而伺服器的響應時間完全不可控,鎖住城市選擇器的時間可能很長,而且這個時間由伺服器響應時間決定,不在程式碼控制範圍內,如果伺服器要等10秒鐘才返回結果,鎖住城市選擇器的時間就有10秒,這是不可接受的。

從使用者角度出發,當連續選擇城市的時候,總是希望顯示最後一次選中的城市的資訊,也就是說,一個更好的辦法是在發出API請求的時候,將之前的API請求全部中止作廢,這樣就保證了獲得的有效結果絕對是使用者的最後一次選擇結果。

在jQuery中,可以通過abort方法取消掉一個AJAX請求:

const xhr = $.ajax(...);

xhr.abort(); //取消掉已經發出的AJAX請求

但是,很不幸,對於fetch沒有對應abort函式的功能,因為fetch返回的是一個Promise物件,在ES6的標準中,Promise物件是不存在“中斷”這樣的概念的。

既然fetch不能幫助我們中止一個API請求,那就只能在應用層實現“中斷”的效果,有一個技巧可以解決這個問題,只需要修改action建構函式。

我們對src/weather/actions.js進行一些修改,程式碼如下:

let nextSeqId = 0;
export const fetchWeather = (cityCode) => {
  return (dispatch) => {
    const apiUrl = `/data/cityinfo/${cityCode}.html`;
    const seqId = ++ nextSeqId;
    const dispatchIfValid = (action) => {
      if (seqId === nextSeqId) {
        return dispatch(action);
      }
    }
    dispatchIfValid(fetchWeatherStarted())
    fetch(apiUrl).then((response) => {
      if (response.status !== 200) {
        throw new Error('Fail to get response with status ' + response.status);
      }
      response.json().then((responseJson) => {
        dispatchIfValid(fetchWeatherSuccess(responseJson.weatherinfo));
      }).catch((error) => {
        dispatchIfValid(fetchWeatherFailure(error));
      });
    }).catch((error) => {
      dispatchIfValid(fetchWeatherFailure(error));
    })
  };
}

在action建構函式檔案中定義一個檔案模組級的nextSeqId變數,這是一個遞增的整數數字,給每一個訪問API的請求做序列編號。

在fetchWeather返回的函式中,fetch開始一個非同步請求之前,先給nextSeqId自增加一,然後自增的結果賦值給一個區域性變數seqId,這個seqId的值就是這一次非同步請求的編號,如果隨後還有fetchWeather構造器被呼叫,那麼nextSeqId也會自增,新的非同步請求會分配為新的seqId。

然後,action建構函式中所有的dispatch函式都被替換為一個新定義的函式dispatchIf-Valid,這個dispatchIfValid函式要檢查一下當前環境的seqId是否等同於全域性的nextSeqId。如果相同,說明fetchWeather沒有被再次呼叫,就繼續使用dispatch函式。如果不相同,說明這期間有新的fetchWeather被呼叫,也就是有新的訪問伺服器的請求被髮出去了,這時候當前seqId代表的請求就已經過時了,直接丟棄掉,不需要dispatch任何action。

雖然不能真正“中止”一個API請求,但是我們可以用這種方法讓一個API請求的結果被忽略,達到了中止一個API請求一樣的效果。

在這個例子中Weather模組只有一種API請求,所以一個API呼叫編號序列就足夠,如果需要多種API請求,則需要更多類似nextSeqId的變數來儲存呼叫編號。

擁有非同步操作中止功能的程式碼,在本書Github程式碼庫目錄下找到,啟動對應的應用,可以看到無論如何選擇城市,最終顯示的天氣資訊和選中的城市都是一致的。

Redux非同步操作的其他方法

上述的redux-thunk並不是在Redux中處理非同步操作的唯一方式,只不過redux-thunk應該是應用最簡單,也是最容易被理解的一種方式。

在Redux的社群中,輔助進行非同步操作的庫還有:

  • redux-saga
  • redux-effects
  • redux-side-effects
  • redux-loop
  • redux-observable

上面列舉的只是最負盛名的一些庫,並不是完整清單,而且隨著更多的解決方法出現,這個列表肯定還將不斷增長。

如何挑選非同步操作方式

所有這些輔助庫,都需要通過一個Redux中介軟體或者Store Enhancer來實現Redux對非同步操作的支援,每一個庫都足夠寫一本書出來講解,所以沒法在這裡一一詳細介紹,在這裡我們只是列出一些要點,幫助讀者研究讓Redux支援非同步操作的庫時需要考慮哪些方面。

第一,在Redux的單向資料流中,什麼時機插入非同步操作?

Redux的資料流轉完全靠action來驅動,圖2顯示了資料流轉的過程,對於redux- thunk,切入非同步操作的時機是在中介軟體中,但是這不是唯一的位置。

通過定製化Store Enhancer,可以在action派發路徑上任何一個位置插入非同步操作,甚至作為純函式的reducer都可以幫助實現非同步操作。非同步操作本身就是一種副作用,reducer的執行過程當然不應該產生非同步操作,但是reducer函式的返回值卻可以包含對非同步操作的“指示”。也就是說,reducer返回的結果可以用純資料的方式表示需要發起一個對伺服器資源的訪問,由reducer的呼叫者去真正執行這個訪問伺服器資源的操作,這樣不違背reducer是一個純函式的原則,在redux-effects中使用的就是這種方法。

很遺憾,很多庫的文件並沒有解釋清楚自己切入非同步操作的位置,這就容易導致很多誤解,需要開發者自己去發掘內在機制。只有確定了切入非同步操作的位置,才能瞭解整個流程,不會犯錯。

第二,對應庫的大小如何?

有的庫看起來功能很強大,單獨一個庫就有幾十KB大小的體積,比如redux-saga,釋出的最小化程式碼有25KB,經過gzip壓縮之後也有7KB,要知道React本身被壓縮之後也不過是45KB大小。

不同的應用對JavaScript的體積有不同的要求。比如,對於視訊類網站,觀看視訊本來就要求訪問者的網路頻寬比較優良,那多出來的這些程式碼大小就不會有什麼影響。但是對於一些預期會在網路環境比較差的情況下訪問的網站,可能就需要計較一下是否值得引入這些庫。

第三,學習曲線是不是太陡?

所有這些庫都涉及一些概念和背景知識,導致學習曲線比較陡,比如redux-saga要求開發者能夠理解ES6的async和await語法,redux-observable是基於Rx.js庫開發的,要求開發者已經掌握響應式程式設計的技巧。

如果一個應用只有一個簡單的API請求,而且使用redux-thunk就能夠輕鬆解決問題,那麼選擇一個需要較陡學習曲線的輔助庫就顯得並不是很恰當;但是如果應用中包含大量的API請求,而且每個請求之間還存在複雜的依賴關係,這時候也許就是考慮使用某個輔助庫的時機。

切記,軟體開發是團隊活動,選用某種技術的時候,不光要看自己能不能接受,還也要考慮團隊中其他夥伴是否容易接受這種技術。畢竟,軟體開發的終極目的是滿足產品需求,不要在追逐看似更酷更炫的技術中迷失了初心。

第四,是否會和其他Redux庫衝突?

所有這些庫都是以Redux中介軟體或者Redux Store Enhancer的形態出現,在用Redux的createStore建立store例項時,可能會組合多箇中間件和多個Store Enhancer,在Store這個遊戲場上,不同的玩家之間可能會發生衝突。

總之,使用任何一個庫在Redux中實現非同步操作,都需要多方面的考慮,到目前為止,業界都沒有一個公認的最佳方法。

相對而言,雖然redux-thunk容易產生程式碼臃腫的問題,但真的是簡單又易用,庫也不大,只有幾行程式碼而已,在第9章中我們會詳細介紹redux-thunk的實現細節。

利用Promise實現非同步操作

除了redux-thunk,還有另一種非同步模式,將Promise作為特殊處理的非同步action物件,這種方案比redux-thunk更加易用,複雜度也不高。

fetch函式返回的結果也是一個Promise物件,用Promise來連線訪問API操作和Redux,是天作之合。

不過,對於Promise在Redux中應該如何使用,也沒有形成統一觀點,相關的庫也很多,但是都很簡單,用一個Redux中介軟體就足夠實現:

  • redux-promise
  • redux-promises(名字只比上面的多了一個表示複數的s)
  • redux-simple-promise
  • redux-promise-middleware

同樣,這樣一個清單可能也會不斷增長,所以我們也不逐一介紹。

小結

在這一章中我們介紹了一個網頁應用必須具備的功能,通過API訪問獲取伺服器資料資源。

無論是從伺服器獲取資料,還是向伺服器提交資料,都是一個非同步的過程。在一個React元件中,我們可以利用componentDidMount,在裝載過程結束時發起對伺服器的請求來獲取資料填充元件內容。

在一個Redux應用中,狀態要儘量存在Redux的Store上,所以單個React元件訪問伺服器的方案就不適用了,這時候我們需要在Redux中實現非同步操作。最簡單直接的方法是使用redux-thunk這個中介軟體,但是也有其他的庫作為選擇,每種方案都有其優缺點,開發者要了解權衡決定哪種庫適合自己的應用。

如果覺得本文對您有用,歡迎轉發至朋友圈。截止到6月19日17:00,集贊最多的前兩位同學將免費獲得程墨老師的新書《深入淺出React和Redux》一本,還包郵噢。朋友圈截圖請私信給小編(微信:Rachel_qg)。

2017年7月8日(星期六),「“前端開發創新實踐”線上峰會」將在 CSDN 學院召開。本次峰會集結來自Smashing Magazine、美國Hulu、美團、廣發證券、去哪兒網、百度的多位國內外知名前端開發專家、資深架構師,主題涵蓋響應式佈局、Redux、Mobx、狀態管理、構建方案、程式碼複用、個性化圖表定製度等前端開發重難點技術話題。技術解析加專案實戰,幫你開拓解決問題的思路,增強技術探索實踐能力。目前火熱報名中,5折票價最後一週,欲購從速,詳情點選註冊參會

圖片描述