在 React 18 中,我们宣布了一个新的 startTransition API,并分享了它解决的问题的高级概述。在这篇文章中,我们将深入研究一个真实示例,该示例使用大型开源 React 应用程序通过 startTransition 加速缓慢更新。
tl;博士
如果您不想阅读整个页面,这里有一些视频可以显示在快速和慢速计算机上的区别。
快速计算机:无 startTransition
快速计算机:使用 startTransition
Screen.Recording.2021-06-22.at.3.08.39.PM.mov
电脑慢:没有 startTransition
慢速计算机:使用 startTransition
概述
设置
对于此示例,我们将使用Dan 在此处发布的线程中的Open Targets Platform 应用程序。该项目是一个基于 React 构建的单页应用程序,使用 Apollo GraphQL 进行数据获取和管理以及 Material-UI 组件集合。
(非常感谢维护它的团队建议尝试它!)
安装:
git clone https://github.com/opentargets/platform-app.git
yarn
yarn start
- 打开http://localhost:3000/target/ENSG00000282608/associations/bubbles
请记住,React 18 处于 Alpha 阶段,此版本主要针对库维护者。欢迎您使用它,但它尚未准备好用于生产使用,并且可能仍然存在问题和错误!
用户界面概览
当您在给定链接上加载应用程序时,您将看到一个页面,其中包含气泡关系图表和一个更改最小关系阈值的滑块。无需了解图表显示的内容,只需知道有一个滑块,其值会在更新时更改带有大量气泡的图表:
当您更新侧边时,图表将根据滑块值更新:
Screen.Recording.2021-06-21.at.12.28.50.PM.mov
问题
这个 UI 似乎表现不错,但让我们仔细看看性能。
如果您玩弄滑块,您可能会注意到滑块在四处移动时会卡顿。这种效果在较慢的设备上更为明显,所以让我们将其降低 4 倍以查看它。为此,我们可以加载 Chrome DevTools 的性能选项卡,并将 CPU 设置更改为“4x 减速”。使用此设置,Chrome 会人为地减慢网站速度,以在较慢的设备上显示它的外观。此工具是查看其他用户如何体验您网站的好方法:
随着减速,您可以看到滑块在变化时滞后:
Screen.Recording.2021-06-21.at.1.16.51.PM.mov
让我们运行一个快速的配置文件,看看我们是否能找出导致卡顿的原因。为此,加载与以前相同的配置文件工具,但这次在移动滑块之前点击“记录”:
当您打开配置文件时,您会看到一些带有 mousedown 事件的大块工作,然后是一个或多个 mousemove 事件,然后是一个 mouse up 事件,对应于当您单击滑块时 JavaScript 线程正在执行的工作,移动它,然后松开鼠标:
如果我们放大鼠标移动之一,我们可以看到该事件花费了 1 秒的时间来完成。等待一整秒来获得有关 mousemove 之类的交互的反馈需要很多时间。这就是让我们的滑块感觉缓慢的原因:
那么发生了什么?
分派鼠标事件时,ClassicAssociationBubbles 组件会调用setMinScore
新值:
// 简化自 ClassicAssociationsBubble.js
函数 ClassicAssociationsBubbles ( { associations } ) {
const [ minScore , setMinScore ] = useState ( 0.1 ) ;
常量 数据 = 计算数据( minScore ) ;
return (
< >
< Slider value = { minScore } onChange = { ( _ , val ) => {
// 当滑块发生变化时,更新 minScore
setMinScore ( val ) ;
} / >
< ExpensiveChart data = { data } / >
< / >
) ;
}
如您所见,此设置滑块值的更新会导致重新计算此组件中的所有内容的更新,包括使用minScore
. 这两个更新捆绑在一起,因此一个更新非常昂贵。
我们如何在 React 17 及更早版本中修复它?
让我们停下来想一想这个问题以及如何解决它。
我们有两个 UI 组件,一个滑块和该滑块的结果。理想情况下,滑块和结果都会快速更新。但是,在这种情况下,页面上发生了如此多的变化,以至于任何 JavaScript 函数都不可能使所有更新足够快,以至于用户不会注意到延迟——要做的工作太多了。
如果我们不能同时更新它们,也许我们可以将它们分成两个不同的状态。然后,至少在理论上,滑块状态可以与结果分开更新。它可能不会让我们一路走到那里,但让我们看看它让我们走多远。
尝试 #1:使用两种状态
因此,让我们首先将 minScore 状态拆分为两种不同的状态:一种用于控制滑块,另一种用于控制结果:
函数 FastSlider ( { defaultValue , onChange } ) {
const [ value , setValue ] = useState ( defaultValue ) ;
return (
< Slider
value = { value }
onChange = { ( e , nextValue ) => {
// 当滑块改变时,更新
// 滑块的值,然后
// 结果的值
setValue ( nextValue ) ;
onChange ( nextValue ) ;
} }
/ >
) ;
}
function ClassicAssociationsBubbles ( { associations } ) {
const [ minScore , setMinScore ] = useState ( 0.1 ) ;
常量 数据 = 计算数据( minScore ) ;
return (
< >
< FastSlider defaultValue = { 0.1 } onChange = { val => {
setMinScore ( val ) ;
} / >
< ExpensiveChart data = { data } / >
< / >
) ;
}
但是如果你在应用程序中加载它,你会看到没有任何变化。这是因为 React 在同一个事件中批量更新状态,以提高性能,因此不会有两个状态更新紧随其后。但是现在他们分开了,也许我们可以在每次更新时做一些不同的事情。
尝试 #2:添加 setTimeout
我们可以做的一件事是以某种方式推迟第二次更新,以允许滑块先更新。
我们可以用 setTimeout 做到这一点:
onChange = { ( e , nextValue ) => {
// 更新滑块
setValue ( nextValue ) ;
setTimeout ( ( ) => {
// 更新结果
onChange ( nextValue ) ;
} , 0 ) ;
} }
现在您可以看到滑块的响应速度更快,但在滑块停止移动后很长一段时间内结果仍会继续更新。这是因为所有状态更新仍然被调度,并且将在超时触发时进行处理:
Screen.Recording.2021-06-21.at.3.30.15.PM.mov
尝试 #3:添加去抖动
如果我们每 100 毫秒只安排一次更新,而不是安排所有结果都更新呢?我们可以使用一种称为去抖动的技术来做到这一点:
函数 FastSlider ( { defaultValue , onChange } ) {
const [ value , setValue ] = useState ( defaultValue ) ;
const timeout = useRef ( null ) ;
return (
< Slider
value = { value }
onChange = { ( e , nextValue ) => {
// 如果滑块在过去 100 毫秒内移动
过 // 我们将跳过更新并安排一个新的更新。
clearTimeout ( timeout . current ) ;
// 更新滑块。
设置值(下一个值);
// 如果滑块在 100 毫秒内没有移动,
// 更新结果。
超时。current = setTimeout ( ( ) => {
onChange ( nextValue ) ;
} , 100 ) ;
} }
/ >
) ;
}
Screen.Recording.2021-06-21.at.3.43.50.PM.mov
这是迄今为止最好的,事实上,你可以用 React 17 及更低版本做的最好的。
但是这种方法仍然存在一些问题。
首先,如果渲染速度很快,它可以在 100 毫秒内发生。这意味着您延迟更新的时间比开始呈现更新所需的时间更长:
您可以尝试通过使用节流而不是去抖动来解决这个问题,但是节流和去抖动都有另一个问题。大约 1 秒内仍有大量工作要做。在此期间,滑块再次无响应(如您在上面视频末尾看到的那样)。这是显示工作的配置文件:
为了解决这个问题,我们需要一些方法来分解大量的工作并尽快开始处理它。
使用 startTransition 响应 18
在 React 18 之前,去抖动或限制状态更新是这个问题的最佳解决方案。在 React 18 alpha 中,我们可以使用新的startTransition
API 来解决剩余的问题。使用startTransition
,我们可以向用户提供滑块的快速反馈,并开始在后台渲染结果,以便它们在可用时立即渲染,而不会出现任何长时间任务滞后于页面的情况。
让我们来看看它是如何工作的。
升级到 React 18
首先,让我们升级到 React 18:
纱线添加 react@alpha react-dom@alpha
当我们第一次升级时,控制台中有一个警告,告诉我们需要切换到 createRoot:
新的根 API 将允许我们开始使用 startTransition 等并发功能,所以让我们更新它:
// 在
ReactDOM之前的 index.js 。渲染(<应用 / > , 文档。的getElementById ('根' ));
// index.js 在
const root = ReactDOM 之后。createRoot (文件。的getElementById ('根' ));
根。渲染(<应用程序 / > );
现在我们已经准备好使用并发特性了!
注意:如果您的应用程序使用
<StrictMode>
并且您在升级到 Alpha 后看到问题,请检查删除它是否可以解决问题。这可能意味着您使用的某些库与 Strict Effects ( #19 )不兼容。我们将与库作者一起解决这个问题,并在稳定版本发布前的未来几个月内提供更多指导。
添加 startTransition
要使用startTransition
,我们可以删除去抖动逻辑并将第二个更新包装在startTransition
:
import { startTransition } from 'react' ;
// ...
函数 FastSlider ( { defaultValue , onChange } ) {
const [ value , setValue ] = useState ( defaultValue ) ;
return (
< Slider
value = { value }
onChange = { ( e , nextValue ) => {
// 更新滑块值
setValue ( nextValue ) ;
// 调用 onChange 回调。
onChange ( nextValue ) ;
} }
/ >
) ;
}
function ClassicAssociationsBubbles ( { associations } ) {
const [ minScore , setMinScore ] = useState ( 0.1 ) ;
常量 数据 = 计算数据( minScore ) ;
返回 (
< >
< FastSlider 默认值= { 0.1 } 的onChange = { VAL => {
//更新的结果,在过渡。
startTransition (() => {
setMinScore (VAL );
} );
} / >
< ExpensiveChart 数据= {数据} / >
< / >
) ;
}
这给了我们以下结果(仍然应用 4 倍减速):
Screen.Recording.2021-06-21.at.5.06.16.PM.mov
在这里你可以看到,一开始,没有太多更新,React 立即渲染结果。但是,随着滑块移动得更多,结果渲染成本越来越高,它们开始延迟。但即使它们被渲染,滑块也永远不会锁定。它总是感觉响应,而结果正在呈现。
如果我们消除人为减速并在高端设备上全速运行,我们可以看到性能甚至比我们开始时有所提高:
Screen.Recording.2021-06-22.at.9.42.18.AM.mov
它是如何工作的?
让我们看一下配置文件,看看它是如何工作的:
在这里我们可以看到,在整个交互过程中,React 一直在工作。我们在鼠标移动发生时处理它们,以更新滑块状态,当我们不在滑块上工作时,我们正在进行渲染工作。
但是我们渲染的是什么?
我们正在渲染结果!一旦 React 完成渲染第一个滑块更新,它就会开始渲染结果的过渡。由于此更新选择了并发渲染,因此 React 将做三件事:
- Yielding:每 5 毫秒,React 将停止工作以允许浏览器执行其他工作,例如运行承诺或触发事件。这就是为什么前面示例中的大部分工作现在被切成小块的原因。React 已经将它需要做的所有事情分解成更小的工作,并且足够聪明,可以暂停并让浏览器处理挂起的事件(这称为“屈服”)。在我们的例子中,yield 允许浏览器从 Slider 触发更多的 mousemove 事件来告诉 React 鼠标仍在移动。
- 中断:当第二个鼠标移动事件发生时,我们为 Slider 安排另一个更新以移动它。但是,如果我们已经在渲染上次更新的结果,那么 React 如何渲染该更新?答案是,当 React 再次开始工作时,它会看到在鼠标移动期间安排了新的紧急更新,并停止处理待处理的结果(因为它们已经过时了)。React 切换到渲染紧急的 Slider 更新,当紧急工作完成后,它会返回渲染结果。我们称之为“中断”,因为渲染结果被“中断”以渲染滑块。
- 跳过旧结果:如果 React 刚刚开始渲染它正在处理的第一个结果,那么它会开始构建要渲染的结果队列,这将花费太多时间(如上面的 setTimeout 示例)。所以 React 所做的就是跳过旧的工作。当它从中断中恢复时,它会从头开始渲染最新的值。这意味着 React 只在用户实际需要看到呈现的 UI 上工作,而不是旧状态。
总之,这意味着您可以随意移动滑块,而 React 将能够同时更新滑块和结果,针对用户实际需要查看的工作进行优化。
这是并发渲染,只有在 React 18 中才有可能。
添加视觉反馈 isPending
到目前为止,我们已经展示了如何使用新功能来解决使用并发渲染的现有问题,但新功能还允许我们使用以前无法实现的模式来改善用户体验!
如果我们仔细查看我们的解决方案,您会发现有时在更改滑块和结果显示在页面上之间存在延迟(该视频仍然应用了 4 倍减速):
Screen.Recording.2021-06-22.at.2.55.36.PM.mov
拆分所有工作帮助我们确保我们的应用程序响应用户,但我们仍然必须实际完成所有工作才能在屏幕上呈现内容。当这种情况发生时,用户会想知道结果在哪里。
在 React 18 之前,这不是什么大问题。用户知道正在加载某些内容,因为页面在呈现时被锁定。使用 React 18,用户可以在我们在后台渲染时与页面交互,因此我们需要某种方式向用户显示他们正在查看的信息已经过时并且正在更新。
我们需要的是一种在 React 渲染他们看不到的内容时向用户显示挂起状态的方法。React 18 提供了新的useTransition
Hook!这个钩子返回一个startTransition
开始过渡的函数,以及一个在渲染过渡时的isPending
值true
。这意味着您可以告诉用户后台正在发生某些事情,而他们仍然能够与页面进行交互:
import { useTransition } from 'react' ;
// ...
function ClassicAssociationsBubbles ( { associations } ) {
// React 18 中的新钩子
const [ isPending , startTransition ] = useTransition ( ) ;
const [ minScore , setMinScore ] = useState ( 0.1 ) ;
常量 数据 = 计算数据( minScore ) ;
return (
< >
< FastSlider defaultValue = { 0.1 } onChange = { val => {
// 在转换中更新结果
startTransition ( ( ) => {
setMinScore ( val ) ;
} ) ;
} / >
< ExpensiveChart
//使用 isPending 标志添加一个挂起的类
className = { isPending ? 'pending' : 'done' }
数据= {数据}
/ >
< / >
) ;
}
// index.css
. 待办的 {
不透明度:0.7 ;
// 将挂起的 UI 延迟 0.4 秒,以便它
// 不会显示我们是否渲染得足够快。
过渡:不透明度 0.2秒 0.4秒 线性;
}
. 完成 {
不透明度:1 ;
过渡:不透明度 0 s 0s 线性;
}
使用 isPending 标志,如果更新时间过长(例如 4 倍减速),结果会变暗:
Screen.Recording.2021-06-22.at.3.06.23.PM.mov
这种变暗的待处理视觉状态仅在渲染开始花费太长时间时才会显示。在没有 4 倍减速的高端设备上,渲染通常足够快,我们不会显示加载状态。查看最终结果:
Screen.Recording.2021-06-22.at.3.08.39.PM.mov
在这里,更新总是很快,滑块总是响应用户。
有关最终实现的示例,请参阅此分支。