「React18 新特性」深入淺出用戶體驗大師—transition
在 React 18 中,引進了一個新的 API —— startTransition
還有二個新的 hooks —— useTransition
和 useDeferredValue
,本質上它們離不開一個概念 transition
。
什麼叫做 transition
英文翻譯爲 ‘過渡’,那麼這裏的過渡指的就是在一次更新中,數據展現從無到有的過渡效果。用 ReactWg 中的一句話描述 startTransition 。
在大屏幕視圖更新的時,startTransition 能夠保持頁面有響應,這個 api 能夠把 React 更新標記成一個特殊的更新類型
transitions
,在這種特殊的更新下,React 能夠保持視覺反饋和瀏覽器的正常響應。
單單從上述對 startTransition
的描述,我們很難理解這個新的 api 到底解決什麼問題。不過不要緊,接下來讓我逐步分析這個 api 到底做了什麼,以及它的應用場景。
二 transition 使命
1 transition 的誕生
爲什麼會出現 Transition 呢?Transition 本質上解決了渲染併發的問題,在 React 18 關於 startTransition 描述的時候,多次提到 ‘大屏幕’ 的情況,這裏的大屏幕並不是單純指的是尺寸,而是一種數據量大,DOM 元素節點多的場景,比如數據可視化大屏情況,在這一場景下,一次更新帶來的變化可能是巨大的,所以頻繁的更新,執行 js 事務頻繁調用,瀏覽器要執行大量的渲染工作,所以給用戶感覺就是卡頓。
Transition 本質上是用於一些不是很急迫的更新上,在 React 18 之前,所有的更新任務都被視爲急迫的任務,在 React 18 誕生了 concurrent Mode
模式,在這個模式下,渲染是可以中斷,低優先級任務,可以讓高優先級的任務先更新渲染。可以說 React 18 更青睞於良好的用戶體驗。從 concurrent Mode
到 susponse
再到 startTransition
無疑都是圍繞着更優質的用戶體驗展開。
startTransition 依賴於 concurrent Mode
渲染併發模式。也就是說在 React 18 中使用 startTransition
,那麼要先開啓併發模式,也就是需要通過 createRoot
創建 Root 。我們先來看一下兩種模式下,創建 Root 區別。
傳統 legacy 模式
import ReactDOM from 'react-dom'
/* 通過 ReactDOM.render */
ReactDOM.render(
<App />,
document.getElementById('app')
)
v18 concurrent Mode 併發模式
import ReactDOM from 'react-dom'
/* 通過 createRoot 創建 root */
const root = ReactDOM.createRoot(document.getElementById('app'))
/* 調用 root 的 render 方法 */
root.render(<App/>)
上面說了 startTransition 使用條件,接下來探討一下 startTransition 到底應用於什麼場景。前面說了 React 18 確定了不同優先級的更新任務,爲什麼會有不同優先級的任務。世界上本來沒有路,走的人多了就成了路,優先級產生也是如此,React 世界裏本來沒有優先級,場景多了就出現了優先級。
如果一次更新中,都是同樣的任務,那麼也就無任務優先級可言,統一按批次處理任務就可以了,可現實恰好不是這樣子。舉一個很常見的場景:就是有一個 input
表單。並且有一個大量數據的列表,通過表單輸入內容,對列表數據進行搜索,過濾。那麼在這種情況下,就存在了多個併發的更新任務。分別爲
-
第一種:input 表單要實時獲取狀態,所以是受控的,那麼更新 input 的內容,就要觸發更新任務。
-
第二種:input 內容改變,過濾列表,重新渲染列表也是一個任務。
第一種類型的更新,在輸入的時候,希望是的視覺上馬上呈現變化,如果輸入的時候,輸入的內容延時顯示,會給用戶一種極差的視覺體驗。第二種類型的更新就是根據數據的內容,去過濾列表中的數據,渲染列表,這個種類的更新,和上一種比起來優先級就沒有那麼高。那麼如果 input 搜索過程中用戶更優先希望的是輸入框的狀態改變,那麼正常情況下,在 input 中綁定 onChange 事件用來觸發上述的兩種類的更新。
const handleChange=(e)=>{
/* 改變搜索條件 */
setInputValue(e.target.value)
/* 改變搜索過濾後列表狀態 */
setSearchQuery(e.target.value)
}
上述這種寫法,那麼 setInputValue
和 setSearchQuery
帶來的更新就是一個相同優先級的更新。而前面說道,輸入框狀態改變更新優先級要大於列表的更新的優先級。 ,這個時候我們的主角就登場了。用 startTransition
把兩種更新區別開。
const handleChange=()=>{
/* 高優先級任務 —— 改變搜索條件 */
setInputValue(e.target.value)
/* 低優先級任務 —— 改變搜索過濾後列表狀態 */
startTransition(()=>{
setSearchQuery(e.target.value)
})
}
- 如上通過 startTransition 把不是特別迫切的更新任務 setSearchQuery 隔離出來。這樣在真實的情景效果如何呢?我們來測試一下。
2 模擬場景
接下來我們模擬一下上述場景。流程大致是這樣的:
-
有一個搜索框和一個 10000 條數據的列表,列表中每一項有相同的文案。
-
input 改變要實時改變 input 的內容(第一種更新),然後高亮列表裏面的相同的搜索值(第二種更新)。
-
用一個按鈕控制 常規模式 |
transition
模式。
/* 模擬數據 */
const mockDataArray = new Array(10000).fill(1)
/* 高量顯示內容 */
function ShowText({ query }){
const text = 'asdfghjk'
let children
if(text.indexOf(query) > 0 ){
/* 找到匹配的關鍵詞 */
const arr = text.split(query)
children = <div>{arr[0]}<span style={{ color:'pink' }} >{query}</span>{arr[1]} </div>
}else{
children = <div>{text}</div>
}
return <div>{children}</div>
}
/* 列表數據 */
function List ({ query }){
console.log('List渲染')
return <div>
{
mockDataArray.map((item,index)=><div key={index} >
<ShowText query={query} />
</div>)
}
</div>
}
/* memo 做優化處理 */
const NewList = memo(List)
-
List
組件渲染一萬個ShowText
組件。在 ShowText 組件中會通過傳入的 query 實現動態高亮展示。 -
因爲每一次改變
query
都會讓 10000 個重新渲染更新,並且還要展示 query 的高亮內容,所以滿足併發渲染的場景。
接下來就是 App 組件編寫。
export default function App(){
const [ value ,setInputValue ] = React.useState('')
const [ isTransition , setTransion ] = React.useState(false)
const [ query ,setSearchQuery ] = React.useState('')
const handleChange = (e) => {
/* 高優先級任務 —— 改變搜索條件 */
setInputValue(e.target.value)
if(isTransition){ /* transition 模式 */
React.startTransition(()=>{
/* 低優先級任務 —— 改變搜索過濾後列表狀態 */
setSearchQuery(e.target.value)
})
}else{ /* 不加優化,傳統模式 */
setSearchQuery(e.target.value)
}
}
return <div>
<button onClick={()=>setTransion(!isTransition)} >{isTransition ? 'transition' : 'normal'} </button>
<input onChange={handleChange}
placeholder="輸入搜索內容"
value={value}
/>
<NewList query={query} />
</div>
}
我們看一下 App 做了哪些事情。
-
首先通過 handleChange 事件來處理 onchange 事件。
-
button
按鈕用來切換 transition (設置優先級) 和 normal (正常模式)。接下來就是見證神奇的時刻。
常規模式下效果:
- 可以清楚的看到在常規模式下,輸入內容,內容呈現都變的異常卡頓,給人一種極差的用戶體驗。
transtion 模式下效果:
- 把大量併發任務通過 startTransition 處理之後,可以清楚看到,input 會正常的呈現,更新列表任務變得滯後,不過用戶體驗大幅度提升,
整體效果:
- 來感受一些 startTransition 的魅力。
總結: 通過上面可以直觀的看到 startTransition 在處理過渡任務,優化用戶體驗上起到了舉足輕重的作用。
3 爲什麼不是 setTimeout
上述的問題能夠把 setSearchQuery
的更新包裝在 setTimeout
內部呢,像如下這樣。
const handleChange=()=>{
/* 高優先級任務 —— 改變搜索條件 */
setInputValue(e.target.value)
/* 把 setSearchQuery 通過延時器包裹 */
setTimeout(()=>{
setSearchQuery(e.target.value)
},0)
}
- 這裏通過 setTimeout ,把更新放在 setTimeout 內部,那麼我們都知道 setTimeout 是屬於延時器任務,它不會阻塞瀏覽器的正常繪製,瀏覽器會在下次空閒時間之行 setTimeout 。那麼效果如何呢?我們來看一下:
4.gif
- 如上可以看到,通過 setTimeout 確實可以讓輸入狀態好一些,但是由於 setTimeout 本身也是一個宏任務,而每一次觸發 onchange 也是宏任務,所以 setTimeout 還會影響頁面的交互體驗。
綜上所述,startTransition 相比 setTimeout 的優勢和異同是:
-
一方面:startTransition 的處理邏輯和 setTimeout 有一個很重要的區別,setTimeout 是異步延時執行,而 startTransition 的回調函數是同步執行的。在 startTransition 之中任何更新,都會標記上
transition
,React 將在更新的時候,判斷這個標記來決定是否完成此次更新。所以 Transition 可以理解成比 setTimeout 更早的更新。但是同時要保證 ui 的正常響應,在性能好的設備上,transition 兩次更新的延遲會很小,但是在慢的設備上,延時會很大,但是不會影響 UI 的響應。 -
另一方面,就是通過上面例子,可以看到,對於渲染併發的場景下,setTimeout 仍然會使頁面卡頓。因爲超時後,還會執行 setTimeout 的任務,它們與用戶交互同樣屬於宏任務,所以仍然會阻止頁面的交互。那麼
transition
就不同了,在 conCurrent mode 下,startTransition
是可以中斷渲染的 ,所以它不會讓頁面卡頓,React 讓這些任務,在瀏覽器空閒時間執行,所以上述輸入 input 內容時,startTransition 會優先處理 input 值的更新,而之後纔是列表的渲染。
4 爲什麼不是節流防抖
那麼我們再想一個問題,爲什麼不是節流和防抖。首先節流和防抖能夠解決卡頓的問題嗎?答案是一定的,在沒有 transition 這樣的 api 之前,就只能通過防抖和節流來處理這件事,接下來用防抖處理一下。
const SetSearchQueryDebounce = useMemo(()=> debounce((value)=> setSearchQuery(value),1000) ,[])
const handleChange = (e) => {
setInputValue(e.target.value)
/* 通過防抖處理後的 setSearchQuery 函數。 */
SetSearchQueryDebounce(e.target.value)
}
- 如上將 setSearchQuery 防抖處理。然後我們看一下效果。
通過上面可以直觀感受到通過防抖處理後,基本上已經不影響 input 輸入了。但是面臨一個問題就是 list 視圖改變的延時時間變長了。那麼 transition 和節流防抖 本質上的區別是:
-
一方面,節流防抖 本質上也是 setTimeout ,只不過控制了執行的頻率,那麼通過打印的內容就能發現,原理就是讓 render 次數減少了。而 transitions 和它相比,並沒有減少渲染的次數。
-
另一方面,節流和防抖需要有效掌握
Delay Time
延時時間,如果時間過長,那麼給人一種渲染滯後的感覺,如果時間過短,那麼就類似於 setTimeout(fn,0) 還會造成前面的問題。而 startTransition 就不需要考慮這麼多。
5 受到計算機性能影響
transition 在處理慢的計算機上效果更加明顯,我們來看一下 Real world example
注意看滑塊速度
- 處理性能高,更快速的設備上。不使用 startTransition 。
- 處理性能高,更快速的設備上。使用 startTransition。
- 處理性能差,慢速的設備上,不使用 startTransition。
- 處理性能差,慢速的設備上,使用 startTransition。
三 transition 特性
既然已經講了 transition 的產生初衷,接下來看 transition 的功能介紹 。
1 什麼是過度任務。
一般會把狀態更新分爲兩類:
-
第一類緊急更新任務。比如一些用戶交互行爲,按鍵,點擊,輸入等。
-
第二類就是過渡更新任務。比如 UI 從一個視圖過渡到另外一個視圖。
2 什麼是 startTransition
上邊已經用了 startTransition
開啓過度任務,對於 startTransition 的用法,相信很多同學已經清楚了。
startTransition(scope)
- scope 是一個回調函數,裏面的更新任務都會被標記成過渡更新任務,過渡更新任務在渲染併發場景下,會被降級更新優先級,中斷更新。
使用
startTransition(()=>{
/* 更新任務 */
setSearchQuery(value)
})
3 什麼是 useTranstion
上面介紹了 startTransition ,又講到了過渡任務,本質上過渡任務有一個過渡期,在這個期間當前任務本質上是被中斷的,那麼在過渡期間,應該如何處理呢,或者說告訴用戶什麼時候過渡任務處於 pending
狀態,什麼時候 pending
狀態完畢。
爲了解決這個問題,React 提供了一個帶有 isPending 狀態的 hooks —— useTransition 。useTransition 執行返回一個數組。數組有兩個狀態值:
-
第一個是,當處於過渡狀態的標誌——isPending。
-
第二個是一個方法,可以理解爲上述的 startTransition。可以把裏面的更新任務變成過渡任務。
import { useTransition } from 'react'
/* 使用 */
const [ isPending , startTransition ] = useTransition ()
那麼當任務處於懸停狀態的時候,isPending
爲 true
,可以作爲用戶等待的 UI 呈現。比如:
{ isPending && < Spinner / > }
useTranstion 實踐
接下來我們做一個 useTranstion 的實踐,還是複用上述 demo 。對上述 demo 改造。
export default function App(){
const [ value ,setInputValue ] = React.useState('')
const [ query ,setSearchQuery ] = React.useState('')
const [ isPending , startTransition ] = React.useTransition()
const handleChange = (e) => {
setInputValue(e.target.value)
startTransition(()=>{
setSearchQuery(e.target.value)
})
}
return <div>
{isPending && <span>isTransiton</span>}
<input onChange={handleChange}
placeholder="輸入搜索內容"
value={value}
/>
<NewList query={query} />
</div>
}
- 如上用
useTransition
,isPending
代表過渡狀態,當處於過渡狀態時候,顯示isTransiton
提示。
接下來看一下效果:
可以看到能夠準確捕獲到過渡期間的狀態。
4 什麼是 useDeferredValue
如上場景我們發現,本質上 query 也是 value ,不過 query 的更新要滯後於 value 的更新。那麼 React 18 提供了 useDeferredValue
可以讓狀態滯後派生。useDeferredValue 的實現效果也類似於 transtion
,當迫切的任務執行後,再得到新的狀態,而這個新的狀態就稱之爲 DeferredValue 。
useDeferredValue 和上述 useTransition 本質上有什麼異同呢?
相同點:
- useDeferredValue 本質上和內部實現與 useTransition 一樣都是標記成了過渡更新任務。
不同點:
-
useTransition 是把 startTransition 內部的更新任務變成了過渡任務
transtion
, 而 useDeferredValue 是把原值通過過渡任務得到新的值,這個值作爲延時狀態。 一個是處理一段邏輯,另一個是生產一個新的狀態。 -
useDeferredValue 還有一個不同點就是這個任務,本質上在 useEffect 內部執行,而 useEffect 內部邏輯是異步執行的 ,所以它一定程度上更滯後於
useTransition
。useDeferredValue
=useEffect
+transtion
那麼回到 demo 上來,似乎 query 變成 DeferredValue 更適合現實情況,那麼對 demo 進行修改。
export default function App(){
const [ value ,setInputValue ] = React.useState('')
const query = React.useDeferredValue(value)
const handleChange = (e) => {
setInputValue(e.target.value)
}
return <div>
<button>useDeferredValue</button>
<input onChange={handleChange}
placeholder="輸入搜索內容"
value={value}
/>
<NewList query={query} />
</div>
}
- 如上可以看到 query 是 value 通過 useDeferredValue 產生的。
效果:
7.gif
四 原理
接下來又到了原理環節,從 startTransition 到 useTranstion 再到 useDeferredValue 原理本質上很簡單,
1 startTransition
首先看一下最基礎的 startTransition 是如何實現的。
react/src/ReactStartTransition.js -> startTransition
export function startTransition(scope) {
const prevTransition = ReactCurrentBatchConfig.transition;
/* 通過設置狀態 */
ReactCurrentBatchConfig.transition = 1;
try {
/* 執行更新 */
scope();
} finally {
/* 恢復狀態 */
ReactCurrentBatchConfig.transition = prevTransition;
}
}
-
startTransition
原理特別簡單,有點像 React v17 中 batchUpdate 的批量處理邏輯。就是通過設置開關的方式,而開關就是transition = 1
,然後執行更新,裏面的更新任務都會獲得transtion
標誌。 -
接下來在 concurrent mode 模式下會單獨處理
transtion
類型的更新。
其原理圖如下所示。
9.jpg
2 useTranstion
接下來看一下 useTranstion
的內部實現。
react-reconciler/src/ReactFiberHooks.new.js -> useTranstion
function mountTransition(){
const [isPending, setPending] = mountState(false);
const start = (callback)=>{
setPending(true);
const prevTransition = ReactCurrentBatchConfig.transition;
ReactCurrentBatchConfig.transition = 1;
try {
setPending(false);
callback();
} finally {
ReactCurrentBatchConfig.transition = prevTransition;
}
}
return [isPending, start];
}
這段代碼不是源碼,我把源碼裏面的內容進行組合,壓縮。
-
從上面可以看到,useTranstion 本質上就是
useState
+startTransition
。 -
通過 useState 來改變 pending 狀態。在 mountTransition 執行過程中,會觸發兩次
setPending
,一次在transition = 1
之前,一次在之後。一次會正常更新setPending(true)
,一次會作爲transition
過渡任務更新setPending(false);
,所以能夠精準捕獲到過渡時間。
其原理圖如下所示。
10.jpg
3 useDeferredValue
最後,讓我們看一下 useDeferredValue
的內部實現原理。
react-reconciler/src/ReactFiberHooks.new.js -> useTranstion
function updateDeferredValue(value){
const [prevValue, setValue] = updateState(value);
updateEffect(() => {
const prevTransition = ReactCurrentBatchConfig.transition;
ReactCurrentBatchConfig.transition = 1;
try {
setValue(value);
} finally {
ReactCurrentBatchConfig.transition = prevTransition;
}
}, [value]);
return prevValue;
}
useDeferredValue 處理流程是這樣的。
-
從上面可以看到 useDeferredValue 本質上是
useDeferredValue
=useState
+useEffect
+transition
-
通過傳入 useDeferredValue 的 value 值,useDeferredValue 通過 useState 保存狀態。
-
然後在 useEffect 中通過
transition
模式來更新 value 。這樣保證了 DeferredValue 滯後於 state 的更新,並且滿足transition
過渡更新原則。
其原理圖如下所示。
11.jpg
四 總結
本章節講到的知識點如下:
-
Transition
產生初衷,解決了什麼問題。 -
startTransition
的用法和原理。 -
useTranstion
的用法和原理。 -
useDeferredValue
的用法和原理。
感興趣的同學可以是一下
參考文檔
-
New feature: startTransition
-
Real world example: adding startTransition for slow renders
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/iZ1unVR4OI_AYNsQOn6UfQ