在 React 18 中,引进了一个新的 API —— startTransition
还有二个新的 hooks —— useTransition
和 useDeferredValue
,本质上它们离不开一个概念 transition
。
什么叫做 transition
英文翻译为 ‘过渡’,那么这里的过渡指的就是在一次更新中,数据展现从无到有的过渡效果。用 ReactWg 中的一句话描述 startTransition 。
在大屏幕视图更新的时,startTransition 能够保持页面有响应,这个 api 能够把 React 更新标记成一个特殊的更新类型
transitions
,在这种特殊的更新下,React 能够保持视觉反馈和浏览器的正常响应。
单单从上述对 startTransition
的描述,我们很难理解这个新的 api 到底解决什么问题。不过不要紧,接下来让我逐步分析这个 api 到底做了什么,以及它的应用场景。
为什么会出现 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 中绑定 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)
})
}
接下来我们模拟一下上述场景。流程大致是这样的:
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 做了哪些事情。
button
按钮用来切换 transition (设置优先级) 和 normal (正常模式)。接下来就是见证神奇的时刻。常规模式下效果:
transtion 模式下效果:
整体效果:
总结: 通过上面可以直观的看到 startTransition 在处理过渡任务,优化用户体验上起到了举足轻重的作用。
上述的问题能够把 setSearchQuery
的更新包装在 setTimeout
内部呢,像如下这样。
const handleChange=()=>{
/* 高优先级任务 —— 改变搜索条件 */
setInputValue(e.target.value)
/* 把 setSearchQuery 通过延时器包裹 */
setTimeout(()=>{
setSearchQuery(e.target.value)
},0)
}
4.gif
综上所述,startTransition 相比 setTimeout 的优势和异同是:
transition
,React 将在更新的时候,判断这个标记来决定是否完成此次更新。所以 Transition 可以理解成比 setTimeout 更早的更新。但是同时要保证 ui 的正常响应,在性能好的设备上,transition 两次更新的延迟会很小,但是在慢的设备上,延时会很大,但是不会影响 UI 的响应。transition
就不同了,在 conCurrent mode 下,startTransition
是可以中断渲染的 ,所以它不会让页面卡顿,React 让这些任务,在浏览器空闲时间执行,所以上述输入 input 内容时,startTransition 会优先处理 input 值的更新,而之后才是列表的渲染。那么我们再想一个问题,为什么不是节流和防抖。首先节流和防抖能够解决卡顿的问题吗?答案是一定的,在没有 transition 这样的 api 之前,就只能通过防抖和节流来处理这件事,接下来用防抖处理一下。
const SetSearchQueryDebounce = useMemo(()=> debounce((value)=> setSearchQuery(value),1000) ,[])
const handleChange = (e) => {
setInputValue(e.target.value)
/* 通过防抖处理后的 setSearchQuery 函数。 */
SetSearchQueryDebounce(e.target.value)
}
通过上面可以直观感受到通过防抖处理后,基本上已经不影响 input 输入了。但是面临一个问题就是 list 视图改变的延时时间变长了。那么 transition 和节流防抖 本质上的区别是:
Delay Time
延时时间,如果时间过长,那么给人一种渲染滞后的感觉,如果时间过短,那么就类似于 setTimeout(fn,0) 还会造成前面的问题。而 startTransition 就不需要考虑这么多。transition 在处理慢的计算机上效果更加明显,我们来看一下 Real world example
注意看滑块速度
既然已经讲了 transition 的产生初衷,接下来看 transition 的功能介绍 。
一般会把状态更新分为两类:
上边已经用了 startTransition
开启过度任务,对于 startTransition 的用法,相信很多同学已经清楚了。
startTransition(scope)
使用
startTransition(()=>{
/* 更新任务 */
setSearchQuery(value)
})
上面介绍了 startTransition ,又讲到了过渡任务,本质上过渡任务有一个过渡期,在这个期间当前任务本质上是被中断的,那么在过渡期间,应该如何处理呢,或者说告诉用户什么时候过渡任务处于 pending
状态,什么时候 pending
状态完毕。
为了解决这个问题,React 提供了一个带有 isPending 状态的 hooks —— useTransition 。useTransition 执行返回一个数组。数组有两个状态值:
import { useTransition } from 'react'
/* 使用 */
const [ isPending , startTransition ] = useTransition ()
那么当任务处于悬停状态的时候,isPending
为 true
,可以作为用户等待的 UI 呈现。比如:
{ isPending && < Spinner / > }
接下来我们做一个 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
提示。接下来看一下效果:
可以看到能够准确捕获到过渡期间的状态。
如上场景我们发现,本质上 query 也是 value ,不过 query 的更新要滞后于 value 的更新。那么 React 18 提供了 useDeferredValue
可以让状态滞后派生。useDeferredValue 的实现效果也类似于 transtion
,当迫切的任务执行后,再得到新的状态,而这个新的状态就称之为 DeferredValue 。
useDeferredValue 和上述 useTransition 本质上有什么异同呢?
相同点:
不同点:
transtion
,而 useDeferredValue 是把原值通过过渡任务得到新的值,这个值作为延时状态。 一个是处理一段逻辑,另一个是生产一个新的状态。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>
}
效果:
7.gif
接下来又到了原理环节,从 startTransition 到 useTranstion 再到 useDeferredValue 原理本质上很简单,
首先看一下最基础的 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
标志。transtion
类型的更新。其原理图如下所示。
9.jpg
接下来看一下 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];
}
这段代码不是源码,我把源码里面的内容进行组合,压缩。
useState
+ startTransition
。setPending
,一次在 transition = 1
之前,一次在之后。一次会正常更新 setPending(true)
,一次会作为 transition
过渡任务更新 setPending(false);
,所以能够精准捕获到过渡时间。其原理图如下所示。
10.jpg
最后,让我们看一下 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
= useState
+ useEffect
+ transition
transition
模式来更新 value 。这样保证了 DeferredValue 滞后于 state 的更新,并且满足 transition
过渡更新原则。其原理图如下所示。
11.jpg
本章节讲到的知识点如下:
Transition
产生初衷,解决了什么问题。startTransition
的用法和原理。useTranstion
的用法和原理。useDeferredValue
的用法和原理。感兴趣的同学可以是一下