
useState
useState 可以說是我們日常最常用的 hook 之一了,在實際使用過程中,有一些簡單的小技巧能幫助你提升性能 & 減少出 bug 的概率。
- 使用 惰性初始值 (https://reactjs.org/docs/hooks-reference.html#lazy-initial-state)
通常我們會使用以下的方式初始化 state。
const [state, useState] = useState(0);
對于簡單的初始值,這樣做完全沒有任何性能問題,但如果初始值是根據復雜計算得出來的,我們這么寫就會產生性能問題。
const initalState = heavyCompute(() => { /* do some heavy compute here*/});
const [state,useState] = useState(initalState);
相信你已經發現這里的問題了,對于 useState 的初始值,我們只需要計算一次,但是根據 React Function Component 的渲染邏輯,在每一次 render 的時候,都會重新調用該函數,因此 initalState 在每次 render 時都會被重新計算,哪怕它只在第一次渲染的時候被用到,這無疑會造成嚴重過的性能問題,我們可以通過 useState 的惰性初始值來解決這個問題。
// 這樣初始值就只會被計算一次了
const [state,useState] = useState(() => heavyCompute(() => { /* do some heavy compute here*/}););
不要把只需要計算一次的的東西直接放在函數組件內部頂層 block 中。
- 使用 函數式更新 (https://zh-hans.reactjs.org/docs/hooks-reference.html#functional-updates)
當我們想更新 state 的時候,我們通常會這樣調用 setState。
const [state,setState] = useState(0);
setState(state + 1);
// next render
// state = 1
看上去沒有任何問題,我們來看看另外一個 case。
const Demo: FC = () => {
const [state,setState] = useState(0);
useEffect(() => {
setTimeout(() => setState(state + 1), 3000);
},[]);
return <div notallow={() => setState(state + 1)}>{state}</div>;
};
點擊 div,我們可以看到計數器增加,那么 3 秒過后,計數器的值會是幾呢?
答案是 1
這是一個非常反直覺的結果,原因在于第一次運行函數時,state 的值為 0,而 setTimeout 中的回調函數捕獲了第一次運行 Demo 函數時 state 的值,也就是 0,所以 setState(state + 1)執行后 state 的值變成了 1,哪怕當前 state 值已經不是 0 了。
讓我們通過函數式更新修復這個問題。
const Demo: FC = () => {
const [state,setState] = useState(0);
useEffect(() => {
setTimeout(() => setState(prev => prev + 1), 3000);
},[]);
return <div notallow={() => setState(prev => prev + 1)}>{state}</div>;
};
讓我們再運行一次程序試試,這次 3 秒后,state 并沒有變成 1,而是增加了 1。
直接在 setState 中依賴 state 計算新的 state 在異步執行的函數中會由于 js 閉包捕獲得到預期外的結果,此時可以使用 setState(prev => getNewState(prev)) 函數式更新來解決。
- 使用 useImmer (https://github.com/immerjs/use-immer) 替代 useState。
相信很多同學聽過 immer.js 這個庫,簡單來說就是基于 proxy 攔截 getter 和 setter 的能力,讓我們可以很方便的通過修改對象本身,創建新的對象,那么這有什么用呢?我們知道,React 通過 Object.is 函數比較 props,也就是說對于引用一致的對象,react是不會刷新視圖的,這也是為什么我們不能直接修改調用 useState 得到的 state 來更新視圖,而是要通過 setState 刷新視圖,通常,為了方便,我們會使用 es6 的 spread 運算符構造新的對象(淺拷貝)。
const [state,setState] = useState({
a: 1,
b: {
c: [1,2]
d: 2
},
});
setState({
...state,
b: {
...state.b,
c: [...state.b.c, 3],
},
})
我相信你已經發現問題了,對于嵌套層級多的對象,使用 spread 構造新的對象寫起來心智負擔很大,也不易于維護,這時候聰明的你肯定想到了,我直接 deepClone,修改后再 setState 不就完事了。
const [state,setState] = useState({
a: 1,
b: {
c: [1,2]
d: 2
},
});
const newState = deepClone(state);
newState.b.c.push(3);
setState(newState);
這樣就完全沒有心智負擔的問題了,程序也運作良好,然而,這不是沒有代價的,且不說 deepClone 本身對于嵌套層級復雜的對象就非常耗時,同時因為整個對象都是 deepClone 過來的,而不是淺拷貝,react 認為整個大對象都變了,這時候使用到對象里的引用值的組件也都會刷新,哪怕這兩個引用前后值根本沒有變化。
有沒有兩全其美的方法呢?
當然是用的,這里就要用到我們提到的 immer.js 了。
const [state,setState] = useState({
a: 1,
b: {
c: [1,2]
d: 2
},
});
setState(produce(state, draft => {
draft.b.c.push(3);
}))
這里我們可以看到,即使用了 deepClone 沒有心智負擔的寫法,同時 immer 只會改變寫部分的引用 (也就是所謂的“Copy On Write”),其余沒用變動的部分引用保持不變,react 會跳過這部分的更新,這樣我們就同時獲得了簡易的寫法和良好的性能。
事實上,我們還可以使用 useImmer 這個語法糖來進一步簡化調用方式。
const [state,setState] = useImmer({
a: 1,
b: {
c: [1,2]
d: 2
},
});
setState(prev => {
prev.b.c.push(3);
}))
可以看到,使用 useImmer 之后,setState 幾乎跟原生的 useState提供的函數式更新 api 一模一樣,只不過,你可以在 setState 內直接修改對象生成新的 state ,同時 useImmer 還對普通的 setState 用法做了兼容,你也可以直接在 setState 內返回新的 state,完全沒有心智負擔。
useEffect
前面講了useState,那么還有一個開發過程中最常用的就是 useEffect 了,接下來來聊聊我的日常使用過程中 useEffect 的坑吧。
在很多情況下,我們可能會寫出這樣的代碼
// dep1和dep2分別是兩個獨立的state
useEffect(() => {
setDep2(compute(dep1))
},[dep1])
咋一眼看上去沒有任何問題,dep1 這個 state 變動的時候我更新一下 dep2 的值,然而這其實是一種反模式,我們可能會習慣性的把 useEffect 當成一個 watch 來用,但每次我們 setState 過后,函數組件又會重新執行一遍,useEffect 也會重新跑一遍,這里你肯定會想,那不是成死循環了,但其實不然,useEffect 提供的第二個參數允許我們傳遞依賴項,在依賴項不變的情況下會跳過 effect 執行,這才讓我們的代碼可以正常運行。
所以到這里,聰明的你肯定已經發現問題了么?
要是我 dep 數組寫的不對,那不是有可能出現無限循環?
在實際開發過程中,如此高的心智負擔必然不利于代碼的維護,因此我們來聊一聊什么是 effect,setState 又該在哪里調用,我們來看一個圖:

這里的 input / output 部分即 IO,也就是副作用,Input 產生一些值,而中間的純函數對 Input 做一些轉換,最終生成一堆數據,通過 output driver 執行副作用,也就是渲染,修改標題等操作,對應到 React 中,我們可以這樣理解。
所有使用 hook 得到的狀態即為 input 副作用產生的值。
function 組件函數本身是中間轉換的純函數。
React.render 函數作為 driver 負責讀取轉換好之后的值,并且執行渲染這個副作用 (其它的副作用在 useEffect 和 useLayoutEffect中執行 )。
基于以上的心智模型,我們可以得出這么幾個結論:
- 不要直接在函數頂層 block 中調用 setState 或者執行其它副作用的操作!
(提醒一下,直接在函數頂層 block 中調用 setState,if 條件一下沒寫好,組件就掛了)
- 所有組件內部狀態的轉換都應該歸于純函數中,不要把 useEffect 當成 watch 來用。
我們可以使用這種方式計算新的 state。
const Demo = () => {
const [dep1,setDep1] = useState(0);
const dep2 = compute(dep1);
}
(注意這里并沒有使用 useMemo )
在 React 中,每次渲染都會重新調用函數,因此直接寫在函數體內的自然就是 compute state ,在沒有嚴重性能問題的情況下不推薦使用 useMemo, 依賴項寫錯了容易出 bug。
- 盡可能在 event 中執行 setState,以確??深A測的 state 變化,例如:
- onClick 事件
- Promise
- setTimeout
- setInterval
- ...
- 依賴項為空數組的 useEffect 中,可以放心調用 setState。
useEffect(() => {
const timer = setInterval(() => { setState(newState) },1000)
return () => clearInterval(timer);
,[]);
- 不要同時使用一堆依賴項 & 多個 useEffect !!!
如果你寫過以下的代碼:
useEffect(() => {
// do something and set some state
// setDep3
// setDep4
},[dep1,dep2])
useEffect(() => {
// do something and set some state
},[dep3,dep4])
這樣的代碼非常容易造成循環依賴的問題,而且一旦出了問題,非常難排查很解決,整個 state 的更新很難預測,相關的 state 更新如果建議一次更新 (可以考慮使用 useReducer 并且在可能的情況下,盡量將狀態更新放到事件而不是 useEffect 里)。
useContext
在多個組件共享狀態以及要向深層組件傳遞狀態時,我們通常會使用 useContext 這個 hook 和 createContext 搭配,也就是下面這樣:
const Context = React.createContext();
const App = () => {
const sharedState = useState({});
return <Context.Provider value={sharedState} >
<A />
<B />
</Context.Provider>
}
const A = () => {
const [state,setState] = useContext(Context);
return <div notallow={() => {setState(prev => ({...prev, a: prev.a+1}))}}>{state.a}</div>
}
const B = () => {
const [state,setState] = useContext(Context);
return <div notallow={() => {setState(prev => ({...prev, b: prev.b+1}))}}>{state.b}</div>
}
這也是 React 官方推薦的共享狀態的方式,然而在需要共享狀態的組件非常多的情況下,這有著嚴重的性能問題,在上述例子里,哪怕 A 組件只更新 state.a,并沒有用到 state.b,B 組件更新 state.b 的時候 A 組件也會刷新,在組件非常多的情況下,就卡死了,用戶體驗非常不好。好在這個地方有很多種方法可以解決這個問題,這里我要推薦最簡單的一種,也就是 react-tracked (https://react-tracked.js.org/) 這個庫,它擁有和 useContext 差不多的 api,但基于 proxy 和組件內部的 useForceUpdate 做到了自動化的追蹤,可以精準更新每個組件,不會出現修改大的 state,所有組件都刷新的情況。
import { useState } from 'react';
import { createContainer } from 'react-tracked';
// 聲明
const initialState = {
count: 0,
text: 'hello',
};
const useMyState = () => useState(initialState);
export const { Provider: SharedStateProvider, useTracked: useSharedState } =
createContainer(useMyState);
// 使用
const Counter = () => {
const [state, setState] = useSharedState();
const increment = () => {
setState((prev) => ({ ...prev, count: prev.count + 1 }));
};
return (
<div>
{state.count}
<button notallow={increment}>+1</button>
</div>
);
};
useCallback
一個很常見的誤區是為了心理上的性能提升把函數通通使用 useCallback 包裹,在大多數情況下,javascript 創建一個函數的開銷是很小的,哪怕每次渲染都重新創建,也不會有太大的性能損耗,真正的性能損耗在于,很多時候 callback 函數是組件 props 的一部分,因為每次渲染的時候都會重新創建 callback 導致函數引用不同,所以觸發了組件的重渲染。然而一旦函數使用 useCallback 包裹,則要面對聲明依賴項的問題,對于一個內部捕獲了很多 state 的函數,寫依賴項非常容易寫錯,因此引發 bug。所以,在大多數場景下,我們應該只在需要維持函數引用的情況下使用 useCallback,例如下面這個例子:
const [userText, setUserText] = useState("");
const handleUserKeyPress = useCallback(event => {
// do something here
}, []);
useEffect(() => {
window.addEventListener("keydown", handleUserKeyPress);
return () => {
window.removeEventListener("keydown", handleUserKeyPress);
};
}, [handleUserKeyPress]);
return (
<div>
{userText}
</div>
);
這里我們需要在組件卸載的時候移除 event listener callback,因此需要保持 event handler 的引用,所以這里需要使用 useCallback 來保持引用不變。
然而一旦我們使用 useCallback,我們又會面臨聲明依賴項的問題,這里我們可以使用 ahook 中的 useMemoizedFn (https://ahooks.js.org/zh-CN/hooks/use-memoized-fn) 的方式,既能保持引用,又不用聲明依賴項。
const [state, setState] = useState('');
// func 地址永遠不會變化
const func = useMemoizedFn(() => {
console.log(state);
});
是不是覺得很神奇,為什么不用聲明依賴項也能保持函數引用不變,而內部的變量又可以捕獲最新的 state,實際上,這個 hook 的實現異常的簡單,我們只需要用到 useRef 和 useMemo。
/*
param: fn
fn每次進來都是新建,引用會變化
fnRef.current = fn
fnRef每次持有的都是新的fn,捕獲了最新的閉包變量
memoizeFn.current只會被初始化一次
memoizeFn.current指向的函數每次會去調fnRef.current,這樣每次都能用fnRef里的新的fn
這樣memoizedFn函數地址不變,同時也捕獲了最新的閉包變量
*/
function useMemoizedFn(fn) {
const fnRef = useRef(fn);
fnRef.current = useMemo(() => fn, [fn]);
const memoizedFn = useRef<T>();
if (!memoizedFn.current) {
memoizedFn.current = function (...args) {
return fnRef.current.apply(this, args);
}
}
return memoizedFn.current;
}
所有需要用到 useCallback 的地方都可以用 useMemoizedFn 代替。
memo & useMemo
對于需要優化渲染性能的場景,我們可以使用 memo 和 useMemo,通常用法如下:
const MyComponent = React.memo(function MyComponent(props) {
/* 使用 props 渲染 */
});
function Parent({ a, b }) {
// Only re-rendered if `a` changes:
const child1 = useMemo(() => <Child1 a={a} />, [a]);
// Only re-rendered if `b` changes:
const child2 = useMemo(() => <Child2 b={b} />, [b]);
return (
<>
{child1}
{child2}
</>
)
}
考慮到 useMemo 需要聲明依賴項,而 memo 不需要,會自動對所有 props 進行淺比較 (Object.is),因此大多數場景下,我們可以結合上面提到的 useImmer 以及 useMemoizedFn 保持對象和函數的引用不變,以此減少不必要的渲染,對于 Context 共享的數據,我們可以使用 react-tracked 進行精準渲染,這些庫的好處是不需要聲明依賴項,能減小維護成本和心智負擔,對于剩下的沒法 cover 的場景,我們再使用 useMemo 進行更細粒度的渲染控制。
useReducer
相對于上文中提到的這些 hook,useReducer 是我們日常開發過程中很少會用到的一個 hook (因為大部分需要 flux 這樣架構的軟件一般都直接上狀態管理庫了)。
但是,我們可以思考一下,在很多場景下,我們真的需要額外的狀態管理庫么?
我們來看一下下面的這個例子:
const Demo = () => {
const [state,setState] = {
isRunning: false,
time: 0
};
const idRef = useRef(0);
useEffect(() => {
idRef.current = setInterval(() => setState({ ...state,time: state.time + 1 }),1000);
return () => clearInterval(idRef.current);
},[]);
return <div>
{state.time}
<button notallow={() => setState({...state,isRunning: true}))}>
Start
</button>
<button notallow={() => setState({ ...state,isRunning: false })}>
Stop
</button>
<button notallow={() => setState({ isRunning: false, time: 0 })}>
Reset
</button>
</div>
}
這是一個非常簡單的計數器例子,雖說運作良好,但是卻反映了一個問題,當我們需要同時操作一系列相關的 state 時,在不借助外部狀態管理庫的情況下,隨著程序的規模變大,函數組件內部可能會充斥著非常多的 setState 一系列 state 的操作,這樣視圖就和實際邏輯耦合起來了,代碼變得難以維護,但其實我們不一定需要使用外部的狀態管理庫解決這個問題,很多時候 useReducer 就能幫我們搞定這個問題,我們嘗試用 useReducer 重寫一下這個邏輯。
我們先寫一個 reducer 的純函數:
如果看到這里,你已經忘記了reducer之類的概念,我們來復習一下吧。 reducer 通常是一個純函數,它接受一個action和一個payload,當然還有上一次的state,基于這三者,reducer計算出next state,就是這么簡單。
function reducer(state, action) {
switch (action.type) {
case 'start':
return { ...state, isRunning: true };
case 'stop':
return { ...state, isRunning: false };
case 'reset':
return { isRunning: false, time: 0 };
case 'tick':
return { ...state, time: state.time + 1 };
default:
throw new Error();
}
}
我們再來定義一下初始狀態以及 action 類型:
const initialState = {
isRunning: false,
time: 0
};
// The start action object
{ type: 'start' }
// The stop action object
{ type: 'stop' }
// The reset action object
{ type: 'reset' }
// The tick action object
{ type: 'tick' }
接下來只要用 useReducer 把他們組合起來就行了:
function Stopwatch() {
const [state, dispatch] = useReducer(reducer, initialState);
const idRef = useRef(0);
useEffect(() => {
if (!state.isRunning) {
return;
}
idRef.current = setInterval(() => dispatch({type: 'tick'}), 1000);
return () => {
clearInterval(idRef.current);
idRef.current = 0;
};
}, [state.isRunning]);
return (
<div>
{state.time}s
<button notallow={() => dispatch({ type: 'start' })}>
Start
</button>
<button notallow={() => dispatch({ type: 'stop' })}>
Stop
</button>
<button notallow={() => dispatch({ type: 'reset' })}>
Reset
</button>
</div>
);
}
這樣我們就把 reducer 這個狀態的變更邏輯從組件中抽離出去了,代碼看起來清晰易懂,維護起來也方便多了。
Q: reducer 是個純函數,如果我需要獲取異步數據呢?
A: 可以使用 use-reducer-async (https://github.com/dai-shi/use-reducer-async) 這個庫,只要引入一個極小的包,就能擁有 effect 的能力。
import { useReducerAsync } from "use-reducer-async";
const initialState = {
sleeping: false,};const reducer = (state, action) => {
switch (action.type) {
case 'START_SLEEP': return { ...state, sleeping: true };
case 'END_SLEEP': return { ...state, sleeping: false };
default: throw new Error('no such action type');
}};
const asyncActionHandlers = {
SLEEP: ({ dispatch }) => async (action) => {
dispatch({ type: 'START_SLEEP' });
await new Promise(r => setTimeout(r, action.ms));
dispatch({ type: 'END_SLEEP' });
},};
const Component = () => {
const [state, dispatch] = useReducerAsync(reducer, initialState, asyncActionHandlers);
return (
<div>
<span>{state.sleeping ? 'Sleeping' : 'Idle'}</span>
<button type="button" notallow={() => dispatch({ type: 'SLEEP', ms: 1000 })}>Click</button>
</div>
);};
結語
React Hook 心智負擔真的很重,希望 react-forget (https://zhuanlan.zhihu.com/p/443807113) 能早日 production ready。