React Hooks 是 React 16.8 的新功能,可以在不編寫 class 的情況下使用狀態等功能,從而使得函數式從無狀態的變化為有的。React 的類型包 @types/react 中也同步把 .SFC (Stateless Functional Component) 改為了 React.FC (Functional Component)。
通過這一升級,原先 class 寫法的組件也就完全可以被函數式組件替代。雖然是否要把老項目中所有類組件全部改為函數式組件因人而異,但新寫的組件還是值得嘗試的,因為代碼量的確減少了很多,尤其是重復的代碼(例如 componentDidMount + componentDidUpdate + componentWillUnmount = useEffect)。
從 16.8 發布(今年2月)至今也有大半年了,但本人水平有限,尤其在 useEffect 和異步任務搭配使用的時候經常踩到一些坑。特作本文,權當記錄,供遇到同樣問題的同僚借鑒參考。我會講到三個項目中非常常見的問題:
- 如何在組件加載時發起異步任務
- 如何在組件交互時發起異步任務
- 其他陷阱
一、react Hooks 發起異步請求
- 使用 useEffect 發起異步任務,第二個參數使用空數組可實現組件加載時執行方法體,返回值函數在組件卸載時執行一次,用來清理一些東西,例如計時器。
- 使用 AbortController 或者某些庫自帶的信號量 ( axios.CancelToken) 來控制中止請求,更加優雅地退出。
- 當需要在其他地方(例如點擊處理函數中)設定計時器,在 useEffect 返回值中清理時,使用局部變量或者 useRef 來記錄這個 timer。不要使用 useState。
- 組件中出現 setTimeout 等閉包時,盡量在閉包內部引用 ref 而不是 state,否則容易出現讀取到舊值的情況。
- useState 返回的更新狀態方法是異步的,要在下次重繪才能獲取新值。不要試圖在更改狀態之后立馬獲取狀態。
二、如何在組件加載時發起異步任務
這類需求非常常見,典型的例子是在列表組件加載時發送請求到后端,獲取列表后展現。
發送請求也屬於 React 定義的副作用之一,因此應當使用 useEffect來編寫。基本語法我就不再過多說明,代碼如下:
import React, { useState, useEffect } from 'react'; const SOME_API = '/api/get/value'; export const MyComponent: React.FC<{}> = => { const [loading, setLoading] = useState(true); const [value, setValue] = useState(0); useEffect( => { (async => { const res = await fetch(SOME_API); const data = await res.json; setValue(data.value); setLoading(false); }); }, []); return ( <> { loading ? ( <h2>Loading...</h2> ) : ( <h2>value is {value}</h2> ) } </> ); }
如上是一個基礎的帶 Loading 功能的組件,會發送異步請求到后端獲取一個值並顯示到頁面上。如果以示例的標准來說已經足夠,但要實際運用到項目中,還不得不考慮幾個問題。
三、如果在響應回來之前組件被銷毀了會怎樣?
React 會報一個 Warning
Warning: Can't perform a React state update on an unmounted component. This is a no-op, but it indicates a memory leak in your application. To fix, cancel all subions and asynchronous tasks in a useEffect cleanup http://function.in Notification
大意是說在一個組件卸載了之后不應該再修改它的狀態。雖然不影響運行,但作為完美主義者代表的程序員群體是無法容忍這種情況發生的,那么如何解決呢?
問題的核心在於,在組件卸載后依然調用了 setValue(data.value)和 setLoading(false)來更改狀態。因此一個簡單的辦法是標記一下組件有沒有被卸載,可以利用 useEffect的返回值。
// 省略組件其他內容,只列出 diff useEffect( => { let isUnmounted = false; (async => { const res = await fetch(SOME_API); const data = await res.json; if (!isUnmounted) { setValue(data.value); setLoading(false); } }); return => { isUnmounted = true; } }, []);
這樣可以順利避免這個 Warning。
有沒有更加優雅的解法?
上述做法是在收到響應時進行判斷,即無論如何需要等響應完成,略顯被動。一個更加主動的方式是探知到卸載時直接中斷請求,自然也不必再等待響應了。這種主動方案需要用到 AbortController。
AbortController 是一個瀏覽器的實驗接口,它可以返回一個信號量(singal),從而中止發送的請求。這個接口的兼容性不錯,除了 IE 之外全都兼容(如 Chrome, Edge, FF 和絕大部分移動瀏覽器,包括 Safari)。
useEffect( => { let isUnmounted = false; const abortController = new AbortController; // 創建 (async => { const res = await fetch(SOME_API, { singal: abortController.singal, // 當做信號量傳入 }); const data = await res.json; if (!isUnmounted) { setValue(data.value); setLoading(false); } }); return => { isUnmounted = true; abortController.abort; // 在組件卸載時中斷 } }, []);
singal 的實現依賴於實際發送請求使用的方法,如上述例子的 fetch方法接受 singal屬性。如果使用的是 axios,它的內部已經包含了 axios.CancelToken,可以直接使用,例子在這里。
四、如何在組件交互時發起異步任務
另一種常見的需求是要在組件交互(比如點擊某個按鈕)時發送請求或者開啟計時器,待收到響應后修改數據進而影響頁面。這里和上面一節(組件加載時)最大的差異在於 React Hooks 只能在組件級別編寫,不能在方法( dealClick)或者控制邏輯( if, for等)內部編寫,所以不能在點擊的響應函數中再去調用 useEffect。但我們依然要利用 useEffect的返回函數來做清理工作。
以計時器為例,假設我們想做一個組件,點擊按鈕后開啟一個計時器(5s),計時器結束后修改狀態。但如果在計時未到就銷毀組件時,我們想停止這個計時器,避免內存泄露。用代碼實現的話,會發現開啟計時器和清理計時器會在不同的地方,因此就必須記錄這個 timer。看如下的例子:
import React, { useState, useEffect } from 'react'; export const MyComponent: React.FC<{}> = => { const [value, setValue] = useState(0); let timer: number; useEffect( => { // timer 需要在點擊時建立,因此這里只做清理使用 return => { console.log('in useEffect return', timer); // <- 正確的值 window.clearTimeout(timer); } }, []); function dealClick { timer = window.setTimeout( => { setValue(100); }, 5000); } return ( <> <span>Value is {value}</span> <button onClick={dealClick}>Click Me!</button> </> ); }
既然要記錄 timer,自然是用一個內部變量來存儲即可(暫不考慮連續點擊按鈕導致多個 timer 出現,假設只點一次。因為實際情況下點了按鈕還會觸發其他狀態變化,繼而界面變化,也就點不到了)。
這里需要注意的是,如果把 timer升級為狀態(state),則代碼反而會出現問題。考慮如下代碼:
import React, { useState, useEffect } from 'react'; export const MyComponent: React.FC<{}> = => { const [value, setValue] = useState(0); const [timer, setTimer] = useState(0); // 把 timer 升級為狀態 useEffect( => { // timer 需要在點擊時建立,因此這里只做清理使用 return => { console.log('in useEffect return', timer); // <- 0 window.clearTimeout(timer); } }, []); function dealClick { let tmp = window.setTimeout( => { setValue(100); }, 5000); setTimer(tmp); } return ( <> <span>Value is {value}</span> <button onClick={dealClick}>Click Me!</button> </> ); }
有關語義上 timer到底算不算作組件的狀態我們先拋開不談,僅就代碼層面來看。利用 useState來記住 timer狀態,利用 setTimer去更改狀態,看似合理。但實際運行下來,在 useEffect返回的清理函數中,得到的 timer卻是初始值,即 0。
為什么兩種寫法會有差異呢?
其核心在於寫入的變量和讀取的變量是否是同一個變量。
第一種寫法代碼是把 timer作為組件內的局部變量使用。在初次渲染組件時, useEffect返回的閉包函數中指向了這個局部變量 timer。在 dealClick中設置計時器時返回值依舊寫給了這個局部變量(即讀和寫都是同一個變量),因此在后續卸載時,雖然組件重新運行導致出現一個新的局部變量 timer,但這不影響閉包內老的 timer,所以結果是正確的。
第二種寫法, timer是一個 useState的返回值,並不是一個簡單的變量。從 React Hooks 的源碼來看,它返回的是 [hook.memorizedState,dispatch],對應我們接的值和變更方法。當調用 setTimer和 setValue時,分別觸發兩次重繪,使得 hook.memorizedState指向了 newState(注意:不是修改,而是重新指向)。但 useEffect返回閉包中的 timer依然指向舊的狀態,從而得不到新的值。(即讀的是舊值,但寫的是新值,不是同一個)
如果覺得閱讀 Hooks 源碼有困難,可以從另一個角度去理解:雖然 React 在 16.8 推出了 Hooks,但實際上只是加強了函數式組件的寫法,使之擁有狀態,用來作為類組件的一種替代,但 React 狀態的內部機制沒有變化。在 React 中 setState內部是通過 merge 操作將新狀態和老狀態合並后,重新返回一個新的狀態對象。不論 Hooks 寫法如何,這條原理沒有變化。現在閉包內指向了舊的狀態對象,而 setTimer和 setValue重新生成並指向了新的狀態對象,並不影響閉包,導致了閉包讀不到新的狀態。
我們注意到 React 還提供給我們一個 useRef, 它的定義是:
useRef 返回一個可變的 ref 對象,其 current屬性被初始化為傳入的參數(initialValue)。返回的 ref 對象在組件的整個生命周期內保持不變。
ref 對象可以確保在整個生命周期中值不變,且同步更新,是因為 ref 的返回值始終只有一個實例,所有讀寫都指向它自己。所以也可以用來解決這里的問題。
import React, { useState, useEffect, useRef } from 'react'; export const MyComponent: React.FC<{}> = => { const [value, setValue] = useState(0); const timer = useRef(0); useEffect( => { // timer 需要在點擊時建立,因此這里只做清理使用 return => { window.clearTimeout(timer.current); } }, []); function dealClick { timer.current = window.setTimeout( => { setValue(100); }, 5000); } return ( <> <span>Value is {value}</span> <button onClick={dealClick}>Click Me!</button> </> ); }
事實上我們后面會看到, useRef和異步任務配合更加安全穩妥。
其他陷阱 修改狀態是異步的
這個其實比較基礎了。
import React, { useState } from 'react'; export const MyComponent: React.FC<{}> = => { const [value, setValue] = useState(0); function dealClick { setValue(100); console.log(value); // <- 0 } return ( <span>Value is {value}, AnotherValue is {anotherValue}</span> ); }
useState返回的修改函數是異步的,調用后並不會直接生效,因此立馬讀取 value獲取到的是舊值( 0)。
React 這樣設計的目的是為了性能考慮,爭取把所有狀態改變后只重繪一次就能解決更新問題,而不是改一次重繪一次,也是很容易理解的。
在 timeout 中讀不到其他狀態的新值
import React, { useState, useEffect } from 'react'; export const MyComponent: React.FC<{}> = => { const [value, setValue] = useState(0); const [anotherValue, setAnotherValue] = useState(0); useEffect( => { window.setTimeout( => { console.log('setAnotherValue', value) // <- 0 setAnotherValue(value); }, 1000); setValue(100); }, []); return ( <span>Value is {value}, AnotherValue is {anotherValue}</span> ); }
這個問題和上面使用 useState去記錄 timer類似,在生成 timeout 閉包時,value 的值是 0。雖然之后通過 setValue修改了狀態,但 React 內部已經指向了新的變量,而舊的變量仍被閉包引用,所以閉包拿到的依然是舊的初始值,也就是 0。
要修正這個問題,也依然是使用 useRef,如下:
import React, { useState, useEffect, useRef } from 'react'; export const MyComponent: React.FC<{}> = => { const [value, setValue] = useState(0); const [anotherValue, setAnotherValue] = useState(0); const valueRef = useRef(value); valueRef.current = value; useEffect( => { window.setTimeout( => { console.log('setAnotherValue', valueRef.current) // <- 100 setAnotherValue(valueRef.current); }, 1000); setValue(100); }, []); return ( <span>Value is {value}, AnotherValue is {anotherValue}</span> ); }
還是 timeout 的問題
假設我們要實現一個按鈕,默認顯示 false。當點擊后更改為 true,但兩秒后變回 false( true 和 false 可以互換)。考慮如下代碼:
import React, { useState } from 'react'; export const MyComponent: React.FC<{}> = => { const [flag, setFlag] = useState(false); function dealClick { setFlag(!flag); setTimeout( => { setFlag(!flag); }, 2000); } return ( <button onClick={dealClick}>{flag ? "true" : "false"}</button> ); }
我們會發現點擊時能夠正常切換,但是兩秒后並不會變回來。究其原因,依然在於 useState的更新是重新指向新值,但 timeout 的閉包依然指向了舊值。所以在例子中, flag一直是 false,雖然后續 setFlag(!flag),但依然沒有影響到 timeout 里面的 flag。
解決方法有二。
第一個還是利用 useRef:
import React, { useState, useRef } from 'react'; export const MyComponent: React.FC<{}> = => { const [flag, setFlag] = useState(false); const flagRef = useRef(flag); flagRef.current = flag; function dealClick { setFlag(!flagRef.current); setTimeout( => { setFlag(!flagRef.current); }, 2000); } return ( <button onClick={dealClick}>{flag ? "true" : "false"}</button> ); }
第二個是利用 setFlag可以接收函數作為參數,並利用閉包和參數來實現
import React, { useState } from 'react'; export const MyComponent: React.FC<{}> = => { const [flag, setFlag] = useState(false); function dealClick { setFlag(!flag); setTimeout( => { setFlag(flag => !flag); }, 2000); } return ( <button onClick={dealClick}>{flag ? "true" : "false"}</button> ); }
當 setFlag參數為函數類型時,這個函數的意義是告訴 React 如何從當前狀態產生出新的狀態(類似於 redux 的 reducer,不過是只針對一個狀態的子 reducer)。既然是當前狀態,因此返回值取反,就能夠實現效果。
總結
在 Hook 中出現異步任務尤其是 timeout 的時候,我們要格外注意。useState只能保證多次重繪之間的狀態值是一樣的,但不保證它們就是同一個對象,因此出現閉包引用的時候,盡量使用 useRef而不是直接使用 state 本身,否則就容易踩坑。反之如果的確碰到了設置了新值但讀取到舊值的情況,也可以往這個方向想想,可能就是這個原因所致。
參考總結自:
- 官網的 useRef 說明
- How to create React custom hooks for data fetching with useEffect
- setTimeout in React Components Using Hooks
- React - useState - why setTimeout function does not have latest state value?
- 博客文章:https://www.sohu.com/a/348870257_463970