先預(yù)警一下,完全消化本文內(nèi)容有點難。
全文共 5104 字,閱讀需要花費 10 分鐘。
useDeferredValue,一個出了很久,但是我?guī)缀鯖]咋在實踐中用到過的超冷門 hook。它有多冷門呢,我之前甚至都覺得沒必要介紹它。
直到前幾天,一個粉絲給了我重要的思路,我才認識到它的威力,逐漸深入了解之后發(fā)現(xiàn)它簡直就是一個寶藏 hook,說它是為了 Suspense 量身訂做的都不為過。
此時,我使用 useTransition 勉強實現(xiàn)了該功能。主要代碼如下:
export default function Index() { const [api, setApi] = useState(postApi) const [isPending, startTransition] = useTransition() function __inputChange() { startTransition(() => { api.cancel() setApi(postApi()) }) } ....
<Suspense fallback={<div>loading...</div>}> <List api={api} isPending={isPending} /></Suspense>
const List = ({api, isPending}) => { const posts = use(api) return ( <ul className='_04_list' style={{opacity: isPending ? 0.5 : 1}}> {posts.map((post) => ( <div key={post.id} className='_04_item'> <h2>{post.title}</h2> <p>{post.body}</p> </div> ))} </ul> )}
useTransition 能夠阻止 Suspense 在請求發(fā)生時,渲染 fallback 中的 Loading 組件,并且,isPending 也能表示請求正在發(fā)生,因此,我把 isPending 傳入到子組件中,那么我們就可以在子組件中自定義請求狀態(tài)。
這基本達到了我想要的交互效果。
但是一個嚴(yán)重的問題是,我每次輸入,都會發(fā)送一個請求,當(dāng)我快速輸入時,我希望通過取消上一次還沒完成的請求的方式來優(yōu)化交互效果。useTransition 并不支持我這樣做。
核心原因是因為 useTransition 的任務(wù)會排隊依次執(zhí)行,當(dāng)我想要在下一個任務(wù)開始時,取消上一個請求時,上一個任務(wù)已經(jīng)執(zhí)行完了。因此 api.cancel() 雖然成功執(zhí)行了,但是并起不到取消請求的效果,它執(zhí)行時,已經(jīng)沒有未完成的請求了。
useTransition 無法取消請求。我思考了很久,也沒摸索出來一個合適的方案。因此之前我只能使用防抖來做這個優(yōu)化。
const [api, setApi] = useState(postApi)const [isPending, startTransition] = useTransition()const timer = useRef(null)function __inputChange() { clearTimeout(timer.current) timer.current = setTimeout(() => { startTransition(() => { api.cancel() setApi(postApi()) }) }, 300) }...
但是很顯然,這不是很優(yōu)雅,因為防抖實際上和 useTransition 有類似的作用,用了防抖之后,useTransition 在這里的存在就變得有點尷尬了。
意外之喜的是,有大佬級別的粉絲在評論區(qū)給我提供了一個非常優(yōu)雅的解決思路。那就是利用 useDeferredValue。
肅然起敬!!!!
在保證了代碼優(yōu)雅的情況之下,輕松實現(xiàn)了我理想中的效果。useDeferredValue 直接補齊了 React 19 異步開發(fā)中,最佳實踐的最后一塊短板!
代碼就這么幾行,但是要理解 useDeferredValue,可能就要花點時間了。我們一起來學(xué)習(xí)一下。
useDeferredValue 是一個可以推遲 UI 更新的 hook。這句話理解起來有點困難。需要我稍微給各位道友解讀一下。
在正常情況下,一個 state 的變化,會導(dǎo)致 UI 發(fā)生變化。例如下面這個案例。
function Index() { const [counter, setCounter] = useState(0) function __clickHanler() { setCounter(counter + 1) } return ( <div> <div id='tips'>基礎(chǔ)案例,state 遞增</div> <button onClick={__clickHanler}>counter++</button> <div className="counter">counter: {counter}</div> <div className="counter">counter: {counter}</div> </div> )}
這里需要注意的是,狀態(tài) counter 被兩個元素使用,因此,這兩個元素的更改,實際上是一個任務(wù)。他們必定會同時響應(yīng) counter 的變化。
但是這個時候,我們可以利用 useDeferredValue,把他們拆分成兩個任務(wù)。
function Index() { const [counter, setCounter] = useState(0) const deferred = useDeferredValue(counter) function __clickHanler() { setCounter(counter + 1) } return ( <div> <div id='tips'>基礎(chǔ)案例,state 遞增</div> <button onClick={__clickHanler}>counter++</button> <div className="counter"> counter: {counter} </div> <div className="counter"> counter: {deferred} </div> </div> )}
注意看,我們使用 counter 作為 useDeferredValue 的初始值,并將其返回值替換第二個元素。
const deferred = useDeferredValue(counter)
<div className="counter"> counter: {deferred}</div>
此時,第二個元素的更新,就不再與第一個元素同步。它更新的優(yōu)先級被降低。這個時候它的執(zhí)行在理論上是可以被更高的優(yōu)先級插隊和中斷的。
但是由于渲染都太短了,我們?nèi)庋蹮o法區(qū)分出來兩個任務(wù)已經(jīng)被分開了,因此我們把第二個元素重構(gòu)成一個子組件,并模擬成一個耗時組件。此時我們就能明顯看出區(qū)別來。
<Expensive counter={deferred} />
const Expensive = ({counter}) => { const start = performance.now() while (performance.now() - start < 200) {} return ( <div className="counter">Deferred: {counter}</div> )}
演示效果如下。
因此,我們可以利用 useDeferredValue 推遲 UI 的更新。將對應(yīng)任務(wù)的優(yōu)先級降低,使其可以被插隊與中斷。
在這里,我們要更加清楚的理解任務(wù)和渲染任務(wù),才能對案例的分析更加的精準(zhǔn)。以上一個例子的 Expensive 組件為例。
狀態(tài)變化時,diff 會發(fā)生,Expensive 函數(shù)本身作為 diff 過程的一部分,它必定也會執(zhí)行,但是這里我們注意,它對應(yīng)的渲染任務(wù),卻是可以被阻止執(zhí)行的。
例如在上面的例子中,當(dāng)我快速點擊按鈕遞增時,Expensive 組件不會依次遞增。效果如下:
我們發(fā)現(xiàn),Expensive 組件的渲染直接從 0 變成了 7。
這是因為作為一個耗時任務(wù),又被標(biāo)記了低優(yōu)先級,因此它的渲染任務(wù)不停的被優(yōu)先級更高的 counter 中斷并放棄。因此直接從 0 變成了 7。
但是此時我們也發(fā)現(xiàn)另外一個情況,那就是 counter 直接對應(yīng)的高優(yōu)先級執(zhí)行也沒有那么流暢,這是為什么呢?其實很簡單,因為在我們的模擬案例中,并沒有把耗時定位在渲染上。這可能和實踐情況會不太一樣。我們把耗時寫在了 Expensive 函數(shù)里,而這個函數(shù)每次都會執(zhí)行,它的執(zhí)行阻塞了渲染。
const Expensive = ({counter}) => { const start = performance.now() while (performance.now() - start < 200) {} return ( <div className="counter">Deferred: {counter}</div> )}
?
所以這里我們一定要區(qū)分開渲染任務(wù)和 Expensive 函數(shù),他們是不同的,UI 渲染是一個異步任務(wù),而 Expensive 函數(shù)是同步執(zhí)行的。useDeferredValue 推遲的是 UI 渲染任務(wù)。因此,我們需要特別注意的是,不要在同步邏輯上執(zhí)行過多的耗時任務(wù)。
但是我們可以通過任務(wù)拆分的方式,把執(zhí)行耗時時間分散到更多的子組件中去,這樣 React 就可以利用任務(wù)中斷的機制,在不阻塞渲染的情況下,中斷低優(yōu)先級的任務(wù)。
借用官網(wǎng)的一個復(fù)雜案例來跟大家演示。
function SlowList({ text }) { // Log once. The actual slowdown is inside SlowItem. console.log('[ARTIFICIALLY SLOW] Rendering 250 <SlowItem />'); let items = []; for (let i = 0; i < 250; i++) { items.push(<SlowItem key={i} text={text} />); } return ( <ul className="items"> {items} </ul> );}function SlowItem({ text }) { let startTime = performance.now(); while (performance.now() - startTime < 1) { // Do nothing for 1 ms per item to emulate extremely slow code } return ( <li className="item"> Text: {text} </li> )}
此時我們注意觀察,不要錯漏這個細節(jié)。slowList 中包含了 250 個子組件。每個子組件都渲染 1ms,那么整個組件渲染就需要耗時至少 250ms。
在父組件中,我們把 deferred 傳遞給 SlowList。
<SlowList text={deferred} />
那么此時表示,slowList 的任務(wù)是低優(yōu)先級。counter 對應(yīng)的任務(wù)可以中斷它的執(zhí)行。當(dāng)我快速點擊時,執(zhí)行效果如下。
此時一個很明顯的區(qū)別就是,counter 的 UI 變化變得更加流暢了。這是因為耗時被拆分到了多個子組件中,React 就有機會中斷這些函數(shù)的執(zhí)行,并執(zhí)行優(yōu)先級更高的任務(wù),以確保高優(yōu)先級任務(wù)的流暢。
如果你沒有使用 React Compiler,你需要使用 memo 手動緩存 SlowList。
const SlowList = memo(function SlowList({ text }) { // ...});
useDefferdValue 會首先使用舊值傳遞給組件。
<SlowList text={deferred} />
因此,當(dāng) counter 發(fā)生變化時,deferred 依然是舊值,那么此時,如果我們使用 memo 包裹,SlowList 的 props 就沒有發(fā)生變化,我們可以跳過此次針對 SlowList 的更新。
這跟 React 的性能優(yōu)化策略有關(guān)。
看了上面兩個例子,肯定還是有一部分人會覺得很懵,不要急,接下來我們把運行原理分析一下,整個情況就清晰了。
useDeferredValue 會嘗試將 UI 任務(wù)更新兩次。
第一次,會給子組件傳遞舊值。此時 SlowList 接收到的 props 會與上一次完全相同。如果結(jié)合了 React.memo,那么該組件就不會重新渲染。該組件可以重復(fù)使用之前的渲染結(jié)果。
?
Compiler 編譯之后不需要 memo。
此時,高優(yōu)先級的任務(wù)渲染會發(fā)生,渲染完成之后,將會開始第二次渲染。此時,將會傳入剛才更新之后的新值。對于 SlowList
而言,props 發(fā)生了變化,整個組件會重新渲染。
我們通常會將已經(jīng)非常明確的耗時任務(wù)標(biāo)記為 deferred,因此,這些任務(wù)都被視為低優(yōu)先級。當(dāng)重要的高優(yōu)先級更新已經(jīng)完成,低優(yōu)先級任務(wù)在第二次渲染時嘗試更新...
在它第二次更新的過程中,如果又有新的高優(yōu)先級任務(wù)進來,那么 React 就會中斷并放棄第二次更新,去執(zhí)行高優(yōu)先級的任務(wù)。
i
注意:是中斷,并放棄這次更新,所以表現(xiàn)出來的結(jié)果就是,中間會漏掉許多任務(wù)的執(zhí)行。
這樣的運行機制有一個非常重要的好處。
那就是,如果你的電腦性能足夠強悍,那么第二次的更新可能會快速完成,高優(yōu)先級的任務(wù)來不及中斷,那么我們的頁面響應(yīng)就是非常理想的。
但是如果我們的電腦性能比較差,第二次更新還沒完成,新的高優(yōu)先級任務(wù)又來了,那么就可以通過中斷的方式,降級處理,保證重要 UI 的流暢,放棄低優(yōu)先級任務(wù)。
?
在不同性能的設(shè)備上,有不同的反應(yīng),這個是跟防抖、節(jié)流的最重要的區(qū)別。
那我們回過頭來,分析一下最開始的那個案例,重新看一眼代碼
export default function Index() { const [api, setApi] = useState(postApi) const deferred = useDeferredValue(api) function __inputChange(e) { api.cancel() setApi(postApi()) } ...
<Suspense fallback={<div>loading...</div>}> <List api={deferred} isPending={api !== deferred} /></Suspense>
這里我們將 api 做為 state,當(dāng) api 被重新賦值時,List 會經(jīng)歷兩次更新。
首先點擊事件觸發(fā),請求立即發(fā)生。api 被改變。觸發(fā)組件更新。
第一次更新時,deferred 使用舊值傳參,此時對于 List 而言,api 沒有發(fā)生變化。因此,利用這個機制,我們可以阻止 Suspense 直接渲染成 fallback。
在 Suspense 包裹之下,只有當(dāng)接口請求成功之后,deferred 的第二次更新才會發(fā)生,因此,在這個過程中,如果我們快速進行第二次點擊,可以直接取消上一次請求,讓第二次更新來不及執(zhí)行。此時新的請求發(fā)生。
?
這里要結(jié)合 Suspense 的執(zhí)行機制來理解。
這種場景的最佳實踐代碼非常的簡潔和優(yōu)雅。寫起來也很舒服,性能也非常強悍。但是理解起來會比較困難。因此想要做到靈活運用,還需要多多消化。
但是,等你徹底掌握它之后,你就會發(fā)現(xiàn) React 19 在異步交互上真的太優(yōu)雅了。這樣的開發(fā)體驗,是依賴 useEffect 完全比不了的。
后續(xù)的分享中,我將會繼續(xù)為大家分享 React Action 的設(shè)計核心思維與具體使用。
本文鏈接:http://www.www897cc.com/showinfo-26-94859-0.html有點東西啊!一個被小瞧的冷門Hook 補全了 React 19 異步優(yōu)秀實踐的最后一環(huán)
聲明:本網(wǎng)頁內(nèi)容旨在傳播知識,若有侵權(quán)等問題請及時與本網(wǎng)聯(lián)系,我們將在第一時間刪除處理。郵件:2376512515@qq.com
下一篇: 百度二面,有點小激動!附面試題