React hooks異步操作踩坑記錄
useEffect 和異步任務(wù)搭配使用的時(shí)候會(huì)遇到的一些坑總結(jié)。
三個(gè)常見的問題:
? 1、如何在組件加載的時(shí)候發(fā)起異步任務(wù)
? 2、如何在組件交互的時(shí)候發(fā)起異步任務(wù)
? 3、其他陷阱
一、react hooks發(fā)異步請求
1、使用useEffect發(fā)起異步任務(wù),第二個(gè)參數(shù)使用空數(shù)組可以實(shí)現(xiàn)組件加載的時(shí)候執(zhí)行方法體,返回值函數(shù)在組件卸載時(shí)執(zhí)行一次,用來清理一些東西。
2、使用 AbortController 或者某些庫自帶的信號(hào)量 ( axios.CancelToken) 來控制中止請求,更加優(yōu)雅地退出
3、當(dāng)需要在其他地方(例如點(diǎn)擊處理函數(shù)中)設(shè)定定時(shí)器,在useEffect返回值中清理時(shí),使用局部變量或者useRef來記錄這個(gè)timer,不要使用useState.
4、組件中出現(xiàn)setTimeout等閉包時(shí),盡量在閉包內(nèi)部引用ref而不是state,否則容易出現(xiàn)讀取到舊的值
5、useState返回的更新狀態(tài)方法是異步的,要在下次重繪的時(shí)候才能獲取新的值。不要試圖在更改狀態(tài)之后立馬獲取狀態(tài)。
二、如何在組件加載的時(shí)候發(fā)起異步任務(wù)
這類需求非常常見,典型的例子是在列表組件加載的時(shí)候發(fā)送請求到后端,獲取列表展現(xiàn)。
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> ) } </> ); }
如上是一個(gè)基礎(chǔ)的帶 Loading 功能的組件,會(huì)發(fā)送異步請求到后端獲取一個(gè)值并顯示到頁面上。
如果以示例的標(biāo)準(zhǔn)來說已經(jīng)足夠,但要實(shí)際運(yùn)用到項(xiàng)目中,還不得不考慮幾個(gè)問題
三、如果在響應(yīng)回來之前組件被銷毀了會(huì)怎樣?
這時(shí)候React會(huì)報(bào)一個(gè)??警告信息:
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
就是說一個(gè)組件卸載之后不應(yīng)該再修改他的狀態(tài)。雖然不影響運(yùn)行,但是這種問題不應(yīng)該存在。那么如何解決?
問題的核心在于,在組件卸載后依然調(diào)用了 setValue(data.value)和 setLoading(false)來更改狀態(tài)。
因此一個(gè)簡單的辦法是標(biāo)記一下組件有沒有被卸載,可以利用 useEffect的返回值。
// 省略組件其他內(nèi)容,只列出 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; } }, []);
這樣可以順利避免這個(gè) Warning。
有沒有更加優(yōu)雅的解法?
上述做法是在收到響應(yīng)時(shí)進(jìn)行判斷,即無論如何需要等響應(yīng)完成,略顯被動(dòng)。
一個(gè)更加主動(dòng)的方式是探知到卸載時(shí)直接中斷請求,自然也不必再等待響應(yīng)了。
這種主動(dòng)方案需要用到 AbortController。
AbortController 是一個(gè)瀏覽器的實(shí)驗(yàn)接口,它可以返回一個(gè)信號(hào)量(singal),從而中止發(fā)送的請求。
這個(gè)接口的兼容性不錯(cuò),除了 IE 之外全都兼容(如 Chrome, Edge, FF 和絕大部分移動(dòng)瀏覽器,包括 Safari)。
useEffect( => { let isUnmounted = false; const abortController = new AbortController; // 創(chuàng)建 (async => { const res = await fetch(SOME_API, { singal: abortController.singal, // 當(dāng)做信號(hào)量傳入 }); const data = await res.json; if (!isUnmounted) { setValue(data.value); setLoading(false); } }); return => { isUnmounted = true; abortController.abort; // 在組件卸載時(shí)中斷 } }, []);
singal 的實(shí)現(xiàn)依賴于實(shí)際發(fā)送請求使用的方法,如上述例子的 fetch方法接受 singal屬性。
如果使用的是 axios,它的內(nèi)部已經(jīng)包含了 axios.CancelToken,可以直接使用,例子在這里。
import React, { Component } from 'react'; import axios from 'axios'; class Example extends Component { signal = axios.CancelToken.source(); state = { isLoading: false, user: {}, } componentDidMount() { this.onLoadUser(); } componentWillUnmount() { this.signal.cancel('Api is being canceled'); } onLoadUser = async () => { try { this.setState({ isLoading: true }); const response = await axios.get('https://randomuser.me/api/', { cancelToken: this.signal.token, }) this.setState({ user: response.data, isLoading: true }); } catch (err) { if (axios.isCancel(err)) { console.log('Error: ', err.message); // => prints: Api is being canceled } else { this.setState({ isLoading: false }); } } } render() { return ( <div> <pre>{JSON.stringify(this.state.user, null, 2)}</pre> </div> ) } }
四、如何在組件交互時(shí)發(fā)起異步任務(wù)
? 另一種常見的需求是要在組件交互(比如點(diǎn)擊某個(gè)按鈕)時(shí)發(fā)送請求或者開啟計(jì)時(shí)器,待收到響應(yīng)后修改數(shù)據(jù)進(jìn)而影響頁面。
這里和上面一節(jié)(組件加載時(shí))最大的差異在于 React Hooks 只能在組件級(jí)別編寫,不能在方法(dealClick
)或者控制邏輯(if
, for
等)內(nèi)部編寫,所以不能在點(diǎn)擊的響應(yīng)函數(shù)中再去調(diào)用 useEffect
。但我們依然要利用 useEffect
的返回函數(shù)來做清理工作。
? 以計(jì)時(shí)器為例,假設(shè)我們想做一個(gè)組件,點(diǎn)擊按鈕后開啟一個(gè)計(jì)時(shí)器(5s),計(jì)時(shí)器結(jié)束后修改狀態(tài)。
但如果在計(jì)時(shí)未到就銷毀組件時(shí),我們想停止這個(gè)計(jì)時(shí)器,避免內(nèi)存泄露。用代碼實(shí)現(xiàn)的話,會(huì)發(fā)現(xiàn)開啟計(jì)時(shí)器和清理計(jì)時(shí)器會(huì)在不同的地方,因此就必須記錄這個(gè) timer。
看如下的例子:
import React, { useState, useEffect } from 'react'; export const MyComponent: React.FC<{}> = () => { const [value, setValue] = useState(0); let timer: number; useEffect(() => { // timer 需要在點(diǎn)擊時(shí)建立,因此這里只做清理使用 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,自然是用一個(gè)內(nèi)部變量來存儲(chǔ)即可(暫不考慮連續(xù)點(diǎn)擊按鈕導(dǎo)致多個(gè) timer 出現(xiàn),假設(shè)只點(diǎn)一次。因?yàn)閷?shí)際情況下點(diǎn)了按鈕還會(huì)觸發(fā)其他狀態(tài)變化,繼而界面變化,也就點(diǎn)不到了)。
這里需要注意的是,如果把 timer
升級(jí)為狀態(tài)(state),則代碼反而會(huì)出現(xiàn)問題。
考慮如下代碼:
import React, { useState, useEffect } from 'react'; export const MyComponent: React.FC<{}> = () => { const [value, setValue] = useState(0); const [timer, setTimer] = useState(0); // 把 timer 升級(jí)為狀態(tài) useEffect(() => { // timer 需要在點(diǎn)擊時(shí)建立,因此這里只做清理使用 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> </> ); }
有關(guān)語義上 timer
到底算不算作組件的狀態(tài)我們先拋開不談,僅就代碼層面來看。
利用 useState
來記住 timer
狀態(tài),利用 setTimer
去更改狀態(tài),看似合理。
但實(shí)際運(yùn)行下來,在 useEffect
返回的清理函數(shù)中,得到的 timer
卻是初始值,即 0
。
為什么兩種寫法會(huì)有差異呢?
其核心在于寫入的變量和讀取的變量是否是同一個(gè)變量。
第一種寫法: 代碼是把 timer
作為組件內(nèi)的局部變量使用。在初次渲染組件時(shí),useEffect
返回的閉包函數(shù)中指向了這個(gè)局部變量 timer
。在 dealClick
中設(shè)置計(jì)時(shí)器時(shí)返回值依舊寫給了這個(gè)局部變量(即讀和寫都是同一個(gè)變量),因此在后續(xù)卸載時(shí),雖然組件重新運(yùn)行導(dǎo)致出現(xiàn)一個(gè)新的局部變量 timer
,但這不影響閉包內(nèi)老的 timer
,所以結(jié)果是正確的。
第二種寫法: timer
是一個(gè) useState
的返回值,并不是一個(gè)簡單的變量。從 React Hooks 的源碼來看,它返回的是 [hook.memorizedState, dispatch]
,對應(yīng)我們接的值和變更方法。當(dāng)調(diào)用 setTimer
和 setValue
時(shí),分別觸發(fā)兩次重繪,使得 hook.memorizedState
指向了 newState
(注意:不是修改,而是重新指向)。但 useEffect
返回閉包中的 timer
依然指向舊的狀態(tài),從而得不到新的值。(即讀的是舊值,但寫的是新值,不是同一個(gè))
? 如果覺得閱讀 Hooks 源碼有困難,可以從另一個(gè)角度去理解:雖然 React 在 16.8 推出了 Hooks,但實(shí)際上只是加強(qiáng)了函數(shù)式組件的寫法,使之擁有狀態(tài),用來作為類組件的一種替代,但 React 狀態(tài)的內(nèi)部機(jī)制沒有變化。在 React 中 setState
內(nèi)部是通過 merge 操作將新狀態(tài)和老狀態(tài)合并后,重新返回一個(gè)新的狀態(tài)對象。不論 Hooks 寫法如何,這條原理沒有變化?,F(xiàn)在閉包內(nèi)指向了舊的狀態(tài)對象,而 setTimer
和 setValue
重新生成并指向了新的狀態(tài)對象,并不影響閉包,導(dǎo)致了閉包讀不到新的狀態(tài)。
我們注意到 React 還提供給我們一個(gè) useRef
, 它的定義是:
- useRef 返回一個(gè)可變的 ref 對象,其
current
屬性被初始化為傳入的參數(shù)(initialValue)。 - 返回的 ref 對象在組件的整個(gè)生命周期內(nèi)保持不變。
ref 對象可以確保在整個(gè)生命周期中值不變,且同步更新,是因?yàn)?ref 的返回值始終只有一個(gè)實(shí)例,所有讀寫都指向它自己。所以也可以用來解決這里的問題。
import React, { useState, useEffect, useRef } from 'react'; export const MyComponent: React.FC<{}> = () => { const [value, setValue] = useState(0); const timer = useRef(0); useEffect(() => { // timer 需要在點(diǎn)擊時(shí)建立,因此這里只做清理使用 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> </> ); }
事實(shí)上我們后面會(huì)看到,useRef
和異步任務(wù)配合更加安全穩(wěn)妥。
五、其他陷阱
修改狀態(tài)是異步的
//錯(cuò)誤例子 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
返回的修改函數(shù)是異步的,調(diào)用后并不會(huì)直接生效,因此立馬讀取 value
獲取到的是舊值(0
)。
React 這樣設(shè)計(jì)的目的是為了性能考慮,爭取把所有狀態(tài)改變后只重繪一次就能解決更新問題,而不是改一次重繪一次,也是很容易理解的。
在 timeout 中讀不到其他狀態(tài)的新值
//錯(cuò)誤例子 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> ); }
這個(gè)問題和上面使用 useState
去記錄 timer
類似,在生成 timeout 閉包時(shí),value 的值是 0。
雖然之后通過 setValue
修改了狀態(tài),但 React 內(nèi)部已經(jīng)指向了新的變量,而舊的變量仍被閉包引用,所以閉包拿到的依然是舊的初始值,也就是 0。
要修正這個(gè)問題,也依然是使用 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 的問題
假設(shè)我們要實(shí)現(xiàn)一個(gè)按鈕,默認(rèn)顯示 false。當(dāng)點(diǎn)擊后更改為 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> ); }
我們會(huì)發(fā)現(xiàn)點(diǎn)擊時(shí)能夠正常切換,但是兩秒后并不會(huì)變回來。
究其原因,依然在于 useState
的更新是重新指向新值,但 timeout 的閉包依然指向了舊值。
所以在例子中,flag
一直是 false
,雖然后續(xù) setFlag(!flag)
,但依然沒有影響到 timeout 里面的 flag
。
解決方法有二。
第一個(gè)還是利用 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> ); }
第二個(gè)是利用 setFlag
可以接收函數(shù)作為參數(shù),并利用閉包和參數(shù)來實(shí)現(xiàn)(函數(shù)式更新)
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> ); }
當(dāng) setFlag
參數(shù)為函數(shù)類型時(shí),這個(gè)函數(shù)的意義是告訴 React 如何從當(dāng)前狀態(tài)產(chǎn)生出新的狀態(tài)(類似于 redux 的 reducer,不過是只針對一個(gè)狀態(tài)的子 reducer)。既然是當(dāng)前狀態(tài),因此返回值取反,就能夠?qū)崿F(xiàn)效果。
總結(jié)
? 在 Hook 中出現(xiàn)異步任務(wù)尤其是 timeout 的時(shí)候,我們要格外注意。
useState
只能保證多次重繪之間的狀態(tài)值是一樣的,但不保證它們就是同一個(gè)對象,因此出現(xiàn)閉包引用的時(shí)候,盡量使用 useRef
而不是直接使用 state 本身,否則就容易踩坑。
反之如果的確碰到了設(shè)置了新值但讀取到舊值的情況,也可以往這個(gè)方向想想,可能就是這個(gè)原因所致。
以上為個(gè)人經(jīng)驗(yàn),希望能給大家一個(gè)參考,也希望大家多多支持腳本之家。
相關(guān)文章
webpack 2的react開發(fā)配置實(shí)例代碼
本篇文章主要介紹了webpack 2的react開發(fā)配置實(shí)例代碼,小編覺得挺不錯(cuò)的,現(xiàn)在分享給大家,也給大家做個(gè)參考。一起跟隨小編過來看看吧2017-07-07React Native之ListView實(shí)現(xiàn)九宮格效果的示例
本篇文章主要介紹了React Native之ListView實(shí)現(xiàn)九宮格效果的示例,具有一定的參考價(jià)值,感興趣的小伙伴們可以參考一下2017-08-08使用React+ts實(shí)現(xiàn)無縫滾動(dòng)的走馬燈詳細(xì)過程
這篇文章主要給大家介紹了關(guān)于使用React+ts實(shí)現(xiàn)無縫滾動(dòng)的走馬燈詳細(xì)過程,文中給出了詳細(xì)的代碼示例以及圖文教程,對大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價(jià)值,需要的朋友可以參考下2023-08-08在?React?Native?中使用?CSS?Modules的配置方法
有些前端工程師希望也能像開發(fā) web 應(yīng)用那樣,使用 CSS Modules 來開發(fā) React Native,本文將介紹如何在 React Native 中使用 CSS Modules,需要的朋友可以參考下2022-08-08React Hooks之使用useCallback和useMemo進(jìn)行性能優(yōu)化方式
這篇文章主要介紹了React Hooks之使用useCallback和useMemo進(jìn)行性能優(yōu)化方式,具有很好的參考價(jià)值,希望對大家有所幫助。如有錯(cuò)誤或未考慮完全的地方,望不吝賜教2023-06-06