0%

Enhancing Components with Hooks (Ⅰ)

渲染 (rendering) 是 React 應用程序的心臟,當某些東西(屬性,狀態)發生變化時,組件樹會重新渲染,將最新數據反映給使用者界面。到目前為止,useState 一直是描述組件渲染方式的主要工作。但是我們可以做的更多。還有更多的 Hooks 可以定義有關為什麼以及何時進行渲染的規則。還有更多的掛鉤可以增強渲染效能。

之前,我們介紹了 useState,useContext,我們看到可以將這些 Hooks 組合到我們自己的自定義 Hooks 中:useInput 和 useColors。 不過,還有更多,React 附帶了更多可用的 Hooks。再來,我們將仔細研究 useEffect,useLayoutEffect 和 useReducer。所有這些對於構建應用程序都至關重要。我們還將介紹 useCallback 和 useMemo,它們可以幫助優化我們的組件以提高性能。

使用 useEffect

現在,我們對渲染組件時發生的情況有了很好的了解。組件僅僅是渲染用戶界面的函式。渲染在應用程序首次加載以及屬性和狀態(props and state)值更改時發生。但是,有時候我們希望在 React 渲染更新 DOM 之後執行一些額外的程式碼。網路請求、手動變更 DOM、和 logging,該如何做?

我們使用一個簡單的 Checkbox 複選框組件來觀察組件的渲染週期。我們使用 useState 設置一個 checked 值,並使用 setChecked 函式來更改 checked 的值。用戶可以選擇和取消選擇該複選框,但是我們如何提醒用戶該複選框已被選擇呢?

我們使用 console.log 來觀察一下組件的渲染週期:

Checkbox
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
<div id="react-container"></div>

<script type="text/babel">
const { useState } = React;
const { render } = ReactDOM;

const Checkbox = () => {
const [checked, setChecked] = useState(false);

console.log(`before render: checked: ${checked.toString()}`);

return (
<>
<input
type="checkbox"
value={checked}
onChange={() => setChecked(checked => !checked)}
/>
<span className="margin-left-sm">{checked ? "checked" : "not checked"}</span>
{console.log(`rendering: checked: ${checked.toString()}`)}
</>
);

console.log(`after render: checked: ${checked.toString()}`);
};

const App = () => (
<>
<h1>Hello Tainan!</h1>
<Checkbox />
</>
);

render (
<App />,
document.getElementById("react-container")
)

</script>

組件的渲染主要是第 12 行到第 22 行 return 的 JSX 程式碼。我們在第 10 行渲染之前、第 20 行渲染時與第 24 行渲染後添加了 console.log。執行的結果是:

1
2
before render: checked: false
rendering: checked: false

第 24 行渲染後調用的 console.log 沒有被執行,因為它位於 return 之後,將永遠無法到達該代碼。

為確保渲染後按預期方式看到 console.log,我們可以使用 useEffect。放置在 useEffect 函式內的 console.log 意味著該函式將在渲染後被當成一種副作用(side effect)而被調用。

Checkbox
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
const { useState, useEffect } = React;
...
const Checkbox = () => {
const [checked, setChecked] = useState(false);

console.log(`before render: checked: ${checked.toString()}`);

useEffect(() => {
console.log(`after render: checked: ${checked.toString()}`);
});

return (
<>
<input
type="checkbox"
value={checked}
onChange={() => setChecked(checked => !checked)}
/>
<span className="margin-left-sm">{checked ? "checked" : "not checked"}</span>
{console.log(`rendering: checked: ${checked.toString()}`)}
</>
);
};

第 1 行加入 useEffect;第 9 行將原來在渲染後調用的 console.log 放置在 useEffect 函式裡,而 useEffect 函式將在渲染後作為副作用而被調用。

1
2
3
before render: checked: false
rendering: checked: false
after render: checked: false

當渲染需要引起副作用時,我們使用 useEffect。 可以將副作用視為函式除了使用 return 所返回的結果之外還可以完成並影響函式外的事情。 Checkbox 函式使用 return 渲染 UI,但是我們希望 Checkbox 組件做更多的事情。組件執行除 return UI 之外的其他操作稱為 effect

console.log 或與瀏覽器或本機 API 的互動都不是渲染的一部分,這不是 return 的一部分。在 React 應用程序中,渲染會影響這些事件的結果,我們可以使用 useEffect 等待渲染之後,再將值提供給 console.log:

useEffect
1
2
3
useEffect(() => {
console.log(checked ? "Yes, checked" : "No, not checked");
});

同樣,我們可以使用渲染時的 checked 值,然後將其存儲到 localStorage 中。

useEffect
1
2
3
useEffect(() => {
localStorage.setItem("checkbox-value", checked);
});

將 useEffect 視為渲染後發生的函式。當渲染觸發時,我們可以訪問組件中當前的狀態值,並使用它們執行其他操作,這些操作都必須在渲染後調用,否則就與渲染的 UI 不相等了。然後,一旦我們再次渲染,整個過程就會重新開始。 新值,新渲染,新 effects。

依賴陣列 (The Dependency Array)

useEffect 的設計旨在與其他有狀態的 Hooks,例如 useState 和迄今未提及的 useReducer 結合使用。當狀態改變時,React 將重新渲染組件樹。然後,在這些渲染之後調用 useEffect。

我們來看看與 useState 結合使用的範例。

useState and useEffect
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
const { useState, useEffect } = React;
const { render } = ReactDOM;

const App = () => {
const [val, set] = useState("");
const [phrase, setPhrase] = useState("example phrase");

const createPhrase = (e) => {
e.preventDefault();

setPhrase(val);
set("");
};

useEffect(() => {
console.log(`typing "${val}"`);
});

useEffect(() => {
console.log(`saved phrase: "${phrase}"`);
});

return (
<>
<label className="margin-right-sm">Favorite phrase:</label>
<input
value={val}
placeholder={phrase}
onChange={e => set(e.target.value)}
/>
<button
className="t-Button t-Button--hot margin-left-sm"
onClick={createPhrase}
>Send</button>
</>
);
};

render (
<App />,
document.getElementById("react-container")
);

val 是一個狀態變數,表示輸入字段的值。每次輸入字段的值時 val 都會更改,這會使組件在用戶每次鍵入新字符時渲染 App 組件。當用戶單擊 “Send” 按鈕時,文本區域的 val 會被保存到 phrase 狀態變數,並且重置 val 為空字串,這將清空文本字段。這是一個不斷渲染的過程。

這可以按預期工作,但是 useEffect 掛鉤被調用的次數超過了應有的次數。 每次鍵入新字符渲染後,兩個 useEffect Hooks 均會被調用:

console.log
1
2
3
4
5
6
7
8
9
10
11
12
typing ""                             // First Render
saved phrase: "example phrase" // First Render
typing "H" // Second Render
saved phrase: "example phrase" // Second Render
typing "He" // Third Render
saved phrase: "example phrase" // Third Render
typing "Hel" // Fourth Render
saved phrase: "example phrase" // Fourth Render
typing "Hell" // Fifth Render
saved phrase: "example phrase" // Fifth Render
typing "Hello" // Sixth Render
saved phrase: "example phrase" // Sixth Render

我們不希望在每個渲染調用所有的 effect。 我們需要將 useEffect 掛鉤與特定的數據更改相關聯,當某些數據更改時才調用 useEffect。為了解決這個問題,我們可以合併依賴陣列(Dependency array)。依賴陣列可用於控制何時調用 useEffect:

Dependency array
1
2
3
4
5
6
7
useEffect(() => {
console.log(`typing "${val}"`);
}, [val]);

useEffect(() => {
console.log(`saved phrase: "${phrase}"`);
}, [phrase]);

第 3 與第 7 行我們將依賴陣列添加到兩個 effect 中,以控制它們何時被調用。現在,僅在依賴陣列中的值更改時才會調用這些 effect。

1
2
3
4
5
6
7
8
9
typing ""                             // First Render
saved phrase: "example phrase" // First Render
typing "H" // Second Render
typing "He" // Third Render
typing "Hel" // Fourth Render
typing "Hell" // Fifth Render
typing "Hello" // Sixth Render
typing "" // Seventh Render
saved phrase: "Hello" // Seventh Render

依賴陣列畢竟是一個陣列,因此可以檢查依賴陣列中的多個值。假設我們想在 val 或 phreae 發生變化時調用 useEffect。

Dependency array
1
2
3
useEffect(() => {
console.log("either val or phrase has changed");
}, [val, phrase]);

還可以提供一個空陣列,空的依賴陣列會導致 effect 在初始渲染之後僅被調用一次

Dependency array
1
2
3
useEffect(() => {
console.log("only once after initial render");
}, []);

依賴陣列中沒有依賴項意味著沒有更改,因此 effect 將不再被調用。 僅在第一個渲染上調用的 effects 對於初始化非常有用:

Dependency array
1
2
3
useEffect(() => {
welcome();
}, []);

如果從 effect 中返回一個函式,則從組件樹中刪除該組件時將會調用該函式:

Dependency array
1
2
3
4
useEffect(() => {
welcome();
return () => goodbye();
}, []);

這意味著,您可以使用 useEffect 進行初始設定和卸載。空陣列意味著 welcome 將在第一次渲染時只被調用一次。然後,當組件從組件樹中刪除時,我們將返回一個 goodbye 函式作為清理函式。

此模式在許多情況下很有用。我們可以在第一次渲染時訂閱 WebSocket 服務。 然後,我們將使用清理函式退訂 WebSocket 服務。

useEffect
1
2
3
4
5
6
7
8
9
10
11
const [messages, setMessages] = useState([]);
const addMessage = message => setMessages(allMessages => [message, ...allMessages]);

useEffect(() => {
wsService.subscribe(addMessage);
welcome();
return () => {
wsService.unsubscribe();
goodbye();
};
}, []);

在一個 useEffect 進行太多工作,看起來很雜亂,也較容易出錯。將功能分為多個 useEffect 調用,通常是一個比較好的主意。

useEffect
1
2
3
4
5
6
7
8
9
useEffect(() => {
wsService.subscribe(addMessage);
return () => wsService.unsubscribe();
}, []);

useEffect(() => {
welcome();
return () => goodbye();
}, []);

我們還可以進一步增強它。這是自定義掛鉤 (custom hook) 的好地方。

useWsMessages custom hook
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
const useWsMessages = () => {
const [messages, setMessages] = useState([]);
const addMessage = message => setMessages(allMessages => [message, ...allMessages]);

useEffect(() => {
wsService.subscribe(addMessage);
return () => wsService.unsubscribe();
}, []);

useEffect(() => {
welcome();
return () => goodbye();
}, []);

return messages;
};

我們的自定義掛鉤包含處理數據的所有功能,這意味著,我們可以輕鬆地與其他的組件共享此功能。在一個名為 MessageList 的新組件中,我們將可以使用自定義掛鉤:

MessageList
1
2
3
4
5
6
7
8
9
10
11
12
const MessageList = () => {
const messages = useWsMessages();

return (
<>
<h1>{messages.length}</h1>
{messages.map(message => (
<Message key={message.id} {...message} />
))}
</>
);
};

我們可以輕鬆的使用 useWsMessages 自定義掛鉤,當有訂閱的數據到達時,MessageList 組件將會重新渲染。