React 中的副作用處理:effect 初探
React 在 component function 中提供了一個 useEffect
hook 來 隔絕和管理副作用 。React 在每次 render 之後執行 useEffect
。
副作用 side effect
副作用指的是當函式被呼叫時,除了回傳值以外,還會對外部環境產生影響的操作。常見的副作用包括:
- 呼叫 API
- 操作 DOM(例如手動新增事件監聽器)
- 設定計時器(例如 setTimeout、setInterval)
- 訂閱事件(例如 WebSocket 或其他事件系統)
- 修改全域變數
使用 useEffect hook 處理副作用的原因
若直接在 component function 中處理副作用,會造成以下問題:
- 重複副作用執行:由於函式多次執行而產生疊加副作用會造成 react 無 法預測行為,可能導致資料流或程式邏輯無法正常運作。
- 效能問題: 容易阻塞函式計算,導致產生 react element 的速度變慢,進而造成畫面更新的延遲。
- 無法清理副作用:直接操作 DOM 或設置計時器時,無法在 component 卸載時自動清理這些副作用,可能導致記憶體洩漏。
以 useEffect hook 處理副作用的步驟
import React, { useEffect } from "react";
import axios from "axios";
function MyComponent() {
const [id, setId] = useState(1);
useEffect(() => {
// 定義副作用,例如呼叫 API
console.log("Component rendered");
axios.get(`https://api.example.com/data${id}`).then((response) => {
console.log(response.data);
});
// 可選的 cleanup 函式,會在下一次 effect 執行前或 component 卸載時執行
return () => {
console.log("Cleanup before the next effect or on unmount");
};
}, [id]); // 依賴陣列,當陣列中的值有變動時才會執行副作用
return <div>My Component</div>;
}
- 在 component function 中使用
useEffect
hook。 - 在
useEffect
hook 中定義副作用函式,例如呼叫 API。 - 如果有需要清除副作用,可以在副作用函式中回傳一個 cleanup 函式。
- 在 dependencies array 中傳入依賴陣列,以跳過執行不需要的 render 副作用。
實例練習 1
// This is a React Quiz from BFE.dev
import React, { useEffect, useState } from 'react'
import ReactDOM from 'react-dom'
function App() {
const [state, setState] = useState(0)
console.log(state)
useEffect(() => {
setState(state => state + 1)
}, [])
useEffect(() => {
console.log(state)
setTimeout(() => {
console.log(state)
}, 100)
}, [])
return null
}
ReactDOM.render(<App/>, document.getElementById('root'))
解題 1
-
初始渲染, 執行
console.log(state)
,這時的 state 是 0,所以會印出 0。 -
第一個 useEffect hook,
setState(state => state + 1)
會等到所有的 side effect 都執行完後才會執行。 -
第二個 useEffect hook 中的
console.log(state)
會印出 0,這時的 state 是 0,所以會印出 0。 -
當遇到第二個 useEffect hook 中的
setTimeout
會在 100ms 後執行,所以根據 event loop 的機制會被放到宏任務 queue 中,等到 call stack 空了之後才會執行。 -
setState
觸發 re-render:當setState(state => state + 1)
被執行後,React 會進行一次重新渲染,state 的值會從 0 更新為 1,觸發 component re-render。 -
重新渲染後:重新渲染的
console.log(state)
:在重新渲染過程中,state 現在是 1,所以新的console.log(state)
會印出 1。7. 宏任務執行:setTimeout
的 callback 會在 100 毫秒後執行,這時 callback 內的console.log(state)
會依然使用最初setTimeout
被註冊時的閉包環境中的 state 值,該值仍然是初次渲染時的 0,因此會印出 0。
0
0
1
0
實例練習 2
import * as React from "react";
import { useState, useEffect } from "react";
import { createRoot } from "react-dom/client";
import { screen, fireEvent } from "@testing-library/dom";
function App() {
const [state, setState] = useState(0)
console.log(1)
useEffect(() => {
console.log(2)
}, [state])
Promise.resolve().then(() => console.log(3))
setTimeout(() => console.log(4), 0)
const onClick = () => {
console.log(5)
setState(num => num + 1)
console.log(6)
}
return <div>
<button onClick={onClick}>click me</button>
</div>
}
const root = createRoot(document.getElementById('root'));
root.render(<App/>)
setTimeout(() => fireEvent.click(screen.getByText('click me')), 100)
解題 2
- 初始渲染,執行
console.log(1)
,印出1
。 - 執行
useEffect
hook 中的console.log(2)
,印出2
。 - 將
Promise.resolve().then(() => console.log(3))
放到微任務隊列,等待同步程式執行完畢後執行。 - 將
setTimeout(() => console.log(4), 0)
放到宏任務隊列,等待同步程式執行完畢後執行。 - 事件處理 (onClick):
- React 在 100 毫秒後模擬點擊按鈕。
- 將
onClick
callback 放到事件隊列,放到宏任務隊列,等待同步程式執行完畢後執行。
- 從微任務隊列中取出
Promise.resolve().then(() => console.log(3))
,執行console.log(3)
,印出3
。 - 從宏任務隊列中取出
setTimeout(() => console.log(4), 0)
,執行console.log(4)
,印出4
。 - 從宏任務隊列中取出
onClick
callback,執行console.log(5)
,印出5
,setState(num => num + 1)
會等到所有的 side effect 都執行完後才會執行。 console.log(6)
,印出6
。- 執行
setState(num => num + 1)
,觸發 re-render,重新執行 component function,state 從 0 變成 1。 - 重新渲染後,執行
console.log(1)
,印出1
。 - 執行
useEffect
hook 中的console.log(2)
,印出2
。 - 將
Promise.resolve().then(() => console.log(3))
放到微任務隊列,等待同步程式執行完畢後執行。 - 將
setTimeout(() => console.log(4), 0)
放到宏任務隊列,等待同步程式執行完畢後執行。 - 從微任務隊列中取出
Promise.resolve().then(() => console.log(3))
,執行console.log(3)
,印出3
。 - 從宏任務隊列中取出
setTimeout(() => console.log(4), 0)
,執行console.log(4)
,印出4
。
1
2
3
4
5
6
1
2
3
4