驳《React 19 让网站性能变慢》

文摘   2024-07-31 09:05   广东  

首先必须要明确一个观点:React 19 让网站性能变慢,这是片面且没有具体依据的表达。


前因

月初的时候,有很多博主都在发文说:React 19 让网站性能变慢了。我确实刚开始的时候并没有太过于放在心上,毕竟文章内容内容我也看了,也不太经得起推敲。但是没想到群里很多朋友都被此影响,认为 react 19 问题非常大。昨晚直播的时候,有朋友跟我连麦说:"我现在对 React 19 失去了期待和热情"。

所以,我感觉我自己得写一篇文章抢救一下 React 19 在大家心中的形象。毕竟,作为一个国内为数不多的 React 19 资深使用者,我确实感觉它真的很好用。我会在文中为大家分享 React 19 中所提倡的架构思维,让大家能够理解到 React 19 的正确使用姿势。,绝对爽得飞起,不爽你拿刀来砍我!


争议的起因与分析

TanStack Query 的核心开发者之一的大佬 Dominik 在推特上发文吐槽 React 19 中 Suspense 如果内嵌两个异步组件,会导致这两个组件的请求变成串行请求,从而让内容出现的速度变得更慢

<Suspense fallback={<Loading />}>
  <RepoData name="tanstack/query" />
  <RepoData name="tanstack/table" />
</Suspense>

串行请求:多个接口依次请求,上一个请求完成之后,下一个再请求

与之对应概念叫做并行请求,即多个接口可以同时请求

React 19 Suspense 的这个特性,在我之前的文章【React 19 出手解决了异步请求的竞态问题,是好事,还是坏事】也为大家分享了 Suspense 的这个串行的特性。

不过,这里其实他的吐槽里面有一个很重要的问题就是,在 React19 之前的版本中,React 官方团队从未正式提出过一种方案,可以允许开发者在 React 中使用 Suspense 去处理请求数据的异步组件

问题就出在这里。

在 React 19 的版本之前,如何正确的使用 Suspense 其实是还处于一个摸索中的话题。一些三方的请求库都设计了自己的用法,例如 react query,例如 Jotai。当 React 19 出现之后,官方团队设想的 Suspense 用法的实践与这些三方库自己摸索的用法不一样,于是矛盾就产生了。

Dominik 希望 Suspense 可以这样做,并且能够支持并行的请求。

<Suspense fallback={<Loading />}>
  <RepoData name="tanstack/query" />
  <RepoData name="tanstack/table" />
</Suspense>

但是很显然,在 React 官方文档中,除了嵌套案例之外,所有的案例都是只在 <Suspense> 的子组件中只包含一个单一的异步子元素。

也就意味着,如果你并没有使用这些三方库,你应该也不会使用 Suspense,你几乎感受不到这种变化的影响。因此,说 React 19 让网站性能变慢,是非常不客观的。 这种矛盾只是大家各位为自己提出来的开发理念进行争议,而不是说,Dominik 的使用就一定是最佳实践。

在开发理念上存在这种争议是非常正常的事情,例如在之前我为了给自己在 React 哲学中提出的开关思维进行辩护时,我也并不认同 React 官方文档中 useEffect 关于逃生舱的说法与建议。

那么在这一次的争议中,从我个人的角度出发,react-query 并不好用,他虽然功能强大,但是概念太多,使用起来也并不简洁,学习成本也并不低。如果你熟悉并习惯了 React 19 的开发思维,你大概也会跟我有同样的感受。

接下来,我会详细给大家介绍一下 React 19 中新提出来的开发理念。


理念一:将数据存储在 promise 中,并传递 promise

这种理念在几年前只流传在少部分资深程序员之间,由于过于超前,因此很少被人理解。也就没有传播开。但是,在我写 React 19 小册的过程中,有许多人跟我沟通,我发现国内有不少大佬在这之前就在这样使用了。这些大佬大多数都是大厂里的高手。

React 19 中,正式提出 use hook,可以通过正规渠道从 promise 中获取数据,表示这种理念将会被正式确定,并逐渐被更多的人接纳。

例如这样一段代码,我们往子组件中传入获取数据的 promise,并在子组件中通过 use 获取到请求结果

const Message = (props) => {
  const content = use(props.promise)
  return (
    <div className='flex border border-blue-100 p-4 rounded-md shadow'>
      <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="size-6 mt-2">
        <path strokeLinecap="round" strokeLinejoin="round" d="M15.59 14.37a6 6 0 0 1-5.84 7.38v-4.8m5.84-2.58a14.98 14.98 0 0 0 6.16-12.12A14.98 14.98 0 0 0 9.631 8.41m5.96 5.96a14.926 14.926 0 0 1-5.841 2.58m-.119-8.54a6 6 0 0 0-7.381 5.84h4.8m2.581-5.84a14.927 14.927 0 0 0-2.58 5.84m2.699 2.7c-.103.021-.207.041-.311.06a15.09 15.09 0 0 1-2.448-2.448 14.9 14.9 0 0 1 .06-.312m-2.24 2.39a4.493 4.493 0 0 0-1.757 4.306 4.493 4.493 0 0 0 4.306-1.758M16.5 9a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0Z" />
      </svg>
      <div className='flex-1 ml-3'>
        <div>Heads Up!</div>
        <div className='text-sm mt-1 text-gray-600'>{content.value}</div>
      </div>
    </div>

  )
}
import {getMessage} from './api'

export default function Index({
  const promise = getMessage()
  return (
    <Suspense fallback={<Skeleton />}>
      <Message promise={promise} />
    </Suspense>

  )
}

这是一个仅包含初始化的案例,演示如图所示

你会发现,这种写法,最大的优势就是,初始化不再是一个副作用逻辑。我们不再依赖于 useEffect 去获取初始化的数据。

更妙的地方在于,如果我要更新数据重发请求,我只需要做一些小小的改动,那就是将 promise 存储在 state 中即可。在更新时重置 promise,注意看下面这段代码。

export default function Demo01({
  const [promise, update] = useState(getMessage)

  function __handler({
    update(getMessage())
  }

  return (
    <>
      <div className='text-right mb-4'>
        <Button onClick={__handler}>更新数据</Button>
      </div>
      <Suspense fallback={<Skeleton />}>
        <Content promise={promise} />
      </Suspense>
    </>

  )
}

演示效果:

这里一个非常厉害的地方就在于,我们用简洁的代码,在不使用 useEffect 的情况之下,完成了初始化与更新的交互。这就是这种开发理念的巧妙之处。

并且这也可以应对足够多的场景,例如,有的时候,我们并不想立即初始化,那么我们只需要稍作调整即可

- const [promise, update] = useState(getMessage)
+ const [promise, update] = useState(null)

当我们想要初始化的时候,那么就在恰当的时候调用如下语句即可

update(getMessage())

另外一个非常巧妙的地方在于,当我们在更新时,如果参数比较复杂,我们处理起来也并不麻烦,因为我们可以在更新 promise 时,直接执行 getMessage(params) 传入参数。和之前的方案相比,这极大的简化了请求参数的运用,现在变得更加自然。

很显然,在开发体验上,这种在 promise 中存储数据,并且将 promise 存储在 state 中的方式,一定是未来


理念二:promise 作为独立的数据层

在上面的案例中,有一行代码不是很起眼,但是却非常重要。

import {getMessage} from './api'

这行代码所代表的含义,正是整个项目架构思维中最重要的一环。他代表了我们获取数据的方式。

如果当前的组件,只有一个接口请求去获取数据,大家都知道应该如何处理

export const getMessage = async () => {
  const res = await fetch(url, requestOptions)
  return res.json()
}

但是如果当前组件需要两个接口请求的数据,我们应该怎么办呢?有的人就懵了。实际上我们依然应该践行同样的逻辑。我们需要抽象一下这个概念,对于页面组件而言,不管有几个接口,都应该只有一份数据来源。因此,我们应该在 getMessage 中完成数据的聚合。

聚合的方式根据需要自己来定,这两个接口可能有前后依赖关系,也有可能只需要 Promise.all 即可.

// 伪代码
export const getMessage = async () => {
  const res1 = await fetch(url, requestOptions)
  const res2 = await fetch(url, requestOptions)
  return merge(res1, res2)
}

这样,我们可以在任何复杂的场景中,保持页面的简洁。对于组件而言,他依然只认 getMessage 这一个 promise,不会随着复杂度的变化而变得更加复杂。

在这样的开发理念之下,我们可以将该 promise 称为独立的数据层。这类似于许多团队在架构中引入的 node 层,专门用来处理数据的聚合与变化,抹平后台数据库数据结构与应用层数据结构的差异

有的时候你可能需要缓存数据,也应该在我这里定义的数据层中,通过判断来获取。

这种思路可以极大的简化组件的开发思路。也就意味着,大多数情况下,一个组件中不应该出现两个获取数据的行为,因为这会导致情况处理起来变得复杂,我们只需要将两个接口放到 promise 中去处理就可以了。

也就意味着,在最佳实践中,每一个 Suspense 的子组件,都只应该只有一个,而不会出现两个或者更多

//  ❌️ bad
<Suspense fallback={<Loading />}>
  <RepoData name="tanstack/query" />
  <RepoData name="tanstack/table" />
</Suspense>
//  ✅️ good
<Suspense fallback={<Loading />}>
  <Message promise={promise} />
</Suspense>

总结

将数据直接存储在 promise 中,并将 promise 存在 state 上,通过改变和传递 promise 的方式来实现组件需求,是 React 19 明确引导的新开发理念,这种理念能够大量的减少我们对于 useEffect 的依赖。在开发体验上是一次质的飞跃。

而在此基础上,将 promise 独立抽象为数据层,则是架构思维的体现,这种架构思维与 React 19 的开发理念可以非常巧妙的结合在一起,产生异常强大的化学反应。他们能够极大的简化代码的复杂度,当然,还可以提高大家的开发效率。

当然也有坏处:那就是对于新手来说并不友好。最终的代码是简单的,但是理解成本却是很高的。底子不牢固的朋友可能很难第一时间就完全领悟,需要花时间去接受它们。

最后解答一个群有的疑问:我曾经在 React 哲学中提出一个另外一种开发思维:开关思维。 React19 的开发理念,与开关思维有异曲同工之妙,从我个人的使用和体验上来看,并没有明确的高下之分,更多的可能只是使用习惯上的一些小差异,两种方案最终的结果都非常相似,都具备非常强的简洁性。但是这两者之间的思维逻辑是完全不一样的。开关思维强调的是将行为抽象为 on/off 这样的布尔值,通过控制布尔值来做到更新。而 React19 则更加直接一些。因此,两种思维模式都掌握的小伙伴,可以根据喜好随意选择一种即可。

彻底学会 React 19,推荐阅读我的付费小册 React19

成为 React 高手,推荐阅读 React 哲学

这波能反杀
往者不可谏,来者犹可追
 最新文章