React 18的并发特性很强大,但用不好也会踩很多坑。

我在项目中用了几个月的并发特性,从最开始的兴奋,到后来的踩坑,再到现在的熟练使用,经历了很多。本文分享React 18并发特性的踩坑总结和实战经验,包括useTransition、useDeferredValue、Suspense的常见问题,以及如何正确使用这些特性。

这篇文章和之前写的React 18 Bug排查、React 18进阶技巧不同,那两篇侧重具体的Bug和API用法,这篇侧重在实际项目中使用并发特性时遇到的各种坑和总结的经验。

一、并发特性的基本概念

先简单回顾一下并发特性的基本概念。

1. 什么是并发渲染

React 18的并发渲染,是指React可以中断、暂停、恢复渲染。

在React 17及之前,渲染是同步的,一旦开始就不能中断。在React 18中,渲染是并发的,React可以在渲染过程中暂停,去处理更高优先级的更新,然后再回来继续渲染。

这带来的好处是:用户交互不会被阻塞,界面始终保持响应。

2. 并发特性有哪些

React 18的并发特性包括:

  • useTransition:标记非紧急更新
  • useDeferredValue:延迟一个值的更新
  • Suspense:在组件加载时显示降级UI
  • startTransition:useTransition的底层API
  • useSyncExternalStore:用于外部状态库的并发安全

这些特性,都是为了让React应用在并发渲染下保持正确和流畅。

二、useTransition的踩坑

useTransition是最常用的并发特性,但也是最容易踩坑的。

坑一:在startTransition中做副作用

这是最常见的错误。

// 错误写法
const [isPending, startTransition] = useTransition();

const handleClick = () => {
  startTransition(() => {
    // 在startTransition中发请求,这是副作用!
    fetchData().then(data => setData(data));
  });
};

startTransition只能用来做状态更新,不能用来做副作用(发请求、订阅、修改DOM等)。

因为在并发渲染中,startTransition中的更新可能被中断、丢弃或重新执行。如果里面有副作用,可能会执行多次,或者执行了但结果被丢弃。

正确写法:

const handleClick = () => {
  // 副作用在事件处理函数中直接做
  fetchData().then(data => {
    // 状态更新放在startTransition中
    startTransition(() => {
      setData(data);
    });
  });
};

坑二:用useTransition处理所有更新

有些人觉得useTransition好,就把所有更新都用startTransition包起来。

这是不对的。useTransition只适合非紧急更新,比如:

  • 搜索结果过滤
  • 列表渲染
  • 页面切换

但紧急更新不能用useTransition,比如:

  • 输入框的值
  • 按钮的点击反馈
  • 弹窗的显示/隐藏

如果把紧急更新也用useTransition,会导致界面响应变慢,用户体验变差。

正确做法:

const handleInputChange = (e) => {
  // 紧急更新:立即更新输入框
  setInputValue(e.target.value);
  
  // 非紧急更新:延迟更新搜索结果
  startTransition(() => {
    setSearchResults(filter(e.target.value));
  });
};

坑三:isPending的误解

很多人以为isPending为true时,组件不会渲染。这是错误的。

isPending只是表示过渡更新正在进行中,组件依然会渲染,只是用的是旧的状态。你可以用isPending来显示加载状态,但不要假设组件不会渲染。

// 正确用法
{isPending && <Spinner />}
<Results data={data} />

当isPending为true时,Results用的是旧的data,同时显示Spinner。过渡更新完成后,data更新,Spinner消失。

坑四:嵌套使用startTransition

不要在startTransition中再嵌套startTransition。

// 错误写法
startTransition(() => {
  setA(a);
  startTransition(() => {
    setB(b);
  });
});

嵌套使用会导致优先级混乱,行为不可预测。如果有多个状态需要更新,放在同一个startTransition中就行。

正确写法:

startTransition(() => {
  setA(a);
  setB(b);
});

三、useDeferredValue的踩坑

useDeferredValue也是常用的并发特性,但也有一些坑。

坑一:和useTransition混用

useDeferredValue和useTransition功能类似,不要混用。

// 不推荐:同时用useDeferredValue和useTransition
const deferredQuery = useDeferredValue(query);
const [isPending, startTransition] = useTransition();

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

这样写会导致逻辑混乱,优先级不明确。

正确做法: 选择一个用。如果能控制更新,用useTransition;如果不能控制更新来源(比如值来自父组件),用useDeferredValue。

坑二:以为useDeferredValue会延迟固定时间

有些人以为useDeferredValue会延迟固定的时间(比如100ms)再更新。这是错误的。

useDeferredValue的延迟时间是不固定的,取决于:

  • 紧急更新的数量和复杂度
  • 设备的性能
  • React的调度策略

它只是保证紧急更新优先处理,deferred值在空闲时更新。不要依赖固定的延迟时间。

坑三:用useDeferredValue处理原始值

useDeferredValue最适合处理对象和数组,因为可以配合React.memo避免子组件重新渲染。

如果处理的是原始值(字符串、数字),useDeferredValue的效果有限,因为每次值变化,组件还是会重新渲染。

// 效果有限
const deferredQuery = useDeferredValue(query); // query是字符串

// 效果好
const deferredList = useDeferredValue(list); // list是大数组
const MemoizedList = React.memo(List);
<MemoizedList data={deferredList} />

坑四:不配合React.memo使用

useDeferredValue如果不配合React.memo,效果会大打折扣。

因为useDeferredValue只是延迟了值的更新,但如果子组件没有用React.memo包裹,父组件每次渲染,子组件还是会重新渲染。

正确做法:

const deferredList = useDeferredValue(list);
const MemoizedList = React.memo(List);

// 只有deferredList变化时,List才重新渲染
<MemoizedList data={deferredList} />

四、Suspense的踩坑

Suspense是React 18中变化最大的特性,也是最容易踩坑的。

坑一:在Suspense中做数据获取

很多人以为Suspense可以直接用来做数据获取。这是不完全正确的。

Suspense本身不做数据获取,它只是在组件"挂起"(suspend)时显示降级UI。数据获取需要用支持Suspense的库(比如React Query、Relay、SWR的Suspense模式),或者自己实现。

// 错误:普通的fetch不会触发Suspense
function Profile() {
  const [data, setData] = useState(null);
  useEffect(() => {
    fetch('/api/user').then(res => res.json()).then(setData);
  }, []);
  
  if (!data) return <Spinner />;
  return <div>{data.name}</div>;
}

// 这样写,Suspense不会生效
<Suspense fallback={<Spinner />}>
  <Profile />
</Suspense>

正确做法: 用支持Suspense的数据获取库,或者用useTransition配合普通的数据获取。

坑二:Suspense的fallback闪烁

在并发渲染中,Suspense的fallback可能会闪烁。

比如,切换页面时,如果新页面加载很快,fallback可能一闪而过,造成闪烁。

解决方法: 配合useTransition使用,在过渡更新完成前保持旧页面。

const [isPending, startTransition] = useTransition();

const handleNavigate = (page) => {
  startTransition(() => {
    setPage(page);
  });
};

<Suspense fallback={<Spinner />}>
  {page === 'home' && <Home />}
  {page === 'about' && <About />}
</Suspense>

用useTransition后,切换页面时会保持旧页面,直到新页面加载完成,不会闪烁。

坑三:嵌套Suspense的边界问题

嵌套Suspense时,要注意边界问题。

<Suspense fallback={<PageSpinner />}>
  <ProfilePage>
    <Suspense fallback={<CommentsSpinner />}>
      <Comments />
    </Suspense>
  </ProfilePage>
</Suspense>

如果Comments挂起了,会显示CommentsSpinner,而不是PageSpinner。这是因为内层Suspense先捕获了挂起。

这是正确的行为,但有时候不是你想要的。要理解嵌套Suspense的边界,合理设计fallback。

坑四:Suspense和错误边界

Suspense处理的是"加载中"的状态,错误边界(ErrorBoundary)处理的是"加载失败"的状态。

两者要配合使用:

<ErrorBoundary fallback={<ErrorPage />}>
  <Suspense fallback={<Spinner />}>
    <Profile />
  </Suspense>
</ErrorBoundary>

如果Profile加载失败,会被ErrorBoundary捕获,显示ErrorPage。如果只写Suspense,加载失败时会白屏。

五、并发渲染的通用坑

除了具体API的坑,并发渲染还有一些通用的坑。

坑一:渲染中做副作用

在并发渲染中,组件函数可能执行多次(因为渲染可能被中断和恢复)。如果在渲染中做副作用,可能会执行多次。

// 错误:在渲染中修改全局变量
let count = 0;

function Counter() {
  count++; // 渲染中修改全局变量,可能执行多次
  return <div>{count}</div>;
}

正确做法: 副作用放在useEffect或事件处理函数中。

坑二:依赖渲染顺序

不要依赖组件的渲染顺序。

在并发渲染中,组件的渲染顺序可能和你想的不一样。React可能先渲染子组件,再渲染父组件;也可能中断一个组件的渲染,去渲染另一个组件。

// 错误:依赖渲染顺序
let order = 0;

function A() {
  order++;
  return <div>A: {order}</div>;
}

function B() {
  order++;
  return <div>B: {order}</div>;
}

不要在渲染中修改共享变量,不要假设渲染顺序。

坑三:用ref在渲染中读写

在渲染中读写ref,在并发渲染中是不安全的。

// 错误:在渲染中读写ref
function Component() {
  const ref = useRef(0);
  ref.current = ref.current + 1; // 渲染中写ref
  return <div>{ref.current}</div>;
}

ref应该在useEffect或事件处理函数中读写,不要在渲染中读写。

坑四:外部状态库的并发安全

如果你用了外部状态库(Redux、MobX、Zustand等),要确保它们支持并发渲染。

React 18提供了useSyncExternalStore这个API,专门用来让外部状态库在并发渲染下保持安全。如果你的状态库版本比较老,可能不支持并发渲染,会出现问题。

解决方法: 升级状态库到支持React 18的版本,或者用useSyncExternalStore自己封装。

六、实战经验

说说我在实际项目中使用并发特性的经验。

1. 搜索页面的优化

这是最经典的应用场景。

function SearchPage() {
  const [query, setQuery] = useState('');
  const [isPending, startTransition] = useTransition();
  const deferredQuery = useDeferredValue(query);
  
  const handleChange = (e) => {
    setQuery(e.target.value);
    startTransition(() => {
      setResults(filter(e.target.value));
    });
  };
  
  return (
    <div>
      <input value={query} onChange={handleChange} />
      {isPending && <span>搜索中...</span>}
      <Results data={results} />
    </div>
  );
}

效果:输入框立即响应,搜索结果在后台更新,不阻塞输入。用户体验提升明显。

2. 大列表的渲染

渲染一万条数据的列表,用useDeferredValue优化。

function LargeList({ items }) {
  const deferredItems = useDeferredValue(items);
  const MemoizedList = React.memo(List);
  
  return (
    <div>
      <Filter onChange={setFilter} />
      <MemoizedList items={deferredItems} />
    </div>
  );
}

效果:筛选条件变化时,列表的渲染被延迟,筛选控件的响应不被阻塞。

3. 路由切换

配合React Router,实现平滑的路由切换。

function App() {
  const [isPending, startTransition] = useTransition();
  const navigate = useNavigate();
  
  const handleNavigate = (path) => {
    startTransition(() => {
      navigate(path);
    });
  };
  
  return (
    <div>
      <Nav onNavigate={handleNavigate} />
      {isPending && <LoadingBar />}
      <Suspense fallback={<Spinner />}>
        <Routes>
          <Route path="/" element={<Home />} />
          <Route path="/about" element={<About />} />
        </Routes>
      </Suspense>
    </div>
  );
}

效果:切换路由时,旧页面保持显示,新页面加载完成后再切换,不会白屏或闪烁。

4. 表单提交

表单提交时,用useTransition标记提交状态。

function Form() {
  const [isPending, startTransition] = useTransition();
  const [submitted, setSubmitted] = useState(false);
  
  const handleSubmit = async (e) => {
    e.preventDefault();
    startTransition(() => {
      submitForm().then(() => setSubmitted(true));
    });
  };
  
  return (
    <form onSubmit={handleSubmit}>
      <input name="name" />
      <button disabled={isPending}>
        {isPending ? '提交中...' : '提交'}
      </button>
    </form>
  );
}

效果:提交时按钮显示loading,防止重复提交。

七、性能优化的经验

使用并发特性时,也要注意性能优化。

1. 不要过度使用

并发特性不是银弹,不要什么都用。

  • 简单的状态更新,不需要用useTransition
  • 小列表,不需要用useDeferredValue
  • 没有数据获取的组件,不需要用Suspense

过度使用并发特性,会增加代码复杂度,反而可能降低性能。

2. 配合React.memo和useMemo

并发特性配合React.memo和useMemo,效果更好。

  • useDeferredValue + React.memo:避免子组件不必要的渲染
  • useTransition + useMemo:缓存计算结果,避免重复计算

3. 监控性能

使用并发特性后,要监控性能。

  • 用React DevTools查看渲染情况
  • 用Lighthouse或Web Vitals监控性能指标
  • 对比使用前后的性能变化

不要假设用了并发特性就一定更快,要用数据说话。

八、迁移建议

如果你打算在项目中使用并发特性,给你一些迁移建议。

1. 逐步迁移

不要一次性把所有组件都改成并发模式。从一个页面、一个组件开始,验证没问题后再推广。

2. 先升级到React 18

先把项目升级到React 18,用createRoot替代ReactDOM.render,确保基本功能正常。然后再逐步引入并发特性。

3. 开启StrictMode

开发环境开启StrictMode,它能帮你发现并发渲染下的问题(比如不安全的副作用)。

4. 测试

充分测试,尤其是:

  • 快速交互(快速输入、快速点击)
  • 大列表渲染
  • 网络延迟场景
  • 错误处理

九、写在最后

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

它能实实在在地提升用户体验,但也需要我们理解它的原理,避免踩坑。useTransition、useDeferredValue、Suspense,每个API都有自己的适用场景和注意事项。

这几个月的使用经验告诉我:并发特性不是银弹,用好了能提升体验,用不好会引入bug。关键是理解原理,合理使用,充分测试。

2022年了,React 18已经正式发布,越来越多的项目会升级。希望这篇踩坑总结,能帮你在使用并发特性时少走弯路。

最后,用一句话总结:"并发特性的核心是:紧急更新立即响应,非紧急更新延迟处理。理解了这一点,就理解了React 18的并发渲染。"

愿你的React 18应用,流畅而稳定。