我用React 18 alpha重构了一个项目。过程中踩了很多坑。有些坑让我熬了好几个夜。本文是我的实战踩坑记录。需要说明的是React 18还是alpha版本。最终特性可能会有变化。

一、项目背景

先说说为什么要用React 18 alpha。

我们有一个数据可视化的项目。界面比较复杂。有大量的图表和表格。用户交互很多。用React 17的时候。有时候会卡顿。特别是在输入框输入的时候。图表会跟着更新。导致输入很卡。

React 18 alpha发布之后。我看到并发渲染的特性。觉得可以解决我们的卡顿问题。并发渲染可以让React同时准备多个版本的UI。把不紧急的更新推迟。让紧急的更新优先执行。这样用户输入就不会被阻塞了。

于是我就在一个分支上。把项目升级到了React 18 alpha。想试试并发渲染的效果。结果一升级就踩了很多坑。本文就是这些坑的记录。

二、并发渲染的坑

并发渲染是React 18最大的新特性。也是坑最多的地方。

坑1:渲染可以被中断

在React 17及之前。渲染是同步的。一旦开始渲染。就会一直执行完。不会被中断。

但是在React 18的并发模式下。渲染是可以被中断的。React渲染到一半。如果有更紧急的更新来了。就会暂停当前的渲染。先处理紧急的更新。然后再回来继续渲染。甚至可能丢弃之前的渲染结果。重新渲染。

这个变化带来了一个问题。就是渲染过程中的副作用可能会被执行多次。因为渲染被中断之后重新开始。之前执行的代码又会执行一遍。

比如你在render函数里调用了一个函数。这个函数有副作用。比如修改了一个全局变量。或者打印了日志。在并发模式下。这个函数可能会被调用多次。因为渲染被中断了又重新开始。

我们项目里有一个组件。在render的时候计算了一个值。并且把这个值存在了一个外部变量里。结果在并发模式下。这个值经常不对。因为render被中断了。外部变量被修改了多次。最后一次修改的值不是我们期望的。

解决方法是。render函数应该是纯函数。不要有副作用。所有副作用都放在useEffect或者useLayoutEffect里。不要在render的时候修改外部变量。我们把那个计算移到了useMemo里。问题就解决了。

坑2:useEffect的执行时机变了

在React 17里。useEffect是在浏览器绘制之后异步执行的。useLayoutEffect是在浏览器绘制之前同步执行的。

在React 18的并发模式下。useEffect的执行时机有一些变化。因为渲染可以被中断。useEffect可能会延迟执行。或者在某些情况下不执行。

我们项目里有一个组件。在useEffect里读取了DOM元素的尺寸。然后做一些计算。在React 17里没问题。在React 18里。有时候读取到的尺寸是0。因为DOM还没更新完。useEffect就执行了。

解决方法是。如果需要在DOM更新之后立即读取布局。用useLayoutEffect而不是useEffect。useLayoutEffect是同步执行的。在浏览器绘制之前执行。这时候DOM已经更新完了。可以正确读取尺寸。

还有一个坑是。在并发模式下。useEffect的清理函数可能会被调用多次。因为组件可能会被卸载又重新挂载。如果清理函数里有副作用。要注意幂等性。

三、自动批处理的坑

React 18的另一个新特性是自动批处理。在React 17里。只有事件处理函数里的状态更新会被批处理。Promise、setTimeout、原生事件里的更新不会被批处理。

在React 18里。所有的状态更新都会自动批处理。不管是在哪里触发的。这可以减少重新渲染的次数。提升性能。

坑3:依赖批处理的代码出问题了

我们项目里有一些代码。依赖了非批处理的行为。比如在一个Promise回调里。先更新state A。然后读取state A的值。再更新state B。

在React 17里。因为Promise回调里的更新不会被批处理。更新A之后组件会重新渲染。然后再更新B。所以读取A的时候能拿到最新的值。

但是在React 18里。因为自动批处理。A和B的更新会被合并成一次渲染。所以在更新B的时候。读取到的A还是旧的值。

这个问题导致我们的一些逻辑出错了。比如一个表单组件。先更新了表单数据。然后根据表单数据计算校验结果。结果校验结果用的是旧的数据。

解决方法是。不要依赖状态更新之后立即读取状态。应该用函数式更新。或者把需要的值存在变量里。比如先计算好新的A值。存在变量里。然后用这个变量来更新A和计算B。

坑4:测试出问题了

我们的单元测试也出了问题。有些测试在React 17里能通过。在React 18里失败了。

原因是测试里模拟了异步更新。比如用setTimeout或者Promise。在React 17里。这些更新不会被批处理。每次更新都会触发渲染。测试可以按顺序断言。

但是在React 18里。这些更新被批处理了。渲染的次数变少了。有些断言就失败了。因为期望的渲染次数不对。

解决方法是。更新测试用例。不要依赖具体的渲染次数。只断言最终的状态。或者用React Testing Library的waitFor来等待更新完成。

四、Suspense的坑

React 18改进了Suspense。支持了服务端渲染的Suspense。以及更多的使用场景。

坑5:Suspense的fallback闪烁

我们在项目里用了Suspense来做代码分割和数据加载。在React 17里。Suspense的表现基本正常。

但是在React 18 alpha里。Suspense有时候会出现fallback闪烁。就是内容已经加载好了。但是又突然显示fallback。然后又显示内容。

查了很久才发现。是因为并发渲染导致的。当一个组件正在加载的时候。如果有其他更新触发了重新渲染。Suspense的状态可能会被重置。导致fallback又显示出来。

解决方法是。给Suspense的子组件加key。或者把Suspense放在更稳定的位置。不要让它在每次渲染的时候都重新创建。还有就是尽量不要在Suspense的父组件里频繁更新状态。

这个问题在alpha版本里比较明显。希望正式版能修复。

坑6:数据获取的方式不对

React 18的Suspense支持数据获取。就是组件可以在render的时候"挂起"。等待数据加载完成。然后再渲染。

但是这个特性需要数据获取库的支持。比如Relay或者React Query的特定版本。我们最开始以为随便一个异步请求都能和Suspense配合。结果发现不行。

如果你用普通的useEffect加useState来获取数据。Suspense是检测不到的。不会触发挂起。只有用支持Suspense的数据获取库。或者自己实现Suspense兼容的数据获取。才能用这个特性。

我们最后用了React Query的实验版本。它支持Suspense模式。这样才能和Suspense配合使用。

五、startTransition的坑

startTransition是React 18的新API。可以把某些更新标记为非紧急的。让紧急的更新优先执行。

坑7:过度使用startTransition

最开始我觉得startTransition很好用。就把所有的更新都包在startTransition里。结果发现性能反而更差了。

因为startTransition会让更新延迟执行。如果所有更新都延迟了。界面的响应反而变慢了。用户点击按钮。要等一会儿才有反应。

后来我才明白。startTransition应该只用在那些不紧急的。可能会导致大量渲染的更新上。比如搜索框的输入。输入的时候要过滤大量数据。这种更新可以用startTransition。让输入框的响应优先。过滤结果延迟更新。

而像按钮点击、表单输入这种需要立即响应的更新。不要用startTransition。否则用户会觉得卡顿。

坑8:startTransition里的状态读取

和自动批处理类似。startTransition里的状态更新也是异步的。如果你在startTransition之后立即读取状态。读到的是旧值。

我们有一个组件。在startTransition里更新了筛选条件。然后立即用筛选条件去请求数据。结果请求用的是旧的筛选条件。

解决方法是。把筛选条件存在变量里。用这个变量去请求数据。而不是从state里读取。或者把请求逻辑放在useEffect里。监听筛选条件的变化。

六、useDeferredValue的坑

useDeferredValue是另一个新API。可以延迟更新某个值。让渲染优先处理更紧急的更新。

坑9:延迟值导致的不同步

useDeferredValue会返回一个延迟更新的值。在紧急更新的时候。这个值不会立即更新。会等紧急更新处理完之后再更新。

这就导致了一个问题。在同一次渲染里。原始值和延迟值可能不一样。比如你有一个输入框。输入的值是原始值。用useDeferredValue延迟之后的值用来过滤列表。在输入的时候。输入框显示的是最新的输入。但是列表还是旧的过滤结果。要等一会儿才更新。

这个行为是设计如此。但是如果处理不好。会让用户觉得界面不同步。

解决方法是。在使用延迟值的时候。给用户一个视觉提示。比如列表正在加载。或者显示一个骨架屏。让用户知道结果正在更新。

七、第三方库兼容的坑

坑10:第三方库不兼容

这是最大的坑。很多第三方库还没有适配React 18。在并发模式下会出问题。

比如我们用的一个状态管理库。在并发模式下。状态更新会出问题。因为它的内部实现依赖了同步渲染的假设。

还有一个UI组件库。在并发模式下。动画会出问题。因为动画的时序和渲染的时序不匹配了。

我们的解决方法是。对于不兼容的库。要么找替代品。要么不用并发模式。React 18可以不用并发模式。只是用它的其他新特性。比如自动批处理。这样大部分第三方库都能正常工作。

如果你想用并发模式。一定要仔细测试所有第三方库。确保它们在并发模式下正常工作。

八、给想尝试React 18的人的建议

如果你也想尝试React 18 alpha。我有几个建议。

1. 先在小项目上试

不要一上来就用在大项目上。先在小项目或者分支上试。熟悉新特性。踩踩坑。然后再考虑用在正式项目上。

2. 不要急着用并发模式

并发模式是最复杂的特性。坑也最多。可以先用React 18的其他特性。比如自动批处理。等熟悉了之后再开并发模式。

3. 确保render是纯函数

这是最重要的。在并发模式下。render可能会被中断和重新执行。所以render函数必须是纯函数。不能有副作用。所有副作用都放在effect里。

4. 充分测试

升级之后一定要充分测试。特别是交互复杂的组件。并发模式下很多行为和之前不一样。容易出bug。

5. 关注官方更新

alpha版本变化很快。API可能会改。bug也会不断修复。要关注官方的更新日志。及时升级。

九、写在最后

React 18 alpha有很多激动人心的新特性。并发渲染、自动批处理、Suspense改进等。这些特性能让React应用更流畅。用户体验更好。

但是alpha版本毕竟是alpha版本。坑很多。不适合用在生产环境。建议等正式版发布之后再用。

我这次踩坑的经历。让我对React 18有了更深的理解。也期待正式版的发布。

最后用一句话结束本文:"新特性很美好。但是踩坑是必经之路。"愿每一个前端开发者都能在踩坑中成长。做出更流畅的应用。