iT邦幫忙

第 11 屆 iThome 鐵人賽

DAY 19
5
Modern Web

從 Hooks 開始,讓你的網頁 React 起來系列 第 19

[Day 19 - 即時天氣] 在 useEffect 中定義並使用 async 函式

感謝 iT 邦幫忙與博碩文化,本系列文章已出版成書「從 Hooks 開始,讓你的網頁 React 起來」,首刷版稅將全額贊助 iT 邦幫忙鐵人賽,歡迎前往購書,鼓勵筆者撰寫更多優質文章。

昨天的我們已經可以專案中同時呼叫兩道不同的 API 來取得我們需要的資料,眼尖的朋友可能會發現,當我們試圖更新一次資料時,實際上 React 組件實際上被呼叫了兩次,因此也兩次畫面的渲染:

Imgur

如果你對於導致畫面更新的邏輯夠熟悉的話,應該會想到畫面之所以會更新是因為:

  1. 呼叫了 useState 提供的 setWeatherElement 方法
  2. setWeatherElement 設進去的資料的確有改變

而在昨天的程式碼中,因為要拉取不同來源的 API 資料,所以呼叫了兩次 fetch API,並在 fetch 取得資料後,各自一併呼叫 setWeatherElement 了 API,而這也就是畫面之所以會渲染兩次的原因。

如果對於畫面更新的邏輯還不太熟悉的話,可以回頭複習 Day 06 - 醒醒啊!為什麼一動也不動 - useState 的基本使用

根據使用時機選擇一次呈現或分別呈現

其實上面這種做法並沒有錯,但在畫面的呈現上,如果因為使用者網路狀況不好,或其他原因導致兩道 API 回傳資料的速度不一樣的話,畫面就會變得詭異,因為對使用者來說明明是按一次資料更新,但卻會發現畫面上的資料分了兩次進來,畫面看起來大概會變成這樣:

Imgur

因此在這裡比較好的做法其實應該是等到拿完全部的資料後,使用一次 setWeatherElement 把所有拿到的資料給進去,這時候使用者就只會看到一次畫面的更新。

但並不是每種狀況都要等全部的資料回來才顯示給使用者看,因為這樣做就反而喪失了使用 AJAX 拉取資料的好處,舉例來說,當我們在瀏覽電商網站時,好的使用者體驗它不會等到所有資料都載進來之後才顯示網頁,而是會先呈現一個外框的畫面但內容很多是灰灰底尚未載入,等到 API 資料回來後才把圖片顯示出來,甚至是等到使用者的捲軸滾到該頁面時才去拉取資料並顯示:

Imgur

因此實際上應該要等到所有資料都取得後才一次呈現出來,還是資料回來就馬上呈現,端看畫面的內容量和設計呈現。在我們即時天氣 App 中,因為資料量不大,所以等到兩個資料都回來後才呈現,並不會讓使用者等待太久,同時也不會導致使用者覺得點一次按鈕卻不同步的更新了兩次畫面。

使用 async function 來等待回應

現在,我可以把昨天的程式碼改成等到兩個 API 資料都回來後才呼叫 setWeatherElement 去重新渲染畫面。在 JavaScript 中,要做這種「等待」或者說是「當...後,才能...」這種動作時,過去最常使用的是回呼函式(callback function),在 ES6 後更多人使用的則是 Promiseasync function,這兩種語法都可以讓程式碼的語意更清楚,在讀起來時更容易理解,同時還可以搭配使用。

如果你對於 Promiseasync function 的用法還不太清楚,可以從下方參考資源中點選 MDN 的說明文件,或者直接透過 Google 可以找到非常非常非常多說明資料。

這裡我們就來用 async function 來達成,你可以在 CodeSandbox 中打開昨天的專案 Weather APP - fetch multiple data

因為程式碼改變的量稍大,分兩部分來完成。

透過 async 和 Promise 拉取並等待資料回應

第一個部分主要是拉取資料,做法會像這樣:

  1. 在 useEffect 的函式中定義 async function,取名為 fetchData,在這個 function 中會同時呼叫兩道 fetch API
  2. 由於 fetch API 本身就會回傳 Promise,因此透過 async function 中的 await 語法搭配 Promise.all就可以等待 fetch API 的資料都回應後才讓程式碼繼續往後走
  3. fetch API 原本就會回傳 Promise,因此在 fetchCurrentWeatherfetchWeatherForecast 中,不是只是呼叫 fetch 方法,而是把 fetch 方法呼叫後得到的 Promise 回傳出去
  4. fetchCurrentWeatherfetchWeatherForecast 的函式中,不直接去 setWeatherElement 而是把取得的資料回傳出去
  5. 記得要在 useEffect 中執行定義好的 fetchData 這個函式
// ./src/WeatherApp.js
// ...

const WeatherApp = () => {
  const [weatherElement, setWeatherElement] = useState(/* ... */);

  useEffect(() => {
    // STEP 1:在 useEffect 中定義 async function 取名為 fetchData
    const fetchData = async () => {
      // STEP 2:使用 Promise.all 搭配 await 等待兩個 API 都取得回應後才繼續
      const data = await Promise.all([
        fetchCurrentWeather(),
        fetchWeatherForecast(),
      ]);

      console.log('data', data);
    };

    // STEP 5:呼叫 fetchData 這個方法
    fetchData();
  }, []);

  const fetchCurrentWeather = () => {
    // STEP 3-1:加上 return 直接把 fetch API 回傳的 Promise 回傳出去
    return fetch(/* ... */)
      .then((response) => response.json())
      .then((data) => {
        // ...

        // STEP 3-2:把取得的資料內容回傳出去,而不是在這裡 setWeatherElement
        return {
          observationTime: locationData.time.obsTime,
          locationName: locationData.locationName,
          temperature: weatherElements.TEMP,
          windSpeed: weatherElements.WDSD,
          humid: weatherElements.HUMD,
        };
      });
  };

  const fetchWeatherForecast = () => {
    // STEP 4-1:加上 return 直接把 fetch API 回傳的 Promise 回傳出去
    return fetch(/* ... */)
      .then((response) => response.json())
      .then((data) => {
        // ...

        // STEP 4-2:把取得的資料內容回傳出去,而不是在這裡 setWeatherElement
        return {
          description: weatherElements.Wx.parameterName,
          weatherCode: weatherElements.Wx.parameterValue,
          rainPossibility: weatherElements.PoP.parameterName,
          comfortability: weatherElements.CI.parameterName,
        };
      });
  };

  return {
    /* ... */
  };
};

export default WeatherApp;

完成後,順利的話在瀏覽器的開發者工具中將會看到取得的資料現在包在一個陣列中,分別對應到 fetchCurrentWeatherfetchWeatherForecast 從 API 取得的資料:

Imgur

使用解構賦值取出與帶入資料

接著可以透過解構賦值來將資料從陣列中取出,並且放入 setWeatherElement 的物件中:

  1. 取得兩道 API 的資料後,透過陣列的解構賦值將資料取出,分別命名為 currentWeatherweatherForecast
  2. 把取得的資料透過物件的解構賦值放入 setWeatherElement 的物件中
// ./src/WeatherApp.js
const WeatherApp = () => {
  // ...

  useEffect(() => {
    const fetchData = async () => {
      // STEP 6:使用陣列的解構賦值把資料取出
      const [currentWeather, weatherForecast] = await Promise.all([
        fetchCurrentWeather(),
        fetchWeatherForecast(),
      ]);

      // STEP 7:把取得的資料透過物件的解構賦值放入
      setWeatherElement({
        ...currentWeather,
        ...weatherForecast,
      });
    };

    fetchData();
  }, []);
  // ...
};

將程式碼改成這樣後,就只會在取得所有資料後觸發一次畫面的更新,一樣可以在 CodeSandbox 檢視完整的程式碼 - Weather APP - fetch data with async function in useEffect

雖然上面我們這樣寫下來好像很順,但也許可以想想看為什麼我們是把 fetchData 這個函式定義在 useEffect 中而不是先定義在 useEffect 外定義好,在裡面呼叫就好呢?如果有興趣的話,你可以把 fetchData 的函式搬到 useEffect 外面試試看程式是否可以正常運作?看看瀏覽器的開發者工具中有沒有出現任何錯誤提示?

明天我們會再接著繼續討論這個部分!

程式範例

Weather APP - fetch data with async function in useEffect

參考資源

  • Promise @ MDN:若還不清楚 Promise 的用法可以參考
  • async function @ MDN:若還不清楚 async...await 語法的話可以參考

上一篇
[Day 18 - 即時天氣] 拉取並呈現來自多道 API 的資料
下一篇
[Day 20 - 即時天氣] 在 useEffect 中使用呼叫需被覆用的函式 - useCallback 的使用
系列文
從 Hooks 開始,讓你的網頁 React 起來30

尚未有邦友留言

立即登入留言