React Hook 避坑指南(useState & useEffect)
useState
const [state, setState] = useState(initialState);
返回一個 state,以及更新 state 的函數。
在初始渲染期間,返回的狀態(tài) (
state) 與傳入的第一個參數 (initialState) 值相同。setState函數用于更新 state。它接收一個新的 state 值并將組件的一次重新渲染加入隊列。
state的更新
通過 setState 方法可以更新state。例如:查看在線示例
const [count, setCount] = useState(0);function handleOnClick() {setCount(count + 1);setCount(count + 1);setCount(count + 1);}return (<div><div>count: {count}</div><button onClick={handleOnClick}>+1</button></div>);
如果點擊按鈕后連續(xù)調用3次 setCount(count + 1),你會發(fā)現界面上count的值并沒有 +3,仍然是 + 1。
函數式更新
如果新的 state 需要通過使用先前的 state 計算得出,那么可以將函數傳遞給 setState。該函數將接收先前的 state,并返回一個更新后的值。
setCount(count => count + 1);setCount(count => count + 1);setCount(count => count + 1);
更新對象
當useState的值為對象時,可能會存在視圖不更新的情況,例如:查看在線示例
const [list, setList] = useState([0, 1, 2]);const [useInfo, setUserInfo] = useState({name: "張三",age: 18});function handleOnClick() {list.push(4);list.push(4);setList(list);useInfo.name = "李四";useInfo.age = 20;setUserInfo(useInfo);}return (<div><p>姓名:{useInfo.name}</p><p>年齡:{useInfo.age}</p><p>ist.length: {list.length}</p><button onClick={handleOnClick}>修改</button></div>);
問題原因:React 中默認是淺監(jiān)聽,當state的值為對象時,棧中存的是對象的引用(地址),setState改變的是堆中的數據,棧中的地址還是原地址,React淺監(jiān)聽到地址沒變,故會認為State并未改變,所以沒有重渲染頁面。
解決方案:只要改變了原對象的地址即可,可通過以下幾種方式實現
將原對象進行克隆
使用ES6的拓展運算符
對于數組我們可以使用一些數組自身的方法來進行深拷貝:
// 使用Array.sliceconst nextList = list.slice(0);nextList.push("slice");setList(nextList);// 使用Array.concatconst nextList = list.concat();nextList.push("concat");setList(nextList);
總結:無論是在 useState 中,還是傳入函數中的參數,都不要直接去操作對象本身,先克隆出一份來再操作,避免引起一些意想不到的問題。
無法在setSate后拿到最新的值
由于setSate后并不會立即更新,React會在某個時候將多個 setSate進行合并后再更新。因此無法在 setState后拿到最新的值。一般有以下幾種方式可以拿到最新值:
使用
useRef,但是數據的更新不會引起視圖的更新使用
useEffect,這種方式在很多場景下也不適用,每次更新都會執(zhí)行useEffect中的內容,往往我們在需求并不是如此使用函數式更新
使用 ahooks 的
useGetState【原理:使用useRef將useState的值存起來】
查看在線示例
const [count, setCount] = useState(0);const countRef = useRef(0);useEffect(() => {console.log("useEffect", count);}, [count]);function handleOnClick() {countRef.current += 1;setCount(count + 1);console.log("正常打印", count);console.log("countRef", countRef.current);setCount(count => {console.log("函數式更新獲取最新值", count);return count;});}return (<div><div>count: {count}</div><button onClick={handleOnClick}>+1</button></div>);
查看在線示例
const useGetState = (initiateState) => {const [state, setState] = useState(initiateState);const stateRef = useRef(state);stateRef.current = state;const getState = useCallback(() => stateRef.current, []);return [state, setState, getState];};
定時器中獲取最新值
在下面的例子中,無論是視圖還是打印,count 的值永遠都是0。查看在線示例
const [count, setCount] = useState(0);useEffect(() => {const interval = setInterval(() => {console.log(count);setCount(count + 1);}, 1000);return () => {clearInterval(interval);}}, []);
問題原因:定時器在創(chuàng)建后一直都沒有被清除,因此內部獲取的狀態(tài)始終都是創(chuàng)建時state的狀態(tài)
解決方案:
(1)定時器內部更新state使用函數式更新,函數式更新可以獲取到state的最新狀態(tài)。此方法可以解決視圖更新問題,但是在定時器中的打印仍然是0。
(2)將state作為 useEffect 的依賴,state發(fā)生變化后會重新創(chuàng)建定時器
useEffect
如果你熟悉 React class 的生命周期函數,你可以把
useEffectHook 看做componentDidMount,componentDidUpdate和componentWillUnmount這三個函數的組合。
與 componentDidMount、componentDidUpdate 不同的是,傳給 useEffect 的函數會在瀏覽器完成布局與繪制之后,在一個延遲事件中被調用。這使得它適用于許多常見的副作用場景,比如設置訂閱和事件處理等情況,因為絕大多數操作不應阻塞瀏覽器對屏幕的更新。查看官方文檔
import React, { useState, useEffect } from 'react';function Example() {const [count, setCount] = useState(0);// Similar to componentDidMount and componentDidUpdate:useEffect(() => {// Update the document title using the browser APIdocument.title = `You clicked ${count} times`;});return (<div><p>You clicked {count} times</p><button onClick={() => setCount(count + 1)}>Click me</button></div>);}
useEffect 在每次渲染后都會執(zhí)行,包括第一次渲染后和每次更新。React 保證了每次運行 effect 的同時,DOM 都已經更新完畢。
可以通過第二個參數來控制 useEffect 在什么情況下才執(zhí)行:查看在線示例
import { useState, useEffect } from "react";export default () => {const [count, setCount] = useState(0);const [number, setNumber] = useState(0);// 沒有任何依賴,每次重新渲染都要執(zhí)行useEffect(() => {console.log("null", count);});// 依賴值為空,只在第一次渲染后執(zhí)行一次useEffect(() => {console.log("[]", count);}, []);// 只有依賴值發(fā)生變化后,才會執(zhí)行;第一次渲染也會執(zhí)行useEffect(() => {console.log("count", count);}, [count]);function addCount() {setCount(count + 1);}function addNumber() {setNumber(number + 1);}return (<div><div>count: {count}</div><div>number: {number}</div><button onClick={addCount}>count+1</button><button onClick={addNumber}>number+1</button></div>);};
依賴值為對象的時
我們經常會將一個對象作為依賴,一般我們都是希望對象的內容發(fā)生變化時,去執(zhí)行某些操作。在實際的業(yè)務開發(fā)中,我們會遇到一些莫名其妙的坑,列舉幾個常見的現象:
明明對象的內容已經發(fā)生了變化,但是為什么沒有觸發(fā)useEffect
明明對象的內容沒有發(fā)生變化,但是為什么一直觸發(fā)useEffect
這看起來有點像在說繞口令,出現問題的本質就是因為對象是引用類型,通過下面幾個例子可以更加深入的理解
案例1:改變對象中的屬性值,未觸發(fā)useEffect
const [info, setInfo] = useState({name: "張三",age: 18});useEffect(() => {console.log("info", info);}, [info]);function handleChangeName(e) {const value = e.target.value;setInfo((info) => {info.name = value;return info;});}return <input onChange={handleChangeName} />;
問題原因:調用 setInfo 時,是直接改變的入參,此時返回改變后的信息其引用是沒有發(fā)生變化的。
注意點:在任何情況下,都不能直接去改變入參,或者是直接改變state值本身。
// 錯誤寫法info.name = value;setInfo(info);// 錯誤寫法setInfo((info) => {info.name = value;return info;});// 正確寫法setInfo({...info,name: value});// 正確寫法setInfo((info) => {return {...info,name: value};});
案例2:接受父組件的對象屬性作為依賴,useEffect頻繁觸發(fā)
開發(fā)組件時,對某些屬性需要設置默認值,一般的寫法就是解構props時同時賦予默認值
const {count = 0,list = []= props;
如果父組件沒有傳遞list屬性,每當父組件重新渲染時,子組件會跟隨重新渲染,每次渲染都會觸發(fā)useEffect。在線查看示例
import { useState, useEffect } from "react";const Com = () => {const [count, setCount] = useState(0);function hanleOnClick() {setCount((count) => count + 1);}return (<div><button onClick={hanleOnClick}>add</button><SubCom count={count} /></div>);};const SubCom = (props) => {const { list = [], count } = props;useEffect(() => {console.log(list);}, [list]);return <div>子組件{count}</div>;};export default Com;
問題原因:當父組件更新時,會重新渲染子組件,每次渲染,props.list 都被賦予了新的引用, 雖然看起來都是空數組,但是useEffect 是判斷l(xiāng)ist的引用發(fā)生了變化,所以就會執(zhí)行。一旦該組件用于復雜場景,導致更新頻繁就會出現白屏現象。
正確寫法:在用到的地方去做兼容處理,而不是直接賦予默認值。
案例3:對象內容未變化時,我們不希望觸發(fā)useEffect
將對象作為依賴時,往往都是希望其內容發(fā)生變化時,才觸發(fā)相應的執(zhí)行。但是 useEffect 的本質是監(jiān)聽引用的變化,很多情況下這與我們實際的業(yè)務開發(fā)有點不相符。
業(yè)務層經常會對一些狀態(tài)進行重置,
setState([])或者setState({})。有可能本身state的值就是[]或者{},重置后,內容未發(fā)生變化,但是引用已經改變,從而導致觸發(fā)useEffect。查看在線示例
import { useState, useEffect } from "react";const Com = () => {const [list, setList] = useState([]);function reset() {setList([]);}return (<div><p>{list.join(",")}</p><button onClick={reset}>reset</button><SubCom list={list} /></div>);};const SubCom = (props) => {const { list } = props;useEffect(() => {console.log(list);}, [list]);return <div>子組件</div>;};export default Com;
解決方案:
將對象轉為字符串后再作為useEffect的依賴。
useEffect(() => {console.log(list);}, [JSON.stringify(list)]);
使用 ahooks 的 useDeepCompareEffect 來解決。用法與 useEffect 一致,但 deps 通過 lodash isEqual 進行深比較。
import { useRef } from 'react';import type { DependencyList, useEffect, useLayoutEffect } from 'react';import isEqual from 'lodash/isEqual';type EffectHookType = typeof useEffect | typeof useLayoutEffect;type CreateUpdateEffect = (hook: EffectHookType) => EffectHookType;const depsEqual = (aDeps: DependencyList = [], bDeps: DependencyList = []) => {return isEqual(aDeps, bDeps);};export const createDeepCompareEffect: CreateUpdateEffect = (hook) => (effect, deps) => {const ref = useRef<DependencyList>();const signalRef = useRef<number>(0);// 本地更新的依賴值與緩存的依賴深比較if (deps === undefined || !depsEqual(deps, ref.current)) {// 將依賴保存一份ref.current = deps;// 如果發(fā)現變更,則改變signalRef的值,是為了觸發(fā)真正的useEffectsignalRef.current += 1;}hook(effect, [signalRef.current]);};
案例4:第一次渲染時,不希望觸發(fā)useEffect
useEffect 在 第一次渲染后和每次更新 都會執(zhí)行。
有的業(yè)務場景并不希望在第一次加載的時候觸發(fā),此場景可通過創(chuàng)建一個標志位來解決。當然可以直接使用 ahooks 中的 useUpdateEffect 這個hook,其原理也是使用標志位來實現的。查看在線示例
import { useState, useEffect, useRef } from "react";export default () => {const [count, setCount] = useState(0);const isMounted = useRef(false);// 第一次渲染置為falseuseEffect(() => {isMounted.current = false;}, []);useEffect(() => {console.log("第一渲染時會執(zhí)行");}, [count]);// 第一次渲染將標志位置為trueuseEffect(() => {if (!isMounted.current) {isMounted.current = true;} else {console.log("第一渲染時不會執(zhí)行,后續(xù)更新才會執(zhí)行");}}, [count]);return (<div><button onClick={() => setCount((c) => c + 1)}>+1</button></div>);};
案例5:兩個useEffect更新相互依賴,無限更新導致白屏
const {value,defaultValue = 0.5,onChange} = props;const [innerValue, setInnerValue] = useState<number>(defaultValue);// 取名為useEffect1useEffect(() => {if (value !== undefined) {setInnerValue(value);}}, [value]);// 取名為useEffect2useEffect(() => {onChange?.(innerValue);}, [innerValue]);
組件功能:這里是一個自定義的表單組件,其中 value 是受控屬性,當改變表單值時,通過 onChange 通知上層,上層改變 value 值。
如果業(yè)務層在初始化時,對value 賦予的初始值不是undefined 并且不等于 defaultValue 的值,則會導致白屏現象,下面來分析一下整個過程:
假設業(yè)務層對 value 賦予了一個初始值0.6。在第一次加載時,useEffect1 和 useEffect2 都會執(zhí)行一遍。
useEffect1 執(zhí)行時,會將 innerValue 的值設置為 0.6
useEffect2 執(zhí)行時,會將 innerValue 的值通過onChange 方法通知到業(yè)務層,這里要注意,此時的 innerValue 值為 defaultValue 的值,是0.5 。并不是 useEffect1 中改變后的 0.6;
當業(yè)務層監(jiān)聽到調用了 onChange 時,會將 onChange 傳過來的值也就是0.5更新到 value上。
當進入第二次更新時,useEffect1 監(jiān)聽到 value 的值從0.6變?yōu)榱?.5,因此會執(zhí)行useEffect1 。useEffect2 監(jiān)聽到 innerValue 的值從0.5 變?yōu)榱?.6,因此也會執(zhí)行useEffect2,從而又觸發(fā)了onChange
由于 value 與 innerValue 的值永遠都在同一次更新中,更新為了不同的值,會導致這個更新會無限的循環(huán)執(zhí)行下去,從而導致白屏。

問題點:
在第一次加載時,就會觸發(fā)useEffect2導致調用onChange方法。
如果是業(yè)務層手動變更了value值,也會觸發(fā)onChange
正確寫法:
在真正手動改變表單值的時候,去調用 onChange,而不是直接去使用useEffect監(jiān)聽innerValue的變化
案例6:不要將普通變量作為依賴
查看在線示例
import { useState, useEffect } from "react";export default () => {const [count, setCount] = useState(0);const list = [];useEffect(() => {console.log("觸發(fā)useEffect", count);}, [list]);return (<div><p>{count}</p><button onClick={() => setCount((c) => c + 1)}>+1</button></div>);};
問題原因: 組件在每次更新時,會對list賦予新的值,與 案例2 原理相同。
案例7:依賴監(jiān)聽useRef的值,有時可以觸發(fā)更新,有時無法觸發(fā)更新
查看在線示例
import { useState, useEffect, useRef } from "react";export default () => {const [count, setCount] = useState(0);const countRef = useRef(0);// 取名為useEffect1useEffect(() => {console.log("count", count);}, [count]);// 取名為useEffect2useEffect(() => {console.log("countRef", countRef);}, [countRef.current]);return (<div><p>{count}</p><button onClick={() => setCount((c) => c + 1)}>button1</button><button onClick={() => (countRef.current += 1)}>button2</button></div>);};
現象:
點擊
button1時,會觸發(fā)useEffect1點擊
button2時,不會觸發(fā)useEffect2再次點擊
button1時,會觸發(fā)useEffect1和useEffect2
問題原因:只有狀態(tài)變更的時候,才會觸發(fā)更新,而狀態(tài)變更,只有 useState 和 useReducer 可以觸發(fā)更新。
使用指南:建議不要使用 useRef 的值作為依賴,除非你十分確定當 useRef 的值改變時,有state發(fā)生了改變。

