React 18即将发布,最令人期待的特性就是并发模式(Concurrent Mode)。并发模式是React团队酝酿了好几年的特性,它能让React应用在处理大量更新的时候依然保持流畅,用户体验更好。

目前React 18还处于alpha阶段,API可能还会有变化,但核心概念和设计思路已经比较清晰了。本文基于React 18 alpha版本,从零开始介绍并发模式的概念、原理和使用方法,帮你快速上手React 18的并发特性。

先说明一下:本文基于的是2021年8月的React 18 alpha版本,部分API在正式版中可能会有调整。如果正式版发布后有变化,我会更新本文。

一、为什么需要并发模式

在介绍并发模式之前,先说说为什么需要它,它解决了什么问题。

React 17及之前的问题:同步渲染。

在React 17及之前的版本中,React的渲染是同步的、不可中断的。一旦开始渲染,就必须把整个组件树渲染完,中间不能停下来。如果组件树很大,渲染一次需要几百毫秒甚至更久,在这段时间里,浏览器的主线程被完全占用,用户的输入、点击、滚动都得不到响应,界面就会卡顿。

举个例子:你有一个搜索框,用户输入的时候,下面要实时显示搜索结果,搜索结果有几千条。用户每输入一个字符,React就要重新渲染整个结果列表,渲染一次需要200毫秒。在这200毫秒里,用户继续输入的字符得不到响应,界面会卡一下,用户体验很差。

这就是同步渲染的问题:渲染任务不能被打断,高优先级的任务(比如用户输入)必须等低优先级的任务(比如渲染大列表)完成之后才能执行。

并发模式的解决方案:可中断的渲染。

并发模式的核心思想是:让渲染变成可中断的。React在渲染的过程中,可以暂停当前的渲染任务,去处理更高优先级的任务(比如用户输入),处理完之后再回来继续渲染,或者干脆放弃当前的渲染,重新开始。

这样一来,用户的输入总能得到及时响应,界面不会卡顿,用户体验就好了。

还是上面的搜索例子:在并发模式下,用户输入的时候,React会暂停当前的搜索结果渲染,优先处理用户的输入,让输入框立刻显示用户输入的字符。然后再用空闲时间渲染搜索结果。如果用户又输入了新的字符,React就放弃之前的渲染,重新开始。这样用户输入永远是流畅的,搜索结果可能会稍微延迟一点,但整体体验好很多。

这就是并发模式的价值:让React应用在处理大量更新的时候依然保持流畅,优先响应用户的交互。

二、并发模式的核心概念

在深入API之前,先理解几个并发模式的核心概念。

1. 优先级(Priority)。

并发模式下,React会给不同的更新分配不同的优先级。高优先级的更新(比如用户输入、点击)会优先处理,低优先级的更新(比如渲染大列表、加载数据)可以延迟处理。

React内部定义了几种优先级:

  • Immediate:最高优先级,同步执行,不能被打断
  • UserBlocking:用户阻塞级,比如用户输入、点击、拖拽,需要很快响应
  • Normal:普通级,比如网络请求回来后的更新、数据渲染
  • Low:低优先级,比如列表渲染、通知
  • Idle:最低优先级,比如后台分析、埋点上报

优先级越高,越先被处理,也越不容易被打断。优先级越低,越容易被高优先级的任务打断。

2. 时间切片(Time Slicing)。

时间切片是并发模式实现可中断渲染的技术手段。React把渲染任务分成很多个小的任务单元,每个任务单元执行很短的时间(一般是几毫秒),然后检查有没有更高优先级的任务需要处理。如果有,就暂停当前任务,去处理高优先级任务;如果没有,就继续执行下一个任务单元。

这样,浏览器的主线程不会被长时间占用,每隔几毫秒就有机会响应用户的输入,界面就不会卡顿。

时间切片对开发者是透明的,你不需要手动分割任务,React会自动帮你做。你只需要告诉React哪些更新是低优先级的,React就会自动用时间切片的方式处理。

3. 可中断的渲染(Interruptible Rendering)。

在并发模式下,渲染过程是可以被中断的。React在渲染组件树的过程中,可以在任意一个组件之后暂停,去处理其他事情,然后再回来继续渲染。

这和以前的同步渲染完全不同。以前的渲染一旦开始,就必须跑完整个组件树,中间不能停。现在的渲染可以随时暂停、随时恢复、随时放弃。

可中断渲染带来的一个影响是:组件的渲染函数可能会被调用多次,因为渲染可能被中断后重新开始。所以在并发模式下,渲染函数必须是纯函数,不能有副作用,否则多次调用可能会出问题。

4. 过渡更新(Transition)。

过渡更新是并发模式引入的一个新概念。有些更新不是紧急的,比如搜索结果的渲染、路由切换、tab切换,这些更新可以延迟一点,不需要立刻完成。React把这类更新叫做"过渡更新"。

过渡更新的优先级比较低,可以被高优先级的更新(比如用户输入)打断。这样,用户的交互永远是流畅的,过渡更新的结果可能会稍微延迟一点,但不会影响用户体验。

React提供了startTransition API来标记过渡更新,我们后面会详细介绍。

三、如何启用并发模式

了解了核心概念之后,看看如何在React 18中启用并发模式。

在React 18中,并发模式不是默认开启的,需要手动启用。启用的方式很简单,把ReactDOM.render改成ReactDOM.createRoot就行。

// React 17 及之前的写法
import ReactDOM from 'react-dom';
import App from './App';

ReactDOM.render(<App />, document.getElementById('root'));

// React 18 启用并发模式的写法
import ReactDOM from 'react-dom';
import App from './App';

const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(<App />);

就是这么简单。用createRoot代替render,就启用了并发模式。启用之后,React就会自动用并发的方式处理更新,你不需要做其他改动。

当然,启用并发模式之后,你还需要使用并发相关的API(比如startTransition、useDeferredValue)才能充分发挥并发模式的优势。如果只是启用了并发模式但不使用这些API,体验提升可能不明显。

还要注意:启用并发模式之后,整个应用都处于并发模式下,所有的组件都要遵守并发模式的规则(比如渲染函数必须是纯函数)。如果你的应用里有不符合规则的代码,可能会出问题。所以迁移到并发模式的时候,要仔细测试。

四、核心API一:startTransition

startTransition是并发模式最重要的API之一,用来标记过渡更新。

什么是过渡更新?

前面说过,有些更新不是紧急的,可以延迟一点。比如:

  • 搜索框输入时,搜索结果的渲染
  • 路由切换时,新页面的渲染
  • tab切换时,内容的切换
  • 筛选、排序时,列表的重新渲染

这些更新如果用同步的方式处理,可能会阻塞用户的输入,导致卡顿。用startTransition标记之后,React就会把它们当作低优先级的过渡更新,可以被高优先级的更新打断,从而保证用户交互的流畅。

使用方法。

import { startTransition } from 'react';

function SearchBox() {
  const [query, setQuery] = useState('');
  const [results, setResults] = useState([]);

  const handleChange = (e) => {
    // 用户输入是高优先级更新,立刻执行
    setQuery(e.target.value);

    // 搜索结果渲染是过渡更新,可以延迟
    startTransition(() => {
      const newResults = search(e.target.value);
      setResults(newResults);
    });
  };

  return (
    <div>
      <input value={query} onChange={handleChange} />
      <ResultsList results={results} />
    </div>
  );
}

在这个例子中,用户输入的时候,setQuery是高优先级更新,会立刻执行,输入框立刻显示用户输入的字符。而startTransition里面的setResults是过渡更新,优先级比较低,可以被用户的下一次输入打断。这样用户输入永远是流畅的,搜索结果会在空闲的时候更新。

过渡更新的特点。

  1. 可被打断:过渡更新可以被高优先级的更新打断。如果在过渡更新执行的过程中,又有新的高优先级更新,React就会暂停过渡更新,先处理高优先级更新。
  2. 可被放弃:如果过渡更新还没执行完,又有新的过渡更新进来,React就会放弃之前的过渡更新,直接执行最新的。比如用户连续输入好几个字符,之前的搜索结果渲染就会被放弃,只渲染最新的结果。
  3. 显示加载状态:过渡更新在执行的时候,React会提供一个isPending状态,你可以用它来显示加载提示,告诉用户内容正在更新。

useTransition Hook。

除了startTransition函数,React还提供了useTransition Hook,它返回一个isPending状态和一个startTransition函数。isPending表示是否有过渡更新正在执行,你可以用它来显示加载状态。

import { useTransition } from 'react';

function SearchBox() {
  const [query, setQuery] = useState('');
  const [results, setResults] = useState([]);
  const [isPending, startTransition] = useTransition();

  const handleChange = (e) => {
    setQuery(e.target.value);
    startTransition(() => {
      const newResults = search(e.target.value);
      setResults(newResults);
    });
  };

  return (
    <div>
      <input value={query} onChange={handleChange} />
      {isPending && <div>搜索中...</div>}
      <ResultsList results={results} />
    </div>
  );
}

当过渡更新正在执行的时候,isPending为true,显示"搜索中..."的提示。过渡更新完成之后,isPending变为false,提示消失。这样用户就知道内容正在更新,不会觉得界面卡住了。

什么时候用startTransition?

不是所有的更新都需要用startTransition。只有那些不是紧急的、可以延迟的更新才需要用。比如:

  • 搜索结果的渲染
  • 列表的筛选、排序
  • 路由切换
  • tab切换
  • 大量数据的渲染

而用户的直接交互,比如输入框输入、按钮点击、拖拽,这些是高优先级的,不需要用startTransition,应该立刻执行。

一个简单的判断标准:如果这个更新是用户直接触发的、需要立刻看到结果的,就不用startTransition;如果这个更新是间接的、可以稍微延迟的,就用startTransition。

五、核心API二:useDeferredValue

useDeferredValue是另一个并发模式的重要API,用来延迟一个值的更新。

什么是useDeferredValue?

有时候,你有一个值,这个值的更新会触发大量的渲染,但这个值本身不是紧急的,可以延迟一点更新。这时候就可以用useDeferredValue,让React延迟更新这个值。

举个例子:你有一个搜索框,用户输入的时候,要把输入的关键词传给一个很大的结果列表组件,这个组件渲染很慢。用户输入是高优先级的,要立刻响应,但结果列表的渲染可以延迟。这时候你可以用useDeferredValue把搜索关键词延迟一下,传给结果列表组件。

使用方法。

import { useState, useDeferredValue } from 'react';

function SearchBox() {
  const [query, setQuery] = useState('');
  // 延迟 query 的更新
  const deferredQuery = useDeferredValue(query);

  return (
    <div>
      <input value={query} onChange={(e) => setQuery(e.target.value)} />
      {/* 用延迟后的 query 渲染结果列表 */}
      <ResultsList query={deferredQuery} />
    </div>
  );
}

在这个例子中,query是用户输入的值,会立刻更新。deferredQuery是延迟后的值,会在空闲的时候更新。ResultsList用deferredQuery来渲染,所以它的渲染是低优先级的,可以被用户的输入打断。

这样,用户输入的时候,输入框立刻响应,但结果列表会稍微延迟一点更新,不会阻塞用户输入。

useDeferredValue和startTransition的区别。

useDeferredValue和startTransition都能实现延迟更新,但它们的使用场景不同:

  • startTransition是用来包裹一个更新操作的,你在startTransition里面调用setState,标记这个更新是过渡更新。
  • useDeferredValue是用来延迟一个值的,你把一个值传给useDeferredValue,它返回一个延迟后的值。

简单来说,startTransition是从"更新"的角度来控制,useDeferredValue是从"值"的角度来控制。

什么时候用哪个?

  • 如果你能控制更新的代码(比如你自己调用setState),就用startTransition,更直接。
  • 如果你不能控制更新的代码(比如这个值是从props传进来的,或者是从第三方库来的),就用useDeferredValue,在消费这个值的地方延迟。

它们也可以一起使用,比如在startTransition里面更新一个值,然后在子组件里用useDeferredValue进一步延迟。

useDeferredValue的特点。

  1. 延迟更新:useDeferredValue返回的值会延迟更新,优先处理高优先级的更新。
  2. 保留旧值:在延迟更新的过程中,useDeferredValue返回的是旧的值,直到新的值被处理。这样界面不会出现空白,而是先显示旧的内容,等新内容准备好了再切换。
  3. 可被打断:和过渡更新一样,useDeferredValue的更新也可以被高优先级的更新打断。如果在更新的过程中又有新的值进来,就放弃之前的更新,直接处理最新的。

六、核心API三:Suspense

Suspense不是React 18才有的新特性,React 16就有了,但在React 18的并发模式下,Suspense的能力大大增强了。

什么是Suspense?

Suspense是React提供的一个组件,用来在组件等待异步数据的时候显示一个加载状态。比如一个组件需要从服务器获取数据,在数据还没回来的时候,Suspense会显示一个fallback(加载提示),数据回来之后再显示组件内容。

在React 17及之前,Suspense主要用于React.lazy的代码分割,对数据获取的支持还不完善。在React 18的并发模式下,Suspense对数据获取的支持更完善了,可以和并发模式很好地配合。

基本用法。

import { Suspense } from 'react';

function ProfilePage() {
  return (
    <Suspense fallback={<div>加载中...</div>}>
      <ProfileDetails />
      <ProfileTimeline />
    </Suspense>
  );
}

在这个例子中,ProfileDetails和ProfileTimeline都需要异步获取数据。在数据还没回来的时候,Suspense会显示"加载中..."。当所有子组件的数据都准备好了,再显示它们的内容。

Suspense和并发模式的配合。

在并发模式下,Suspense的行为更智能了:

  1. 渐进式加载:在并发模式下,Suspense可以实现渐进式加载。比如一个页面有多个部分,有的部分数据加载快,有的慢。加载快的部分可以先显示,加载慢的部分显示加载状态,不用等所有数据都回来才显示整个页面。
  1. 避免界面闪烁:在并发模式下,Suspense会智能地控制fallback的显示时机。如果数据很快就回来了(比如几百毫秒内),Suspense就不会显示fallback,避免界面闪烁。只有数据加载时间比较长的时候,才显示fallback。
  1. 和过渡更新配合:Suspense可以和startTransition配合,实现更流畅的路由切换。比如路由切换的时候,新页面需要加载数据,用startTransition标记路由切换为过渡更新,在新页面加载的时候,旧页面继续显示,同时显示一个加载提示,等新页面准备好了再切换。这样不会出现白屏,体验更好。

SuspenseList。

React 18还引入了SuspenseList组件,用来控制多个Suspense的显示顺序。

比如一个页面有多个Suspense,你希望它们按顺序显示,而不是谁先加载完谁先显示,就可以用SuspenseList。

import { Suspense, SuspenseList } from 'react';

function ProfilePage() {
  return (
    <SuspenseList revealOrder="forwards">
      <Suspense fallback={<div>加载个人信息...</div>}>
        <ProfileDetails />
      </Suspense>
      <Suspense fallback={<div>加载时间线...</div>}>
        <ProfileTimeline />
      </Suspense>
    </SuspenseList>
  );
}

revealOrder="forwards"表示按顺序显示,第一个Suspense加载完之后才显示第二个,避免界面内容跳动。

SuspenseList还有一个tail属性,可以控制同时显示多少个fallback。比如tail="collapsed"表示只显示当前正在加载的那个fallback,其他的不显示,避免界面上有太多加载提示。

七、并发模式的注意事项

并发模式很强大,但也有一些需要注意的地方。

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

在并发模式下,组件的渲染函数可能会被调用多次(因为渲染可能被中断后重新开始)。所以渲染函数必须是纯函数,不能有副作用。

什么是副作用?比如:

  • 在渲染函数里直接修改外部变量
  • 在渲染函数里调用setState
  • 在渲染函数里订阅事件、设置定时器
  • 在渲染函数里直接操作DOM

这些副作用如果执行多次,可能会导致问题。副作用应该放在useEffect或者事件处理函数里。

React 18在开发模式下会故意调用两次渲染函数,来帮助你发现不纯的渲染。如果你在开发模式下发现渲染函数执行了两次,不要惊讶,这是正常的,是React在帮你检查。

2. useEffect的执行时机变化。

在并发模式下,useEffect的执行时机有一些变化。以前,useEffect总是在浏览器绘制之后异步执行。现在,对于某些高优先级的更新,useEffect可能会在绘制之前同步执行,以保证用户能立刻看到更新后的结果。

这个变化对你的代码影响不大,因为useEffect本来就不应该依赖执行时机。但如果你的代码里有依赖useEffect执行时机的逻辑,可能需要调整。

3. 第三方库的兼容性。

并发模式是一个比较大的变化,有些第三方库可能还没有适配,在并发模式下可能会出问题。比如有些库在渲染函数里有副作用,或者依赖同步渲染的行为,在并发模式下可能会不正常。

所以迁移到并发模式的时候,要仔细测试所有用到的第三方库,确保它们在并发模式下正常工作。如果有库不兼容,可以等库更新之后再迁移,或者给React提issue。

4. 不要过度使用并发API。

并发模式的API(startTransition、useDeferredValue)很强大,但不要滥用。不是所有的更新都需要标记为过渡更新,只有那些确实会导致卡顿的、非紧急的更新才需要用。

如果你的应用本身就很流畅,没有卡顿问题,就不需要用这些API。并发模式是用来解决性能问题的,不是所有应用都需要。

过度使用并发API反而可能会导致问题,比如界面更新延迟、状态不一致等。用的时候要谨慎,只在确实需要的地方用。

八、迁移到React 18的建议

如果你想迁移到React 18并启用并发模式,这里有一些建议:

  1. 先升级到React 18,不启用并发模式。 React 18可以不启用并发模式,只用createRoot就会启用,但你可以先用ReactDOM.render(React 18还支持,只是会有警告),先确保你的应用在React 18下正常工作,再考虑启用并发模式。
  1. 逐步启用并发模式。 不要一下子把整个应用都改成并发模式,可以先在某个路由或者某个组件下启用,测试没问题了再逐步扩大范围。
  1. 修复不纯的渲染。 启用并发模式之前,先检查你的组件,确保渲染函数是纯函数,没有副作用。把副作用移到useEffect或者事件处理函数里。
  1. 测试第三方库。 确保你用到的第三方库在并发模式下正常工作。如果有不兼容的,等库更新或者找替代方案。
  1. 用React DevTools调试。 React 18的DevTools支持并发模式的调试,可以看到更新的优先级、哪些更新被中断了、哪些被放弃了。用DevTools可以帮助你理解并发模式的行为,发现性能问题。
  1. 关注官方文档。 React 18还在开发中,API可能会有变化。关注官方文档和更新日志,及时了解变化。

九、写在最后

React 18的并发模式是React团队酝酿了好几年的特性,它从根本上改变了React的渲染机制,让React应用在处理大量更新的时候依然保持流畅。虽然目前还在alpha阶段,API可能还会有变化,但核心的设计思想已经比较清晰了,值得提前学习和了解。

本文介绍了并发模式的核心概念(优先级、时间切片、可中断渲染、过渡更新),以及三个核心API(startTransition、useDeferredValue、Suspense),还有一些注意事项和迁移建议。希望能帮你快速上手React 18的并发特性。

并发模式不是银弹,不是所有应用都需要。但对于那些有大量更新、对流畅度要求高的应用(比如搜索、数据可视化、复杂表单),并发模式能带来明显的体验提升。提前了解和学习,等React 18正式发布的时候,就能快速上手,享受并发模式带来的好处。

技术在不断发展,React也在不断进化。作为前端开发者,保持学习,跟上技术的步伐,是我们的必修课。希望本文能对你的学习有所帮助。

最后用一句话结束本文:并发模式是React的一次重大进化,它让React从"同步渲染"走向"可中断渲染",也让我们对前端性能优化有了新的思考。拥抱变化,持续学习,我们一起进步。