React Hooks 是 React 16.8 的新功能,能夠在不編寫 class 的狀況下使用狀態等功能,從而使得函數式組件從無狀態的變化爲有狀態的。 React 的類型包 @types/react 中也同步把 React.SFC (Stateless Functional Component) 改成了 React.FC (Functional Component)。html
經過這一升級,原先 class 寫法的組件也就徹底能夠被函數式組件替代。雖然是否要把老項目中全部類組件所有改成函數式組件因人而異,但新寫的組件仍是值得嘗試的,由於代碼量的確減小了不少,尤爲是重複的代碼(例如 componentDidMount + componentDidUpdate + componentWillUnmount = useEffect)。react
從 16.8 發佈(今年2月)至今也有大半年了,但本人水平有限,尤爲在 useEffect 和異步任務搭配使用的時候常常踩到一些坑。特做本文,權當記錄,供遇到一樣問題的同僚借鑑參考。我會講到三個項目中很是常見的問題:ios
useEffect
發起異步任務,第二個參數使用空數組可實現組件加載時執行方法體,返回值函數在組件卸載時執行一次,用來清理一些東西,例如計時器。axios.CancelToken
) 來控制停止請求,更加優雅地退出。useEffect
返回值中清理時,使用局部變量或者 useRef
來記錄這個 timer
。不要使用 useState
。setTimeout
等閉包時,儘可能在閉包內部引用 ref 而不是 state,不然容易出現讀取到舊值的狀況。useState
返回的更新狀態方法是異步的,要在下次重繪才能獲取新值。不要試圖在更改狀態以後立馬獲取狀態。這類需求很是常見,典型的例子是在列表組件加載時發送請求到後端,獲取列表後展示。git
發送請求也屬於 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 功能的組件,會發送異步請求到後端獲取一個值並顯示到頁面上。若是以示例的標準來講已經足夠,但要實際運用到項目中,還不得不考慮幾個問題。github
React 會報一個 Warningtypescript
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 function.in Notificationjson
大意是說在一個組件卸載了以後不該該再修改它的狀態。雖然不影響運行,但做爲完美主義者表明的程序員羣體是沒法容忍這種狀況發生的,那麼如何解決呢?redux
問題的核心在於,在組件卸載後依然調用了 setValue(data.value)
和 setLoading(false)
來更改狀態。所以一個簡單的辦法是標記一下組件有沒有被卸載,能夠利用 useEffect
的返回值。axios
// 省略組件其餘內容,只列出 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 這樣設計的目的是爲了性能考慮,爭取把全部狀態改變後只重繪一次就能解決更新問題,而不是改一次重繪一次,也是很容易理解的。
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>
);
}
複製代碼
假設咱們要實現一個按鈕,默認顯示 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 自己,不然就容易踩坑。反之若是的確碰到了設置了新值但讀取到舊值的狀況,也能夠往這個方向想一想,可能就是這個緣由所致。