为什么会出现 Transition 呢? Transition 本质上解决了渲染并发的问题,在 React 18 关于 startTransition 描述的时候,多次提到 ‘大屏幕’ 的情况,这里的大屏幕并不是单纯指的是尺寸,而是一种数据量大,DOM 元素节点多的场景,比如数据可视化大屏情况,在这一场景下,一次更新带来的变化可能是巨大的,所以频繁的更新,执行 js 事务频繁调用,浏览器要执行大量的渲染工作,所以给用户感觉就是卡顿。
Transition 本质上是用于一些不是很急迫的更新上,在 React 18 之前,所有的更新任务都被视为急迫的任务,在 React 18 诞生了并发渲染(Concurrent Rendering,通过 createRoot 开启),在这个模式下,渲染是可以中断,低优先级任务,可以让高优先级的任务先更新渲染。可以说 React 18 更青睐于良好的用户体验。从并发渲染到 suspense 再到 startTransition 无疑都是围绕着更优质的用户体验展开。
startTransition 依赖于并发渲染模式。也就是说在 React 18 中使用 startTransition ,那么要先开启并发模式,也就是需要通过 createRoot 创建 Root (注意:React 19 已移除 ReactDOM.render,只能通过 createRoot 创建 Root)。我们先来看一下两种模式下,创建 Root 区别。
传统 legacy 模式
import ReactDOM from 'react-dom'
/* 通过 ReactDOM.render */
ReactDOM.render(
<App />,
document.getElementById('app')
)v18 concurrent Mode并发模式
import { createRoot } from 'react-dom/client'
/* 通过 createRoot 创建 root */
const root = 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=(e)=>{
/* 高优先级任务 —— 改变搜索条件 */
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) > -1 ){
/* 找到匹配的关键词 */
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 (正常模式)。接下来就是见证神奇的时刻。常规模式下效果:
transition 模式下效果:
整体效果:
总结: 通过上面可以直观的看到 startTransition 在处理过渡任务,优化用户体验上起到了举足轻重的作用。
上述的问题能够把 setSearchQuery 的更新包装在 setTimeout 内部呢,像如下这样。
const handleChange=(e)=>{
/* 高优先级任务 —— 改变搜索条件 */
setInputValue(e.target.value)
/* 把 setSearchQuery 通过延时器包裹 */
setTimeout(()=>{
setSearchQuery(e.target.value)
},0)
}这里通过 setTimeout ,把更新放在 setTimeout 内部,那么我们都知道 setTimeout 是属于延时器任务,它不会阻塞浏览器的正常绘制,浏览器会在下次空闲时间之行 setTimeout 。那么效果如何呢?我们来看一下:
如上可以看到,通过 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 值的更新,而之后才是列表的渲染。
那么我们再想一个问题,为什么不是节流和防抖。首先节流和防抖能够解决卡顿的问题吗?答案是一定的,在没有 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 和节流防抖 本质上的区别是:
一方面,节流防抖 本质上也是 setTimeout ,只不过控制了执行的频率,那么通过打印的内容就能发现,原理就是让 render 次数减少了。而 transitions 和它相比,并没有减少渲染的次数。
另一方面,节流和防抖需要有效掌握 Delay Time 延时时间,如果时间过长,那么给人一种渲染滞后的感觉,如果时间过短,那么就类似于 setTimeout(fn,0) 还会造成前面的问题。而 startTransition 就不需要考虑这么多。
transition 在处理慢的计算机上效果更加明显,可以看一下 Real world example: adding startTransition for slow renders 中的对比视频,注意看滑块速度:
处理性能高,更快速的设备上。不使用 startTransition 。
处理性能高,更快速的设备上。使用 startTransition。
处理性能差,慢速的设备上,不使用 startTransition。
处理性能差,慢速的设备上,使用 startTransition。
既然已经讲了 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 /> }接下来我们做一个 useTransition 的实践,还是复用上述 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>isPending</span>}
<input onChange={handleChange}
placeholder="输入搜索内容"
value={value}
/>
<NewList query={query} />
</div>
}useTransition , isPending 代表过渡状态,当处于过渡状态时候,显示 isPending 提示,能够准确捕获到过渡期间的状态。如上场景我们发现,本质上 query 也是 value ,不过 query 的更新要滞后于 value 的更新。那么 React 18 提供了 useDeferredValue 可以让状态滞后派生。useDeferredValue 的实现效果也类似于 transition,当迫切的任务执行后,再得到新的状态,而这个新的状态就称之为 DeferredValue 。
useDeferredValue 和上述 useTransition 本质上有什么异同呢?
相同点:
不同点:
transition,而 useDeferredValue 是把原值通过过渡任务得到新的值,这个值作为延时状态。 一个是处理一段逻辑,另一个是产生一个新的状态。useTransition。 useDeferredValue = useEffect + transition那么回到 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>
}接下来又到了原理环节,从 startTransition 到 useTransition 再到 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 ,然后执行更新,里面的更新任务都会获得 transition 标志。
接下来在 concurrent mode 模式下会单独处理 transition 类型的更新。
接下来看一下 useTransition 的内部实现。
react-reconciler/src/ReactFiberHooks.new.js -> useTransition
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); ,所以能够精准捕获到过渡时间。最后,让我们看一下 useDeferredValue 的内部实现原理。
react-reconciler/src/ReactFiberHooks.new.js -> updateDeferredValue
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 + transitiontransition 模式来更新 value 。 这样保证了 DeferredValue 滞后于 state 的更新,并且满足 transition 过渡更新原则。本章节讲到的知识点如下:
Transition 产生初衷,解决了什么问题。startTransition 的用法和原理。useTransition 的用法和原理。useDeferredValue 的用法和原理。