iT邦幫忙

第 11 屆 iThome 鐵人賽

DAY 20
6
Modern Web

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

[Day 20 - 即時天氣] 在 useEffect 中使用呼叫需被覆用的函式 - useCallback 的使用

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

keywords: useEffect, useCallback

昨天的內容中,我們透過 async function 搭配 Promise.all 的使用,等到取得所有需要的資料後才更新畫面。但在昨天的程式碼中,我們把 fetchData 這個 async function 定義在 useEffect() 內,為什麼我們要這麼做?這麼做有什麼好處呢?還有其他做法嗎?

今天的內容重點簡單來說就是:「如果某個函式不需要被覆用,那麼可以直接定義在 useEffect 中,但若該方法會需要被共用,則把該方法提到 useEffect 外面後,記得用 useCallback 進行處理後再放到 useEffect 的 dependencies 中」。

讓我們先從昨天的程式碼開始回顧起。

將 function 定義在 useEffect 中

一樣可以在 CodeSandbox 上打開昨天的專案 - Weather APP - fetch data with async function in useEffect,在 useEffect 中是這樣寫的:

  • useEffect 的函式中定義 fetchData 這個函式
  • useEffect 的函式中呼叫 fetchData()
// ./src/WeatherApp.js
// ...

const WeatherApp = () => {
  // ...

  useEffect(() => {
    // 在 `useEffect` 的函式中定義 `fetchData` 這個函式
    const fetchData = async () => {
      const [currentWeather, weatherForecast] = await Promise.all([
        fetchCurrentWeather(),
        fetchWeatherForecast(),
      ]);

      setWeatherElement({
        ...currentWeather,
        ...weatherForecast,
      });
    };

    // 在 `useEffect` 的函式中呼叫 `fetchData()`
    fetchData();
  }, []);

  // ...
};

還記得在「Day 17 - 頁面載入時就去請求資料 - useEffect 的基本使用」曾提到過一個重點:

「組件渲染完後,如果 dependencies 有改變,才會呼叫 useEffect 內的 function」

現在當我們把 fetchData 這個函式定義在 useEffect 中時,因為在整個 useEffect() 中沒有相依於任何 React 內的資料狀態(stateprops),因此在 useEffect 第二個參數的 dependencies 陣列中仍然可以留空就好(即,[]),也因為 dependencies 陣列內都固定沒有元素,因此只會在畫面第一次渲染完成後被呼叫到而已。

這種在 useEffect 內定義函式並呼叫的作法本身沒有任何問題,但眼尖的朋友可能也會發現,在昨天的專案中,原本用來「重新整理」的按鈕現在已經失效了,因為原先用來呼叫 API 的 fetchCurrentWeatherfetchWeatherForecast 這兩個方法,現在都變成是回傳 Promise 而不是直接在取得資料後呼叫 setWeatherElement 來更新 React 組件內的資料狀態。

那麼如果要讓「重新整理」的按鈕恢復原有的功能,可以怎麼做呢?

將共用到的 function 定義在 useEffect 外

onClick 中呼叫要執行的方法

如果要讓「重新整理」的按鈕恢復原有功能,最快速的方式就是把要做的事情寫在 onClick 的事件處理器中,在 onClick 後一樣透過 async 函式搭配 Promise.all 去等待資料回來,像是這樣:


const WeatherApp = () => {
  // ...

  return (
    <Container>
      <WeatherCard>
        {/* ... */}
        <Redo
          onClick={async () => {
            const [currentWeather, weatherForecast] = await Promise.all([
              fetchCurrentWeather(),
              fetchWeatherForecast(),
            ]);
            setWeatherElement({
              ...currentWeather,
              ...weatherForecast,
            });
          }}
        >
          {/* ... */}
          <RedoIcon />
        </Redo>
      </WeatherCard>
    </Container>
  );
};

或者如果你覺得在 JSX 中寫這麼多程式邏輯的東西看起來不太乾淨,也可以把 onClick 的事件處理器定義成一個函式,像是下面這樣,把事件處理器取名為 handleClick

// ./src/WeatherApp.js
// ...

const WeatherApp = () => {
  // ...

  const handleClick = async () => {
    const [currentWeather, weatherForecast] = await Promise.all([
      fetchCurrentWeather(),
      fetchWeatherForecast(),
    ]);

    setWeatherElement({
      ...currentWeather,
      ...weatherForecast,
    });
  };

  return (
    <Container>
      <WeatherCard>
        {/* ... */}
        <Redo onClick={handleClick}>
           {/* ... */}
          <RedoIcon />
        </Redo>
      </WeatherCard>
    </Container>
  );
};

export default WeatherApp;

修改的流程像是這樣:

Imgur

這麼做就可以讓「重新整理」的按鈕恢復原有的功能。

建立可共用的函式

上面的做法固然沒什麼問題,但你可能也發現到 handleClick 這個方法,和在 useEffect 中定義的 fetchData 這個方法做的事情是一模一樣的:

Imgur

既然做的事情都一樣,這麽說起來應該只需要定義一個函式,分別在 useEffectonClick 時去呼叫就好,於是:

  1. handleClick 的方法名稱改名為 fetchData
  2. 把寫在 useEffect 中定義的 fetchData 給移除,並把 useEffect 搬到 fetchData 後執行
  3. onClick 中的事件處理器改成 fetchData
  4. 由於現在 fetchCurrentWeatherfetchWeatherForecast 都不需要去修改 React 的資料狀態,是可以獨立存在的函式,因此為了避免每次組件重新渲染時都會重新去定義這兩個函式,可以把這兩個函式搬到 WeatherApp 的外面
// STEP 4:將 fetchCurrentWeather 和 fetchWeatherForecast 往外搬
// 定義 fetchCurrentWeather ...
// 定義 fetchWeatherForecast ...

const WeatherApp = () => {
  // ...

  // STEP 1:將名稱從 handleClick 改成 fetchData
  const fetchData = async () => {
    const [currentWeather, weatherForecast] = await Promise.all([
      fetchCurrentWeather(),
      fetchWeatherForecast(),
    ]);

    setWeatherElement({
      ...currentWeather,
      ...weatherForecast,
    });
  };

  useEffect(() => {
    console.log('execute function in useEffect');
    // STEP 2:把 fetchData 的定義移除

    fetchData();
  }, []);

  return (
    <Container>
      {console.log('render')}
      <WeatherCard>
        {/* ... */}
        {/* STEP 3:將 handleClick 改成 fetchData */}
        <Redo onClick={fetchData}>
          最後觀測時間:
          {new Intl.DateTimeFormat('zh-TW', {
            hour: 'numeric',
            minute: 'numeric',
          }).format(new Date(weatherElement.observationTime))}{' '}
          <RedoIcon />
        </Redo>
      </WeatherCard>
    </Container>
  );
};

如此現在就可以在 useEffect 中和 onClick 中共用 fetchData 這個方法!

在 useEffect 的 dependencies 中放入函式 - useCallback 的使用

React Hooks ESLint Plugin 顯示錯誤提示

現在程式雖然可以正常運作,但是在 CodeSandbox 中的 Problem 面板卻出現提示,內容顯示:「React Hook useEffect has a missing dependency: 'fetchData'. Either include it or remove the dependency array. (react-hooks/exhaustive-deps)」:

Imgur

可以看到這個錯誤提示是由 ESLint 發出的,ESLint 是用來檢查 JavaScript 程式碼中有無語法錯誤或是撰寫風格不符的工具,在這個工具中可以根據專案或團隊的需要設定不同的規則,而這裡之所以會跳出錯誤提示,是因為在 CodeSandbox 上是透過 create-react-app 這個官方工具來建立的 React 專案,因此預設會根據 React 官方的建議來安裝與設定 ESLint。

這個 ESLint 的錯誤提示是由名為 react-hooks 的 ESLint Plugin 顯示,告訴我們在 useEffect 中似乎遺漏了 dependencies,它認為應該要把 fetchData 放到 useEffecdependencies 的陣列中。

這個錯誤提示之所以會產生,是因為先前當我們把 fetchData 定義在 useEffect 中時,React Hooks ESLint Plugin 可以很清楚的知道在 fetchData 這個函式中,並沒有相依到任何和 React 組件有關的資料狀態(即,stateprops),因此允許我們在 dependencies 陣列中不帶入任何元素。

但是當我們把 fetchData 搬到 useEffect 外之後,React Hooks ESLint Plugin 不確定 fetchData 中是否有使用到 React 內部的資料狀態(即,stateprops),如果 fetchData 有相依到 stateprops 但在 dependencies 中卻沒把相依的資料放入陣列時,就可能使得 fetchData 沒辦法適時的重新被呼叫到而產生問題,因此 React Hooks ESLint Plugin 才會建議我們把 fetchData 放到 dependencies 中

雖然即使不照著上面 React Hooks ESLint Plugin 的建議,程式執行起來也不會有問題,因為它的提示是為了避免我們未來可能犯錯。但現在我們還是依照 React Hooks ESLint Plugin 的建議把 fetchData 放到 useEffectdependencies 中吧!

⚠️ 警告,下面的部分請先不要馬上照著做,否則可能會出現無限迴圈的情況!

像這樣:

Imgur

存檔之後,熟悉的感覺又發生了...。可是這熟悉的感覺一點也不對味,無窮迴圈又發生了...WTF...

Imgur

讓我們來看看為什麼會這樣!

無窮迴圈的原因

之所以會有這個問題發生,是因為當我們把 fetchData 放到 dependencies 中,因為 fetchData 是一個函式,而在 JavaScript 中函式本質上就是物件的一種,物件在 JavaScript 中直接用 === 判斷並不是直接看屬性名稱和屬性值相不相同來決定的。舉例來說,當我們定義了兩個物件,即使物件內的屬性名稱和屬性值都一樣,使用 === 來判斷也會得到 false

const a = {
  title: '第十一屆鐵人賽',
};
const b = {
  title: '第十一屆鐵人賽',
};
console.log(a === b);   // false

現在,再來複習一下 useEffect 內函式被呼叫的原則是:

「組件渲染完後,如果 dependencies 有改變,才會呼叫 useEffect 內的 function」

每一次 Function Component 被呼叫時,都會再定義一次新的 fetchData(但函式的內容都相同),因此雖然對我們來說,因為 fetchData 內做的事是一樣,所以我們覺得它是相同的;但對 useEffectdependencies 來說每次的 fetchData 卻都是不同的,也就是你的 fetchData 不是你的 fetchData(我在說啥...)。

而這也就是為什為會導致無窮迴圈的緣故了,因為 useEffect 認為每次的 dependencies 都不同,所以組件渲染完後,就又都去執行 useEffect 內的函式,然後 fetchData 中的 setWeatherElement 會被呼叫,然後組件又重新渲染,無窮迴圈就這樣產生...:

Imgur

避免 useEffect 內的函式不斷執行 - useCallback 的使用

在上面我們有提到,在 JavaScript 中即使物件或函式內容完全相同的情況下,使用 === 來判斷也可能會得到 false,那麼什麼時候會得到 true 呢?簡單來說,當這兩個「東西」是指稱到同一個「位址」時就會是 true,舉例來說:

// 在 JavaScript 物件中
const a = {
  title: '第十一屆鐵人賽',
};

// 讓 b 指稱到和 a 同一個記憶體位址
const b = a;
console.log(a === b); // true

// 這時修改 b 就會直接修改到 a
b.title = '從 Hooks 開始,讓你的網頁 React 起來';
console.log(a); // { title: '從 Hooks 開始,讓你的網頁 React 起來' }

當兩個物件指稱到同一個位址時,這時候 JavaScript 就會判斷這兩個是相同的。

⚠️ 上面所提到「內容相同」但 JavaScript 卻可判斷成「不同」的情況,只適用於「物件」,廣泛來說包含「物件」、「陣列」和「函式」,詳細內容可參考筆者先前的筆記 - 談談 JavaScript 中 by reference 和 by value 的重要觀念

在 React Hooks 則提供了 useCallback 這樣的方法,在有需要時,它可以幫我們把這個函式保存下來,讓它不會隨著每次組件重新執行後,因為作用域不同而得到兩個不同的函式。

useCallback 的用法和 useEffect 幾乎一樣,同樣可以帶入兩個參數,第一個參數是一個函式,在這個函式中就去執行你真正要呼叫的函式,第二個參數一樣是 dependencies。不同的地方是 useCallback 會回傳一個函式,只有當 dependencies 有改變時,這個回傳的函式才會改變

const memoizedCallback = useCallback(() => {
  doSomething(a, b);
}, [a, b]);

透過 useCallback 就可以避免因為 Functional Component 每次重新執行後,函式內容明明相同,但卻被判斷為不同,進而導致 useEffect 又再次被呼叫到的情況。

讓我們來把 useCallback 實際應用到即時天氣 App 中。整個流程如下:

  1. 從 react 中載入 useCallback 這個 React Hook
  2. 使用 useCallback 並將回傳的函式取名為 fetchData
  3. 把原本的 fetchData 改名為 fetchingData 放到 useCallback 的函式內
  4. 記得要在 useCallback 中呼叫 fetchingData 這個方法
  5. 因為 fetchingData 沒有相依到 React 組件中的資料狀態(states 或 props),所以 useCallback 的 dependencies 陣列中可以不帶入任何元素
  6. 把透過 useCallback 回傳的 fetchData 函式放到 useEffect 的 dependencies 中
// STEP 1:從 react 中載入 useCallback
import React, { useState, useEffect, useCallback } from 'react';

// ...
// 定義 fetchCurrentWeather ...
// 定義 fetchWeatherForecast ...

const WeatherApp = () => {
  console.log('--- invoke function component ---');
  const [weatherElement, setWeatherElement] = useState({
    /* ... */
  });

  // STEP 2:使用 useCallback 並將回傳的函式取名為 fetchData
  const fetchData = useCallback(() => {
    // STEP 3:把原本的 fetchData 改名為 fetchingData 放到 useCallback 的函式內
    const fetchingData = async () => {
      const [currentWeather, weatherForecast] = await Promise.all([
        fetchCurrentWeather(),
        fetchWeatherForecast(),
      ]);

      setWeatherElement({
        ...currentWeather,
        ...weatherForecast,
      });
    };

    // STEP 4:記得要呼叫 fetchingData 這個方法
    fetchingData();
    // STEP 5:因為 fetchingData 沒有相依到 React 組件中的資料狀態,所以 dependencies 陣列中不帶入元素
  }, []);

  useEffect(() => {
    console.log('execute function in useEffect');

    fetchData();

    // STEP 6:把透過 useCallback 回傳的函式放到 useEffect 的 dependencies 中
  }, [fetchData]);

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

export default WeatherApp;

使用 useCallback 後,只要它的 dependencies 沒有改變,它回傳的 fetchData 就可以指稱到同一個函式,把這個 fetchData 放到 useEffect 的 dependencies 後,就不會重新呼叫 useEffect 內的函式,如下圖所示:

Imgur

也因此進而解決了無窮迴圈的問題:

Imgur

今天的內容重點簡單來說:「如果某個函式不需要被覆用,那麼可以直接定義在 useEffect 中,但若該方法會需要被共用,則把該方法提到 useEffect 外面後,記得用 useCallback 進行處理後再放到 useEffect 的 dependencies 中」。

useCallback 本身的用法不難就和 useEffect 很接近,差別在於它是會回傳一個函式。但如果對於 JavaScript 本來的觀念還有些不清楚的話,反而會是理解今天內容比較困難的地方,在下面的參考文章中,列出一些和 JavaScript 觀念有關的文章可以參考,有些概念也會在後面做更多的闡述。

今天完整的程式碼一樣放在 CodeSandbox 上,需要的話可以打開 Weather APP - fetch data with useCallback 檢視。

程式範例

Weather APP - fetch data with useCallback @ CodeSandbox

參考文章


上一篇
[Day 19 - 即時天氣] 在 useEffect 中定義並使用 async 函式
下一篇
[Day 21 - 即時天氣] 處理天氣圖示以及 useMemo 的使用
系列文
從 Hooks 開始,讓你的網頁 React 起來30

1 則留言

1
JackKuo
iT邦新手 4 級 ‧ 2021-08-28 21:37:10

受益良多,感謝解說。

我要留言

立即登入留言