《React 思維進化》Chapter 5 筆記(下)
#前言
這系列文章是一邊閱讀《React 思維進化:一次打破常見的觀念誤解,躍升專業前端開發者》,一邊做的筆記摘要。
由於主要是寫給自己看的筆記,所以會比較精簡,也會省略一些細節說明。從 Chapter 2 ~ Chapter 5,總共會有五篇文章。
- 《React 思維進化》Chapter 2 筆記
- 《React 思維進化》Chapter 3 筆記
- 《React 思維進化》Chapter 4 筆記
- 《React 思維進化》Chapter 5 筆記(上)
- 《React 思維進化》Chapter 5 筆記(下)
#5-5 副作用處理常見的情境設計技巧
理想的副作用處理是:「其造成的影響是可逆的,且無論執行多少次都不會壞掉」
常見的副作用設計問題
- 疊加性質而非覆蓋性質的操作
- Race condition(競態條件)
- Memory Leak
Fetch 請求伺服器端 API
以下程式碼中,fetchUserData 是非同步的,它會進行一次後端 API 的網路請求,並回傳一個 promise。
因此當這個 effect 短時間內被連續執行時,可能會有 Race condition(競態條件)問題。
白話來說就是說假設短時間內打同一支 API 兩次,先打的那次不一定會比後打的那次早得到結果,因為 API 回傳的時間是由當時的網路狀態或伺服器端的處理速度決定的。
export default function UserProfile(props) {
const [userData, setUserData] = useState(null);
useEffect(() => {
async function startFetching() {
const data = await fetchUserData(props.userId);
setUserData(data);
}
startFetching();
}, [props.userId]);
// ...
}
解決方法:
建立一個簡單的 flag,讓每次 render 的 effect 函式本身都記得自己「是否該忽略 fetch 結果」的 flag。
這個 flag 變數 ignoreResult
預設會是 false
,當 re-render 時,由於會執行前一次 render 版本的 cleanup 函式,所以會把前一次 effect 函式中的 ignoreResult
改為 true。
export default function UserProfile(props) {
const [userData, setUserData] = useState(null);
useEffect(() => {
let ignoreResult = false;
async function startFetching() {
const data = await fetchUserData(props.userId);
if (!ignoreResult) {
setUserData(data);
}
}
startFetching();
return () => {
ignoreResult = true;
};
}, [props.userId]);
// ...
}
控制外部套件
一般來說,若想要串接第三方套件,通常需要先間其功能初始化:
使用以下方法來確保初始化只執行一次
export default function App() {
const thirdPartyPackageRef = useRef(null);
useEffect(() => {
// 自己寫的條件式邏輯,來確保初始化動作不會重複執行
// 即使這個 effect 函式 重複多次,這個效果也不會壞掉
if (!thirdPartyPackageRef.current) {
thirdPartyPackageRef.current = initPackage();
}
// 這裡是因為真的沒有依賴才填[],不是因為想讓它只執行一次
}, []);
}
#5-6 useCallback 與 useMemo 的正確使用時機
useCallback 深入解析
其實 useCallback 本身的效果並不是效能優化,單就「使用了 useCallback」這件事來說,反而會使效能變得更慢。
不過雖然 useCallback 的效果並不是效能優化,但它的行為卻能夠協助 React 其他效能優化手段保持正常運作。
(這部分我們在5-3有提到 ,這邊會再說明一次)
- useCallback 的呼叫方式
它接收兩個參數:
- fn:一個函式,我們會傳遞一個依賴 component 內資料(例如 props, state)的函式
- dependencies:一個陣列,類似 useEffect 的 dependencies,不過在 useCallback 中這是必填的
const cachedFn = useCallback(fn, dependencies);
- useCallback 的運作流程
- 當 component 第一次 render 時:useCallback 會接收我們傳入的函式及 dependencies 並記憶起來,然後再將我們傳入的函式原封不動地傳回。
- 當後續 re-render 時:useCallback 會將 dependencies 中的依賴項目與前一次 render 版本比較。
- 若相同:忽略本次傳入的函式,直接回傳上次 render 時所記憶的舊版函式。
- 若不同:將新傳入的函式及 dependencies 記憶起來覆蓋舊的版本,並將這次的新函式原封不動的回傳。
- 為什麼其實本身不能提供優化效果
我們可以發現,使用了 useCallback,其實每次 render 時,函式都還是會被重新建立,所以並不會因為使用了它而避免不必要的函式產生,而且 dependencies 的比較動作、記憶函式,這些也都造成效能與記憶體的額外消耗。
useCallback 會對函式進行快取(也就是會記住函式),而既然本身反而會使效能變慢,它真正的用途是什麼呢?
useCallback 的實際使用情境
useCallback 的作用是:讓函式反應資料流的變化
以下透過說明兩種最常見的實際情境來進行說明。
1. 維持依賴鏈的連動反應
在下面的例子中,useEffect 的 dependencies 效能優化永遠都會失敗,因為每次 render 時都會產生一個新的 fetchData,所以在 dependencies 比較中就會判定依賴有發生更新,而使 effect 函式每次 render 後都會執行一次,即使 query 根本沒變。
甚至這樣的優化效果比沒提供 useEffect 的 dependencies 參數時還糟糕,畢竟比較 dependencies 還是的動作還是要花費效能成本。
export default function SearchResults() {
const [query, setQuery] = useState('react');
async function fetchData() {
const result = await fetch(`
https://foo.com/api/search?query=${query}
`);
// ...
}
useEffect(() => {
fetchData();
}, [fetchData]);
// ...
}
這個問題的本質是因為在這個 componet 資料流的 資料
=> 函式
=> 副作用
依賴鏈中,函式
這個節點無法正確反應資料更新與否(無論 資料
有沒有更新,函式
都會重新產生),而這連帶導致 副作用
無法判斷源頭 資料
是否有更新,因為副作用直接依賴的 函式
在每次 render 時都發生了改變。
癥結點為:「函式無法正確反應資料更新與否」,因此我們要使用 useCallback 來解決這個問題。
export default function SearchResults() {
const [query, setQuery] = useState('react');
const fetchData = useCallback(async () => {
const result = await fetch(`
https://foo.com/api/search?query=${query}
`);
// ...
}, [query]);
useEffect(() => {
fetchData();
}, [fetchData]);
// ...
}
範例中,fetchData 函式依賴了 query 變數,因此我們將 query 填入 useCallback 的 dependencies,當 query 值與前一次 render 不同時,useCallback 才會回傳新版本的函式,fetchData 才會發生改變。
2. 配合 memo:快取 component render 的畫面結果並重用
在 React 中,透過 useEffect 可以做到「當依賴的資料沒改變時就跳過處理」的優化,而 component render 畫面結果的動作也有類似的優化手段,也就是 React 內建的 memo
方法:
function Child(props) {
return (
<>
<p>Hello, {props.name}</p>
<button onClick={props.showAlert}>Alert</button>
</>
);
}
const MemoizedChild = memo(Child);
什麼是 memo
memo 是 React 提供的 higher order component。
當一個 component 的 props 相同的時候預計都會 render 出一樣的畫面結果時,可以用 memo 來把 component 進行加工。
如果 props 與前一次 render 時的 props 內容完全相同的話,React 就會跳過本次 render 流程,直接回傳上一次的 render 結果。
簡單來說, memo 會檢查 props 的資料,來幫助判斷 component 是否可以跳過畫面 render 的處理,以達到節省效能成本的目的。
補充:什麼是 higher order component(HOC)
HOC 會接收一個 component 作為參數,然後回傳一個加工後的全新 component。就像一個 component 的加工廠,把一個 component 輸入進去,經過加工後輸出一個有額外功能或資料的 component。
memo 問題
然而,memo 也會遇到跟 useEffect 類似的問題:
當 props 中包含函式型別的屬性,且該函式在每次 render 時都不同的話,memo 的優化效果就無法成功發揮。
如以下範例:
使用了 memo 來加工 Child,如果 props 跟一次 render 完全一樣的話,就會跳果本次 render 流程。
但在 Parent 中,showAlert 函式會在每次 render 時都重新產生,因此當它作為 props 傳入 MemoizedChild 時,會被 memo 機制判定是不同的值,導致優化失效。
function Child(props) {
return (
<>
<p>Hello, {props.name}</p>
<button onClick={props.showAlert}>Alert</button>
</>
);
}
const MemoizedChild = memo(Child);
function Parent() {
// 每此 render 時都會重新產生
const showAlert = () => alert('Hi');
return <MemoizedChild name="iris" showAlert={showAlert} />;
}
這時候 useCallback 又派上用場了:
function Child(props) {
return (
<>
<p>Hello, {props.name}</p>
<button onClick={props.showAlert}>Alert</button>
</>
);
}
const MemoizedChild = memo(Child);
function Parent() {
// 將函式用 useCallback 包起來,就不會每次 render 時都不同值
const showAlert = useCallback(() => alert('Hi'), []);
return <MemoizedChild name="iris" showAlert={showAlert} />;
}
總結 useCallback 的兩個常見使用時機:
- 當 component 裡的函式有被 effect 函式呼叫
- 函式會透過 props 傳給一個 memo 過的子元件
useMemo 深入解析
基本上 useMemo 的用途與使用情境都跟 useCallback 差不多,差別在於:
- useMemo 用來快取陣列或物件
- useMemo 本身能真正用於節省計算的效能成本
維持依賴鏈的連動反應
就如 useCallback 能夠將資料流的變動反應到函式一樣,useMemo 可以將資料流的變動反應到物件、陣列。
以下範例中,由於每次 render 時,都會重新產生 numbers 陣列,導致 useEffect 的 dependendies 效能優化失敗;而同樣的原因,也導致 MemoizedChild 的效能優化失敗。
function Child(props) {
return (
<>
<p>Hello, {props.name}</p>
{props.numbers.map((num) => (
<div>{num}</div>
))}
</>
);
}
const MemoizedChild = memo(Child);
function Parent() {
const numbers = [1, 2, 3];
useEffect(() => {
console.log(numbers);
// 副作用的 dependenies 程式,但效能優化失敗
}, [numbers]);
// MemoizedChild 的效能優化失敗
return <MemoizedChild name="iris" numbers={numbers} />;
}
解決方法:使用 useMemo 處理
function Child(props) {
return (
<>
<p>Hello, {props.name}</p>
{props.numbers.map((num) => (
<div>{num}</div>
))}
</>
);
}
const MemoizedChild = memo(Child);
function Parent() {
// 將陣列資料以 useMemo 進行快取,這樣 numbers 就不會每次 render 都不同值
const numbers = useMemo(() => [1, 2, 3], []);
useEffect(() => {
console.log(numbers);
}, [numbers]);
return <MemoizedChild name="iris" numbers={numbers} />;
}
節省計算複雜資料的效能
每當 useMemo 的 dependencies 中的依賴資料有更新時,我們傳給 useMemo 的計算函式才會被執行,否則就跳過計算直接回傳之前的快取結果,因此它本身可以用於節省計算複雜資料的效能成本。
const memoizedValue = useMemo(() => computeExpensiveValue(a, b), [a, b]);
總結 useMemo 的常見使用時機:
- 當 component render 時才產生的物件或陣列資料有被 effect 函式所依賴
- 物件或陣列資料會透過 props 傳給一個 memo 過的子元件
- 記住耗時複雜的計算結果
#5-7 Hooks 的運作思維與設計理念
Fiber Node
- 相較於 React element 是「描述某個歷史時刻的畫面結構」,會隨著畫面的 re-render 而不斷產生好幾份;Fiber node 是負責「保存並維護 React 應用程式的最新狀態資料」,整個應用程式中只會存在一份。
- 由於 React element 是描述某個歷史時刻的畫面,因次建立後就不會再修改;而 fiber node 是保存最新的狀態,所以會隨著資料更新而不斷被修改。
hooks 的運作是依賴於固定的呼叫順序
假設我們呼叫多次 useState
export default function App() {
const [name, setName] = useState('Iris');
const [number, setNumber] = useState(100);
const [flag, setFlag] = useState(false);
// ...
}
我們可以發現:我們只提供了 state 的初始值,並沒有提供 React 這三個 state 分別的命名。
可能有人會疑惑:「不是有用 name
, number
, flag
來命名了嗎?」但 useState 的回傳值是一個陣列,所以 name
, number
, flag
這些名稱是 useState 回傳後,我們才以陣列解構的方式重新命名的。
既然沒有告知 React 每個 state 自定義名稱或 key 之類的資訊,那麼 React 內部是如何區分這些資料的存放的?
其實在 fiber node 內部,它存放這些 state 資料是以 linked list 的方式「一個 state 連著下一個 state」來存放的。
memoizedState:
baseState: 'Iris'
memoizedState: 'Iris'
next:
baseState: 100
memoizedState: 100
next:
baseState: false
memoizedState: false
它會依照呼叫順序,一層一層地存放,而這種結構意味著:如果在某次 render 跳過某個 hook 的呼叫,就可能導致後面的呼叫的所有 hook 與前一次 render 無法對應。
如這個範例,點擊按鈕時會使一個 hook 呼叫被跳過。導致兩次 render 之間的 hooks 呼叫順序無法對應:
首次 render 時 re-render 時
[ flag ] [ flag ]
[ foo ] <--對應錯誤--> [ bar ]
[ bar ] <--對應錯誤--> [ fizz ]
[ fizz ]
這時 React 會發現 hook 呼叫總量不一致的問題並報錯。
這就是為什麼 React 規定只能在 function component 的頂層作用域呼叫 hook,不能在條件式迴圈中使用它們。所有 hook 都必須保證在每次 render 都會被呼叫到,因為一旦有某個 hook 被跳過,後面所有 hook 順序都會跳號,導致 React 內部在存取狀態資料時會有錯置的問題。
那要怎麼安全地讓 hook 不再被執行到?
唯一個方法是:unmount 包含了這些 hook 的 component
function Foo() {
useEffect(() => {...});
// ...
}
function App() {
const [isFoo, setIsFoo] = useState(true);
return isFoo ? <Foo/>: <Bar/>
}
為什麼要將 hook 設計成以順序性的方式來儲存資料呢?
若要儲存、區別資料,大多數人直覺想到的方法應該會是以一個唯一的 key 值。例如:
// ❌ 這只是個假想的 API 設計,實際上並沒有這個寫法
const [name, setName] = useState('name', 'Iris');
const [number, setNumber] = useState('number', 100);
const [flag, setFlag] = useState('flag', false);
然而這種自定義 key 的設計會有個難以避免的問題 —— 命名衝突
命名衝突
例如以下程式碼,我們自定義了一個 custom hook,裡面使用了 useState 並給了它 name
這個 key;而在 MyComponent 我們也使用了 useState 並同樣也給了它 name
這個 key。
這就導致兩者之間的命名衝突,因為它們在同一個 component 中使用了相同的 key。這種情況下,useName 和 MyComponent 內的狀態會互相干擾,導致預期之外的行為。
// Custom hook
function useName() {
const [name, setName] = useState('name', 'Alice');
return [name, setName];
}
// Component 使用 custom hook
function MyComponent() {
const [name, setName] = useName();
const [anotherName, setAnotherName] = useState('name', 'Bob');
// ...
}
鑽石問題
基於 key 來命名的 hooks 設計也會導致鑽石問題,又稱多重繼承問題,這是命名衝突的進階延伸版。
範例:
假設我們想在遊戲資料定義「玩家」跟「怪物」兩種類型,而他們都有「位置座標」這個相同的資料概念,我們想要重用這部分:
function usePosition() {
// ❌ 這只是個假想的 API 設計
const [x, setX] = useState('positionX', 0);
const [y, setY] = useState('positionY', 0);
return { x, setX, y, setY };
}
export function usePlayer() {
const posotion = usePosition();
// ...
return { ....., posotion };
}
export function useMonster() {
const posotion = usePosition();
// ...
return { ....., posotion };
}
在 usePlayer
和 useMonster
這兩個 custom hook 中都使用了 usePosition
這個 custom hook,而 usePosition
裡用 key 的方式來定義 state。
此時當我們的 GameApp
component 中同時呼叫 usePlayer
和 useMonster
時,鑽石問題就產生了:
import { usePlayer, useMonster } from './hooks';
export default function GameApp() {
const player = usePlayer();
const monster = useMonster();
// ...
}
在一個 component 中呼叫 usePosition
兩次,它們會在 component 中都嘗試著註冊名為 positionX
和 positionY
的 hook,導致命名衝突,如下圖:
如果是以順序性的方式,基於 hooks 在 component 裡的固定呼叫順序,如:第一個呼叫的 hook
-> 第二個呼叫的 hook
-> 第三個呼叫的 hook
-> 第四個呼叫的 hook
,它們會自然地形成樹狀結構,不會有鑽石問題: