1、自动批处理以减少渲染
什么是批处理?
批处理是 React将多个状态更新分组到单个重新渲染中以获得更好的性能。
例如,如果在同一个点击事件中有两个状态更新,React 总是将它们分批处理到一个重新渲染中。如果运行下面的代码,会看到每次点击时,React 只执行一次渲染,尽管设置了两次状态:
function App () {
const [ count , setCount ] = useState ( 0 ) ;
const [ flag , setFlag ] = useState ( false ) ;
function handleClick ( ) {
setCount ( c => c + 1 ) ; // 还没有重新渲染
setFlag ( f => ! f ) ; // 还没有重新渲染
// React 只会在最后重新渲染一次(这是批处理!)
}
return (
< div >
< button onClick = { handleClick } > Next < / button >
< h1 style = { { color : flag ? "blue" : "black" } } > { count } < / h1 >
< / div >
) ;
}
这对性能非常有用,因为它避免了不必要的重新渲染。它还可以防止组件呈现仅更新一个状态变量的“半完成”状态,这可能会导致错误。
这可能会让你想起餐厅服务员在选择第一道菜时不会跑到厨房,而是等完成订单。
然而,React 的批量更新时间并不一致。例如,如果需要获取数据,然后更新handleClick
上面的状态,那么 React不会批量更新,而是执行两次独立的更新。
这是因为 React 过去只在浏览器事件(如点击)期间批量更新,但这里在事件已经被处理(在 fetch 回调中)之后更新状态:
function App() {
const [count, setCount] = useState(0);
const [flag, setFlag] = useState(false);
function handleClick() {
fetchSomething().then(() => {
// React 17 及更早版本不会对这些进行批处理,因为
// 它们在回调中 *after* 事件运行,而不是 *during* 它
setCount ( c => c + 1 ) ; // 导致重新渲染
setFlag ( f => ! f ) ; // 导致重新渲染
} );
}
return (
<div>
<button onClick={handleClick}>Next</button>
<h1 style={{ color: flag ? "blue" : "black" }}>{count}</h1>
</div>
);
}
在 React 18 之前,只在 React 事件处理程序期间批量更新。默认情况下,React 中不会对 promise
、setTimeout
、本机事件处理程序或任何其他事件中的更新进行批处理。
什么是自动批处理?
从 React 18 开始createRoot
,所有更新都将自动批处理,无论它们来自何处。
这意味着超时、承诺、本机事件处理程序或任何其他事件内的更新将以与 React 事件内的更新相同的方式进行批处理。
希望这会导致更少的渲染工作,从而在应用程序中获得更好的性能:
function App() {
const [count, setCount] = useState(0);
const [flag, setFlag] = useState(false);
function handleClick() {
fetchSomething().then(() => {
// React 18 及更高版本确实批处理这些:
setCount ( c => c + 1 ) ;
setFlag ( f => ! f ) ;
// React 只会在最后重新渲染一次(这是批处理!)
});
}
return (
<div>
<button onClick={handleClick}>Next</button>
<h1 style={{ color: flag ? "blue" : "black" }}>{count}</h1>
</div>
);
}
注意:作为采用 React 18 的一部分,预计将升级到createRoot
。旧行为的render
存在只是为了更容易地对两个版本进行生产实验。
无论更新发生在何处,React 都会自动批量更新,因此:
function handleClick() {
setCount(c => c + 1);
setFlag(f => !f);
// React will only re-render once at the end (that's batching!)
}
行为与此相同:
setTimeout(() => {
setCount(c => c + 1);
setFlag(f => !f);
// React will only re-render once at the end (that's batching!)
}, 1000);
行为与此相同:
fetch(/*...*/).then(() => {
setCount(c => c + 1);
setFlag(f => !f);
// React will only re-render once at the end (that's batching!)
})
行为与此相同:
elm.addEventListener('click', () => {
setCount(c => c + 1);
setFlag(f => !f);
// React will only re-render once at the end (that's batching!)
});
:::tips 注意:React 仅在通常安全的情况下才批量更新。 ::: 例如,React 确保对于每个用户启动的事件(如单击或按键),DOM 在下一个事件之前完全更新。例如,这可确保在提交时禁用的表单不能被提交两次。
如果不想批处理怎么办?
通常,批处理是安全的,但某些代码可能依赖于在状态更改后立即从 DOM 中读取某些内容。对于这些用例,可以使用ReactDOM.flushSync()
选择退出批处理:
import { flushSync } from 'react-dom'; // Note: react-dom, not react
function handleClick() {
flushSync(() => {
setCounter(c => c + 1);
});
// React has updated the DOM by now
flushSync(() => {
setFlag(f => !f);
});
// React has updated the DOM by now
}
2、Suspense 的 SSR 支持
这基本上是服务器端渲染 (SSR) 逻辑的扩展。在典型的 React SSR 应用程序中,会发生以下步骤:
- 服务器获取需要在 UI 上显示的相关数据
- 服务器将整个应用程序呈现为 HTML 并将其发送给客户端作为响应
- 客户端下载 JavaScript 包(除了 HTML)
- 在最后一步,客户端将 javascript 逻辑连接到 HTML(称为 hydration)
典型 SSR 应用程序的问题在于,在下一步可以开始之前,必须立即完成整个应用程序的每个步骤。这会使应用程序在初始加载时变慢且无响应。
React 18 正试图解决这个问题。<Suspense>
组件已经以这样的方式进行了革命性的改变,它将应用程序分解为更小的独立单元,这些单元经过提到的每个步骤。这样一旦用户看到内容,它就会变成互动的。
3、startTransition
什么是过渡?
将状态更新分为两类:
- 紧急更新反应直接交互,如打字、悬停、拖动等。
- 过渡更新将 UI 从一个视图过渡到另一个视图。
单击、悬停、滚动或打字等紧急更新需要立即响应以匹配对物理对象行为方式的直觉。否则他们会觉得“错了”。
然而,转换是不同的,因为用户不希望在屏幕上看到每个中间值。
例如,当在下拉列表中选择过滤器时,希望过滤器按钮本身在单击时立即响应。但是,实际结果可能会单独转换。
一个小的延迟是难以察觉的,而且通常是预料之中的。如果在结果渲染完成之前再次更改过滤器,只关心看到最新的结果。
在典型的 React 应用程序中,大多数更新在概念上都是过渡更新。但出于向后兼容性的原因,过渡是可选的。
默认情况下,React 18 仍然将更新处理为紧急更新,可以通过将更新包装到startTransition
.
这解决了什么问题?
构建流畅且响应迅速的应用程序并不总是那么容易。有时,诸如单击按钮或输入输入之类的小动作可能会导致屏幕上发生很多事情。这可能会导致页面在所有工作完成时冻结或挂起。
例如,考虑在过滤数据列表的输入字段中键入。需要将字段的值存储在 state 中,以便可以过滤数据并控制该输入字段的值。代码可能如下所示:
// 更新输入值和搜索结果
setSearchQuery(input);
在这里,每当用户键入一个字符时,都会更新输入值并使用新值来搜索列表并显示结果。
对于大屏幕更新,这可能会导致页面在呈现所有内容时出现延迟,从而使打字或其他交互感觉缓慢且无响应。
即使列表不是太长,列表项本身也可能很复杂并且每次击键时都不同,并且可能没有明确的方法来优化它们的呈现。
从概念上讲,问题在于需要进行两种不同的更新。第一个更新是紧急更新,用于更改输入字段的值,以及可能会更改其周围的一些 UI。
第二个是显示搜索结果的不太紧急的更新。
// 紧急:显示输入的内容
setInputValue(input) ;
// 不急:显示结果
setSearchQuery(input) ;
用户希望第一次更新是即时的,因为这些交互的本机浏览器处理速度很快。但是第二次更新可能会有点延迟。
用户不希望它立即完成,这很好,因为可能有很多工作要做。(实际上,开发人员经常使用去抖动等技术人为地延迟此类更新。)
在 React 18 之前,所有更新都被紧急渲染。
这意味着上面的两个状态仍然会同时呈现,并且仍然会阻止用户看到他们交互的反馈,直到一切都呈现出来。缺少的是一种告诉 React 哪些更新是紧急的,哪些不是的方法。
新startTransitionAPI
通过更新标记为“转换”来解决此问题:
import { startTransition } from 'react' ;
// 紧急:显示输入的内容
setInputValue(input) ;
// 将内部的任何状态更新标记为转换
startTransition (() => {
// Transition: 显示结果
setSearchQuery(input) ;
}) ;
包装在其中的更新startTransition
被视为非紧急处理,如果出现更紧急的更新(如点击或按键),则会中断。
如果用户中断转换(例如,连续输入多个字符),React 将抛出未完成的陈旧渲染工作,仅渲染最新更新。
Transitions 可以保持大多数交互敏捷,即使它们导致显着的 UI 更改。它们还可以避免浪费时间渲染不再相关的内容。
它与 setTimeout 有何不同?
上述问题的一个常见解决方案是将第二次更新包装在 setTimeout
中:
// 显示你输入的内容
setInputValue(input) ;
// 显示结果
setTimeout (() => {
setSearchQuery (input) ;
}, 0);
这将延迟第二次更新,直到呈现第一次更新之后。节流和去抖动是这种技术的常见变体。
一个重要的区别是startTransition
不安排在以后喜欢的setTimeout
时。它立即执行。传递给的函数startTransition
同步运行,但其中的任何更新都标记为“转换”。
React 将在稍后处理更新时使用此信息来决定如何呈现更新。这意味着比在超时中包装更新更早地开始呈现更新。
在快速设备上,两次更新之间的延迟非常小。在较慢的设备上,延迟会更大,但 UI 会保持响应。
另一个重要的区别是 a 内的大屏幕更新setTimeout
仍然会锁定页面,只是在超时之后。
如果用户在超时触发时仍在键入或与页面交互,他们仍将被阻止与页面交互。但是标记为 的状态更新startTransition
是可中断的,因此它们不会锁定页面。
它们让浏览器在呈现不同组件之间的小间隙中处理事件。
如果用户输入发生变化,React 将不必继续渲染用户不再感兴趣的内容。
最后,因为setTimeout
只是延迟更新,显示加载指示器需要编写异步代码,这通常很脆弱。
通过转换,React 可以跟踪挂起状态,根据转换的当前状态更新它,并能够在用户等待时显示加载反馈。
可以在哪里使用它?
可以使用startTransition
来包装要移动到后台的任何更新。通常,这些类型的更新分为两类: