React 并发 API 实战,这几个例子看懂你就明白了

React
94
0
0
2024-08-17

目录

  • 什么是并发
  • 它和 React 有什么关系
  • 中断和切换是如何工作的
  • 那 Suspense 呢?
  • 如何启动 transition
  • 结束语

什么是并发

并发是一种执行模型,它允许程序的不同部分可以不按顺序执行,而不影响最终结果。你可能听说过多线程或多进程。由于浏览器中的 JavaScript 只能访问一个线程(虽然 Web Workers 在单独的线程中运行,但它们和 React 关系不大),我们不能使用多线程来并行处理一些计算。为了确保资源的最佳利用和页面的响应性,JavaScript 必须采用不同的并发模型:协作式多任务。这听起来可能有点复杂,但别担心,你已经熟悉这个模型了,而且肯定用过。

它和 React 有什么关系

在 React 18 之前,React 中的所有更新都是同步的。如果 React 开始处理一个更新,它会完成它,不管你在干嘛(当然,除非你关闭了标签页)。即使这意味着忽略了此时发生的用户事件,或者如果你有一些特别重的组件,页面会冻结。对于较小的更新来说,这还好,但对于涉及渲染大量组件的更新(比如路由变化),它对用户体验产生了负面影响。

React 18 引入了两种类型的更新:紧急状态更新和 transition 状态更新。默认情况下,所有状态更新都是紧急的,这样的更新不能被中断。transition 是低优先级的更新,可以被中断。从现在起,我也将使用“高优先级更新”和“低优先级更新”来指代它们。

为了保持向后兼容性,默认情况下,React 18 的行为和之前的版本一样,所有更新都是高优先级的,因此不可中断。要启用并发渲染,你需要通过使用startTransitionuseDeferredValue将更新标记为低优先级。

中断和切换是如何工作的

在渲染低优先级更新时,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,我们有一个更酷的版本:useTransitionhook。

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会存储传递的值,并立即返回它,所以inputValuesearchQuery将是相同的字符串。但在随后的高优先级渲染中,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