# V18 - Transition

在 React 18 中,引进了一个新的 API —— startTransition 还有二个新的 hooks —— useTransitionuseDeferredValue,本质上它们离不开一个概念 transition

  • Transition 产生初衷,解决了什么问题。
  • startTransition 的用法和原理。
  • useTranstion 的用法和原理。
  • useDeferredValue 的用法和原理。

# Transition 的诞生

Transition 本质上解决了渲染并发的问题,在 React 18 关于 startTransition 描述的时候,多次提到 ‘大屏幕’ 的情况,这里的大屏幕并不是单纯指的是尺寸,而是一种数据量大,DOM 元素节点多的场景,比如数据可视化大屏情况,在这一场景下,一次更新带来的变化可能是巨大的,所以频繁的更新,执行 js 事务频繁调用,浏览器要执行大量的渲染工作,所以给用户感觉就是卡顿。

Transition 本质上是用于一些不是很急迫的更新上,在 React 18 之前,所有的更新任务都被视为急迫的任务,在 React 18 诞生了 concurrent Mode 模式,在这个模式下,渲染是可以中断,低优先级任务,可以让高优先级的任务先更新渲染。可以说 React 18 更青睐于良好的用户体验。从 concurrent Modesusponse 再到 startTransition 无疑都是围绕着更优质的用户体验展开。

startTransition 依赖于 concurrent Mode 渲染并发模式。也就是说在 React 18 中使用 startTransition ,那么要先开启并发模式,也就是需要通过 createRoot 创建 Root 。我们先来看一下两种模式下,创建 Root 区别。

传统 legacy 模式

import ReactDOM from 'react-dom'
/* 通过 ReactDOM.render  */
ReactDOM.render(
    <App />,
    document.getElementById('app')
)
1
2
3
4
5
6

v18 concurrent Mode并发模式

import ReactDOM from 'react-dom'
/* 通过 createRoot 创建 root */
const root =  ReactDOM.createRoot(document.getElementById('app'))
/* 调用 root 的 render 方法 */
root.render(<App/>)
1
2
3
4
5

上面说了 startTransition 使用条件,接下来探讨一下 startTransition 到底应用于什么场景。 前面说了 React 18 确定了不同优先级的更新任务,为什么会有不同优先级的任务。

如果一次更新中,都是同样的任务,那么也就无任务优先级可言,统一按批次处理任务就可以了,可现实恰好不是这样子。举一个很常见的场景:就是有一个 input 表单。并且有一个大量数据的列表,通过表单输入内容,对列表数据进行搜索,过滤。那么在这种情况下,就存在了多个并发的更新任务。分别为

  • 第一种:input 表单要实时获取状态,所以是受控的,那么更新 input 的内容,就要触发更新任务。
  • 第二种:input 内容改变,过滤列表,重新渲染列表也是一个任务。

第一种类型的更新,在输入的时候,希望是的视觉上马上呈现变化,如果输入的时候,输入的内容延时显示,会给用户一种极差的视觉体验。第二种类型的更新就是根据数据的内容,去过滤列表中的数据,渲染列表,这个种类的更新,和上一种比起来优先级就没有那么高。那么如果 input 搜索过程中用户更优先希望的是输入框的状态改变,那么正常情况下,在 input 中绑定 onChange 事件用来触发上述的两种类的更新。

const handleChange=(e)=>{
   /* 改变搜索条件 */ 
   setInputValue(e.target.value)
   /* 改变搜索过滤后列表状态 */
   setSearchQuery(e.target.value)
}
1
2
3
4
5
6

上述这种写法,那么 setInputValuesetSearchQuery 带来的更新就是一个相同优先级的更新。而前面说道,输入框状态改变更新优先级要大于列表的更新的优先级。 ,这个时候我们的主角就登场了。用 startTransition 把两种更新区别开。

const handleChange=()=>{
    /* 高优先级任务 —— 改变搜索条件 */
    setInputValue(e.target.value)
    /* 低优先级任务 —— 改变搜索过滤后列表状态  */
    startTransition(()=>{
        setSearchQuery(e.target.value)
    })
}
1
2
3
4
5
6
7
8
  • 如上通过 startTransition 把不是特别迫切的更新任务 setSearchQuery 隔离出来。

# 模拟场景

接下来我们模拟一下上述场景。流程大致是这样的:

  • 有一个搜索框和一个 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)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
  • 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>
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25

我们看一下 App 做了哪些事情。

  • 首先通过 handleChange 事件来处理 onchange 事件。
  • button按钮用来切换 transition (设置优先级) 和 normal (正常模式)。接下来就是见证神奇的时刻。

在常规模式下,输入内容,内容呈现都变的异常卡顿,给人一种极差的用户体验。把大量并发任务通过 startTransition 处理之后,可以清楚看到,input 会正常的呈现,更新列表任务变得滞后,不过用户体验大幅度提升,

# 为什么不是 setTimeout

上述的问题能够把 setSearchQuery 的更新包装在 setTimeout 内部呢,像如下这样。

const handleChange=()=>{
    /* 高优先级任务 —— 改变搜索条件 */
    setInputValue(e.target.value)
    /* 把 setSearchQuery 通过延时器包裹  */
    setTimeout(()=>{
        setSearchQuery(e.target.value)
    },0)
}
1
2
3
4
5
6
7
8
  • 这里通过 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)
}
1
2
3
4
5
6

通过防抖处理后,基本上已经不影响 input 输入了。但是面临一个问题就是 list 视图改变的延时时间变长了。那么 transition 和节流防抖 本质上的区别是:

  • 一方面,节流防抖 本质上也是 setTimeout ,只不过控制了执行的频率,那么通过打印的内容就能发现,原理就是让 render 次数减少了。而 transitions 和它相比,并没有减少渲染的次数。
  • 另一方面,节流和防抖需要有效掌握 Delay Time 延时时间,如果时间过长,那么给人一种渲染滞后的感觉,如果时间过短,那么就类似于 setTimeout(fn,0) 还会造成前面的问题。而 startTransition 就不需要考虑这么多。

# Transition 特性

# 什么是过渡任务

一般会把状态更新分为两类:

  • 第一类紧急更新任务。比如一些用户交互行为,按键,点击,输入等。
  • 第二类就是过渡更新任务。比如 UI 从一个视图过渡到另外一个视图。

# 什么是 startTransition

上边已经用了 startTransition 开启过度任务,对于 startTransition 的用法:

startTransition(scope)
1
  • scope 是一个回调函数,里面的更新任务都会被标记成过渡更新任务,过渡更新任务在渲染并发场景下,会被降级更新优先级,中断更新。

# 什么是 useTranstion

上面介绍了 startTransition ,又讲到了过渡任务,本质上过渡任务有一个过渡期,在这个期间当前任务本质上是被中断的,那么在过渡期间,应该如何处理呢,或者说告诉用户什么时候过渡任务处于 pending 状态,什么时候 pending 状态完毕。

为了解决这个问题,React 提供了一个带有 isPending 状态的 hooks —— useTransition 。useTransition 执行返回一个数组。数组有两个状态值:

  • 第一个是,当处于过渡状态的标志——isPending。
  • 第二个是一个方法,可以理解为上述的 startTransition。可以把里面的更新任务变成过渡任务。
import { useTransition } from 'react' 

/* 使用 */
const  [ isPending , startTransition ] = useTransition ()
1
2
3
4

那么当任务处于悬停状态的时候,isPendingtrue,可以作为用户等待的 UI 呈现。

# 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>
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
  • 如上用 useTransitionisPending 代表过渡状态,当处于过渡状态时候,显示 isTransiton 提示。

# 什么是 useDeferredValue

如上场景我们发现,本质上 query 也是 value ,不过 query 的更新要滞后于 value 的更新。那么 React 18 提供了 useDeferredValue 可以让状态滞后派生。useDeferredValue 的实现效果也类似于 transtion,当迫切的任务执行后,再得到新的状态,而这个新的状态就称之为 DeferredValue 。

useDeferredValue 和上述 useTransition 本质上有什么异同呢?

相同点:

  • useDeferredValue 本质上和内部实现与 useTransition 一样都是标记成了过渡更新任务。

不同点:

  • useTransition 是把 startTransition 内部的更新任务变成了过渡任务transtion,而 useDeferredValue 是把原值通过过渡任务得到新的值,这个值作为延时状态。 一个是处理一段逻辑,另一个是生产一个新的状态。
  • useDeferredValue 还有一个不同点就是这个任务,本质上在 useEffect 内部执行,而 useEffect 内部逻辑是异步执行的 ,所以它一定程度上更滞后于 useTransitionuseDeferredValue = 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>
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
  • 如上可以看到 query 是 value 通过 useDeferredValue 产生的。

# 原理

# startTransition

// react/src/ReactStartTransition.js -> startTransition
export function startTransition(scope) {
  const prevTransition = ReactCurrentBatchConfig.transition;
  /* 通过设置状态 */
  ReactCurrentBatchConfig.transition = 1;
  try {  
      /* 执行更新 */
    scope();
  } finally {
    /* 恢复状态 */  
    ReactCurrentBatchConfig.transition = prevTransition;
  }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
  • startTransition 原理特别简单,有点像 React v17 中 batchUpdate 的批量处理逻辑。就是通过设置开关的方式,而开关就是 transition = 1 ,然后执行更新,里面的更新任务都会获得 transtion 标志。
  • 接下来在 concurrent mode 模式下会单独处理 transtion 类型的更新。

transition1

# 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];
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

这段代码不是源码,我把源码里面的内容进行组合,压缩。

  • 从上面可以看到,useTranstion 本质上就是 useState + startTransition
  • 通过 useState 来改变 pending 状态。在 mountTransition 执行过程中,会触发两次 setPending ,一次在 transition = 1 之前,一次在之后。一次会正常更新 setPending(true) ,一次会作为 transition 过渡任务更新 setPending(false); ,所以能够精准捕获到过渡时间。

transition2

# 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;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14

useDeferredValue 处理流程是这样的。

  • 从上面可以看到 useDeferredValue 本质上是 useDeferredValue = useState + useEffect + transition
  • 通过传入 useDeferredValue 的 value 值,useDeferredValue 通过 useState 保存状态。
  • 然后在 useEffect 中通过 transition 模式来更新 value 。 这样保证了 DeferredValue 滞后于 state 的更新,并且满足 transition 过渡更新原则。

transition3

useDeferredValueuseTransition 其实挺相似的:

  • 相同:useDeferredValue 本质上和内部实现与 useTransition 一样都是标记成了非紧急更新任务。
  • 不同:useTransition 是把更新任务变成了延迟更新任务,而 useDeferredValue 是产生一个新的值,这个值作为延时状态。

debounce 的区别:

debouncesetTimeout 总是会有一个固定的延迟,而 useDeferredValue 的值只会在渲染耗费的时间下滞后,在性能好的机器上,延迟会变少,反之则变长。

深入浅出用户体验大师—transition (opens new window)

React 18 深度解析:应用性能提升之道 (opens new window)