目录
- 什么是并发
- 它和 React 有什么关系
- 中断和切换是如何工作的
- 那 Suspense 呢?
- 如何启动 transition
- 结束语
什么是并发
并发是一种执行模型,它允许程序的不同部分可以不按顺序执行,而不影响最终结果。你可能听说过多线程或多进程。由于浏览器中的 JavaScript 只能访问一个线程(虽然 Web Workers 在单独的线程中运行,但它们和 React 关系不大),我们不能使用多线程来并行处理一些计算。为了确保资源的最佳利用和页面的响应性,JavaScript 必须采用不同的并发模型:协作式多任务。这听起来可能有点复杂,但别担心,你已经熟悉这个模型了,而且肯定用过。
它和 React 有什么关系
在 React 18 之前,React 中的所有更新都是同步的。如果 React 开始处理一个更新,它会完成它,不管你在干嘛(当然,除非你关闭了标签页)。即使这意味着忽略了此时发生的用户事件,或者如果你有一些特别重的组件,页面会冻结。对于较小的更新来说,这还好,但对于涉及渲染大量组件的更新(比如路由变化),它对用户体验产生了负面影响。
React 18 引入了两种类型的更新:紧急状态更新和 transition 状态更新。默认情况下,所有状态更新都是紧急的,这样的更新不能被中断。transition 是低优先级的更新,可以被中断。从现在起,我也将使用“高优先级更新”和“低优先级更新”来指代它们。
为了保持向后兼容性,默认情况下,React 18 的行为和之前的版本一样,所有更新都是高优先级的,因此不可中断。要启用并发渲染,你需要通过使用startTransition
或useDeferredValue
将更新标记为低优先级。
中断和切换是如何工作的
在渲染低优先级更新时,React 在渲染完每个组件后会暂停,并检查是否有高优先级更新需要处理。如果有,React 会暂停当前渲染,切换到渲染高优先级更新。处理完这些后,React 会返回到渲染低优先级更新(或者如果它无效了,就丢弃它)。除了高优先级更新,React 还会检查当前渲染是否耗时过长。如果耗时过长,React 会将控制权还给浏览器,以便它可以重绘屏幕,避免卡顿和冻结。
由于 React 只能在组件之间暂停(它不能在组件中间停下来),所以如果你有一两个特别重的组件,并发渲染帮助不大。如果组件渲染需要 300 毫秒,浏览器就会被阻塞 300 毫秒。并发渲染真正发挥作用的地方是当你的组件只是稍微慢一点,但它们的数量比较多,以至于总渲染时间相当长。
那 Suspense 呢?
你可能听说过 CPU 密集型程序。这类程序大多数时间都在积极地使用 CPU 来完成它们的工作。我们之前提到的慢组件可以归类为 CPU 密集型:为了更快地渲染,它们需要更多的资源。
与 CPU 密集型程序相反,还有 I/O 密集型程序。这类程序大部分时间都在与输入输出设备(比如磁盘或网络)交互。在 React 中负责处理 I/O 的组件是 Suspense。
如果组件在低优先级更新期间暂停,Suspense 的行为会有所不同。如果 Suspense 边界内已经有内容显示,React 不会像通常那样处理暂停并显示 fallback 内容,而是会暂停渲染,转而处理其他任务,直到 Promise resolved,然后提交一个带有新内容的完整子树。这样,React 避免了隐藏已经显示的内容。如果组件在首次渲染期间暂停,将显示 fallback 内容。
如何启动 transition
启动 transition 有几种方法,最基本的是startTransition
函数。你像这样使用它:
import { startTransition, useState } from 'react'
const StartTransitionUsage = () => {
const onInputChange = (value: string) => {
setInputValue(value)
startTransition(() => {
setSearchQuery(value)
})
}
const [inputValue, setInputValue] = useState('')
const [searchQuery, setSearchQuery] = useState('')
return (
<div>
<SectionHeader title="Movies" />
<input placeholder="Search" value={inputValue} onChange={(e) => onInputChange(e.target.value)} />
<MoviesCatalog searchQuery={searchQuery} />
</div>
)
}
这里发生的事情是,当用户在搜索输入框中输入时,我们像往常一样更新状态变量inputValue
,然后调用startTransition
,传入一个包含另一个状态更新的函数。这个函数会立即被调用,React 会记录其执行期间所做的任何状态更改,并将它们标记为低优先级更新。请注意,至少在 React 18.2 中,只能传递同步函数给startTransition
。
所以在我们的示例中,我们实际上启动了两个更新:一个是紧急的(更新inputValue
),另一个是 transition(更新searchQuery
)。MoviesCatalog
组件可能会使用 Suspense 来根据搜索查询获取电影,这将使该组件成为 I/O 密集型。此外,它还可以渲染相当长的一系列电影卡片,这可能使它也成为 CPU 密集型。有了 transition,这个组件在加载数据时不会触发 Suspense fallback(会显示过时的 UI),在渲染长列表的电影卡片时也不会卡住浏览器。
需要注意的是,在 CPU 密集型组件的情况下,它们应该用React.memo
包裹起来,否则即使它们的 props 没有变化,它们也会在每次高优先级渲染时重新渲染,这会影响你应用的性能。
startTransition
是最基础的函数,主要用于 React 组件之外。要从 React 组件内部启动 transition,我们有一个更酷的版本:useTransition
hook。
import { useTransition, useState } from 'react'
const UseTransitionUsage = () => {
const onInputChange = (value: string) => {
setInputValue(value)
startTransition(() => {
setSearchQuery(value)
})
}
const [inputValue, setInputValue] = useState('')
const [searchQuery, setSearchQuery] = useState('')
const [isPending, startTransition] = useTransition()
return (
<div>
<SectionHeader title="Movies" isLoading={isPending} />
<input placeholder="Search" value={inputValue} onChange={(e) => onInputChange(e.target.value)} />
<MoviesCatalog searchQuery={searchQuery} />
</div>
)
}
有了这个 hook,你不需要直接导入startTransition
;相反,你调用useTransition()
hook,它会返回一个包含两个元素的数组:一个 boolean 值,表示是否有任何低优先级更新正在进行(从这个组件发起),以及你用来启动 transition 的startTransition
函数。
当你以这种方式启动 transition 时,React 实际上会进行两次渲染:一次高优先级渲染,将isPending
翻转为 true,以及一次低优先级更新,包含你传递给startTransition
的实际状态更改。所以要小心,用React.memo
包裹“昂贵”的组件。
我们还有另一个新 hook 是useDeferredValue
。如果相同的状态在关键和重型组件中都使用,它就变得有用了。就像我们上面的例子一样。多方便啊?这是你如何使用它:
import { useDeferredValue, useState } from 'react'
const UseDeferredValueUsage = () => {
const [inputValue, setInputValue] = useState('')
const searchQuery = useDeferredValue(inputValue)
return (
<div>
<SectionHeader title="Movies" isLoading={inputValue !== searchQuery} />
<input placeholder="Search" value={inputValue} onChange={(e) => setInputValue(e.target.value)} />
<MoviesCatalog searchQuery={searchQuery} />
</div>
)
}
在低优先级渲染中,和高优先级首次渲染中,useDeferredValue
会存储传递的值,并立即返回它,所以inputValue
和searchQuery
将是相同的字符串。但在随后的高优先级渲染中,React 总是返回存储的值。但它也会比较你传递的值和存储的值,如果它们不同,React 会安排一个低优先级更新。如果在低优先级等待更新时,高优先级这时更新了,值再次变化,React 会丢弃它,并安排一个带有最新值的新的低优先级更新。
使用这个 hook,你可以拥有同一状态的两个版本:一个用于关键组件,比如输入字段(通常不能接受延迟),另一个用于像搜索结果这样的组件(用户习惯了更长的延迟)。
结束语
并发无疑是一个有趣的特性,可以确定一些复杂的应用会从中受益。更重要的是,它可能已经在你最喜欢的 React 框架的底层使用了(Remix 和 Next 都已经把它用于路由)。我怀疑一旦数据获取的 Suspense 达到生产就绪的状态,它就会更受欢迎。但现在,你还有时间学习并逐渐将其采用到你的应用中。如果你想更详细地了解 React 中的并发,并了解一些历史背景,可以看看 Ivan Akulov 的这个演讲[1]
参考资料
[1] Ivan Akulov 的这个演讲: https://3perf.com/talks/react-concurrency/
[2] react18并发指导: https://sinja.io/blog/guide-to-concurrency-in-react-18#what-is-concurrency