React 性能优化新招,useTransition

React
96
0
0
2024-03-04
React 知命境第 31 篇

在 React 中,有一个高大上的概念,叫做并发模式 Concurrent React。在并发模式中,引入了两个新概念:任务优先级、异步可中断。当一个任务正在 Reconciler 阶段执行时,如果此时 Scheduler 发现了一个优先级更高的任务,那么,React 可以把正在执行的任务中断,从 Scheculer 中把优先级更高的任务拿过来执行。

Scheduler

Reconciler

Renderer

收集

diff

操作 DOM

优先级

可中断


当有多个 UI 发生变化,我们可以利用这个并发机制,将耗时比较长,会阻塞其他 UI 渲染的更新,标记为低优先级,这样,一部分 UI 就可以顺利无卡顿的渲染,耗时较长的更新则在其他 UI 更新完毕之后再更新。

0

什么样的任务是可中断的

我们这里首先要思考的是任务最小粒度的问题。这是大多数人在学习并发模式时,忽略的重要问题。如果你无法思考清楚,那么你的 React 可能从来没有做到过异步可中断更新,一直是同步更新。

首先我们要明确一个基本概念:一个函数的执行是不可以被中断的。例如有这样一个组件

function SlowComponent({ text }: Props) {
  let startTime = performance.now();
  while (performance.now() - startTime < 1000) {
    
  }
  return (
    <li className="item">
      Text: {text}
    </li>
  )
}

我们发现,函数 SlowComponent 的执行过程中,我们模拟他被阻塞了 1000ms,这个阻塞在函数内部我们没有任何办法能够中断他的执行。React 底层是通过广度优先遍历的方式,将更新任务转换为队列。而这个函数任务已经是最小粒度,无法拆分自然也无法中断。

因此,要做到可中断的更新,我们在编写代码时,应该把阻塞拆分到多个子组件中去。这样每个子组件的执行时间可能稍微比较短,但是多个子组件综合起来的时间就会比较长而造成卡顿。拆分之后,那么在协调器遍历执行子组件的任务时,对于整个大任务而言,就有机会在协调器遍历没有完成时,做到任务中断。否则,React 也无法做到中断。

因此,合理的手动拆分任务,是 React 并发模式能够发挥作用的关键。

例如,我们要渲染一个列表组件,如果列表组件是父组件,列表项是子组件,那么我们应该确保父组件不会有长时间的逻辑要执行,从而把渲染压力拆分到子组件中去,例如如下代码。

function SlowList({ text }: Props) {
  let items = [];
  for (let i = 0; i < 250; i++) {
    items.push(<SlowItem key={i} text={text} />);
  }

  return (
    <ul className="items">
      {items}
    </ul>
  );
}

function SlowItem({ text }: Props) {
  let startTime = performance.now();
  while (performance.now() - startTime < 1) {
    // 每个 item 暂停 1ms,模拟极其缓慢的代码
  }

  return (
    <li className="item">
      Text: {text}
    </li>
  )
}

1、复现卡顿

我们来尝试写一个 demo 复现一下 input 输入卡顿的问题。当我在输入内容时,列表组件会根据我输入内容的变化而发生变化。此时列表组件是一个耗时较长的渲染,因此在 input 中输入内容时会感觉到明显的卡顿。

如下图,此时我在快速输入内容,但输入时卡顿明显。

scroll.gif

该 demo 目录结构如下

+ App
  - index.tsx
  - api.ts
  - List.tsx
  - SearchResults.tsx

首先模拟一个函数,用于创建列表数据

export function createList(param?: string) {
  const p = (param || '').split('')
  const arr: string[] = []
  for(var i = 0; i < 250; i++) {
    const pindex = i % p.length
    arr.push(`${p[pindex] || '^ ^'} - ${Math.random()}`)
  }

  return arr
}

然后我们随意封装一个简单的 List 列表组件,该组件是一个基础 UI 组件,只负责处理数据渲染,不包含逻辑。之所以要这样封装,是为了尽可能的还原真实场景,而非单纯的将本案例看成学习 demo.

import { ReactNode } from 'react'
import s from './index.module.scss'

interface ListProps<T> {
  list?: T[],
  renderItem: (item: T) => ReactNode
}

export default function List<T>(props: ListProps<T>) {
  const {list = [], renderItem} = props
  
  return (
    <div className={s.list}>
      {list.map(renderItem)}
    </div>
  )
}

然后我们基于刚才的基础组件,开始封装业务组件 SearchResults。业务组件表示搜索结果,该组件接收搜索条件,然后根据条件计算出要显示的列表内容,最终由 List 负责展示。我们将列表项子组件 Item 也写在这里,阻塞 1ms 表示子组件渲染耗时。250 个子项则一共至少耗时 250ms.

import { createList } from './api'
import List from './List'

interface Props {
  query: string
}

export default function SearchResults({ query }: Props) {
  const list = createList(query)

  return (
    <List 
      list={list} 
      renderItem={(item) => (
        <Item key={item} text={item} />
      )}
    />
  )
}

function Item(props: { text: string }) {
  let startTime = performance.now();
  while (performance.now() - startTime < 1) {}

  return (
    <div>{props.text}</div>
  )
}

入口文件的内容比较简单,语义为搜索结果要响应输入内容的变化

import { useState } from 'react'
import SearchResults from './SearchResults'

export default function Demo01() {
  const [text, setText] = useState('')
  

  return (
    <div>
      <input type="text" 
        onChange={(e: any) => setText(e.target.value)} 
      />
      <SearchResults query={text} />
    </div>
  )
}

这样,当你在连续输入内容时,你会感觉到输入框此时有明显的卡顿感。

2、useTransition

useTransition 是 React 专门为并发模式提供的一个基础 hook。它能够帮助你在不阻塞 UI 渲染的情况下更新状态。意思就是说,将更新任务的优先级调低一点。

const [isPending, startTransition] = useTransition()

useTransition 的调用不需要参数,他的执行返回两个参数

  • isPending:是否还存在等待处理的 transition,表示被降低优先级的更新还没有完成
  • startTransition:标记任务的优先级为 transition,该优先级低于正常更新

startTransition 的用法如下,我会将更新任务在它的回调函数中执行

function TabContainer() {
  const [isPending, startTransition] = useTransition();
  const [tab, setTab] = useState('about');

  function selectTab(nextTab) {
    startTransition(() => {
      setTab(nextTab);
    });
  }
  // ……
}

回到刚才那个输入卡顿的例子。此案例中,有两个 UI 更新,一个是输入框的 UI,另外一个是列表的 UI,此时,我们只需要在 index.tsx 中,把列表的 UI 使用 startTransition 标记为低优先级即可。代码更改如下

import { useState, useTransition } from 'react'
import SearchResults from './SearchResults'

export default function Demo01() {
  const [text, setText] = useState('')
  const [pending, startTransition] = useTransition()
  function onchange(e: any) {
    startTransition(() => {
      setText(e.target.value)
    })
  }
  

  return (
    <div>
      <input type="text" 
        onChange={onchange}
      />
      <div>{pending ? 'input...' : 'end' }</div>
      <SearchResults query={text} />
    </div>
  )
}

除此之外,在 SearchResults 组件中,我们观察发现列表的代码已经具备可拆分的可能性,那么,我们就只需要给 SearchResults 组件包裹一层 memo 优化,避免冗余的渲染即可

如果不包裹 memo,优化效果会降低很多。
function SearchResults({ query }: Props) {
  
  const list = createList(query)

  return (
    <List 
      list={list} 
      renderItem={(item) => (
        <Item key={item} text={item} />
      )}
    />
  )
}

function Item(props: { text: string }) {
  let startTime = performance.now();
  while (performance.now() - startTime < 1) {}

  return (
    <div>{props.text}</div>
  )
}

+ export default memo(SearchResults)

观察一下运行结果,发现往输入框中输入内容已经变得非常流畅,列表渲染因为多次被中断,加上 memo 的作用,此时我们发现列表的渲染次数变得非常少,最终也能响应最后的正确结果。

scroll.gif

3、防抖

我们最终的优化效果与防抖有一点类似。但是他们的原理和解决的问题完全不一样。防抖是结合闭包和 setTiemout 让任务不发生,更适合用于任务无法拆分的场景。

而 useTransition 则是中断已经开始执行的任务,更适合于任务可以被拆分的场景。