10分鐘學會React Hooks非同步操作
Hook 是 React 16.8 的新增特性。它可以讓你在不編寫 class 的情況下使用 state 以及其他的 React 特性,Hook 不會影響你對React概念得理解 。 恰恰相反,Hook 為已知的 React 概念提供了更直接的 API:props, state,context,refs 以及生命週期。稍後我們將看到,Hook 還提供了一種更強大的方式來組合他們。從而使得函式式元件從無狀態的變化為有狀態的。
React 的型別包 @types/react 中也同步把 React.SFC (Stateless Functional Component) 改為了 React.FC (Functional Component)。
通過這一升級,原先 class 寫法的元件也就完全可以被函式式元件替代。雖然是否要把老專案中所有類元件全部改為函式式元件因人而異,但新寫的元件還是值得嘗試的,因為程式碼量的確減少了很多,尤其是重複的程式碼(例如 componentDidMount + componentDidUpdate + componentWillUnmount = useEffect)。
以下是下面會講到得三個主要問題:
-
如何在元件載入時發起非同步任務
-
如何在元件互動時發起非同步任務
-
其他陷阱
TL;DR
-
使用
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 subscriptions 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)。
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 本身,否則就容易踩坑。反之如果的確碰到了設定了新值但讀取到舊值的情況,也可以往這個方向想想,可能就是這個原因所致。
以上是<React Hooks 非同步操作>的方法分享,PS: 如果你是前端工程師同學,歡迎試用體驗【webfunny監控系統】。