「React18新特性」深入浅出用户体验大师—transition

前端Sharing

共 11237字,需浏览 23分钟

 ·

2021-11-13 17:18

在 React 18 中,引进了一个新的 API —— startTransition 还有二个新的 hooks —— useTransitionuseDeferredValue,本质上它们离不开一个概念 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 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')
)

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)
}

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

那么当任务处于悬停状态的时候,isPendingtrue,可以作为用户等待的 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>isTransitonspan>
}
    <input onChange={handleChange}
        placeholder="输入搜索内容"
        value={value}
    />

   <NewList  query={query} />
div>
}
  • 如上用 useTransitionisPending 代表过渡状态,当处于过渡状态时候,显示 isTransiton 提示。

接下来看一下效果:





可以看到能够准确捕获到过渡期间的状态。

4 什么是 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>useDeferredValuebutton>

    <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


浏览 32
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报
评论
图片
表情
推荐
点赞
评论
收藏
分享

手机扫一扫分享

分享
举报