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。