React 18正式发布后,我在项目中使用了并发特性,踩了不少坑,熬了好几个夜。
本文记录这些踩坑经历,包括具体的问题、原因分析、解决方案,以及避免踩坑的建议。
一、坑一:自动批处理导致的状态读取问题
1. 问题现象
升级到React 18后,有一段代码出了问题。
function Counter() {
const [count, setCount] = useState(0);
const handleClick = () => {
setCount(count + 1);
console.log(count); // React 17: 0,React 18: 0(看起来一样)
};
const handleAsync = async () => {
await fetchData();
setCount(count + 1);
console.log(count); // React 17: 1,React 18: 0!
};
}在React 17中,setTimeout、Promise里的setState不会批处理,每次setState后立即重新渲染。但在React 18中,所有更新都会自动批处理,包括异步回调里的。
结果:在异步回调里setState后,立即读取state,读到的还是旧值。
2. 原因分析
React 18的自动批处理:
- React 17:只有事件处理函数中的更新会批处理
- React 18:所有更新都会批处理,包括setTimeout、Promise、原生事件
- 批处理意味着多次setState合并成一次渲染
- state不会立即更新
3. 解决方案
方案一:用函数式更新
setCount(prev => prev + 1);方案二:用useEffect监听变化
useEffect(() => {
console.log(count); // count变化后执行
}, [count]);方案三:如果确实需要同步更新,用flushSync
import { flushSync } from 'react-dom';
flushSync(() => {
setCount(count + 1);
});
console.log(count); // 此时已经更新但flushSync会影响性能,慎用。
二、坑二:useTransition导致的界面更新延迟
1. 问题现象
我用useTransition优化搜索过滤,结果用户反馈:输入后,结果要等一会儿才更新,感觉卡顿。
function Search() {
const [query, setQuery] = useState('');
const [isPending, startTransition] = useTransition();
const handleChange = (e) => {
startTransition(() => {
setQuery(e.target.value); // 问题在这里!
});
};
return (
<div>
<input value={query} onChange={handleChange} />
<Results query={query} />
</div>
);
}2. 原因分析
我把输入框的值更新也放进了transition里。
- transition里的更新是低优先级的
- 可以被其他更新打断
- React会延迟处理低优先级更新
- 所以输入框的值更新延迟了
输入框的更新,应该是紧急更新,不能用transition。
3. 解决方案
区分紧急和非紧急更新:
function Search() {
const [input, setInput] = useState(''); // 紧急更新:输入框
const [searchQuery, setSearchQuery] = useState(''); // 非紧急:搜索
const [isPending, startTransition] = useTransition();
const handleChange = (e) => {
setInput(e.target.value); // 紧急:立即更新
startTransition(() => {
setSearchQuery(e.target.value); // 非紧急:可以延迟
});
};
return (
<div>
<input value={input} onChange={handleChange} />
{isPending && <div>搜索中...</div>}
<Results query={searchQuery} />
</div>
);
}关键:输入框用紧急更新,搜索结果用transition。
三、坑三:Suspense的fallback闪烁
1. 问题现象
用Suspense做路由切换时,fallback总是闪烁一下,体验不好。
function App() {
const [tab, setTab] = useState('home');
return (
<div>
<button onClick={() => setTab('home')}>首页</button>
<button onClick={() => setTab('about')}>关于</button>
<Suspense fallback={<Spinner />}>
{tab === 'home' && <Home />}
{tab === 'about' && <About />}
</Suspense>
</div>
);
}每次切换标签,都先显示Spinner,再显示内容,即使内容已经加载过了。
2. 原因分析
Suspense的默认行为:
- 只要子组件suspend,就显示fallback
- 即使数据很快就加载完
- 也会闪烁一下
这是Suspense的设计,但体验不好。
3. 解决方案
方案一:用useTransition延迟切换
function App() {
const [tab, setTab] = useState('home');
const [isPending, startTransition] = useTransition();
const switchTab = (newTab) => {
startTransition(() => {
setTab(newTab);
});
};
return (
<div>
<button onClick={() => switchTab('home')}>首页</button>
<button onClick={() => switchTab('about')}>关于</button>
<Suspense fallback={<Spinner />}>
{tab === 'home' && <Home />}
{tab === 'about' && <About />}
</Suspense>
</div>
);
}用transition后,React会保持旧页面,直到新页面加载完成,避免闪烁。
方案二:数据预加载
- 切换前预加载下一个页面的数据
- 切换时数据已经有了
- 不会suspend
四、坑四:useDeferredValue和useEffect的配合问题
1. 问题现象
用useDeferredValue延迟一个值,然后在useEffect里根据这个值发请求,结果请求次数异常。
function Search({ query }) {
const deferredQuery = useDeferredValue(query);
useEffect(() => {
fetchResults(deferredQuery); // 问题:请求次数不对
}, [deferredQuery]);
return <Results query={deferredQuery} />;
}快速输入时,deferredQuery会延迟更新,但每次更新都会触发useEffect,导致多次请求。
2. 原因分析
useDeferredValue的行为:
- 紧急更新时,deferredValue保持旧值
- 浏览器空闲时,deferredValue更新为新值
- 每次更新都会触发重新渲染
- 如果有useEffect依赖它,就会多次执行
快速输入时,可能触发多次deferredValue更新,导致多次请求。
3. 解决方案
方案一:加防抖
useEffect(() => {
const timer = setTimeout(() => {
fetchResults(deferredQuery);
}, 300);
return () => clearTimeout(timer);
}, [deferredQuery]);方案二:用useTransition控制
const [searchQuery, setSearchQuery] = useState('');
const [isPending, startTransition] = useTransition();
const handleChange = (e) => {
startTransition(() => {
setSearchQuery(e.target.value);
});
};
useEffect(() => {
fetchResults(searchQuery);
}, [searchQuery]);方案三:用请求库的缓存和取消
- 用React Query、SWR等库
- 自动处理重复请求
- 自动取消过期请求
五、坑五:并发渲染下的ref问题
1. 问题现象
在并发渲染下,ref的赋值时机出了问题。
function AutoFocusInput() {
const inputRef = useRef(null);
useEffect(() => {
inputRef.current.focus(); // 有时候focus失败
}, []);
return <input ref={inputRef} />;
}在某些并发场景下,useEffect执行时,ref.current还是null。
2. 原因分析
并发渲染下:
- 渲染可以被中断
- ref的赋值时机可能和useEffect不同步
- 如果渲染被中断后重新开始,ref可能还没赋值
- 特别是Suspense和transition场景
3. 解决方案
方案一:用callback ref
function AutoFocusInput() {
const inputRef = useRef(null);
const setRef = (node) => {
inputRef.current = node;
if (node) {
node.focus(); // ref赋值时立即focus
}
};
return <input ref={setRef} />;
}方案二:加null检查
useEffect(() => {
if (inputRef.current) {
inputRef.current.focus();
}
}, []);六、坑六:第三方库不兼容
1. 问题现象
升级React 18后,有些第三方库出了问题。
- 某些UI库的动画异常
- 某些表单库的状态不同步
- 某些数据获取库报错
- 某些工具库的警告
2. 原因分析
React 18的并发渲染,改变了一些底层行为:
- 渲染可以被中断
- useEffect的执行时机可能变化
- 某些依赖同步渲染的库会出问题
- 库需要适配React 18
3. 解决方案
- 升级第三方库到支持React 18的版本
- 查看库的issue和changelog
- 如果库不支持,考虑替换或fork
- 在项目中加兼容处理
- 充分测试
七、避免踩坑的建议
1. 渐进式采用
- 不要一次性把所有组件都改成并发模式
- 先在一个页面或组件试用
- 验证没问题再推广
- 保留回滚方案
2. 充分测试
- 单元测试
- 集成测试
- 手动测试各种交互场景
- 特别是快速输入、快速切换等场景
3. 理解原理
- 理解并发渲染的原理
- 理解useTransition、useDeferredValue的行为
- 理解Suspense的机制
- 理解自动批处理的影响
理解了原理,才能避免踩坑。
4. 关注官方文档
- React官方文档有详细的并发特性说明
- 关注React的更新日志
- 关注社区的最佳实践
- 遇到问题先查文档和issue
5. 不要过度使用
- 不是所有地方都需要并发特性
- 大部分组件,正常写就好
- 只在有性能问题的地方使用
- 保持代码简单
八、写在最后
React 18的并发特性很强大,但也容易踩坑。
我踩的这些坑,很多都是因为没有完全理解并发渲染的原理,想当然地用了API。后来深入理解了原理,很多问题就迎刃而解了。
2022年,React 18刚发布不久,很多库还在适配,最佳实践还在形成。但并发渲染是未来的方向,值得学习和尝试。只要理解原理,渐进式采用,充分测试,就能用好并发特性。
最后,用一句话总结:"React 18并发特性,强大但容易踩坑。理解原理,渐进式采用,充分测试,才能用好它。"
愿你少踩坑,用好React 18。
评论(0)
暂无评论,快来抢沙发~
评论功能仅对会员开放,请先登录
登录