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应用,流畅而稳定。
评论(0)
暂无评论,快来抢沙发~
评论功能仅对会员开放,请先登录
登录