人生不售来回票,一旦动身,绝不能复返
大家好,我是柒八九。
前言
之前通过React 并发原理讲解了React如何实现原理。但是在应用层面涉及的不多,而今天我们就对如何正确的使用并发渲染做进一步的梳理。而提起并发渲染,useTransition和useDeferredValue是我们绕不过去的两座大山。
useTransition和useDeferredValue为我们提供了对过渡的控制,它被认为对我们的UI交互性能将产生革命性的影响。
既然,人家都说是革命性的改变,那是不是我们可以在任何场景使用?是否有一些桎梏?是否有一些让人匪夷所思的特性和"癖好"。让我们今天就对这些进一步讨论和分析。
还有有一句话,希望大家谨记:
并发渲染钩子会导致重新渲染。因此,永远不要在所有状态更新中使用它们
题外话
话说,你们除夕上班吗? 

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

我们能所学到的知识点
- 前置知识点
- 案例分析
- 并发渲染和useTransition
- useTransition会导致重新渲染
- 如何正确的使用useTransition
- useDeferredValue
- debounce VS useTransition
1. 前置知识点
前置知识点 ,只是做一个概念的介绍,不会做深度解释。因为,这些概念在下面文章中会有出现,为了让行文更加的顺畅,所以将本该在文内的概念解释放到前面来。如果大家对这些概念熟悉,可以直接忽略
同时,由于阅读我文章的群体有很多,所以有些知识点可能我视之若珍宝,尔视只如草芥,弃之如敝履 。以下知识点,请酌情使用。
useTransition的使用
首先,确保你的项目已经升级到 React 18 或更高版本。
并且,在你的组件的顶层调用useTransition,以将某些状态更新标记为过渡。
            
            
              jsx
              
              
            
          
          import { useTransition } from 'react';
function Container() {
  const [isPending, startTransition] = useTransition();
  // ...
}参数
useTransition 不接受任何参数。
返回值
useTransition 返回一个包含两个项的数组:
- isPending标志,用于告诉你是否有待处理的过渡。
- startTransition函数,允许你将状态更新标记为过渡。
2. 案例分析
首先,我们用vite构建一个react-ts项目。
            
            
              lua
              
              
            
          
          yarn create vite useTransiont --template react-ts(墙裂推荐大家手动实践一下)
大体的页面结构如下: 
我们将拥有一个App组件,它渲染三个button(A、B和C),并有条件地渲染这些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。在快速切换的过程中,从B到C过程中页面会有不定时间的卡顿。
本来你想快速的看到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页面切换到A或C,不再是瞬间发生 了!而我们对天发誓 没有改变这两个页面上的任何东西,它们目前都只渲染一个字符串,但它们都表现得好像非常耗时。

这里的问题在于,
如果我们将状态更新包装在一个过渡中,
React并不只是在"后台"触发状态更新。实际上,这是一个两步过程。
- 首先,会触发一个立即 的
关键重新渲染,使用从useTransition钩子中提取的isPending布尔值从false变为true。(我们能够在渲染输出中使用它的事实应该是一个重要的线索。)- 只有在这个关键的重新渲染完成后,
React才会开始进行非关键状态更新。
简而言之,useTransition会导致两次重新渲染,而不是一次 。因此,我们看到了上面示例中的行为。如果我在B页面上,并点击A Button,首先触发的是初始重新渲染,此时B Button还是选中状态。非常耗时的B组件在重新渲染时阻塞了主任务1秒钟。只有在这之后,才会安排并执行从B到A的非关键状态更新。
点击顺序 B->A 
5. 如何正确的使用useTransition
记忆所有的值
为了解决上述性能下降的问题,我们需要确保额外的第一次重新渲染尽可能轻量。通常,这意味着我们需要对可能导致它减速的一切进行记忆化处理:
- 所有耗时的组件应该使用React.memo包装,其props应使用useMemo和useCallback进行记忆化处理。
- 所有耗时的操作应使用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官网也说明了这点。

后记
分享是一种态度。
全文完,既然看到这里了,如果觉得不错,随手点个赞和"在看"吧。
