useTransition真的无所不能吗?🤔

人生不售来回票,一旦动身,绝不能复返

大家好,我是柒八九

前言

之前通过React 并发原理讲解了React如何实现原理。但是在应用层面涉及的不多,而今天我们就对如何正确的使用并发渲染做进一步的梳理。而提起并发渲染,useTransitionuseDeferredValue是我们绕不过去的两座大山。

useTransitionuseDeferredValue为我们提供了对过渡的控制,它被认为对我们的UI交互性能将产生革命性的影响。

既然,人家都说是革命性的改变,那是不是我们可以在任何场景使用?是否有一些桎梏?是否有一些让人匪夷所思的特性和"癖好"。让我们今天就对这些进一步讨论和分析。

还有有一句话,希望大家谨记:

并发渲染钩子会导致重新渲染。因此,永远不要在所有状态更新中使用它们

题外话

话说,你们除夕上班吗?

好了,天不早了,干点正事哇。

我们能所学到的知识点

  1. 前置知识点
  2. 案例分析
  3. 并发渲染和useTransition
  4. useTransition会导致重新渲染
  5. 如何正确的使用useTransition
  6. useDeferredValue
  7. debounce VS useTransition

1. 前置知识点

前置知识点 ,只是做一个概念的介绍,不会做深度解释。因为,这些概念在下面文章中会有出现,为了让行文更加的顺畅,所以将本该在文内的概念解释放到前面来。如果大家对这些概念熟悉,可以直接忽略

同时,由于阅读我文章的群体有很多,所以有些知识点可能我视之若珍宝,尔视只如草芥,弃之如敝履 。以下知识点,请酌情使用

useTransition的使用

首先,确保你的项目已经升级到 React 18 或更高版本。

并且,在你的组件的顶层调用useTransition,以将某些状态更新标记为过渡。

jsx 复制代码
import { useTransition } from 'react';

function Container() {
  const [isPending, startTransition] = useTransition();
  // ...
}

参数

useTransition 不接受任何参数。

返回值

useTransition 返回一个包含两个项的数组:

  1. isPending 标志,用于告诉你是否有待处理的过渡。
  2. startTransition 函数,允许你将状态更新标记为过渡。

2. 案例分析

首先,我们用vite构建一个react-ts项目。

lua 复制代码
yarn create vite useTransiont --template react-ts

(墙裂推荐大家手动实践一下)

大体的页面结构如下:

我们将拥有一个App组件,它渲染三个buttonABC),并有条件地渲染这些Button所对应的内容。App将保持切换Button的状态并渲染正确的组件。

jsx 复制代码
export default function App() {
  const [tab, setTab] = useState("A");

  return (
    <div className="container">
      <div className="btns">
        <Button isActive={tab === "A"} onClick={() => setTab("A")} name="A" />
        <Button isActive={tab === "B"} onClick={() => setTab("B")} name="B" />
        <Button isActive={tab === "C"} onClick={() => setTab("C")} name="C" />
      </div>
      <div className="content">
        {tab === "A" && <A />}
        {tab === "B" && <B />}
        {tab === "C" && <C />}
      </div>
    </div>
  );
}

使用yarn dev启动前端项目,其大致的页面结果如下:

我们假设B组件是一个耗时组件 ,它在内部渲染了100个小组件,并且每个组件需要花费大约10毫秒来渲染。理论上来说,渲染100个组件对React来说小菜一碟,但架不住每个组件需要10毫秒。那就得到一个糟糕的结果,渲染B页面将需要1秒钟。

B组件代码

jsx 复制代码
import { ItemsList } from "@components/SlowComponents";
export const B = () => {
  console.log("B被触发了");
  useLogOnRender("B");

  return (
    <div className="projects">
      此组件需要展示大量的耗时内容
      <br /> <br /> <ItemsList />
    </div>
  );
};

B组件渲染的子组件(耗时组件)

jsx 复制代码
const SlowItem = ({ id }: { id: number }) => {
  const startTime = performance.now();
  while (performance.now() - startTime < 10) {
    // 模拟耗时任务,让主线程暂停10ms
  }

  return <li className="item">耗时任务 #{id + 1}</li>;
};

export const ItemsList = () => {
  const items = [...Array(100).keys()];

  return (
    <ul className="items">
      {items.map((id) => (
        <SlowItem id={id} />
      ))}
    </ul>
  );
};

现在尝试在这些Button之间快速切换。如果我尝试从A切换到B,然后立刻切换到C。在快速切换的过程中,从BC过程中页面会有不定时间的卡顿。

本来你想快速的看到C的内容,但是浏览器却对你说:丞妾做不到

但是,作为精益求精 的用户,容不得眼里有一点沙子。用户可不会惯着你,虽然今天是1024(本文起稿日期),但是,小可爱的产品经理,要让你把这个东西给优化处理掉。让用户在访问页面时,有一种像吃了德芙般丝滑的体验。

但是,你思来想去,发现你的武器库中缺失了这种利器。你不好去做优化处理。

这是因为,虽然React状态更新并不是异步的(我们之前的文章有讲过,有兴趣的可以翻找一下)。触发状态更新通常是异步 的:我们会在各种回调函数中异步触发它,以响应用户交互。但一旦状态更新被触发,React会义无反顾同步地计算所有必要的更新,重新渲染所有需要重新渲染的组件 ,将这些更改提交到DOM,以便它们显示在屏幕上。

如果在这期间点击了一个Button按钮,该操作导致的状态更新将被放入任务队列中,在主任务(慢状态更新)完成后执行。

我们可以在控制台输出中看到这种行为:通过点击Button触发的所有重新渲染都将被记录,即使在此期间屏幕被冻结。

点击的顺序为A->B->C


3. 并发渲染和useTransition

关于并发的内容,这篇文章中不打算过多的涉及,有兴趣的可以参考之前的文章React 并发原理

上文讲到通过常规的React更新方式,不能很好的处理上面页面卡顿的现象。而React官方也注意到这种情况。所以,它们为我们带来了,新的渲染方式和API来处理上面的顽疾。

我们先下一个结论。

并发渲染useTransition用于处理缓慢的状态更新

通过并发渲染,我们可以明确标记某些状态更新和由它们引起的重新渲染为"非关键" 。因此,React会在后台 计算这些更新,而不会阻塞主任务 。如果发生关键事件(即正常状态更新),React将暂停其后台渲染,执行关键更新,然后要么返回到先前的任务,要么完全放弃它并启动一个新任务

"后台"是一种数据的抽象:有几点需要说明

  • 由于JavaScript是单线程的。在繁忙的"后台"任务执行过程中,React将定期检查主队列。如果队列中出现新的任务,它将优先于"后台"工作。(这种消息通知是利用MessageChannel,关于这点可以参考我们之前的文章React 并发原理
  • 在后台渲染的是一种叫做Fiber的数据结构(关于这点可以参考我们之前的文章React_Fiber机制(上)/React_Fiber机制(下)

回到上面的问题,在之前的代码中,我们遇到的情况是,点击button渲染对应的内容时,其中一个组件(B)非常慢并且阻塞用户交互,而这种情况正好撞到了并发渲染的枪口上了,它的出现就是为了解决这种情况的。而我们现在要做的就是将B组件的渲染标记为非关键

我们可以使用useTransition钩子来实现这一点。

  • 它返回一个loading布尔值作为第一个参数
  • 以及一个函数作为第二个参数。
    • 在这个函数内部,我们将调用setTab("B")
    • 从此时开始,该状态更新将在"后台"计算,而不会阻塞页面。

此外,我们可以使用isPending布尔值来添加一个加载状态,以表示等待更新完成的过程中正在发生某些事情。

我们把之前的代码稍微粉饰一下:

diff 复制代码
export default function App() {
  const [tab, setTab] = useState('B');

  // 添加useTransition钩子
+  const [isPending, startTransition] = useTransition();

  return (
    <div className="container">
      <div className="btns">
        ...
        <Button
          // 表示内容正在加载
+         isLoading={isPending}
          onClick={() => {
            // 在传递给startTransition的函数中调用setTab
+            startTransition(() => {
+              setTab('B');
+            });
          }}
          name="B"
        />
        ...
      </div>
      ...
    </div>
  );
}

这样就实现了通过并发渲染将耗时渲染的内容标记为非关键,从而改善用户体验。

同时,我们需要改造一下Button组件,让其能够接收表示过渡状态的isPending

diff 复制代码
type ButtonProps = {
  isActive?: boolean;
+  isLoading?: boolean;
  name: string;
  onClick: () => void;
};

export const Button = ({ name, onClick, isActive, isLoading }: ButtonProps) => {
  return (
    <button
      onClick={onClick}
      className={`tab-button ${isActive ? "active" : ""}`}
    >
      {name}
+      {isLoading ? " 🤔..." : ""}
    </button>
  );
};

当我点击B按钮时,加载指示器会出现,如果我立即点击C,我会立即切换到我们想要展示的页面内容。浏览器没有发生页面卡顿。


4. useTransition会导致重新渲染

通过,对第一段代码施以useTransition的魔法,让其从半身不遂 变的行动自如。此时,你感觉到一切都是向着美好的方向前行着,但是事实哪有那么的顺心遂意。

在现实生活中,这些Button中的任何一个都可能非常耗时。此时,你也无法预知到底哪个Button是耗时的。此时你的双脚离地了,病毒就关闭了,聪明的智商又占领高地了

所以,你就将所有这些Button之间的过渡都标记为非关键,并在其中的startTransition中更新状态。

并且,为了体现自己的代码功底,他还贴心的把过渡过程封装成了一个函数

javascript 复制代码
const onBtnClick = (btn) => {
  startTransition(() => {
    setTab(btn);
  });
};

然后在所有按钮上使用这个函数,而不是直接设置状态:

jsx 复制代码
<Button
  isActive={tab === "A"}
  onClick={() => onBtnClick("A")}
  name="A"
/>
<Button
  isActive={tab === "B"}
  onClick={() => onBtnClick("B")}
  name="B"
/>
<Button
  isActive={tab === "C"}
  onClick={() => onBtnClick("C")}
  name="C"
/>

所有来自这些按钮的状态更新现在都将被标记为非关键

在运行代码后,我们发现又出现了新的问题:

如果我们从B页面切换到AC,不再是瞬间发生 了!而我们对天发誓 没有改变这两个页面上的任何东西,它们目前都只渲染一个字符串,但它们都表现得好像非常耗时。

这里的问题在于,

如果我们将状态更新包装在一个过渡中,React并不只是在"后台"触发状态更新。实际上,这是一个两步过程

  1. 首先,会触发一个立即关键重新渲染,使用从useTransition钩子中提取的isPending布尔值从false变为true。(我们能够在渲染输出中使用它的事实应该是一个重要的线索。)
  2. 只有在这个关键的重新渲染完成后,React才会开始进行非关键状态更新。

简而言之,useTransition导致两次重新渲染,而不是一次 。因此,我们看到了上面示例中的行为。如果我在B页面上,并点击A Button,首先触发的是初始重新渲染,此时B Button还是选中状态。非常耗时的B组件在重新渲染时阻塞了主任务1秒钟。只有在这之后,才会安排并执行从BA的非关键状态更新。

点击顺序 B->A


5. 如何正确的使用useTransition

记忆所有的值

为了解决上述性能下降的问题,我们需要确保额外的第一次重新渲染尽可能轻量。通常,这意味着我们需要对可能导致它减速的一切进行记忆化处理:

  • 所有耗时的组件应该使用React.memo包装,其props应使用useMemouseCallback进行记忆化处理。
  • 所有耗时的操作应使用useMemo进行记忆化处理。
  • isPending不应该作为属性或依赖项传递给上述任何内容。

在我们的情况下,简单地包装我们的页面组件就可以了,并且它们没有任何props,所以我们可以直接渲染它们:

jsx 复制代码
// ....
import { A, B, C } from "@components/Content";

const AMemo = React.memo(A);
const BMemo = React.memo(B);
const CMemo = React.memo(C);
export default function App() {
 // 代码省略

  return (
    <div className="container">
      // ...代码省略
      <div className="content">
        {tab === "A" && <AMemo />}
        {tab === "B" && <BMemo />}
        {tab === "C" && <CMemo />}
      </div>
    </div>
  );
}

天晴了雨停了你又觉得你行了 ,此时上面的顽疾被解决了。耗时的B页面重新渲染不再阻止阻塞页面的渲染了。

我们在之前的就聊过Memo的情况。React Memo不是你优化的第一选择。其中有一个结论是:Memo很容易被破坏 ,所以如果在useTransition处理过程中没很好处理Memo的话,会使我们的应用比使用useTransition之前显然更糟糕。得不偿失。

而且,要正确地进行记忆化处理实际上是相当困难的。想象一下,有如下的场景App因初始过渡而重新渲染,BMemo是否会重新渲染?

jsx 复制代码
const ListMemo = React.memo(List);
const BMemo = React.memo(B);

const App = () => {
  // 如果触发startTransition,BMemo是否会重新渲染?
  const [isPending, startTransition] = useTransition();

  return (
    ...
    <BMemo>
      <ListMemo />
    </BMemo>
  )
}

答案是 - 是的,它会重新渲染。而且还是那种像吃了炫迈一样,根本停下来的那种。具体的解决方法吗,我们优先考虑下放State内容提升 ,在最后万不得已的情况才会考虑React.memo

从无到耗时的过渡

确保这种额外的初始重新渲染尽可能轻量的另一种方法是仅在从"无"到"非常耗时的内容"的过渡中使用 useTransition。这种情况的典型示例可能是数据获取,然后将该数据放入状态中。例如:

jsx 复制代码
const App = () => {
  const [data, setData] = useState();

  useEffect(() => {
    fetch('/some-url').then((result) => {
      // 大量的数据
      setData(result);
    })
  }, [])

  if (!data) return 'loading'

  return ... // 在数据可用时渲染大量数据
}

在这种情况下,如果没有数据,我们只返回一个加载状态,这不太可能很耗时。因此,如果我们将setData包装在startTransition中,由此引起的初始重新渲染不会太糟糕:它将使用空状态和加载指示器重新渲染。

更多,更详细的语法,请参看React官网 -useTransition


6. useDeferredValue

还有另一个钩子,允许我们利用并发渲染的威力:useDeferredValue。它的工作方式类似于useTransition,允许我们将某些更新标记为非关键并将它们移至后台 。通常建议在没有访问状态更新函数时使用它,例如,当值来自props时。

然后,我们对上面的代码做一下改造处理:

jsx 复制代码
import { useState, useDeferredValue } from "react";
// ...
import { A, B, C } from "@components/Content";

type Content = "A" | "B" | "C";

const TabContent = ({ tab }: { tab: Content }) => {
  const tabDeffered = useDeferredValue(tab);

  return (
    <>
      {tabDeffered === "A" && <A />}
      {tabDeffered === "B" && <B />}
      {tabDeffered === "C" && <C />}
    </>
  );
};

export default function App() {
  const [tab, setTab] = useState<Content>("A");

  const onBtnClick = (btn: Content) => {
    setTab(btn);
  };

  return (
    <div className="container">
      //....
      <div className="content">
        <TabContent tab={tab} />
      </div>
    </div>
  );
}

将渲染内容提取到一个组件中,并且组件接收tab作为props

然而,在这里也存在双重渲染的问题。

在页面首次渲染时,A Button是默认被选中的,我们依次点击B/C。然后下面是对应的控制台输出。

问题出现了,解决这方面的药方也有,它和解决useTransition的问题是一样的。

  • 所有受影响的内容都已进行了记忆化处理;
  • 尽量,在从"无"到"非常耗时的内容"的过渡中使用useDeferredValue

更多,更详细的语法,请参看React官网 -useDeferredValue


7. debounce VS useTransition

由于useTransition的延迟特性,有些同学就会想到,我是不是可以将其用在防抖上。当我们在输入框中快速输入内容时,我们不希望在每次输入时向后端发送请求 - 这可能会使我们的服务器崩溃。相反,我们希望引入一点延迟,以便只发送完整的文本。

通常,我们会使用类似lodash中的防抖函数(或等效函数)来实现:

或者我们可以使用在美丽的公主和它的27个React 自定义 Hook中的自定义hookuseDebounce。这不就形成了一种闭环了吗。学了,就要用上它。

jsx 复制代码
function App() {
  const [valueDebounced, setValueDebounced] = useState('');

  const onChangeDebounced = debounce((e) => {
    setValueDebounced(e.target.value);
  }, 300);

  useEffect(() => {
    console.log("防抖处理的值(300ms后显示): ", valueDebounced);
  }, [valueDebounced]);

  return (
    <>
      <input type="text" onChange={onChangeDebounced} />
    </>
  );
}

这里的onChange回调被防抖处理,因此setValueDebounced只在我们停止在输入框中输入后的300毫秒后触发。

如果不使用外部库,而是使用useTransition,按照原理来讲,这是可行的。因为在过渡中设置状态是可中断的,所以我们可以利用这个特性来处理值的延迟获取。

jsx 复制代码
function App() {
  const [value, setValue] = useState('');
  const [isPending, startTransition] = useTransition();

  const onChange = (e) => {
    startTransition(() => {
      setValue(e.target.value);
    });
  };

  useEffect(() => {
    console.log("transition处理的值: ", value);
  }, [value]);

  return (
    <>
      <input type="text" onChange={onChange} />
    </>
  );
}

理想很丰满,但是现实很残酷。在我们运行代码后发现,使用useTransition达不到我们的要求。在输入框中每次输入,控制台都很配合的输出对应的值。

React太快了,它能够在我们输入的这段时间内计算和提交"后台"值。

也就是说,useTransition是达不到debounce的效果。也就是实现不了防抖。

这一点,React官网也说明了这点。


后记

分享是一种态度

全文完,既然看到这里了,如果觉得不错,随手点个赞和"在看"吧。

相关推荐
y先森23 分钟前
CSS3中的伸缩盒模型(弹性盒子、弹性布局)之伸缩容器、伸缩项目、主轴方向、主轴换行方式、复合属性flex-flow
前端·css·css3
前端Hardy23 分钟前
纯HTML&CSS实现3D旋转地球
前端·javascript·css·3d·html
susu108301891126 分钟前
vue3中父div设置display flex,2个子div重叠
前端·javascript·vue.js
IT女孩儿2 小时前
CSS查缺补漏(补充上一条)
前端·css
吃杠碰小鸡2 小时前
commitlint校验git提交信息
前端
虾球xz3 小时前
游戏引擎学习第20天
前端·学习·游戏引擎
我爱李星璇3 小时前
HTML常用表格与标签
前端·html
疯狂的沙粒3 小时前
如何在Vue项目中应用TypeScript?应该注意那些点?
前端·vue.js·typescript
小镇程序员3 小时前
vue2 src_Todolist全局总线事件版本
前端·javascript·vue.js
野槐3 小时前
前端图像处理(一)
前端