专栏名称: 前端从进阶到入院
我是 ssh,只想用最简单的方式把原理讲明白。wx:sshsunlight,分享前端的前沿趋势和一些有趣的事情。
目录
相关文章推荐
财宝宝  ·  我家婆娘是60.35公斤。也没什么。关键看肉 ... ·  20 小时前  
财宝宝  ·  你怎么称呼你家那个120斤的美美哒? ... ·  3 天前  
财宝宝  ·  这还要你教?滚!-20250208223758 ·  3 天前  
51好读  ›  专栏  ›  前端从进阶到入院

React 19 出手解决了异步请求的竞态问题,是好事还是坏事?

前端从进阶到入院  · 公众号  ·  · 2024-05-08 16:33

正文

是的,又是竞态问题。

在客户端开发中,这是一个老生常态的问题。一个有经验的前端工程师必定是对这个问题的情况与解决方案如数家珍。 因此竞态问题也经常在面试的过程中被讨论

竞态问题指的是,当我们在交互过程中,由于各种原因导致同一个接口短时间之内连续发送请求,后发送的请求有可能先得到请求结果,从而导致数据渲染出现预期之外的错误。

有的地方也称为竞态条件

因为防止重复执行 可以有效的解决竞态问题,因此许多时候面试官也会直接在面试中问我们如何实现防重。常用的方式就是取消上一次请求,或者设置状态让按钮不能连续点击,想必各位大佬对这些方案都已经非常熟悉,我这里就不展开细说。当然,这个问题虽然被经常讨论,但是要解决好确实需要一点技术功底。

React 19 结合 Suspense 也在竞态问题上,提出了一个自己的解决方案。我们结合新的案例来探讨一下这个问题,看完之后大家感受一下这种方式是好是坏。

00

案例

我们先来看一下本次案例要实现的交互效果。如下图所示。每次点击会新增一条数据到下方的列表中。

我们来实现一下这个效果,首先定义一个用于请求接口的 promise

const getApi = async () => {
  const res = await fetch('https://api.chucknorris.io/jokes/random')
  return res.json()
}

然后和前面的案例一样,我们将每次点击的 api 作为状态存储起来,通过 api 的改变来触发更新的执行。

const [api, setApi] = useState(null)

与此同时,我们还需要一个数组作为状态来管理列表。

const [list, setList] = useState([])

有了这个数组之后,我们需要遍历这个数组渲染成 UI

"list">
  {list.map((item, index) => {
    return <div className='item' key={item}>{item}div>
  })}
</div>

最后需要 loading 显示的部分,我们使用 Suspense 来完成。

<Suspense fallback={<div>loading...div>}>
  <Item api={api} setList={setList} />
Suspense>

需要注意的是,我们这里把 setList 传递进入了子组件。这个细节需要仔细思考我的动因。

我们要考虑的问题是,当我们在 Suspense 之外,需要知道请求成功的状态和数据时,只有在 Suspense 的子组件内部才可以获取到。Suspense 子组件和外面的 Loading 是一个互斥的显示关系。

因此,我们要在子组件内部去获取请求成功的数据结果。

const Item = ({api, setList}) => {
  const [show, setShow] = useState(true)
  const joke = api ? use(api) : {value'nothing'}

  useEffect(() => {
    if (!api) return
    setList((list) => {
      if (!list.includes(joke.value)) {
        return list.concat(joke.value)
      }
      return list
    })
    setShow(false)
  }, [])

  const __cls = show ? '_03_a_value show' : '_03_a_value'

  return (
    <div className={__cls}>{joke.value}div>
  )
}

状态 show 是为了让最后一条数据在列表中显示,而不在这里显示

这里我们使用了 useEffect 来表示子组件渲染完成时需要执行的逻辑。注意 React 19 虽然通过很多方式大幅度弱化了 useEffect 的存在感,但是偶尔在合适的时候使用也是必要的。

我在合并 list 的过程中,添加了一个判断。

setList((list) => {
  if (!list.includes(joke.value)) {
    return list.concat(joke.value)
  }
  return list
})

这个细节在真实项目开发中尤其重要。因为 React 19 严格模式之下,组件会让 useEffect 执行两次,以模拟生产环境的重复请求问题,因此,我这里做了一个判断方式同样的数据连续推送到数组里,从而导致线上 bug 的发生。

一个程序员是否经验丰富,是否成熟,都是体现在这些生产环境的细节中

完整代码如下

const getApi = async () => {
  const res = await fetch('https://api.chucknorris.io/jokes/random')
  return res.json()
}

export default function Index({
  const [api, setApi] = useState(null)
  const [list, setList] = useState([])

  function __clickToGetMessage({
    setApi(getApi())
  }

  return (
    <div>
      <div id='tips'>点击按钮新增一条数据,该数据从接口中获取div>
      <button onClick={__clickToGetMessage}>新增数据button>
      <div className="content">
        <div className="list">
          {list.map((item, index) => {
            return <div className='item' key={item}>{item}div>
          })}
        div>
        
        <Suspense fallback={<div>loading...div>}>
          <Item api={api} setList={setList} />
        Suspense>
      div>
    div>

  )
}

const Item = ({api, setList}) => {
  const [show, setShow] = useState(true)
  const joke = api ? use(api) : {value'nothing'}

  useEffect(() => {
    if (!api) return
    setList((list) => {
      if (!list.includes(joke.value)) {
        return list.concat(joke.value)
      }
      return list
    })
    setShow(false)
  }, [])

  const __cls = show ? '_03_a_value show' : '_03_a_value'

  return (
    <div className={__cls}>{joke.value}div>
  )
}

这样之后,我们的目标基本就完成了。接下来,我们需要观察,当我恶意重复点击按钮,会发生什么事情。

01

连续点击

恶意连续点击之前,我根据我以往的经验预测一下可能会发生什么事情。

首先,多次点击会导致多次请求,因此数组中会新增大量的数据。

其次,由于请求太密集,那么点击的先后顺序,与请求成功的先后顺序不一致,因此列表中的顺序也会与点击顺序不同。「竞态问题」

那么我们来试着操作一下,看看该案例会有什么反应。演示结果如下,新增一条数据时,我连续点击了 10 次。

结果我们发现,点击期间,并没有新的数据渲染到页面上,一直是 loading 的状态。

再来看一下此时的请求情况。







请到「今天看啥」查看全文