深度探討 useEffect 使用規(guī)范
我們在乘坐地鐵或者公交車的時候,在地鐵門或者公交車門的旁邊往往會有「禁止依靠」這樣的標(biāo)識。目的是為了警告乘客不要依靠車門,否則開門的時候容易出現(xiàn)不可預(yù)測的危險。
但是如果我們仔細去分析這個危險的話,就會知道,他的真實情況是,在車輛運行過程中,車門緊閉,你依靠在車門上也并不會出現(xiàn)危險,我們在上班高峰期擠地鐵的時候,大量的人也不得不緊靠車門,甚至有的人被擠扁壓在車門上。
在制定團隊項目規(guī)范時也會這樣,例如,我在帶領(lǐng)團隊時,一定會制定一條規(guī)范,要求每次代碼提交之前,個人必須檢查你的代碼里是否存在意外的修改,可能有的人在提交之前手抖往代碼里輸入了一個空格或者逗號,從而導(dǎo)致重大事故。這是一個低概率發(fā)生的事情,但是我仍然會要求每次提交都要檢查。
那么,是不是也就意味著,如果不遵守我這個規(guī)范,就一定會發(fā)現(xiàn)不可預(yù)測的重大事故呢?其實并非如此,我們制定規(guī)范的目的是為了讓程序變得可控,比如團隊里面有10個人的習(xí)慣都比較好,從來不會出現(xiàn)意外內(nèi)容提交的情況,但是只要有一個人出現(xiàn)兩次因為手抖把意外的修改提交到了代碼倉庫,那么這條規(guī)范就會出現(xiàn)。
既然這條規(guī)范的出現(xiàn)是為了避免意外的發(fā)生,于是有一個項目成員就對我的規(guī)范提出了質(zhì)疑,他認為可以在配置上增加 pre-commit
的代碼規(guī)則檢測,如果有意外的發(fā)生,那么代碼規(guī)則檢測不會通過,我們就不用每次在提交之前花費心力去檢查每一條 diff 里的修改了。
雖然我最終沒有同意他的提議,但這是一個非常好的思路
所以作為一個優(yōu)秀的開發(fā)者,我們到底是應(yīng)該只要遵循規(guī)范就是完事了,還是應(yīng)該去看懂規(guī)范出現(xiàn)的背后邏輯,從而靈活的運用他,或者探尋更好的解決方案呢?
我的答案是后者。
如果在這個觀念的基礎(chǔ)之上我們能達成共識,我們再來一起結(jié)合 React 官方文檔,對 useEffect 的使用規(guī)范進行深入探討。
在這之前,我們要首先明確一下 useEffect 的語法規(guī)則,useEffect 的依賴項必須是 state 與 props,否則依賴項發(fā)生了變化,effect 也不會執(zhí)行。所以有的人說:我不愿意把 state 放到依賴項里,甚至反感這樣的行為,我認為是沒有任何理論依據(jù)的。
一、計算屬性
在 vue 和 mobx 里都有計算屬性這樣的概念。因此有的人就想,在 react hook 中,是否可以借助 useEffect 來達到計算屬性的目的。
官方文檔明確的建議是,不需要這樣做
// 案例來自官方文檔
// https://zh-hans.react.dev/learn/you-might-not-need-an-effect
function Form() {
const [firstName, setFirstName] = useState('Taylor');
const [lastName, setLastName] = useState('Swift');
// ?? 避免:多余的 state 和不必要的 Effect
const [fullName, setFullName] = useState('');
useEffect(() => {
setFullName(firstName + ' ' + lastName);
}, [firstName, lastName]);
// ...
}
實際上 react 的 re-render 機制表示 react hook 本身已經(jīng)具備了計算屬性的特性。當(dāng) state 發(fā)生變化,函數(shù)會重新執(zhí)行,內(nèi)部的代碼也會重新執(zhí)行,因此案例中的 fullName 就有一次重新計算結(jié)果的機會
function Form() {
const [firstName, setFirstName] = useState('Taylor');
const [lastName, setLastName] = useState('Swift');
// ? 非常好:在渲染期間進行計算
const fullName = firstName + ' ' + lastName;
// ...
}
因此我們不必借助 useEffect 來實現(xiàn)計算屬性,這是非常好的建議。
二、緩存計算結(jié)果
但是如果情況發(fā)生一些變化呢?fullName 這個案例的計算過程非常簡單,如果這個計算過程非常復(fù)雜需要花費大量的時間呢?此時我們就不得不考慮要減少這個計算過程,只在他需要重新計算的時候計算一次
這個時候,這個案例的解決方案就不再適用了,他只適合簡單的運算過程。復(fù)雜運算過程我們還需要借助別的手段來緩存計算結(jié)果。那么使用 useEffect 是否合適?
不合適。官方文檔中,提供了一個更適合的 hook:useMemo 來緩存運算結(jié)果。
但是為什么呢?
因為執(zhí)行時機的問題。事實上,useEffect 和 useMemo 都有記憶能力,他們的底層實現(xiàn)有部分相似之處,但是有一個不同之處導(dǎo)致了他們的差別非常大,那就是傳入的第一個參數(shù)的執(zhí)行時機。useMemo 在發(fā)現(xiàn)依賴項有改變之后,會立即重新運算緩存的函數(shù)并返回運算結(jié)果。但是 useEffect 則需要等待組件渲染完整之后才會開始執(zhí)行緩存的函數(shù)。類似于
setTimeout(effect, 0)
也就意味著,當(dāng)前一輪執(zhí)行的 JSX 中無法得到 useEffect 的運算結(jié)果。除非我們將運算結(jié)果存儲在一個 state 中,讓 state 發(fā)生改變而得到一輪新的 render。
因此在這種場景之下,useMemo 會比 useEffect 更快更合適。
官方文檔給我們提供了一個案例。
現(xiàn)在有一個復(fù)雜列表 todos,然后還有一個過濾條件 filter,todos 和 filter 運算之后可以得到一個列表 visibleTodos。
const visibleTodos = getFilteredTodos(todos, filter);
function TodoList({ todos, filter }) {
const [newTodo, setNewTodo] = useState('');
// ?? 避免:多余的 state 和不必要的 Effect
// 假設(shè) JSX中使用了 visibleTodos
const [visibleTodos, setVisibleTodos] = useState([]);
useEffect(() => {
setVisibleTodos(getFilteredTodos(todos, filter));
}, [todos, filter]);
// ...
}
正是由于使用了 useEffect,因為執(zhí)行時機的問題,如果不將運算結(jié)果存儲在 state 中,當(dāng)前一輪的 render,在 JSX 中無法得到新的運算結(jié)果,因此只有通過 state 的重新出發(fā)一次 render 的機會讓渲染結(jié)果保持最新。
所以不推薦使用 useEffect,直接去掉就行了
function TodoList({ todos, filter }) {
const [newTodo, setNewTodo] = useState('');
// ? 如果 getFilteredTodos() 的耗時不長,這樣寫就可以了。
const visibleTodos = getFilteredTodos(todos, filter);
// ...
}
但是由于此案例中設(shè)定的是 getFilteredTodos 是一個耗時操作,因此我們需要使用 useMemo 來緩存他的運算結(jié)果。這樣就可以做到當(dāng)其他 state 發(fā)生變化時,getFilteredTodos 不會重新執(zhí)行。
import { useMemo, useState } from 'react';
function TodoList({ todos, filter }) {
const [newTodo, setNewTodo] = useState('');
const visibleTodos = useMemo(() => {
// ? 除非 todos 或 filter 發(fā)生變化,否則不會重新執(zhí)行
return getFilteredTodos(todos, filter);
}, [todos, filter]);
// ...
}
這個案例充分說明了 useMemo 的作用。
但是案例有一些不太合理的地方。例如,todos 和 fitler 都是外部傳入的 props,也就是說,下面這一行代碼更合理的方案是在組件外部計算好,因為他運算所需的條件都是外部條件。
const visibleTodos = getFilteredTodos(todos, filter);
這樣我們就完全不需要考慮因為 re-render 而處理他的冗余運算成本問題了。因為他的運算次數(shù)將會嚴格和 todos、filter 的變化保持一致。
三、與事件相關(guān)的爭議
現(xiàn)在我們來思考一個類似的交互方案,依然是一個任務(wù)列表
給他們設(shè)定一個過濾條件,類別,例如有兩個類別是工作類與旅游類,當(dāng)類別發(fā)生變化的時候,部分任務(wù)會隱藏
此時你就會發(fā)現(xiàn)一個問題,如果類別也需要在 UI 中進行顯示,那么我們就不得不把類別這個過濾條件存放在 state 中去觸發(fā) UI 的變化,與此同時,類別的變化還會導(dǎo)致 todos 也發(fā)生變化
這個時候就存在兩種比較有爭議的寫法
第一種寫法完全更符合語義和解耦的思考。從語義上來說,當(dāng)我們點擊了單選按鈕切換了類別,此時只需要修改 fitler 即可,因為我們只做了這一個操作。但是 filter 的修改,還會造成別的改動:列表也會發(fā)生變化,這是一種額外的副作用。因此我們使用 useEffect 來處理這部分副作用邏輯。
從解耦的角度來說,當(dāng)點擊切換按鈕時,我們不需要關(guān)注額外的邏輯,這對于開發(fā)而言是一種理解上的簡化,因為我們在點擊時只需要關(guān)注按鈕本身,而不需要關(guān)注按鈕切換之后的后續(xù)變化。這種解耦思路更有利于后續(xù)的封裝
副作用:我的修改,導(dǎo)致了除我之外的 UI 發(fā)生變化
function TodoList({ todos }) {
const [newTodos, setNewTodos] = state(todos)
const [fitler, setFilter] = state(1)
useEffect(() => { }, [
setNewTodos(getFilteredTodos(todos, filter));
], [filter])
function onFilterChange(value) {
setFilter(value)
}
}
但是這種更符合語義和解耦的方案,違背了剛才的規(guī)范。因為我們使用 useEffect 去監(jiān)聽一個 state,修改另外一個 state。不過剛才的規(guī)范的目的之一,是為了避免出現(xiàn)冗余的 state,本案例里面并沒有冗余的 state,filter 也是必須存在的。那么看上去前提條件跟規(guī)范有一些出入
于是,React 官方文檔還存在另外一條規(guī)范
react 官方文檔把 useEffect 稱為一種逃離方案「逃生艙」,我們應(yīng)該在最后使用它。因此在這個情況下,官方文檔建議把邏輯放到事件中處理,而不是 useEffect。
在這個案例中,下面的寫法是官方文檔更推薦的寫法
function TodoList({todos}) {
const [newTodos, setNewTodos] = state(todos)
const [fitler, setFilter] = state(1)
function onFilterChange(value) {
setFilter(value)
setNewTodos(getFilteredTodos(todos, value))
}
// ...
}
使用 useEffect 雖然更符合語義和解耦,但是他會額外執(zhí)行一次 render,因此在執(zhí)行速度上,這種寫法是更快的。
useEffect 有更復(fù)雜的執(zhí)行邏輯,如果你對其掌握得不夠準確時,他很容易導(dǎo)致你的程序出現(xiàn)一些你無法理解的迷惑現(xiàn)象,因此在這兩個基礎(chǔ)之上,react 官方文檔的意思就是,useEffect 能不用就不用。
但是如果我們已經(jīng)對 useEffect 的運行機制非常清楚,并且他使用他付出的代價只是一次 re-render,我會更傾向于選擇前者:更符合語義、解耦好更利于封裝,而不是嚴格遵守規(guī)范。
事實上,只要你不亂來,一次 re-render 的成本很低,除非是在一些特殊場景,例如渲染大量的 input 或者高頻渲染
如果在性能上還有爭議的話,那么接下來我們把本次案例進行一個修改,新修改的交互將會更容易出現(xiàn)在我們的實踐中。
當(dāng)過濾條件發(fā)生變化,新的列表并不是從本地數(shù)據(jù)中運算得來,而是接口從服務(wù)端獲取。
那么兩種寫法的代碼就會變成
// 使用 useEffect
function TodoList({ todos }) {
const [newTodos, setNewTodos] = state(todos)
const [fitler, setFilter] = state(1)
useEffect(() => { }, [
api(filter).then(res => {
setNewTodos(res.data)
})
], [filter])
function onFilterChange(value) {
setFilter(value)
}
// ...
}
// 不使用 useEffect
function TodoList({ todos }) {
const [newTodos, setNewTodos] = state(todos)
const [fitler, setFilter] = state(1)
function onFilterChange(value) {
setFilter(value)
api(filter).then(res => {
setNewTodos(res.data)
})
}
// ...
}
此時就會發(fā)現(xiàn),使用 useEffect 的性能劣勢消失不見了。因為即使我們在事件中請求了接口,但是由于異步事件的存在,導(dǎo)致 setFilter 與 setNewTodos 無法合并優(yōu)化,他們只能在不同的時間里觸發(fā) re-render。
而第一種寫法由于解耦做得比較好,因此他可以很容易在自定義 hook 的語法規(guī)則之下,簡化組件的邏輯
function TodoList({ todos }) {
const {newTodos, filter, setFilter}
= useTodoList(api)
function onFilterChange(value) {
setFilter(value)
}
// ...
}
這樣我們就可以把 useEffect 和 異步邏輯通過封裝的方式藏起來。這種情況之下的選擇上,我更傾向于選擇更好的語義和更好的解耦。他在性能上的犧牲非常非常小。
四、useEffectEvent
在官方文檔中
https://zh-hans.react.dev/learn/separating-events-from-effects。
介紹了一個實驗性 api,useEffectEvent,用于從 Effect 中提取非響應(yīng)式邏輯,他能夠繞開閉包的困擾,讀取到最新的 state 和 props
import { useEffect, useEffectEvent } from 'react';
function ChatRoom({ roomId, theme }) {
const onConnected = useEffectEvent(() => {
showNotification('Connected!', theme);
});
// ...
先介紹一下官方案例的交互:
首先我們要完成一個聊天室的切換功能。聊天室切換時,我們需要斷開之前的連接,并接上新的連接。
聊天室在切換后連接成功時,需要有一個提示,表示我進入到了新的聊天室,并已經(jīng)連接成功了。
與此同時,該案例設(shè)計了一個交互點,新增了一個配置,去修改提示組件的風(fēng)格,讓他可以切換到 dark 主題
當(dāng)我選中 Use dark theme 時,那個提示組件也會彈出來露露臉。
事實上,實踐中不應(yīng)該出現(xiàn)這種交互,這里之所以出現(xiàn)是因為把他當(dāng)成一個問題來解決的
在代碼的設(shè)計中,isDark 被設(shè)計成為了一個響應(yīng)數(shù)據(jù)。
const [isDark, setIsDark] = useState(false);
然后我們封裝了一個 CharRoom,使用時將 roomId 與 theme 作為 props 傳入
<ChatRoom
roomId={roomId}
theme={isDark ? 'dark' : 'light'}
/>
在封裝 ChatRoom 時,由于 showNotification 的執(zhí)行需要 theme 作為參數(shù),于是,theme 就不得不作為 useEffect 的依賴項傳入,否則 showNotification 無法獲取最新的 theme 值
這是因為閉包的存在
// theme = isDark ? 'dark' : 'light'
const serverUrl = 'https://localhost:1234';
function ChatRoom({ roomId, theme }) {
useEffect(() => {
const connection = createConnection(serverUrl, roomId);
connection.on('connected', () => {
showNotification('Connected!', theme);
});
connection.connect();
return () => connection.disconnect();
}, [roomId, theme]);
return <h1>Welcome to the {roomId} room!</h1>
}
但是如果把 theme 作為依賴項之后,問題就產(chǎn)生了,由 roomId 切換導(dǎo)致的聊天室的斷開和重連邏輯就變得混亂了,因為當(dāng)你修改主題時,這段邏輯也會執(zhí)行。這明顯是不合理的。
因此,react 團隊正在想辦法設(shè)計一個 api,將 useEffect 的響應(yīng)式邏輯與非響應(yīng)式邏輯區(qū)分開。
解決代碼如下:
function ChatRoom({ roomId, theme }) {
const onConnected = useEffectEvent(() => {
showNotification('Connected!', theme);
});
useEffect(() => {
const connection = createConnection(serverUrl, roomId);
connection.on('connected', () => {
onConnected();
});
connection.connect();
return () => connection.disconnect();
}, [roomId]); // ? 聲明所有依賴項
// ...
事實上,在現(xiàn)有的方案之下,我們也有適合的解決方案。
首先我們要考慮一個交互上的特性,主題的更改,對于提示組件的影響并非是實時的。也就是說,當(dāng)我在修改主題時,我們并不需要一個提示組件出來露露臉。
因此,我們此時有機會考慮設(shè)計一個非響應(yīng)式的數(shù)據(jù)來存儲主題的更改。另一個角度,是否選中的 UI 樣式的修改,是 input 組件內(nèi)部自己的交互邏輯,因此此時也不需要外部提供一個響應(yīng)式數(shù)據(jù)來控制 input 是否被選中。
const isDark = useRef(false);
完整的邏輯代碼如下,該代碼可在對應(yīng)的官方案例中運行
import { useState, useEffect, useRef } from 'react';
import { createConnection, sendMessage } from './chat.js';
import { showNotification } from './notifications.js';
const serverUrl = 'https://localhost:1234';
function ChatRoom({ roomId, theme }) {
useEffect(() => {
const connection = createConnection(serverUrl, roomId);
connection.on('connected', () => {
showNotification('Connected!', theme.current ? 'dark' : 'light');
});
connection.connect();
return () => connection.disconnect();
}, [roomId, theme]);
return <h1>Welcome to the {roomId} room!</h1>
}
export default function App() {
const [roomId, setRoomId] = useState('general');
const isDark = useRef(false);
return (
<>
<label>
Choose the chat room:{' '}
<select
value={roomId}
onChange={e => setRoomId(e.target.value)}
>
<option value="general">general</option>
<option value="travel">travel</option>
<option value="music">music</option>
</select>
</label>
<label>
<input
type="checkbox"
onChange={e => (isDark.current = e.target.checked)}
/>
Use dark theme
</label>
<hr />
<ChatRoom
roomId={roomId}
theme={isDark}
/>
</>
);
}
這樣,在我們前面提到的數(shù)據(jù)驅(qū)動 UI 的哲學(xué)邏輯驅(qū)動之下,精確分析數(shù)據(jù)與 UI 的關(guān)系,我們也完美的解決了這個問題。
五、總結(jié)
react 官方文檔在建議與規(guī)范的角度上會盡可能讓大家避免使用 useEffect,我猜測大概是由于許多初學(xué)者在 useEffect 對于依賴項的使用會產(chǎn)生不少疑問而導(dǎo)致的。但并不代表在 useEffect 的思考上,就沒有更合理的使用方式,他也不是一個反模式。
包括我們制定團隊規(guī)范也是一樣,團隊規(guī)范保障的是整個項目的底線,并不一定能代表項目上限,也不一定能代表最佳實踐。
因此,我更倡導(dǎo)大家在學(xué)習(xí)規(guī)范時,去充分理解規(guī)范出現(xiàn)的背后邏輯,靈活的運用他,并積極探尋更好的解決方案。