1.useState
-
使用單個 state 變量還是多個 state 變量
useState 的出現,讓我們可以使用多個 state 變量來保存 state,比如:
const [left, setLeft] = useState(0); const [top, setTop] = useState(0);
但同時,我們也可以像 Class 組件的 this.state 一樣,將所有的 state 放到一個 object 中, 這樣只需一個 state 變量即可:
const [state, setState] = useState({ left: 0, top: 0 });
如果使用單個 state 變量,每次更新 state 時需要合并之前的 state。因為 useState 返回的 setState 會替換原來的值。這一點和 Class 組件的 this.setState 不同。this.setState 會把更新的字段自動合并到 this.state 對象中。
const handleMouseMove = (e) => { setState((prevState) => ({ ...prevState, left: e.pageX, top: e.pageY, })) };
使用多個 state 變量可以讓 state 的粒度更細,更易于邏輯的拆分和組合。比如,我們可以將關聯的邏輯提取到自定義 Hook 中:
function usePosition() { const [left, setLeft] = useState(0); const [top, setTop] = useState(0); useEffect(() => { // ... }, []); return [left, top, setLeft, setTop]; }
我們發現,每次更新 left 時 top 也會隨之更新。因此,把 top 和 left 拆分為兩個 state 變量顯得有點多余。
在使用 state 之前,我們需要考慮狀態拆分的「粒度」問題。如果粒度過細,代碼就會變得比較冗余。如果粒度過粗,代碼的可復用性就會降低。那么,到底哪些 state 應該合并,哪些 state 應該拆分呢?我總結了下面兩點:1.將完全不相關的 state 拆分為多組 state。比如 size 和 position。
2.如果某些 state 是相互關聯的,或者需要一起發生改變,就可以把它們合并為一組 state。 比如 left 和 top。function Box() { const [position, setPosition] = usePosition(); const [size, setSize] = useState({width: 100, height: 100}); // ... } function usePosition() { const [position, setPosition] = useState({left: 0, top: 0}); useEffect(() => { // ... }, []); return [position, setPosition]; }
-
使用setState更新state的選擇
傳值更新setState(newState);
函數式更新
如果新的 state 需要通過使用先前的 state 計算得出,那么可以將函數傳遞給 setState。該函數將接收先前的 state,并返回一個更新后的值。下面的計數器組件示例展示了 setState 的兩種用法:function Counter({initialCount}) { const [count, setCount] = useState(initialCount); return ( <> Count: {count} <button onClick={() => setCount(initialCount)}>Reset</button> <button onClick={() => setCount(prevCount => prevCount - 1)}>-</button> <button onClick={() => setCount(prevCount => prevCount + 1)}>+</button> </> ); }
“+” 和 “-” 按鈕采用函數式形式,因為被更新的 state 需要基于之前的 state。但是“重置”按鈕則采用普通形式,因為它總是把 count 設置回初始值。
如果你的更新函數返回值與當前 state 完全相同,則隨后的重渲染會被完全跳過。除此之外,我們還可以在其他地方活用函數式更新。
有時候,你的 effect 可能會使用一些頻繁變化的值。你可能會忽略依賴列表中 state,但這通常會引起 Bug:function Counter() { const [count, setCount] = useState(0); useEffect(() => { const id = setInterval(() => { setCount(count + 1); // 這個 effect 依賴于 `count` state }, 1000); return () => clearInterval(id); }, []); // ?? Bug: `count` 沒有被指定為依賴 return <h1>{count}</h1>; }
傳入空的依賴數組
[]
,意味著該 hook 只在組件掛載時運行一次,并非重新渲染時。但如此會有問題,在setInterval
的回調中,count
的值不會發生變化。因為當 effect 執行時,我們會創建一個閉包,并將count
的值被保存在該閉包當中,且初值為0
。每隔一秒,回調就會執行setCount(0 + 1)
,因此,count
永遠不會超過 1。
指定[count]
作為依賴列表就能修復這個 Bug,但會導致每次改變發生時定時器都被重置。事實上,每個setInterval
在被清除前(類似于setTimeout
)都會調用一次。但這并不是我們想要的。要解決這個問題,我們可以使用setState
的函數式更新形式。它允許我們指定 state 該 如何 改變而不用引用 當前 state:function Counter() { const [count, setCount] = useState(0); useEffect(() => { const id = setInterval(() => { setCount(c => c + 1); // ? 在這不依賴于外部的 `count` 變量 }, 1000); return () => clearInterval(id); }, []); // ? 我們的 effect 不適用組件作用域中的任何變量 return <h1>{count}</h1>; }
此時,setInterval 的回調依舊每秒調用一次,但每次 setCount 內部的回調取到的 count 最新值(在回調中變量命名為 c)。
2.useEffect
-
effect清除
通常,組件卸載時需要清除 effect 創建的諸如訂閱或計時器 ID 等資源。要實現這一點,useEffect 函數需返回一個清除函數。為防止內存泄漏,清除函數會在組件卸載前執行。
- 清除訂閱
useEffect(() => { const subscription = props.source.subscribe(); return () => { // 清除訂閱 subscription.unsubscribe(); }; });
- 避免組件unmount后的state update
useEffect(() => { let ignore = false; async function fetchProduct() { const response = await fetch('http://myapi/product/' + productId); const json = await response.json(); if (!ignore) setProduct(json); } fetchProduct(); return () => { ignore = true }; }, [productId]);
-
使用useEffect deps過多
使用 useEffect hook 時,為了避免每次 render 都去執行它的 callback,我們通常會傳入第二個參數「dependency array」(下面統稱為依賴數組)。這樣,只有當依賴數組發生變化時,才會執行 useEffect 的回調函數。
function Example({id, name}) { useEffect(() => { // 由于依賴數組中不包含 name,所以當 name 發生變化時,無法打印日志 console.log(id, name); }, [id]); }
在 React 中,除了 useEffect 外,接收依賴數組作為參數的 Hook 還有 useMemo、 useCallback 和 useImperativeHandle。我們剛剛也提到了,依賴數組中千萬不要遺漏回調函數內部依賴的值。但是,如果依賴數組依賴了過多東西,可能導致代碼難以維護.
const refresh = useCallback(() => { // ... }, [name, searchState, address, status, personA, personB, progress, page, size]);
不要說內部邏輯了,光是看到這一堆依賴就令人頭大!如果項目中到處都是這樣的代碼,可想而知維護起來多么痛苦。如何才能避免寫出這樣的代碼呢?
首先,你需要重新思考一下,這些 deps 是否真的都需要?看下面這個例子:
function Example({id}) { const requestParams = useRef({}); useEffect(() => { requestParams.current = {page: 1, size: 20, id}; }); const refresh = useCallback(() => { doRefresh(requestParams.current); }, []); useEffect(() => { id && refresh(); }, [id, refresh]); // 思考這里的 deps list 是否合理? }
雖然 useEffect 的回調函數依賴了 id 和 refresh 方法,但是觀察 refresh 方法可以發現,它在首次 render 被創建之后,永遠不會發生改變了。因此,把它作為 useEffect 的 deps 是多余的。
其次,如果這些依賴真的都是需要的,那么這些邏輯是否應該放到同一個 hook 中?
function Example({id, name, address, status, personA, personB, progress}) { const [page, setPage] = useState(); const [size, setSize] = useState(); const doSearch = useCallback(() => { // ... }, []); const doRefresh = useCallback(() => { // ... }, []); useEffect(() => { id && doSearch({name, address, status, personA, personB, progress}); page && doRefresh({name, page, size}); }, [id, name, address, status, personA, personB, progress, page, size]); }
可以看出,在 useEffect 中有兩段邏輯,這兩段邏輯是相互獨立的,因此我們可以將這兩段邏輯放到不同 useEffect 中:
useEffect(() => { id && doSearch({name, address, status, personA, personB, progress}); }, [id, name, address, status, personA, personB, progress]); useEffect(() => { page && doRefresh({name, page, size}); }, [name, page, size]);
如果邏輯無法繼續拆分,但是依賴數組還是依賴了過多東西,該怎么辦呢?就比如我們上面的代碼:
useEffect(() => { id && doSearch({name, address, status, personA, personB, progress}); }, [id, name, address, status, personA, personB, progress]);
這段代碼中的 useEffect 依賴了七個值,還是偏多了。仔細觀察上面的代碼,可以發現這些值都是「過濾條件」的一部分,通過這些條件可以過濾頁面上的數據。因此,我們可以將它們看做一個整體,也就是我們前面講過的合并 state:
const [filters, setFilters] = useState({ name: "", address: "", status: "", personA: "", personB: "", progress: "" }); useEffect(() => { id && doSearch(filters); }, [id, filters]);
如果 state 不能合并,在 callback 內部又使用了 setState 方法,那么可以考慮使用 setState callback 來減少一些依賴。比如:
const useValues = () => { const [values, setValues] = useState({ data: {}, count: 0 }); const [updateData] = useCallback( (nextData) => { setValues({ data: nextData, count: values.count + 1 // 因為 callback 內部依賴了外部的 values 變量,所以必須在依賴數組中指定它 }); }, [values], ); return [values, updateData]; };
上面的代碼中,我們必須在 useCallback 的依賴數組中指定 values,否則我們無法在 callback 中獲取到最新的 values 狀態。但是,通過 setState 回調函數,我們不用再依賴外部的 values 變量,因此也無需在依賴數組中指定它。就像下面這樣:
const useValues = () => { const [values, setValues] = useState({}); const [updateData] = useCallback((nextData) => { setValues((prevValues) => ({ data: nextData, count: prevValues.count + 1, // 通過 setState 回調函數獲取最新的 values 狀態,這時 callback 不再依賴于外部的 values 變量了,因此依賴數組中不需要指定任何值 })); }, []); // 這個 callback 永遠不會重新創建 return [values, updateData]; };
說了這么多,歸根到底都是為了寫出更加清晰、易于維護的代碼。如果發現依賴數組依賴過多,我們就需要重新審視自己的代碼。
1.依賴數組依賴的值最好不要超過 3 個,否則會導致代碼會難以維護。
2.如果發現依賴數組依賴的值過多,我們應該采取一些方法來減少它。
3.去掉不必要的依賴。
4.將 Hook 拆分為更小的單元,每個 Hook 依賴于各自的依賴數組。
5.通過合并相關的 state,將多個依賴值聚合為一個。
6.通過 setState 回調函數獲取最新的 state,以減少外部依賴。 -
useMemo
該不該使用 useMemo?對于這個問題,有的人從來沒有思考過,有的人甚至不覺得這是個問題。不管什么情況,只要用 useMemo 或者 useCallback 「包裹一下」,似乎就能使應用遠離性能的問題。但真的是這樣嗎?有的時候 useMemo 沒有任何作用,甚至還會影響應用的性能。
為什么這么說呢?首先,我們需要知道 useMemo本身也有開銷。useMemo 會「記住」一些值,同時在后續 render 時,將依賴數組中的值取出來和上一次記錄的值進行比較,如果不相等才會重新執行回調函數,否則直接返回「記住」的值。這個過程本身就會消耗一定的內存和計算資源。因此,過度使用 useMemo 可能會影響程序的性能。
要想合理使用 useMemo,我們需要搞清楚 useMemo 適用的場景:
- 有些計算開銷很大,我們就需要「記住」它的返回值,避免每次 render 都去重新計算。
- 由于值的引用發生變化,導致下游組件重新渲染,我們也需要「記住」這個值。
讓我們來看個例子:
interface IExampleProps { page: number; type: string; } const Example = ({page, type}: IExampleProps) => { const resolvedValue = useMemo(() => { return getResolvedValue(page, type); }, [page, type]); return <ExpensiveComponent resolvedValue={resolvedValue}/>; };
在上面的例子中,渲染 ExpensiveComponent 的開銷很大。所以,當 resolvedValue 的引用發生變化時,作者不想重新渲染這個組件。因此,作者使用了 useMemo,避免每次 render 重新計算 resolvedValue,導致它的引用發生改變,從而使下游組件 re-render。
這個擔憂是正確的,但是使用 useMemo 之前,我們應該先思考兩個問題:
1.傳遞給 useMemo 的函數開銷大不大?在上面的例子中,就是考慮 getResolvedValue 函數的開銷大不大。JS 中大多數方法都是優化過的,比如 Array.map、Array.forEach 等。如果你執行的操作開銷不大,那么就不需要記住返回值。否則,使用 useMemo 本身的開銷就可能超過重新計算這個值的開銷。因此,對于一些簡單的 JS 運算來說,我們不需要使用 useMemo 來「記住」它的返回值。
2.當輸入相同時,「記憶」值的引用是否會發生改變?在上面的例子中,就是當 page 和 type 相同時,resolvedValue 的引用是否會發生改變?這里我們就需要考慮 resolvedValue 的類型了。如果 resolvedValue 是一個對象,由于我們項目上使用「函數式編程」,每次函數調用都會產生一個新的引用。但是,如果 resolvedValue 是一個原始值(string, boolean, null, undefined, number, symbol),也就不存在「引用」的概念了,每次計算出來的這個值一定是相等的。也就是說,ExpensiveComponent 組件不會被重新渲染。
因此,如果 getResolvedValue 的開銷不大,并且 resolvedValue 返回一個字符串之類的原始值,那我們完全可以去掉 useMemo,就像下面這樣:
interface IExampleProps { page: number; type: string; } const Example = ({page, type}: IExampleProps) => { const resolvedValue = getResolvedValue(page, type); return <ExpensiveComponent resolvedValue={resolvedValue}/>; };
保持引用不變
// 使用 useMemo function Example() { const users = useMemo(() => [1, 2, 3], []); return <ExpensiveComponent users={users} /> }
在上面的例子中,我們用 useMemo 來「記住」users 數組,不是因為數組本身的開銷大,而是因為 users 的引用在每次 render 時都會發生改變,從而導致子組件 ExpensiveComponent 重新渲染(可能會帶來較大開銷)。
在編寫自定義 Hook 時,返回值一定要保持引用的一致性。因為你無法確定外部要如何使用它的返回值。如果返回值被用做其他 Hook 的依賴,并且每次 re-render 時引用不一致(當值相等的情況),就可能會產生 bug。比如:
function Example() { const data = useData(); const [dataChanged, setDataChanged] = useState(false); useEffect(() => { setDataChanged((prevDataChanged) => !prevDataChanged); // 當 data 發生變化時,調用 setState。如果 data 值相同而引用不同,就可能會產生非預期的結果。 }, [data]); console.log(dataChanged); return <ExpensiveComponent data={data} />; } const useData = () => { // 獲取異步數據 const resp = getAsyncData([]); // 處理獲取到的異步數據,這里使用了 Array.map。因此,即使 data 相同,每次調用得到的引用也是不同的。 const mapper = (data) => data.map((item) => ({...item, selected: false})); return resp ? mapper(resp) : resp; };
在上面的例子中,我們通過 useData Hook 獲取了 data。每次 render 時 data 的值沒有發生變化,但是引用卻不一致。如果把 data 用到 useEffect 的依賴數組中,就可能產生非預期的結果。另外,由于引用的不同,也會導致 ExpensiveComponent 組件 re-render,產生性能問題。
因此,在使用 useMemo 之前,我們不妨先問自己幾個問題:
1.要記住的函數開銷很大嗎?
2.返回的值是原始值嗎?
3.記憶的值會被其他 Hook 或者子組件用到嗎?一、應該使用 useMemo 的場景
1.保持引用相等
- 對于組件內部用到的 object、array、函數等,如果用在了其他 Hook 的依賴數組中,或者作為 props 傳遞給了下游組件,應該使用 useMemo。
- 自定義 Hook 中暴露出來的 object、array、函數等,都應該使用 useMemo 。以確保當值相同時,引用不發生變化。
- 使用 Context 時,如果 Provider 的 value 中定義的值(第一層)發生了變化,即便用了 Pure Component 或者 React.memo,仍然會導致子組件 re-render。這種情況下,仍然建議使用 useMemo 保持引用的一致性。
- 成本很高的計算
二、無需使用 useMemo 的場景
- 如果返回的值是原始值: string, boolean, null, undefined, number, symbol(不包括動態聲明的 Symbol),一般不需要使用 useMemo。
- 僅在組件內部用到的 object、array、函數等(沒有作為 props 傳遞給子組件),且沒有用到其他 Hook 的依賴數組中,一般不需要使用 useMemo。
-
useRef
useRef 返回一個可變的 ref 對象,其 .current 屬性被初始化為傳入的參數(initialValue)。返回的 ref 對象在組件的整個生命周期內保持不變。
1.使用ref訪問子組件function TextInputWithFocusButton() { const inputEl = useRef(null); const onButtonClick = () => { // `current` 指向已掛載到 DOM 上的文本輸入元素 inputEl.current.focus(); }; return ( <> <input ref={inputEl} type="text" /> <button onClick={onButtonClick}>Focus the input</button> </> ); }
除此之外,
useRef()
比ref
屬性更有用。它可以很方便地保存任何可變值,其類似于在 class 中使用實例字段的方式。
這是因為它創建的是一個普通 Javascript 對象。而useRef()
和自建一個{current: ...}
對象的唯一區別是,useRef
會在每次渲染時返回同一個 ref 對象。- 使用useRef保證引用不變
// 使用 useRef function Example() { const {current: users} = useRef([1, 2, 3]); return <ExpensiveComponent users={users} /> }
- 使用ref保存可變變量
實現獲取上一輪的 props 或 state
function Counter() { const [count, setCount] = useState(0); const prevCount = usePrevious(count); return <h1>Now: {count}, before: {prevCount}</h1>; } function usePrevious(value) { const ref = useRef(); useEffect(() => { ref.current = value; }); return ref.current; }
實現effect deps減少
function Example(props) { // 把最新的 props 保存在一個 ref 中 const latestProps = useRef(props); useEffect(() => { latestProps.current = props; }); useEffect(() => { function tick() { // 在任何時候讀取最新的 props console.log(latestProps.current); } const id = setInterval(tick, 1000); return () => clearInterval(id); }, []); // 這個 effect 從不會重新執行 }