React Hooks 使用誤區(qū),駁官方文檔
大廠技術(shù) 高級前端 Node進階
點擊上方 程序員成長指北,關(guān)注公眾號
回復1,加入高級Node交流群
作為 React Hooks 庫 ahooks[1] 的作者,我應該算一個非常非常資深的 React Hooks 用戶。在兩年多的 React Hooks 使用過程中,我越來越發(fā)現(xiàn)大家(包括我自己)對 React Hooks 的使用姿勢存在很大誤區(qū),歸根到底是官方文檔的教程很不嚴謹,存在錯誤的指引。
1. 不是所有的依賴都必須放到依賴數(shù)組中
對于所有的 React Hooks 用戶,都有一個共識:“useEffect 中使用到外部變量,都應該放到第二個數(shù)組參數(shù)中”,同時我們會安裝 eslint-plugin-react-hooks[2] 插件,來提醒自己是不是忘了某些變量。
以上共識來自官方文檔:
我愿稱該條規(guī)則為萬惡之源,這條規(guī)則以高亮展示,所有的新人都很重視,包括我自己。然而在實際的開發(fā)中,發(fā)現(xiàn)事情并不是這樣的。
下面舉一個比較簡單的例子,要求如下:
-
有兩個字段 User 和 Email,都是可以隨時變化的 -
只有當 User 變化時,打印 User 和 Email 的值
這個例子比較簡單,先貼下源碼:
function App() {
const [email, setEmail] = useState('');
const [user, setUser] = useState('Tom');
useEffect(() => {
console.log(user, email);
}, [user]);
return (
<div style={{ padding: 64 }}>
<label style={{ display: 'block' }}>
User:
<select value={user} onChange={(e) => setUser(e.target.value)}>
<option value="Tom">Tom</option>
<option value="Jack">Jack</option>
</select>
</label>
<label style={{ display: 'block', marginTop: 16 }}>
Email:
<input value={email} onChange={e => setEmail(e.target.value)} />
</label>
</div>
);
}
我們能看到示例代碼中,useEffect 是不符合 React 官方建議的,email 變量沒有放到依賴數(shù)組中,ESLint 警告如下:
那如果按照規(guī)范,我們把依賴項都放到第二個數(shù)組參數(shù)中,會怎樣呢?
useEffect(() => {
console.log(user, email);
}, [user, email]);
如上的代碼雖然符合了 React 官方的規(guī)范,但不滿足我們的業(yè)務需求了,當 email 變化時,也觸發(fā)了函數(shù)執(zhí)行。
此時陷入了困境,當滿足 useEffect 使用規(guī)范時,業(yè)務需求就不能滿足了。當滿足業(yè)務需求時,useEffect 就不規(guī)范了。
我的建議為:
-
不要使用 eslint-plugin-react-hooks插件,或者可以選擇性忽略該插件的警告。 -
只有一種情況,需要把變量放到 deps 數(shù)組中,那就是當該變量變化時,需要觸發(fā) useEffect 函數(shù)執(zhí)行。而不是因為 useEffect 中用到了這個變量!
2. deps 參數(shù)不能緩解閉包問題
假如完全按第二個建議來寫代碼,很多人又擔心,會不會造成一些不必要的閉包問題?我的結(jié)論是:閉包問題和 useEffect 的 deps 參數(shù)沒有太大關(guān)系。
比如我有一個這樣的需求:當進入頁面 3s 后,輸出當前最新的 count。代碼如下:
function Demo() {
const [count, setCount] = useState(0);
useEffect(() => {
const timer = setTimeout(() => {
console.log(count)
}, 3000);
return () => {
clearTimeout(timer);
}
}, [])
return (
<button
onClick={() => setCount(c => c + 1)}
>
click
</button>
)
}
以上代碼,實現(xiàn)了初始化 3s 后,輸出 count。但很遺憾,這里肯定會出閉包問題,哪怕進來之后我們多次點擊了 button,輸出的 count 仍然為 0。
那假如我們把 count 放到 deps 中,是不是就好了?
useEffect(() => {
const timer = setTimeout(() => {
console.log(count)
}, 3000);
return () => {
clearTimeout(timer);
}
}, [count])
如上代碼,此時確實沒有閉包問題了,但在每次 count 變化時,定時器卸載并重新開始計時了,不滿足我們的最初需求了。
要解決的唯一辦法為:
const [count, setCount] = useState(0);
// 通過 ref 來記憶最新的 count
const countRef = useRef(count);
countRef.current = count;
useEffect(() => {
const timer = setTimeout(() => {
console.log(countRef.current)
}, 3000);
return () => {
clearTimeout(timer);
}
}, [])
雖然上面的代碼,很繞,但確實,只有這個解決方案。請記住這段代碼,功能真的很強大。
const countRef = useRef(count);
countRef.current = count;
上面的例子,可以發(fā)現(xiàn),閉包問題是不能僅僅通過遵守 React 規(guī)則來避免的。我們必須清晰的知道,在什么場景下會出現(xiàn)閉包問題。
2.1 正常情況下是不會有閉包問題的
const [a, setA] = useState(0);
const [b, setB] = useState(0);
const c = a + b;
useEffect(()=>{
console.log(a, b, c)
}, [a]);
useEffect(()=>{
console.log(a, b, c)
}, [b]);
useEffect(()=>{
console.log(a, b, c)
}, [c]);
在一般的使用過程中,是不會有閉包問題的,如上代碼中,完全不會有閉包問題,和 deps 怎么寫沒有任何關(guān)系。
2.2 延遲調(diào)用會存在閉包問題
在延遲調(diào)用的場景下,一定會存在閉包問題。 什么是延遲調(diào)用?
-
使用 setTimeout、setInterval、Promise.then 等 -
useEffect 的卸載函數(shù)
const getUsername = () => {
return new Promise((resolve, reject) => {
setTimeout(() => {
resolve('John');
}, 3000);
})
}
function Demo() {
const [count, setCount] = useState(0);
// setTimeout 會造成閉包問題
useEffect(() => {
const timer = setTimeout(() => {
console.log(count);
}, 3000);
return () => {
clearTimeout(timer);
}
}, [])
// setInterval 會造成閉包問題
useEffect(() => {
const timer = setInterval(() => {
console.log(count);
}, 3000);
return () => {
clearInterval(timer);
}
}, [])
// Promise.then 會造成閉包問題
useEffect(() => {
getUsername().then(() => {
console.log(count);
});
}, [])
// useEffect 卸載函數(shù)會造成閉包問題
useEffect(() => {
return () => {
console.log(count);
}
}, []);
return (
<button
onClick={() => setCount(c => c + 1)}
>
click
</button>
)
}
在以上示例代碼中,四種情況均會出現(xiàn)閉包問題,永遠輸出 0。這四種情況的根因都是一樣的,我們看一下代碼的執(zhí)行順序:
-
組件初始化,此時 count = 0 -
執(zhí)行 useEffect,此時 useEffect 的函數(shù)執(zhí)行,JS 引用鏈記錄了對 count=0的引用關(guān)系 -
點擊 button,count 變化,但對之前的引用已經(jīng)無能為力了
可以看到,閉包問題均是出現(xiàn)在延遲調(diào)用的場景下。解決辦法如下:
const [count, setCount] = useState(0);
// 通過 ref 來記憶最新的 count
const countRef = useRef(count);
countRef.current = count;
useEffect(() => {
const timer = setTimeout(() => {
console.log(countRef.current)
}, 3000);
return () => {
clearTimeout(timer);
}
}, [])
......
通過 useRef 來保證任何時候訪問的 countRef.current 都是最新的,以解決閉包問題。
到這里,我重申下我對 useEffect 的建議:
-
只有變化時,需要重新執(zhí)行 useEffect 的變量,才要放到 deps 中。而不是 useEffect 用到的變量都放到 deps 中。 -
在有延遲調(diào)用場景時,可以通過 ref 來解決閉包問題。
3. 盡量不要用 useCallback
我建議在項目中盡量不要用 useCallback,大部分場景下,不僅沒有提升性能,反而讓代碼可讀性變的很差。
3.1 useCallback 大部分場景沒有提升性能
useCallback 可以記住函數(shù),避免函數(shù)重復生成,這樣函數(shù)在傳遞給子組件時,可以避免子組件重復渲染,提高性能。
const someFunc = useCallback(()=> {
doSomething();
}, []);
return <ExpensiveComponent func={someFunc} />
基于以上認知,很多同學(包括我自己)在寫代碼時,只要是個函數(shù),都加個 useCallback,是你么?反正我以前是。
但我們要注意,提高性能還必須有另外一個條件,子組件必須使用了 shouldComponentUpdate 或者 React.memo 來忽略同樣的參數(shù)重復渲染。
假如 ExpensiveComponent 組件只是一個普通組件,是沒有任何用的。比如下面這樣:
const ExpensiveComponent = ({ func }) => {
return (
<div onClick={func}>
hello
</div>
)
}
必須通過 React.memo 包裹 ExpensiveComponent ,才會避免參數(shù)不變的情況下的重復渲染,提高性能。
const ExpensiveComponent = React.memo(({ func }) => {
return (
<div onClick={func}>
hello
</div>
)
})
所以,useCallback 是要和 shouldComponentUpdate/React.memo 配套使用的,你用對了嗎?當然,我建議一般項目中不用考慮性能優(yōu)化的問題,也就是不要使用 useCallback 了,除非有個別非常復雜的組件,單獨使用即可。
3.2 useCallback 讓代碼可讀性變差
我看到過一些代碼,使用 useCallback 后,大概長這樣:
const someFuncA = useCallback((d, g, x, y)=> {
doSomething(a, b, c, d, g, x, y);
}, [a, b, c]);
const someFuncB = useCallback(()=> {
someFuncA(d, g, x, y);
}, [someFuncA, d, g, x, y]);
useEffect(()=>{
someFuncB();
}, [someFuncB]);
在上面的代碼中,變量依賴一層一層傳遞,最終要判斷具體哪些變量變化會觸發(fā) useEffect 執(zhí)行,是一件很頭疼的事情。
我期望不要用 useCallback,直接裸寫函數(shù)就好:
const someFuncA = (d, g, x, y)=> {
doSomething(a, b, c, d, g, x, y);
};
const someFuncB = ()=> {
someFuncA(d, g, x, y);
};
useEffect(()=>{
someFuncB();
}, [...]);
在 useEffect 存在延遲調(diào)用的場景下,可能造成閉包問題,那通過咱們?nèi)f能的方法就能解決:
const someFuncA = (d, g, x, y)=> {
doSomething(a, b, c, d, g, x, y);
};
const someFuncB = ()=> {
someFuncA(d, g, x, y);
};
+ const someFuncBRef = useRef(someFuncB);
+ someFuncBRef.current = someFuncB;
useEffect(()=>{
+ setTimeout(()=>{
+ someFuncBRef.current();
+ }, 1000)
}, [...]);
對 useCallback 的建議就一句話:沒事別用 useCallback。
4. useMemo 建議大量使用
相較于 useCallback 而言,useMemo 的收益是顯而易見的。
// 沒有使用 useMemo
const memoizedValue = computeExpensiveValue(a, b);
// 使用 useMemo
const memoizedValue = useMemo(() => computeExpensiveValue(a, b), [a, b]);
如果沒有使用 useMemo,computeExpensiveValue 會在每一次渲染的時候執(zhí)行。如果使用了 useMemo,只有在 a 和 b 變化時,才會執(zhí)行一次 computeExpensiveValue。
這筆賬大家應該都會算,所以我建議 useMemo 可以大量使用。
當然也不是無節(jié)制的使用,在很簡單的基礎(chǔ)類型計算時,可能 useMemo 并不劃算。
const a = 1;
const b = 2;
const c = useMemo(()=> a + b, [a, b]);
比如上面的例子,請問計算 a+b 的消耗大?還是記錄 a/b ,并比較a/b 是否變化的消耗大?
明顯 a+b 消耗更小。
const a = 1;
const b = 2;
const c = a + b;
這筆賬大家可以自己算,我建議簡單的基礎(chǔ)類型計算,就不要用 useMemo 了~
5. useState 的正確使用姿勢
useState 應該算最簡單的一個 Hooks,但在使用中,也有很多技巧可循,如果嚴格按照以下幾點,代碼可維護性直接翻倍。
5.1 能用其他狀態(tài)計算出來就不用單獨聲明狀態(tài)
一個 state 必須不能通過其它 state/props 直接計算出來,否則就不用定義 state。
const SomeComponent = (props) => {
const [a, setA] = useState(1);
const [b, setB] = useState(2);
const onClick = () => {
const current = a + 1;
setA(current);
setB(current*2)
}
return (
<div onClick={onClick}>
increment
</div>
)
}
上面的示例中,變量 b 可以通過變量 a 計算出來,那就不要定義 b 了!
const SomeComponent = (props) => {
const [a, setA] = useState(1);
const b = a*2;
const onClick = () => {
const current = a + 1;
setA(current);
}
return (
<div onClick={onClick}>
increment
</div>
)
}
一般在項目中此類問題都比較隱晦,層層傳遞,在 Code Review 中很難一眼看出。如果能把變量定義清楚,那事情就成功了一半。
5.2 保證數(shù)據(jù)源唯一
在項目中同一個數(shù)據(jù),保證只存儲在一個地方。
不要既存在 redux 中,又在組件中定義了一個 state 存儲。
不要既存在父級組件中,又在當前組件中定義了一個 state 存儲。
不要既存在 url query 中,又在組件中定義了一個 state 存儲。
function SearchBox({ data }) {
const [searchKey, setSearchKey] = useState(getQuery('key'));
const handleSearchChange = e => {
const key = e.target.value;
setSearchKey(key);
history.push(`/movie-list?key=${key}`);
}
return (
<input
value={searchKey}
placeholder="Search..."
onChange={handleSearchChange}
/>
);
}
在上面的示例中,searchKey 存儲在兩個地方,既在 url query 上,又定義了一個 state。完全可以優(yōu)化成下面這樣:
function SearchBox({ data }) {
const searchKey = parse(localtion.search)?.key;
const handleSearchChange = e => {
const key = e.target.value;
history.push(`/movie-list?key=${key}`);
}
return (
<input
value={searchKey}
placeholder="Search..."
onChange={handleSearchChange}
/>
);
}
在實際項目開發(fā)中,此類問題也是比較隱晦,編碼時應注意。
5.3 useState 適當合并
項目中有木有寫過這樣的代碼:
const [firstName, setFirstName] = useState();
const [lastName, setLastName] = useState();
const [school, setSchool] = useState();
const [age, setAge] = useState();
const [address, setAddress] = useState();
const [weather, setWeather] = useState();
const [room, setRoom] = useState();
反正我最開始是寫過,useState 拆分過細,導致代碼中一大片 useState。
我建議,同樣含義的變量可以合并成一個 state,代碼可讀性會提升很多:
const [userInfo, setUserInfo] = useState({
firstName,
lastName,
school,
age,
address
});
const [weather, setWeather] = useState();
const [room, setRoom] = useState();
當然這種方式我們在變更變量時,一定不要忘記帶上老的字段,比如我們只想修改 firstName:
setUserInfo(s=> ({
...s,
fristName,
}))
其實如果是 React Class 組件,state 是會自動合并的:
this.setState({
firstName
})
在 Hooks 中,可以有這種用法嗎?其實是可以的,我們自己封裝一個 Hooks 就可以,比如 ahooks 的 useSetState[3],就封裝了類似的邏輯:
const [userInfo, setUserInfo] = useSetState({
firstName,
lastName,
school,
age,
address
});
// 自動合并
setUserInfo({
firstName
})
我自己在項目中大量使用了 useSetState 來代替 useState,來管理復雜類型的 state,媽媽更愛我了。
六、總結(jié)
作為資深的 React Hooks 用戶,我很認可 React Hooks 帶來的提效,這也是我這幾年完全擁抱 Hooks 的原因。同時我也越來越覺得 React Hooks 難駕馭,尤其隨著 React 18 的 concurrent mode 的到來,不知道會帶來什么坑。
最后再給大家三個建議:
-
可以多使用別人封裝好的高級 Hooks 來提效,比如 ahooks[4] 庫(哈哈哈 -
可以多看看別人封裝好的 Hooks 源碼,加深對 React Hooks 理解,比如 ahooks[5] 庫(哈哈哈 -
可以關(guān)注下我的公眾號,我會經(jīng)常發(fā)布一些我自己寫的技術(shù)文章,以及轉(zhuǎn)發(fā)一些我認為比較好的文章,愛你喲(づ ̄3 ̄)づ╭?~
Node 社群
我組建了一個氛圍特別好的 Node.js 社群,里面有很多 Node.js小伙伴,如果你對Node.js學習感興趣的話(后續(xù)有計劃也可以),我們可以一起進行Node.js相關(guān)的交流、學習、共建。下方加 考拉 好友回復「Node」即可。
“分享、點贊、在看” 支持一波??
參考資料ahooks: https://github.com/alibaba/hooks
[2]eslint-plugin-react-hooks: https://www.npmjs.com/package/eslint-plugin-react-hooks#installation
[3]useSetState: https://ahooks.js.org/zh-CN/hooks/use-set-state
[4]ahooks: https://github.com/alibaba/hooks
[5]ahooks: https://github.com/alibaba/hooks
