前言
這篇文章旨在總結 React Hooks 的使用技巧以及在使用過程中需要注意的問題,其中會附加一些問題產生的原因以及解決方式。但是請注意,文章中所給出的解決方式並不一定完全適用,解決問題的方案有很多種,也許你所在的團隊針對這些問題已經給出了對應的規範,亦或是你已經對這些問題的解決方式形成了更好的認知。所以你的著重點應該放在 你是否在使用 React hooks 過程中意識到了這些問題 以及 你對這些問題的思考
useState hook
初始化狀態
如果元件的某個狀態需要依靠大量計算得到初始值,一般我們會定義一個函式來初始化狀態
在 class 元件中
state = { state1: calcInitialState() }
沒什麼問題,在元件不被重新掛載的情況下,即使元件多次重新渲染,calcInitialState 也只會被執行一次
而在函式元件中, 有兩種方式
const state1 = useState(calcInitialState) // 元件多次渲染時,calcInitialState 僅會被執行一次 const state1 = useState(calcInitialState()) // 元件每次渲染時,calcInitialState 都會被執行
函式元件每次重新渲染,都會執行函式元件本身,在第一次渲染時,useState 會讀取初始值,如果是初始值函式,則會被執行,並且函式的返回值被作為初始狀態,此時,這兩種寫法表現相同。但是在後續重新渲染過程,useState 雖然不會讀取預設狀態值,也不會對預設狀態值做任何處理,但是第二種寫法中的 calcInitialState 仍然會被執行,且是毫無意義的。內部執行流程見原始碼 mountState 和 updateState
狀態的捕獲方式 (this & 閉包)
前段時間,我在團隊內部分享了常用的 React Hooks 原理以及原始碼,當時我提到了,不論是 class 元件還是函式元件, 他們的狀態都儲存在元件對應的 fiber 上。函式元件 和 class 元件狀態更新的流程如下圖所示:
詳細可見原始碼 updateClassInstance 和 updateReducer
對於渲染的那一部分(JSX)來說,可以一直拿到最新的的狀態。只是獲取狀態的方式不同,class 元件是通過 this.state 指向 fiber 節點上儲存的狀態,函式元件則是通過 useState
這個函式的返回值獲取,那麼問題在於函式元件拿到的狀態是儲存在閉包中的,這個閉包由 useState
執行產生。
換個角度來說,對於函式元件,我們需要特別重視“渲染”這個概念。函式元件每次渲染,其內部宣告的函式或者是返回的 UI(JSX) 都只能捕獲到當前這次渲染的 props 和 state,這對於 UI(JSX) 來說完全沒有問題。 但是對於函式元件內部的函式,特別是延遲迴調的函式,需要特別注意等到回撥函式執行時,回撥函式中捕獲的 state 和 props 是否是你所期望的。
想要避免函式元件中閉包問題帶來的困擾,需要理解並記住下面兩句話
- 函式元件每一次渲染都有它自己的 props 和 state
- 函式元件每一次渲染都有它自己的事件處理函式
狀態粒度
狀態粒度過細
在編寫 class 元件時,幾乎不用考慮狀態粒度的問題,因為開發者總是可以一次性宣告所有狀態或者一次性更新所有狀態,就像這樣
handleClick = () => {
this.setState({
currentPage: 2,
pageSize: 20,
total: 100
})
}
大多數開發者並不會蠢到使用三次 setState
去更新這三個狀態,但是在函式元件中只能這樣
const handleClick = () => {
setCurrent(2)
setPageSize(20)
setTotal(100)
}
看到這段程式碼,嗯,可能會讓人感覺到有點不舒服,問題就出在更新粒度過細,事實上一個分頁元件的 currentPage,pageSize,total 經常會需要同時被更新,但是多次觸發 setXXX 還是會讓人感到隱隱的不安,即使多次觸發更新可能會被 React 的 batchUpdate
機制合併為一次,但是當 setXXX 方法執行脫離了 React 的上下文時會觸發多次更新,例如非同步結束時的回撥中。
此時,我們可以在 useState 中儲存一個物件,將相關聯的狀態放在一起。也可以使用 useReducer 來管理多個狀態。
狀態粒度過粗
當一個 state 有一定的複雜度的時候,我並不推薦暴力的將 class 元件宣告 state 的方式硬生生塞到 useState 中,因為這也許會將 class 中 state 的粒度過粗的缺陷引入進來
問題一:難以發現可以被複用的狀態邏輯
當一個元件的狀態越來越多,元件的可讀性和可維護性就會越來越差,不少人應該都深有體會, 就像這樣:
ps :擷取自真實的業務程式碼
class XXX extends React.Component{
constructor(props: any) {
super(props);
this.state = {
tableListMap: {},
showPreview: false,
showRegModal: false,
dataSource: [],
columns: [],
tablePartitionList: [],
incrementColumns: [],
loading: false,
isChecked: {},
isShowImpala: false,
tableListSearch: {},
schemaList: [],
fetching: false,
tableListLoading: false,
bucketList: [],
showPreviewPath: false,
previewPath: '',
currentObject: { object: [''], index: 0, bucket: '' },
isCompressed: false,
matchType: null
};
}
}
試想一下,在函式元件中,一個 useState 裡面被塞進如此多的狀態,且不談能否發現其中可複用的狀態和邏輯,即便你慧眼如炬,發現了它跟其他元件之間有可以複用的狀態和邏輯。大概率,也很難在保證在當前元件(歷史程式碼)不會出問題的情況下將可以複用的狀態邏輯提取出來。
問題二:將無關的狀態放在同一個 useState 中可能讓狀態更新變得不好控制
舉個例子,假如頁面上有個按鈕,當點選這個按鈕時,需要同時從不同的介面中拿到兩份資料並渲染到頁面上,此時如果這兩份資料被存放在同一個 useState 中
function DataViewer (props) {
const [dataMap, setDataMap] = useState({ data1: undefined, data2: undefined })
const loadData1 = async () => {
if (visible) {
const data1 = await fetchData1()
setDataMap({ ...dataMap, data1 })
}
}
const loadData2 = async () => {
if (visible) {
const data2 = await fetchData2()
setDataMap({ ...dataMap, data2 })
}
}
const handleClick = () => {
loadData1()
loadData2()
}
// ...
}
問題很明顯,只要兩個請求都完成,無論成功還是失敗,dataMap 中都不會有先完成的那個請求返回的資料。
在 class 元件中,基本上是不會出現這種問題的,因為總是可以通過 this 拿到當前的最新的狀態,不會出現多次更新中狀態覆蓋的問題。當然在函式元件中,也可以使用 useRef 暫存介面資料,然後一起更新狀態,但需要額外寫一些邏輯,這裡就不介紹這種黑科技了。
此時第一個解決辦法是,將第一個介面返回的資料用變數暫存起來等到第二個介面完成再去更新 dataMap,就像這樣
const loadDataMap = async () => {
if (visible) {
const data1 = await fetchData1()
const data2 = await fetchData2()
setDataMap({ data1, data2 })
}
}
這樣做帶來的問題是,一定要等第一個請求完成,才能去發起第二個請求,對於使用者體驗來說並不友好。
好吧,想要兩個介面並行,還可以使用 Promise.allSettled 並行的處理兩個請求
const loadDataMap = () => {
if (visible) {
Promise.allSettled([ fetchData1(), fetchData2() ])
.then(results => {
//...
})
}
}
看起來好像沒什麼問題了。但是,如果對介面的狀態、返回值等有額外的處理邏輯時,你就需要將所有的介面的處理邏輯都塞到 .then 的回撥中,並且這種方法一定要兩個介面都完成才能更新狀態然後在頁面中展示資料,也無法單獨的檢測到其中的某一個介面是否處於 pending 狀態,這種方式似乎也不是那麼友好。
這樣看來比較完美的解決方式只有兩種
避開閉包, 你只需要在更新狀態時傳入一個函式就可以了, 就像這樣
setDataMap(dataMap => ({ ...dataMap, data2 }))
狀態切分
const [ data1, setData1 ] = useState() const [ data2, setData2 ] = useState()
解決問題的方式往往不止一種,你需要根據實際的業務情況自行去選擇你認為更加合適的方式。
如何設計狀態粒度
官方文件的 QA 中如是說道
把所有 state 都放在同一個useState
呼叫中,或是每一個欄位都對應一個useState
呼叫,這兩方式都能跑通。當你在這兩個極端之間找到平衡,然後把相關 state 組合到幾個獨立的 state 變數時,元件就會更加的可讀。如果 state 的邏輯開始變得複雜,我們推薦 用 reducer 來管理它,或使用自定義 Hook。
個人認為,聚合相關的狀態,拆分無關的狀態,是一種比較好的實踐方式。比如將分頁器元件的 currentPage、pageSize、total 三個狀態放在同一個 useState 中,將不同請求的返回的資料拆分到不同的 useState 中。另外還有一些情況是,狀態的邏輯比較複雜,這個時候也可以使用 useReducer 來管理狀態,這樣就可以將一些複雜的邏輯抽離到 reducer 中。
狀態更新的兩種方式
不論是函式元件還是 class 元件,更新狀態的方式都有兩種: setState(newState)
和 setState(oldState => newState)
,它們之間的差異在於,一個注重結果,一個注重目的。 setState(newState)
用於描述新的狀態,而 setState(oldState => newState)
用於描述新的狀態與舊的狀態相比應當做出什麼樣的改變。
這樣說可能有點抽象,簡單來說 setState(newState)
是用新的狀態替換掉舊的狀態, setState(oldState => newState)
是用來通過舊的狀態計算出新的狀態
useEffect hook
為什麼需要 useEffect
從理論上講函式元件就是單純的用來渲染的,也就是所謂的純函式,事實上沒有 React Hooks 之前也確實是這樣的。而其他的操作如資料獲取,設定定時器,修改 DOM 等都被稱作副作用。
為什麼不能直接在函式元件內直接執行副作用?
- 有一些副作用操作可能會影響到渲染,如修改 DOM
- 有一些副作用操作是需要清除的,如定時器
- 如果直接在函式元件內部直接進行副作用操作,那麼函式元件每次重新渲染時都會執行這些操作,沒法控制這些操作何時執行何時不執行
useEffect 怎樣解決這些問題?
- useEffect 包裹的函式會在瀏覽器渲染完成之後執行,保證不會影響到元件的渲染。另外被 useEffect 包裹的函式執行脫離了函式元件本身的執行上下文,所以不會對函式元件本身的執行造成影響
- useEffect 包裹的函式可以 return 一個函式,用於清除副作用
- useEffect 可以傳入依賴項陣列,當依賴項變化時才去執行副作用操作
useEffect 的這些特性有點像事件回撥,只不過事件回撥函式的觸發依靠 dom 事件如點選、輸入等,而 useEffect 包裹的函式出發依靠依賴項的變化。很多時候,將一些副作用操作放到事件回撥函式中去執行是更好的選擇,這樣就可以不用考慮 useEffect 依賴項的問題了。
useEffect 是如何捕獲 props 和 state 的
useEffect 包裹的函式中,捕獲 props 和 state 的方式跟普通函式沒兩樣,依賴於函式元件本身的執行上下文。useEffect 內部並沒有做什麼如資料繫結、依賴 fiber 等特別的事情。因此,函式元件每一次渲染都有它自己的 effects。在函式元件渲染完成後,產生的 effects 會被儲存到元件對應的 fiber 上,等待特定的時機執行這些 effects (副作用)。 即便是 effects 中某些非同步回撥執行時,頁面已經重新渲染了很多次了,這些非同步的回撥函式中捕獲的 props 和 state 還是產生這些 effects 的那次渲染中元件的 state 和 props。
useEffect 的依賴項
哪些應該被放在 useEffect 的依賴項中
理論上來說。useEffect
的心智模型更接近於 effect 在某些值變化時去執行,但是有的時候為了保證 effect 中捕獲的 props 和 state 是你所期望的,你不得不將 effect 中用到的所有的元件內的變數都放到依賴項中。如果你在專案中設定了對應的 lint 規則,lint 工具也會告訴你應該這樣做,但是這好像與 useEffect
的心智模型產生了一些衝突。
這種衝突帶來的後果是,effect 可能會頻繁的執行,如下例是一個每秒遞增的計數器
function Counter () {
const [count, setCount] = useState(1)
useEffect(() => {
const timerId = setInterval(() => {
setCount(count + 1);
}, 1000);
return () => clearInterval(timerId);
}, [count]);
return (
<>{count}</>
)
}
如果移除了這個 useEffect 的依賴項中的 count,那麼定時器中的回撥函式就會一直執行 setCount(1 + 1)
,這不是我們所期望的。好吧,老實一點,將 count 放到依賴項中,但是此時定時器會被頻繁的清除和建立,這可能會影響定時器回撥的觸發頻率,這也不是我們所期望的。
到現在為止,問題還是沒有得到解決,我還是傾向於 useEffect 的依賴項是用來觸發 effect 的,而不是用來解決閉包問題的,那麼只能想辦法移除掉 useEffect 對 count 的依賴。
如何減少 useEffect 的依賴項
消除
useEffect
中不必要的捕獲
如上例中的 useEffect 可以寫成useEffect(() => { const timerId = setInterval(() => { setCount(count => count + 1); }, 1000); return () => clearInterval(timerId); }, []);
- 將依賴從 effect 中解耦
還是這個定時計數器的例子,如果我們想要通過 props 傳遞一個 step 屬性給這個元件,用來告訴這個元件每秒遞增的值的大小
<Counter step={2}/>
於是 Counter 元件就變成了這樣
function Counter ({step}) {
const [count, setCount] = useState(1)
useEffect(() => {
const timerId = setInterval(() => {
setCount(count => count + step);
}, 1000);
return () => clearInterval(timerId);
}, [step]);
return (
<>{count}</>
)
}
現在的問題是,當 step 的值變化時,仍然會重啟定時器。現在該 useReducer
上場了。
function Counter ({step}) {
const [count, dispatch] = useReducer(reducer, 0);
function reducer(state, action) {
if (action.type === 'tick') {
return state + step;
} else {
throw new Error('type in action is not true');
}
}
useEffect(() => {
const timerId = setInterval(() => {
dispatch({ type: 'tick' });
}, 1000);
return () => clearInterval(timerId);
}, []);
return (
<>{count}</>
)
}
那麼現在問題來了:
Q:_為什麼我們可以不在依賴項中加入 _dispatch_
?_
A:因為 React 向我們保證了 _dispatch_ (來源於useReducer)
、setState (來源於useState)
以及ref.current (來源於useRef)
即使元件多次渲染,它們的引用地址也不會改變
Q:_為什麼 reducer 中捕獲到的 step 值是最新的?_
A:對於 useReducer
來說,React 只會記住它的 action,並不會記住它的 reducer。也就是說每次元件重新渲染執行 useReducer 時,它都會重新讀取 reducer
上文中說過 useState
和 useReducer
在原始碼中是同一個東西。在實際使用過程中,相比於 useState ,useReducer 可以讓我們把 更新邏輯(reducer)和 描述發生了什麼(action)分開 ,而這一點正好可以用來移除不必要的依賴。
當然實際業務中,很少會碰到上述例子中的場景,絕大部分情況下,都只用將想要用來觸發執行 effect 的值放到 useEffect 的依賴項中。對於無法用上面講的兩種方法解決的場景,也可以通過 useRef 來繞過煩人的閉包問題。
函式應當作為 useEffect 的依賴項嗎
個人觀點是:絕大多數情況下,不應該將函式作為 useEffect 的依賴項。至於能否安心的將函式從依賴項中移除主要看
- 函式是否參與 React 的資料流
簡單來說,就是看這個函式中是否用到了函式元件內部的變數(useRef 除外)。如果一個函式並沒有參與 React 資料流,但是在useEffect
中用到了,此時你應該將這個函式提取到元件外部,這樣你就可以在useEffect
的依賴項中無腦移除掉這個函式。 - 函式是否被非同步延遲呼叫
函式被延遲呼叫的情況下很容易產生閉包問題,這時即使將函式作為 useEffect 的依賴項,也無法解決閉包問題,反而可能增加 effect 的觸發頻率,下文中的 使用可變資料替代不可變資料 一節會介紹一種方法能夠保證在函式引用地址不變的情況下,使函式自動捕元件內變數最新的值的方法。
大多數情況下都可以不用將函式放在 useEffect 的依賴項中。也許有一些極端特殊的業務場景,這時只能將函式用 useCallback 包裹,然後放到 useEffect 的 依賴項中 (我目前沒碰到過這種情況) 。
useRef hook
按照我個人的理解,useRef 更像是 class 元件的例項屬性,即 this.xxx。在函式元件中,useRef 可以看做是一個容器,你可以任意操作這個容器中的資料,並且這個容器中的引用地址不會因為元件多次重新渲染而改變。在我看來它就是函式元件的作弊器同時也是解決函式元件中閉包問題的絕世利器。
useRef 的特徵
- 當 useRef 儲存的值變化時,並不會引起元件重新渲染
- 可以用來存放可變資料,在元件多次渲染時,能保持 ref (useRef 返回值)本身的引用地址不變
ps: useRef 能保證返回值引用地址不變的原因是,即使元件多次渲染,useRef 返回的 ref 還是第一次執行時返回的那個 ref。在函式元件第一次渲染時, React 內部會將 useRef 的返回值(ref)儲存在元件對應的 fiber 節點上,後續元件重新渲染時,React 內部不會對 useRef 做任何處理,直接返回 fiber 節點上儲存的 ref。詳情可見原始碼 mountRef 和 updateRef
基於 useRef 的特徵可以做什麼
實現一個自定義 hook 用來統計元件的渲染次數
const useRenderTimes = () => { const ref = useRef(0) ref.current += 1 return ref.current }
記錄上一次元件渲染時的某個值
const usePreValue = (value) => { const ref = useRef(undefined) const preValue = ref.current ref.current = value return preValue }
避開不必要的重新渲染
如果某一個狀態與渲染無關,那麼你可以使用 useRef 代替 useState 。還記得上述 useEffect 那一節中的 Counter 元件嗎,如果將 count 作為 useEffect 的依賴項,那麼定時器會不停的建立/銷燬,上面給出了兩種解決方法,現在我們來說說另一種方式。思路是,只要在 count 變化時,不重新渲染元件就好了, 那麼可以使用 useRef 儲存 count 值,當然,這種方式的僅限於 count 不參與渲染的情況,或者也可以在 useRef 中儲存值改變的同時去觸發元件重新渲染。
這樣的場景其實很常見,比如有一個表單,當使用者在表單中填寫完成後,點選 submit 按鈕,將資料通過介面傳送給後端。如果這個表單不是一個受控元件,那麼相比於 useState 用 useRef 儲存表單資料是個更好的選擇,因為它不會導致不必要的重新渲染。function Counter () { const count = useRef(1) useEffect(() => { const timerId = setInterval(() => { count.current += 1; }, 1000); return () => { clearInterval(timerId) console.log(count.current) }; }, []); }
有時在函式元件中使用 useState 是為了在元件重新渲染之後仍然能拿到某個值,但我們希望讓這個值變化時不要觸發元件更新,亦或是想避免 useState 的不可變資料導致的閉包問題,那麼這個時候就是使用 useRef 的時機。
如何看待 useRef
在我看來在 React Hooks 中 useRef
最起碼與 useState
是同等重要的,知乎有篇文章中的一句話這樣說,
每一個希望深入 hook 實踐的開發者都必須記住這個結論,無法自如地使用 useRef 會讓你失去 hook 將近一半的能力。
表示認同。
useCallback Hook
關於 useCallback,官網上的介紹是
把內聯回撥函式及依賴項陣列作為引數傳入useCallback
,它將返回該回撥函式的 memoized 版本,該回撥函式僅在某個依賴項改變時才會更新。當你把回撥函式傳遞給經過優化的並使用引用相等性去避免非必要渲染(例如shouldComponentUpdate
)的子元件時,它將非常有用。
又看到了熟悉的詞彙-“依賴項”,要想保證在 useCallback 中包裹的函式捕獲到當前渲染時函式元件內部的值,必須將 useCallback 包裹的函式中所有引用到的函式元件內部的值都放到依賴項中。另外,請注意官網介紹的 useCallback 的作用是- “效能優化”。
你真的需要為函式元件中的每一個函式都包裹上 useCallback 嗎? 就拿官方文件中的 shouldComponentUpdate
舉例,我們在函式元件中定義了一個函式 handleClick
並用 useCallback 包裹,然後通過 props 傳遞給子元件,子元件中通過shouldComponentUpdate
對比 handleClick
,決定是否需要更新。
function Parent () {
const handleClick = useCallback(()=>{
//...
},[...])
return (<Child handleClick={handleClick}/>)
}
class Child extends React.Component {
shouldComponentUpdate(nextProps) {
return this.props.handleClick !== nextProps.handleClick
}
// ...
}
那麼此時應該有一個疑問,效能提升到底有多大,如果你感興趣的話,不妨動起手來,寫個示例,對比一下 performance 效能皮膚,你應該看到 useCallback 對效能提升到底有多大,同時根據測試結果,可以大概得到什麼時候應該用 useCallback 來提升效能。useCallback 的另一個作用是可以維持函式引用地址不變。但是它仍然會在依賴項變化時重新生成函式,想要維持函式引用地址一直不變還要是要使用 useRef
我牴觸 useCallback 的原因是,在我看來它本身的作用比較雞肋,而且使用 useCallback,必須注意依賴項,這又還會帶來額外的心智負擔。
useMemo Hook
使用 useMemo 時需要注意的點不多,官方文件也寫的非常明白了
useMemo 的作用是
把“建立”函式和依賴項陣列作為引數傳入 useMemo
,它僅會在某個依賴項改變時才重新計算 memoized 值。這種優化有助於避免在每次渲染時都進行高開銷的計算。
請把著重點放在 “高開銷的計算” 上,有的時候,可能也並不需要 useMemo
使用 useMemo 時需要注意的是
你可以把**useMemo**
作為效能優化的手段,但不要把它當成語義上的保證。將來,React 可能會選擇“遺忘”以前的一些 memoized 值,並在下次渲染時重新計算它們,比如為離屏元件釋放記憶體。先編寫在沒有useMemo
的情況下也可以執行的程式碼 —— 之後再在你的程式碼中新增useMemo
,以達到優化效能的目的。
函式元件中的閉包問題
結合上文,可以總結出在函式元件中,閉包問題主要是因為函式的延遲呼叫,不論是 useEffect 包裹的函式還是定時器回撥函式亦或者是非同步請求的回撥函式,它們內部捕獲到的變數都是存在外部函式元件執行時產生的閉包中,那麼想要規避閉包帶來的困擾,思路有兩個
減少函式內部對外部變數的依賴
比如上述定時計數器例子中
setCount(count + 1)
// 替換為
setCount(count => count + 1)
使用可變資料替代不可變資料
在 class 元件中很少遇到閉包的困擾是因為在 class 元件中訪問元件的 state 和 props 都是通過 this,雖然 this.state 和 this.props 指向的是是不可變資料,但是 this 內部儲存的資料是可變的並且 this 的引用地址不會發生改變。那麼函式元件中有沒有類似 this 的東西呢?有, useRef。
對於非函式型別,可以使用 useRef 替代 useState
這樣即使是延遲呼叫的函式,也可以通過 ref.current 取到最新的值,因為延遲呼叫的函式裡面取的是 useRef 返回值的引用地址。上文中的例子中也這樣用過了。需要注意的是,如果 useRef 中儲存的值參與了渲染,比如function demo () { const text = useRef("") return <>{text.current}</> }
這時,更新 useRef 中儲存的值,並不會引起檢視重新渲染。但是我們可以通過更新另一個狀態 (useState) 來使檢視同步。如果在元件中實在找不到一個可以在 useRef 內部的值變化時去觸發更新的狀態,那麼也可以寫一個自定義 hook 去強制觸發更新
function useForceUpdate () {
const [, forceUpdate] = useReducer(x => x + 1, 0)
return forceUpdate
}
封裝一下,就可以得到一個儲存可變資料的 useState
function useMutableState (init) {
const stateRef = useRef(init)
const [, updateState] = useReducer((preState, action) => {
stateRef.current = typeof action === 'function'
? action(preState)
: action
return stateRef.current
}, init)
return [stateRef, updateState]
}
對於函式型別,也可以通過 useRef 保持函式引用地址不變,函式內部自動捕獲最新的值
function useStableFn(fn, deps) { const fnRef = useRef(); fnRef.current = fn; return useCallback(() => { return fnRef.current(); }, []); }
結語
在 React Hooks 中很難總結出真正完美的最佳實踐,就連官方文件和部落格上也只是描述了 React Hooks 的心智模型。上文中的有些觀點或者示例違背了官方給出的心智模型,不得不承認我是 useRef 的愛好者。但是對於 React Hooks 的實踐來說,沒有銀彈。重要的是,你是否理解 hooks 是如何工作的,以及你有沒有自己的避坑指南。