React 18在2022年3月正式发布,最大的变化就是引入了并发特性(Concurrent Features)。

很多人用了React 18,但对并发特性的理解还停留在"用了新API"的层面。比如用了useTransition、useDeferredValue,但不知道它们为什么能让页面更流畅。

本文深入剖析React 18并发特性的底层原理,包括Fiber架构、时间切片、优先级调度、自动批处理、Suspense、Transition等。帮你从底层理解React 18的并发机制,而不只是会用API。

一、React 18之前的问题

要理解React 18的并发特性,先要知道React 18之前有什么问题。

1. 同步渲染,无法中断

React 18之前,渲染是同步的、不可中断的。一旦开始渲染,就必须渲染完,中间不能停。

如果组件树很大,渲染一次需要几百毫秒,这期间浏览器主线程被占用,用户的输入、动画都会卡顿。

比如,用户在输入框打字,同时有一个大列表在渲染。因为渲染是同步的,大列表的渲染会阻塞用户输入,导致打字卡顿。

2. 所有更新优先级相同

React 18之前,所有的更新优先级都是一样的。用户输入和后台数据更新,优先级一样,都会触发完整的重新渲染。

但实际上,用户输入的优先级应该更高,因为它直接影响用户体验。后台数据更新可以慢一点,不影响用户体验。

3. 批处理有限

React 18之前,只有在React事件处理函数中的多个setState才会被批处理。在setTimeout、Promise回调、原生事件中,多个setState不会被批处理,会触发多次渲染。

这导致不必要的渲染,影响性能。

二、Fiber架构:并发的基础

React 16引入了Fiber架构,为并发特性打下了基础。React 18的并发特性,就是在Fiber架构上实现的。

1. 什么是Fiber

Fiber是React的新的协调引擎。它把渲染任务拆分成一个个小的单元(Fiber节点),每个单元可以独立执行、暂停、恢复。

在React 15及之前,渲染是递归的,一旦开始就不能中断。Fiber把递归改成了循环,可以在每个Fiber节点之后检查是否需要中断,把控制权交还给浏览器。

2. Fiber的双缓存机制

Fiber用了双缓存机制:current树和workInProgress树。

  • current树:当前屏幕上显示的树
  • workInProgress树:正在构建的新树

渲染的时候,在workInProgress树上构建新的UI,构建完成后,把current指针指向workInProgress树,完成切换。

这样,渲染过程中用户看到的还是旧的UI,不会出现中间状态。

3. Fiber的副作用列表

每个Fiber节点都有一个effectTag,标记这个节点需要做什么操作(比如插入、更新、删除)。

渲染完成后,所有有副作用的Fiber节点会被收集到一个副作用列表里,然后统一执行。

这样,DOM操作是批量的,不会频繁触发重排重绘。

三、时间切片:让渲染可中断

时间切片(Time Slicing)是并发特性的核心。

1. 原理

时间切片的原理是:把渲染任务拆分成小的片段,每个片段执行一小段时间(默认5ms),然后检查是否有更高优先级的任务(比如用户输入)。如果有,就暂停当前渲染,先处理高优先级任务;如果没有,就继续渲染。

这样,浏览器主线程不会被长时间占用,用户输入和动画就能及时响应。

2. 实现方式

时间切片是通过MessageChannel实现的。React在每个时间片结束后,用MessageChannel.postMessage()把下一个时间片放到宏任务队列里。这样,浏览器就能在两个时间片之间处理用户输入、动画等任务。

为什么不用setTimeout?因为setTimeout有最小延迟(4ms),而且嵌套层级深了之后延迟会更大。MessageChannel没有这个问题。

3. 可中断和不可中断

不是所有渲染都能中断。React的渲染分为两个阶段:

  • render阶段:构建Fiber树,可中断
  • commit阶段:提交DOM更新,不可中断

render阶段可以中断,因为它只是在内存里构建树,没有修改DOM。commit阶段不能中断,因为它在修改DOM,中断会导致UI不一致。

四、优先级调度:让重要的事先做

并发特性的另一个核心是优先级调度。

1. 优先级分类

React 18把更新分为不同的优先级:

  • Immediate:同步执行,不可中断(比如受控输入)
  • UserBlocking:用户阻塞型,比如点击、输入,需要快速响应
  • Normal:普通优先级,比如数据更新、网络请求回来后的更新
  • Low:低优先级,比如通知、提醒
  • Idle:空闲优先级,比如埋点、分析

不同优先级的任务,调度顺序不同。高优先级的任务可以打断低优先级的任务。

2. 调度器(Scheduler)

优先级调度是由Scheduler库实现的。Scheduler是React独立的调度库,负责管理任务的优先级和执行顺序。

Scheduler用小顶堆来管理任务,优先级最高的任务先执行。每个任务有一个过期时间,过期了就必须立即执行。

3. 优先级插队

当一个低优先级的渲染正在执行时,如果来了一个高优先级的更新,React会:

  1. 暂停当前的低优先级渲染
  2. 开始高优先级的渲染
  3. 高优先级渲染完成后,重新开始低优先级的渲染(不是继续,而是从头开始)

注意,低优先级的渲染是从头开始,不是继续。因为高优先级的更新可能改变了状态,之前的渲染结果可能已经无效了。

五、自动批处理:减少不必要的渲染

React 18的另一个重要变化是自动批处理(Automatic Batching)。

1. React 18之前的批处理

React 18之前,只有在React事件处理函数中的多个setState才会被批处理。

function handleClick() {
  setCount(c => c + 1)
  setFlag(f => !f)
  // 这两个setState会被批处理,只触发一次渲染
}

但在setTimeout、Promise回调、原生事件中,多个setState不会被批处理:

setTimeout(() => {
  setCount(c => c + 1)
  setFlag(f => !f)
  // React 18之前:触发两次渲染
  // React 18之后:只触发一次渲染
}, 1000)

2. React 18的自动批处理

React 18之后,所有的更新都会被自动批处理,不管是在什么上下文中。

这是因为React 18用了并发渲染,所有的更新都会被加入队列,然后在同一个渲染周期里统一处理。

3. 如何退出批处理

如果你不想批处理,想让某个setState立即触发渲染,可以用flushSync:

import { flushSync } from 'react-dom'

function handleClick() {
  flushSync(() => {
    setCount(c => c + 1)
  })
  // 这里count已经更新了,DOM也更新了
  setFlag(f => !f)
}

但flushSync会影响性能,不建议频繁使用。

六、Transition:区分紧急和非紧急更新

Transition是React 18的新API,用来区分紧急更新和非紧急更新。

1. 为什么需要Transition

有些更新是紧急的,比如用户输入、点击按钮,需要立即响应。

有些更新不是紧急的,比如搜索结果过滤、路由切换、列表渲染,可以慢一点。

如果把这些更新都当成紧急的,就会导致用户输入卡顿。Transition让我们可以把非紧急的更新标记为Transition,让它们在后台执行,不阻塞用户输入。

2. useTransition

useTransition返回一个数组:[isPending, startTransition]。

import { useTransition } from 'react'

function SearchResults() {
  const [query, setQuery] = useState('')
  const [isPending, startTransition] = useTransition()

  function handleChange(e) {
    // 紧急更新:立即更新输入框
    setQuery(e.target.value)
    
    // 非紧急更新:在后台渲染搜索结果
    startTransition(() => {
      setSearchQuery(e.target.value)
    })
  }

  return (
    <div>
      <input value={query} onChange={handleChange} />
      {isPending && <div>加载中...</div>}
      <Results query={searchQuery} />
    </div>
  )
}

startTransition里的更新,会被标记为低优先级,可以被高优先级的更新打断。

3. isPending的作用

isPending表示Transition是否正在执行。可以用它来显示加载状态,告诉用户正在处理。

注意,isPending不是说数据在加载,而是说React正在后台渲染。渲染完成后,isPending会变成false。

七、useDeferredValue:延迟更新某个值

useDeferredValue是另一个新API,用来延迟更新某个值。

1. 原理

useDeferredValue接收一个值,返回一个延迟更新的副本。

当原始值变化时,延迟值不会立即更新,而是等当前紧急更新处理完之后,再更新。

import { useDeferredValue } from 'react'

function SearchResults({ query }) {
  const deferredQuery = useDeferredValue(query)
  // deferredQuery会延迟更新
  return <Results query={deferredQuery} />
}

2. 和useTransition的区别

  • useTransition:由你控制哪些更新是Transition
  • useDeferredValue:由React控制某个值的更新时机

如果你能控制更新的代码,用useTransition。如果你不能控制(比如值是从props传进来的),用useDeferredValue。

八、Suspense:声明式的加载状态

Suspense在React 16就引入了,但在React 18中,Suspense和并发渲染结合,变得更强大。

1. 基本用法

Suspense让你可以声明式地处理加载状态:

<Suspense fallback={<Loading />}>
  <SomeComponent />
</Suspense>

如果SomeComponent在渲染过程中"挂起"(抛出一个Promise),React会显示fallback。等Promise resolve后,再渲染SomeComponent。

2. Suspense和并发渲染

在React 18中,Suspense和并发渲染结合,可以实现更流畅的体验。

比如,路由切换时,新页面还在加载,React可以保持旧页面的交互,同时在后台加载新页面。加载完成后,再切换到新页面。

这就是所谓的"选择性水合"(Selective Hydration)和"流式服务端渲染"(Streaming SSR)。

3. SuspenseList

SuspenseList是一个新组件,用来控制多个Suspense的显示顺序:

<SuspenseList revealOrder="forwards">
  <Suspense fallback={<Loading />}><ComponentA /></Suspense>
  <Suspense fallback={<Loading />}><ComponentB /></Suspense>
  <Suspense fallback={<Loading />}><ComponentC /></Suspense>
</SuspenseList>

revealOrder="forwards"表示按顺序显示,A加载完了再显示B,B加载完了再显示C。这样可以避免页面内容跳动。

九、并发渲染的注意事项

使用React 18的并发特性,有一些注意事项。

1. 渲染函数必须是纯函数

因为渲染可以被中断和重新开始,所以渲染函数(函数组件的函数体)必须是纯函数,不能有副作用。

如果渲染函数有副作用(比如修改全局变量、直接操作DOM),中断后重新执行,可能会导致不可预期的结果。

副作用应该放在useEffect里。

2. 不要在渲染中读取可变数据

渲染中不要读取可变的全局数据(比如window.xxx、模块级变量)。因为渲染可能被中断,重新开始时数据可能已经变了,导致渲染结果不一致。

应该用state和props。

3. useId生成稳定的ID

并发渲染中,组件可能被多次渲染,生成的ID可能不一致。React 18提供了useId hook,用来生成稳定的ID:

function MyComponent() {
  const id = useId()
  return <div id={id}>...</div>
}

useId生成的ID,在多次渲染中是稳定的,而且在服务端渲染和客户端水合中也是一致的。

4. 严格模式(Strict Mode)

React 18的严格模式,会在开发环境中故意双重调用渲染函数、effect、等,来帮助发现不纯的渲染。

这是为了让你的组件适应并发渲染。如果严格模式下出了问题,说明你的组件可能有副作用,需要修复。

十、并发特性的实际效果

说了这么多原理,并发特性能带来什么实际效果?

1. 更流畅的用户输入

用户输入时,大列表的渲染不会阻塞输入。打字更流畅,不会卡顿。

2. 更快的响应速度

高优先级的更新(比如点击、输入)能立即响应,低优先级的更新(比如数据更新)在后台执行。

3. 更少的不必要渲染

自动批处理减少了渲染次数,提高了性能。

4. 更好的加载体验

Suspense和Transition让加载状态更可控,用户体验更好。

十一、写在最后

React 18的并发特性,是React自Fiber架构以来最大的变化。

它让React从同步渲染走向了并发渲染,能够更好地应对复杂的应用场景。但并发特性也带来了一些新的概念和注意事项,需要我们深入理解。

本文从Fiber架构、时间切片、优先级调度、自动批处理、Transition、Suspense等方面,剖析了React 18并发特性的底层原理。希望能帮你更好地理解和使用React 18。

2022年了,React 18已经正式发布。如果你还在用React 17,建议升级到React 18,体验并发特性带来的性能提升。

最后,用一句话总结:"React 18的并发特性,本质上是让渲染可中断、可调度、可优先级化,从而让用户体验更流畅。"

祝大家的React应用都能又快又流畅。