5-8 了解定義函式的適當位置以及 useCallback 的使用

本單元對應的專案分支為:create-function-with-use-callback

單元核心#

這個單元的主要目標包含:

  • 了解 useCallback 的使用方式
  • 了解當函式不需要共用時,可以直接將函式定義在 useEffect 內並呼叫使用
  • 了解當函式需要共用時,可以把函式拉到 useEffect 外定義
  • 了解如何讓函式與元件的資料狀態解耦,以利未來程式的拆檔與管理

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

當函式不需要共用時,可以直接定義在 useEffect 內#

先來看一下,在上一個單元中我們怎麼呼叫 fetchData 這個方法:

const App = () => {
useEffect(() => {
// 在 useEffect 的函式中定義 fetchData 這個函式
const fetchData = async () => {
/* ... */
};
// 在 useEffect 的函式中呼叫 fetchData()
fetchData();
}, []);
};

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

重點

當函式不需要共用時,可以直接定義在 useEffect 中。

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

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

當函式需要共用時,可以拉到 useEffect 外#

現在我們因為在 useEffect 中以及在使用者點擊重新整理的按鈕時,都需要更新資料,因此比較好的做法不是在 onClick 中重複撰寫一次和 fetchData 一模一樣的程式碼,而是把這個 fetchData 的方法搬到 useEffect 外,而後就可以在 useEffectonClick 時都去呼叫這個方法。

也就是把程式碼改成:

const App = () => {
// ...
// STEP 1:把 fetchData 從 useEffect 中搬出來
const fetchData = async () => {/* ... */};
// STEP 2:在 useEffect 中呼叫 fetchData
useEffect(() => {
console.log('execute function in useEffect');
fetchData();
}, []);
return (
{/* STEP 3:在 onClick 中呼叫 fetchData */}
<Refresh onClick={fetchData} isLoading={isLoading}>
{/* ... */}
</Refresh>
{/* ... */}
);
};

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

useCallback 的使用#

除了直接把函式拉到 useEffect 外去定義之外,你可能還有看過有一種做法是使用 useCallback 把這個會被共用的函式給包起來,寫法上會像這樣:

Imgur

這個 useCallback 是做什麼用的呢?

我們知道只要 React 內的資料狀態有變動時,這整個用來產生 React 元件的 Function 都會再重新執行一次,也就是說,每次只要資料狀態有變更時,fetchData 這個函式就會被重新宣告定義一次,每一次的 fetchData 內容雖然都是一樣的,但因為都會經歷重新宣告與賦值的過程,所以其實是「新的」函式。

這裡所謂「內容相同」但卻又是「新的」到底是什麼意思呢?

我們曾經在前面的單元中提到,dependencies 陣列中元素是否相同是透過 Object.is() 這個方法來判斷,這個判斷方式就和 === 的方式大同小異,在 JavaScript 中要判斷兩個物件是否相同時,並不是直接判斷物件中的屬性名稱和屬性值相同就可以,而是要看它們是否參照到同一個記憶體位置。舉例來說,當我們定義了兩個物件,即使這兩個物件內的屬性名稱和屬性值都一樣,使用 === 來判斷也會得到 false

const foo = { learn: 'react' };
const bar = { learn: 'react' };
console.log(foo === bar); // false
重點

在許多開發的說明文件中,經常會看到變數或值使用 foobar,雖然對初學者來說,這個變數很惱人,不清楚為什麼要這樣做,但實際上有經驗的開發者一看就知道這是「沒意義」的變數,單純只是示範用的,因此算是一種共同的默契。撰寫文件的人不需要花不必要的心力去思考變數名稱,而閱讀文件的人一看到 foobar 時,就會知道這只是說明用的變數。

而在 JavaScript 中「函式」本質上其實就是物件的一種,因此即使宣告了兩個內容相同的函式,在等值的判斷上還是不同的:

const foo = () => 'react';
const bar = () => 'react';
console.log(foo === bar); // false

回到 App 元件中的 fetchData 函式來看,現在只要 App 元件的資料狀態一有變動,就會重新產生一個內容相同但實際上並不相同(參照到不同的記憶體位址)的函式。一般來說,在元件重新轉譯時一併重新定義元件內的函式並不會有什麼太大的影響,但有少部分的情況(記住是很少數的時候),你可能會希望不要讓這個 fetchData 每次都被重新宣告,例如當這個函式有可能被放到 dependencies 陣列中的情況。

舉例來說,假設現在把 fetchData 這個函式放到 dependencies 陣列中:

const fetchData = async () => {
/* ... */
};
// 如果把 `fetchData` 這個函式放到相依陣列中
useEffect(() => {
fetchData();
}, [fetchData]);

這時候將會出現無窮迴圈的情況,這是因為對於 useEffect dependencies 陣列中的 fetchData 來說,每一次元件經過重新轉譯後,雖然 fetchData 函式中的內容相同,但每次的 fetchData 其實都是不同的(指稱到不同記憶體位置)。

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

舉例來說:

const foo = { learn: 'react' };
const bar = foo;
// 讓 bar 指稱到和 foo 同一個記憶體位址
console.log(foo === bar); // true,因為 bar 和 foo 指稱到的是同一個位址
// 當指稱到同一個位址時,修改 bar 就會連帶修改到 foo
bar.learn = '從 Hooks 開始,讓你的網頁 React 起來';
console.log(foo); // { title: '從 Hooks 開始,讓你的網頁 React 起來' }

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

上面雖然是使用物件來舉例,但套用到函式時的概念也一樣。如果想要在 React 元件重新轉譯後,仍可以指稱到同一個記憶體位置的函式時,就可以使用 useCallback 這個 React Hooks 來把 fetchData 這個函式給保存下來,讓它不會每次因為元件重新轉譯(render)就變成一個「新的」函式。

這裡先來看一下 useCallback 這個 React Hooks 可以怎麼使用。

useCallback 的用法和 useEffect 幾乎一樣,同樣可以帶入兩個參數,第一個參數是一個函式,在這個函式中可以去執行原本函式要做的事、或是去呼叫原本的函式,第二個參數一樣是 dependencies 陣列。不同的地方是 useCallback 回傳的是一個函式,只有當 dependencies 有改變時,useCallback 才會回傳一個新的函式

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

透過 useCallback 就可以避免 Functional Component 每次重新執行後,函式內容明明相同,但卻會重新定義新函式的情況。

實際使用 useCallback#

回到原本的 App 元件的程式碼,可以試著把 useCallback 實際應用到臺灣好天氣中。整個流程如下:

  1. 從 react 中載入 useCallback 這個 React Hook
import React, { useState, useEffect, useCallback } from 'react';
  1. 使用 useCallback 並將回傳的函式取名為 fetchData
  2. 在 useCallback 的 dependencies 陣列中帶入空陣列
  3. 使用 useCallback 後,只要 useEffect 中的 dependencies 沒有改變,它回傳的 fetchData 就可以指稱到同一個函式。再把這個 fetchData 放到 useEffect 的 dependencies 後,就不會重新呼叫 useEffect 內的函式。
const App = () => {
// useCallback 中可以放入函式,這裡可以把原本 fetchData 做的事放入 useCallback 的函式中
const fetchData = useCallback(async () => {
setWeatherElement((prevState) => (/* ... */);
// ...
}, []);
useEffect(() => {
fetchData();
}, [fetchData]);
// ...
}

當我們把 fetchData 中原本的函式內容用 useCallback 包起來後,只要 useCallback() 中 dependencies 陣列中的元素沒有改變,那麼這個 fetchData 就會一直指稱到相同的函式,而不會在每次函式重新轉譯時就再次建立新的 fetchData 函式。

是否有必要使用 useCallback?#

實際上 useCallback 被使用到的機會沒有這麼高,useCallback 雖然能夠避免 React 元件在重新轉譯後再次建立新的函式,但多數時候即使重複建立這些函式,對於瀏覽器和電腦的負擔並不會增加太多,相較之下,為了比較 useCallback 中的 dependencies 陣列元素是否相同還可能會消耗更多效能,因此多數的時候並不需要使用到 useCallback 這個方法。

只有在一些情況下,例如當 useEffect 的 dependencies 陣列會需要相依於某個函式時,開發者可以透過 useCallback 來把這個函式保存下來,以避免這個函式在元件重新轉譯後又是「新的」,進而導致 useEffect 每次都會重新執行的情況。

以目前「臺灣好天氣」的程式碼來說, useCallback 是可用可不用的,在這裡比較大的用意是向讀者示範 useCallback 這個方法的使用,因為實務上仍會看到開發者使用 useCallback 來作為效能提升的方式之一。

整理 React Hooks 中函式定義的位置#

在這一系列的章節中,你會發現同樣的 fetchCurrentWeather, fetchWeatherForecastfetchData 這幾個函式,會根據不同的使用情境,定義在程式碼不同的位置,這裡讓我們來整理一下。

當函式不需要共用時,直接將函式定義在 useEffect 內並呼叫使用#

假設 fetchCurrentWeather 只有需要在頁面第一次載入時會被呼叫,而沒有其他情況會需要被呼叫時(例如,點擊重新整理按鈕)。這時候比較好的做法是在 useEffect 中去定義這個函式,並且呼叫它。

前面有提到過,只要元件重新轉譯,定義在元件內的函式都會因為元件的重新轉譯而被重新宣告,但是當我們把函式的定義放在 useEffect 中時,因為 useEffect 的內容只有在其 dependencies 陣列中的元素有所不同時才會被執行,因此可以減少函式不斷被重新定義的次數。舉例來說:

const App = () => {
const [weatherElement, setWeatherElement] = useState();
// 當函式不需要共用時,直接把函式的定義放在 useEffect 內,並呼叫該函式
useEffect(() => {
const fetchCurrentWeather = () => {
setWeatherElement(/* ... */);
};
fetchCurrentWeather();
}, []);
};

當函式需要共用時,把函式拉到 useEffect 外定義#

現在,如果 fetchCurrentWeather 同時需要在 useEffect 中呼叫,而且同時也需要在其他情況下(例如,使用者點擊時)被呼叫時,可以把這個函式拉到 useEffect 外定義:

const App = () => {
const [weatherElement, setWeatherElement] = useState();
// 當函式需要共用時,把該函式拉到 useEffect 外定義
const fetchCurrentWeather = () => {
setWeatherElement(/* ... */);
};
useEffect(() => {
fetchCurrentWeather();
}, []);
return <button onClick={fetchCurrentWeather}>API 拉取資料</button>;
};

這種情況下,雖然 fetchCurrentWeather 可能會因為元件重新轉譯而不斷被重新定義,但一般來說對於瀏覽器的負擔或效能的影響並不會太大,並不一定需要使用 useCallback 來特別處理。

讓函式與資料狀態解耦#

最後,如果在 useEffect 中需要拉取來自多支 API 的資料時,比較好的做法是讓拉取 API 的方法與元件的資料狀態(即,state 或 props)脫鉤,也就是說,不要在該函式中使用到 useState 回傳的 state 或 setSomething,或者是父層元件傳入的 props。如此拉取 API 的函式就可以定義在 React 元件外面,未來更方便將程式碼做拆檔與管理:

// 讓函式與資料狀態解耦
const fetchCurrentWeather = () => {
return 'currentWeatherData';
};
const fetchWeatherForecast = () => {
return 'weatherForecast';
};
const App = () => {
const [weatherElement, setWeatherElement] = useState();
// 把需要更新資料狀態的方法統一在一個函式中管理
const fetchData = () => {
const currentWeather = fetchCurrentWeather();
const weatherForecast = fetchWeatherForecast();
setWeatherElement(/* ... */);
};
useEffect(() => {
fetchData();
}, []);
return <button onClick={fetchData}>API 拉取資料</button>;
};

我們只需要將更新資料狀態的方法統一在一個函式( fetchData )中管理即可,至於這個 fetchData 是應該要定義在 useEffect 內或外,則又回到前面提到的兩個情況,如果 fetchData 只會在 useEffect 中被使用,而沒有共用的情況,那可以直接在 useEffect 內定義該函式並呼叫即可;但若該函式可能會在不同地方被呼叫,則需要把該函式拉到 useEffect 外加以定義。

換你了!建立可以在 useEffect 中被共用的函式#

在這個單元中我們整理了 React Hooks 中定義函式適當的位置,並且說明了 useCallback 這個 Hooks,useCallback 雖然多半的情況下,用與不用的差異不會太大,但這個單元中剛好是個可以實際使用它的機會,就請讀者試試看吧。

現在請你建立一個可以同時在 useEffectonClick 中共同使用的 fetchData 函式,讓重新整理的按鈕恢復它原有的功能。你可以參考這樣的流程:

  • useCallback 方法從 react 套件 import 進來
  • fetchData 拉到 useEffect 外
  • 使用 useCallback 以確保 fetchData 不會因為元件重新轉譯而變成新的,記得要帶入 useCallback 的 dependencies 陣列
  • useEffect 內(初次載入頁面)與點擊重新整理按鈕時呼叫 fetchData
  • fetchData 放到 useEffect 的 dependencies array 中

本單元相關之網頁連結、完整程式碼與程式碼變更部分可於 create-function-with-use-callback 分支檢視:https://github.com/pjchender/learn-react-from-hook-realtime-weather-app/tree/create-function-with-use-callback

Imgur