# V18 - Transition

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

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

# 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')
)
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

上述这种写法,那么 setInputValue 和 setSearchQuery 带来的更新就是一个相同优先级的更新。而前面说道,输入框状态改变更新优先级要大于列表的更新的优先级。 ,这个时候我们的主角就登场了。用 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

那么当任务处于悬停状态的时候,isPending 为 true,可以作为用户等待的 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
  • 如上用 useTransition , isPending 代表过渡状态,当处于过渡状态时候,显示 isTransiton 提示。

# 什么是 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>
}
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

useDeferredValue 与 useTransition 其实挺相似的:

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

同 debounce 的区别:

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

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

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