感謝 iT 邦幫忙與博碩文化,本系列文章已出版成書「從 Hooks 開始,讓你的網頁 React 起來」,首刷版稅將全額贊助 iT 邦幫忙鐵人賽,歡迎前往購書,鼓勵筆者撰寫更多優質文章。
useEffect
, useCallback
在昨天的內容中,我們透過 async function
搭配 Promise.all
的使用,等到取得所有需要的資料後才更新畫面。但在昨天的程式碼中,我們把 fetchData
這個 async function
定義在 useEffect()
內,為什麼我們要這麼做?這麼做有什麼好處呢?還有其他做法嗎?
今天的內容重點簡單來說就是:「如果某個函式不需要被覆用,那麼可以直接定義在
useEffect
中,但若該方法會需要被共用,則把該方法提到useEffect
外面後,記得用useCallback
進行處理後再放到useEffect
的 dependencies 中」。
讓我們先從昨天的程式碼開始回顧起。
一樣可以在 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 內的資料狀態(state
或 props
),因此在 useEffect
第二個參數的 dependencies 陣列中仍然可以留空就好(即,[]
),也因為 dependencies 陣列內都固定沒有元素,因此只會在畫面第一次渲染完成後被呼叫到而已。
這種在 useEffect
內定義函式並呼叫的作法本身沒有任何問題,但眼尖的朋友可能也會發現,在昨天的專案中,原本用來「重新整理」的按鈕現在已經失效了,因為原先用來呼叫 API 的 fetchCurrentWeather
和 fetchWeatherForecast
這兩個方法,現在都變成是回傳 Promise
而不是直接在取得資料後呼叫 setWeatherElement
來更新 React 組件內的資料狀態。
那麼如果要讓「重新整理」的按鈕恢復原有的功能,可以怎麼做呢?
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;
修改的流程像是這樣:
這麼做就可以讓「重新整理」的按鈕恢復原有的功能。
上面的做法固然沒什麼問題,但你可能也發現到 handleClick
這個方法,和在 useEffect
中定義的 fetchData
這個方法做的事情是一模一樣的:
既然做的事情都一樣,這麽說起來應該只需要定義一個函式,分別在 useEffect
和 onClick
時去呼叫就好,於是:
handleClick
的方法名稱改名為 fetchData
useEffect
中定義的 fetchData
給移除,並把 useEffect
搬到 fetchData
後執行onClick
中的事件處理器改成 fetchData
fetchCurrentWeather
和 fetchWeatherForecast
都不需要去修改 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
這個方法!
現在程式雖然可以正常運作,但是在 CodeSandbox 中的 Problem 面板卻出現提示,內容顯示:「React Hook useEffect has a missing dependency: 'fetchData'. Either include it or remove the dependency array. (react-hooks/exhaustive-deps)」:
可以看到這個錯誤提示是由 ESLint 發出的,ESLint 是用來檢查 JavaScript 程式碼中有無語法錯誤或是撰寫風格不符的工具,在這個工具中可以根據專案或團隊的需要設定不同的規則,而這裡之所以會跳出錯誤提示,是因為在 CodeSandbox 上是透過 create-react-app 這個官方工具來建立的 React 專案,因此預設會根據 React 官方的建議來安裝與設定 ESLint。
這個 ESLint 的錯誤提示是由名為 react-hooks 的 ESLint Plugin 顯示,告訴我們在 useEffect
中似乎遺漏了 dependencies
,它認為應該要把 fetchData
放到 useEffec
的 dependencies
的陣列中。
這個錯誤提示之所以會產生,是因為先前當我們把 fetchData
定義在 useEffect
中時,React Hooks ESLint Plugin 可以很清楚的知道在 fetchData
這個函式中,並沒有相依到任何和 React 組件有關的資料狀態(即,state
或 props
),因此允許我們在 dependencies
陣列中不帶入任何元素。
但是當我們把 fetchData
搬到 useEffect
外之後,React Hooks ESLint Plugin 不確定 fetchData
中是否有使用到 React 內部的資料狀態(即,state
或 props
),如果 fetchData
有相依到 state
或 props
但在 dependencies 中卻沒把相依的資料放入陣列時,就可能使得 fetchData
沒辦法適時的重新被呼叫到而產生問題,因此 React Hooks ESLint Plugin 才會建議我們把 fetchData
放到 dependencies 中。
雖然即使不照著上面 React Hooks ESLint Plugin 的建議,程式執行起來也不會有問題,因為它的提示是為了避免我們未來可能犯錯。但現在我們還是依照 React Hooks ESLint Plugin 的建議把 fetchData
放到 useEffect
的 dependencies
中吧!
⚠️ 警告,下面的部分請先不要馬上照著做,否則可能會出現無限迴圈的情況!
像這樣:
存檔之後,熟悉的感覺又發生了...。可是這熟悉的感覺一點也不對味,無窮迴圈又發生了...WTF...
讓我們來看看為什麼會這樣!
之所以會有這個問題發生,是因為當我們把 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
內做的事是一樣,所以我們覺得它是相同的;但對 useEffect
的 dependencies
來說每次的 fetchData
卻都是不同的,也就是你的 fetchData
不是你的 fetchData
(我在說啥...)。
而這也就是為什為會導致無窮迴圈的緣故了,因為 useEffect
認為每次的 dependencies
都不同,所以組件渲染完後,就又都去執行 useEffect
內的函式,然後 fetchData
中的 setWeatherElement
會被呼叫,然後組件又重新渲染,無窮迴圈就這樣產生...:
在上面我們有提到,在 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 中。整個流程如下:
useCallback
這個 React HookuseCallback
並將回傳的函式取名為 fetchData
fetchData
改名為 fetchingData
放到 useCallback
的函式內useCallback
中呼叫 fetchingData
這個方法fetchingData
沒有相依到 React 組件中的資料狀態(states 或 props),所以 useCallback
的 dependencies 陣列中可以不帶入任何元素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
內的函式,如下圖所示:
也因此進而解決了無窮迴圈的問題:
今天的內容重點簡單來說:「如果某個函式不需要被覆用,那麼可以直接定義在 useEffect
中,但若該方法會需要被共用,則把該方法提到 useEffect
外面後,記得用 useCallback
進行處理後再放到 useEffect
的 dependencies 中」。
useCallback
本身的用法不難就和 useEffect
很接近,差別在於它是會回傳一個函式。但如果對於 JavaScript 本來的觀念還有些不清楚的話,反而會是理解今天內容比較困難的地方,在下面的參考文章中,列出一些和 JavaScript 觀念有關的文章可以參考,有些概念也會在後面做更多的闡述。
今天完整的程式碼一樣放在 CodeSandbox 上,需要的話可以打開 Weather APP - fetch data with useCallback 檢視。
Weather APP - fetch data with useCallback @ CodeSandbox