在 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> ) }
这样,当你在连续输入内容时,你会感觉到输入框此时有明显的卡顿感。
2useTransition
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 则是中断已经开始执行的任务,更适合于任务可以被拆分的场景。