【译】你应该知道的关于Concurrent React 的一切「以及为什么它是一个颠覆性的变革」

原文地址

介绍

UI(用户界面)由许多不同的"部分"组成,而每个部分对用户交互的响应速度都不同。

有些部分,比如表单中的输入字段,响应用户交互非常迅速,几乎是即时的,而其他部分,比如非常长的筛选列表或页面之间的导航,响应较慢,可能需要一段时间才能响应。

在没有并发特性的情况下,即React(以及所有其他JS UI库/框架)的同步渲染中,存在这样的情况:UI中较慢的部分通过阻塞其执行而拖慢了较快部分的执行,从而降低了它们的响应速度。

React的并发渲染器通过允许我们在后台渲染慢速部分而不阻塞快速部分,将快速部分与慢速部分解耦,从而使得每个部分都可以以自己的速度响应用户交互。

因此,虽然React中的并发渲染不会使您的应用程序更快,但通过使UI更加响应,它会让应用程序感觉更快。

在本文中,我们将深入探讨React的并发渲染,了解它解决了哪些问题,它的工作原理是什么,以及如何通过使用并发特性来利用它。

问题

设想一下:

您正在编写一个组件,用于渲染一个经过筛选的列表,而这个筛选过程是在客户端进行的(不会导致额外的服务器调用)。同时,假设由于某种原因,渲染筛选后的列表是一个占用CPU资源较多的任务,因此它需要几毫秒的时间来完成渲染。

在这种情况下,我们有两个主要的UI元素:一个文本输入框,其值将用于对列表进行筛选,以及列表本身。

为了说明这种情况,以下是一个示例:

实时演示

javascript 复制代码
import React, { useState } from "react";
import { list } from "./list";
import "./style.css";

export default function App() {
  const [filter, setFilter] = useState("");

  return (
    <div className="container">
      <input value={filter} onChange={(e) => setFilter(e.target.value)} />

      <List filter={filter} />
    </div>
  );
}

const List = ({ filter }) => {
  const filteredList = list.filter((entry) =>
    entry.name.toLowerCase().includes(filter.toLowerCase())
  );

  sleep(100);

  return (
    <ul>
      {filteredList.map((item) => (
        <li key={item.id}>
          {item.name} - ${item.price}
        </li>
      ))}
    </ul>
  );
};

const sleep = (ms) => {
  const start = performance.now();

  while (performance.now() - start < ms);
};

在这个示例中,为了模拟一个占用CPU资源较多的任务,我们在 组件中使用了一个名为 sleep 的函数,它会同步阻塞主线程,从而使得渲染变得非常缓慢。

附注:我们使用 sleep 函数来模拟大量 CPU 负载,而不是使用一个巨大的列表。这样我们可以轻松地模拟不同的工作负载,并为读者提供更一致的体验,因为读者可能使用配置各异的硬件来运行这些演示。

请注意,当我们开始对列表进行筛选时,整个UI会在片刻间冻结,直到它能够处理所有内容,然后"跳转"到最终状态。 这是在没有人为减速的情况下的实际效果。

理想情况下,我们希望找到一种优化这个组件的方法,使其渲染更快,但在优化方面我们也有限制,有时即使进行了这些优化,渲染仍然可能过慢。

然而,在这种情况下,我们希望保持UI中的快速部分响应灵敏,即使其他部分可能很慢。

在我们的示例中,只有 组件是慢的,那么为什么整个UI都要因为一个慢速组件而变得无响应呢?难道我们不能在 组件慢的情况下,保持输入框的响应性吗?

罪魁祸首是...

同步渲染

在没有并发特性的情况下(即不使用 startTransition、useTransition 或 useDeferredValue),React会同步地渲染组件,这意味着一旦它开始渲染,除非发生异常,否则没有其他东西可以中断它,因此它只会在完成渲染后才会执行其他任务。

实际上,这意味着无论渲染需要多长时间,在渲染过程中发生的任何新事件都只会在渲染完成后才会被处理。

这里有一个很好的实验来说明这一点:

实时演示

附注:您可以通过点击底部的选项卡来在实时预览中打开控制台。

javascript 复制代码
export default function App() {
  const [value, setValue] = useState("");
  const [key, setKey] = useState(Math.random());

  return (
    <div className="container">
      <input
        value={value}
        onChange={(e) => {
          console.log(
            `%c Input changed! -> "${e.target.value}"`,
            "color: yellow;"
          );
          setValue(e.target.value);
        }}
      />

      {/* 
        By using a random number as the key to <Slow />,
        each time we click on this button we rerender it,
        even though it is memoized.
      */}
      <button onClick={() => setKey(Math.random())}>Render Slow</button>

      <Slow key={key} />
    </div>
  );
}

/**
 * We're memoizing this component so that changes
 * to the input do not make this component rerender.
 */
const Slow = memo(() => {
  sleep(2000);

  console.log("%c Slow rendered!", "color: teal;");

  return <></>;
});

在这个实验中,我们有一个受控输入框,一个需要2秒钟才能渲染的 组件,以及一个按钮,用于强制重新渲染 。

不接受任何属性,并且被记忆化(memoized),所以输入变化不会导致 重新渲染。但是,它使用了一个带有 key 的键,并且每次点击按钮时,我们会将其设置为不同的随机数,这就是为什么点击按钮会强制重新渲染 。

我们开始与实验进行交互,首先在输入框中输入 "Hello",并且可以看到每次输入变化时,我们会将变化记录到控制台。

然后,我们点击按钮,使得 重新渲染,在 React 正在进行渲染时,我们在输入框中输入 "World"。

尽管在JS忙于渲染React组件时,我们仍然可以与浏览器进行交互,但这些交互并不会中断渲染。这一点很明显,因为即使 仅在调用 sleep 后将 "Slow rendered!" 消息记录到控制台,我们与输入框的交互大多发生在 sleep 结束之前,但所有与该输入框交互产生的日志都出现在 "Slow rendered!" 日志之后。

这就是为什么慢速组件最终会阻塞UI的快速部分,因为一旦我们开始渲染慢速组件,我们只能在完成慢速组件的渲染之后处理对其他UI部分的新更新。

解决方案

为了解决这个问题,React 18引入了并发渲染,我将在下面进行解释。

我们从更新开始。

在并发渲染的上下文中,更新是指任何导致重新渲染的操作,例如,每当我们使用新值调用 setState 时,这都会导致一个更新。

javascript 复制代码
const Component = () => {
  const [count, setCount] = useState(0);

  // This is an update because it will cause a rerender
  const handleIncrement = () => {
    setCount((count) => count + 1);
  };

  // This is NOT an update because as we're passing the same value
  // to `setCount`, it won't cause a rerender
  // (Actually, for some reason the first time we call it,
  // it WILL cause a rerender, but subsequent calls won't)
  const handleSame = () => {
    setCount(count);
  };

  return (
    <>
      <button onClick={handleIncrement}>Increment</button>
      <button onClick={handleSame}>Same</button>
    </>
  );
};

接下来,我们将更新分为两类:

  • 高优先级(紧急)更新
  • 低优先级(非紧急)更新

高优先级更新是由调用 setState、useReducer 的 dispatch 或使用 useSyncExternalStore(以及我们订阅的存储的更新片段)引起的,并触发高优先级渲染,这就是我们所熟悉的常规同步渲染,因此一旦高优先级渲染开始,它将不会停止,直到完成。

此外,值得注意的是,当应用程序首次渲染时(通过调用 ReactDOM.createRoot),这第一次渲染始终是高优先级渲染。

javascript 复制代码
const Component = () => {
  const [filter, setFilter] = useState("");

  const handleInputChanged = (e) => {
    // Causes a high priority update
    setFilter(e.target.value);
  };

  return (
    <>
      <input value={filter} onChange={handleInputChanged} />
    </>
  );
};

低优先级更新是由调用 startTransition 或 useDeferredValue 引起的,并触发低优先级渲染,它仅在高优先级渲染完成后开始运行,并且会被任何高优先级更新中断。

javascript 复制代码
const Component = () => {
  const [filter, setFilter] = useState("");
  const [delayedFilter, setDelayedFilter] = useState("");
  const [isPending, startTransition] = useTransition();

  const handleInputChanged = (e) => {
    // Causes a high priority update
    setFilter(e.target.value);

    startTransition(() => {
      // Causes a low priority update
      setDelayedFilter(e.target.value);
    });
  };

  return (
    <>
      <input value={filter} onChange={handleInputChanged} />
    </>
  );
};

当低优先级的重新渲染被中断时,它会等待中断它的高优先级重新渲染完成,然后从头开始再次进行渲染。

更新的一个重要特性是,它们会与同一优先级的其他更新一起批处理,因此在同一调用堆栈中发生的所有高优先级更新将被批处理在一起,并导致单个高优先级重新渲染。低优先级的更新也会有相同的情况发生。

javascript 复制代码
const Component = () => {
  const [filter, setFilter] = useState("");
  const [otherFilter, setOtherFilter] = useState("");
  const [delayedFilter, setDelayedFilter] = useState("");
  const [delayedOtherFilter, setDelayedOtherFilter] = useState("");
  const [isPending, startTransition] = useTransition();

  const handleInputChanged = (e) => {
    // These two will be batched and cause
    // a SINGLE high priority update
    setFilter(e.target.value);
    setOtherFilter(e.target.value.toUpperCase());

    startTransition(() => {
      // These two will be batched and cause
      // a SINGLE low priority update
      setDelayedFilter(e.target.value);
      setDelayedOtherFilter(e.target.value.toUpperCase());
    });
  };

  return (
    <>
      <input value={filter} onChange={handleInputChanged} />
    </>
  );
};

下面的图表说明了这一过程:

然后,为了在UI中保持快速部分的响应性,同时又有其他慢速部分,我们将对快速部分的更新设置为高优先级,对慢速部分的更新设置为低优先级。

这样一来,每当一个快速部分需要在后台渲染一个慢速部分时,由于对快速部分的更新是高优先级的,它将中断正在渲染的慢速部分,以保持快速部分的响应性。然后,当快速部分完成渲染后,它会回到渲染慢速部分。

让我们看看这在实践中是如何工作的。

并发列表过滤

在这里,我们看到了之前相同的列表过滤示例,但现在我们使用并发特性来解耦快速部分(输入框)和慢速部分(列表过滤),以保持快速部分的响应性。

实时演示

javascript 复制代码
export default function App() {
  // This state will be updated by
  // HIGH priority updates
  const [filter, setFilter] = useState("");
  // This state will be updated by
  // LOW priority updates
  const [delayedFilter, setDelayedFilter] = useState("");
  const [isPending, startTransition] = useTransition();

  // Ignore this for now, it's just
  // a hook to help us debug concurrent
  // features, later I'll explain how it works
  useDebug({ filter, delayedFilter });

  return (
    <div className="container">
      <input
        value={filter}
        onChange={(e) => {
          setFilter(e.target.value);
          startTransition(() => {
            // Here we're triggering the low
            // priority update that will
            // change `delayedFilter`'s value
            setDelayedFilter(e.target.value);
          });
        }}
      />

      <List filter={delayedFilter} />
    </div>
  );
}

// Notice we're memoing List now
const List = memo(({ filter }) => {
  const filteredList = list.filter((entry) =>
    entry.name.toLowerCase().includes(filter.toLowerCase())
  );

  sleep(100);

  return (
    <ul>
      {filteredList.map((item) => (
        <li key={item.id}>
          {item.name} - ${item.price}
        </li>
      ))}
    </ul>
  );
});

这里有很多内容需要解释,让我们从代码的更改开始分析。

首先,我们创建了一个名为 delayedFilter 的状态,并将其传递给 组件,它将通过低优先级更新进行更新。

其次,当用户与输入框进行交互时,我们同时触发高优先级更新(修改 value)和低优先级更新(通过在 startTransition 中调用 setDelayedFilter 修改 delayedFilter)。

最后,我们对 组件进行了记忆化,因此现在,它不会在其父组件(本例中是 )重新渲染时重新渲染,而是仅在其接收到的属性与上一次重新渲染时接收到的属性不同时才会重新渲染。

现在让我们分析其行为。

当 首次渲染时,这个初始渲染是一个高优先级渲染,因为它由 ReactDOM.createRoot 触发,因此 filter 和 delayedFilter 都具有相同的空字符串值。

然后,我们开始与输入框进行交互,输入 "tasty"。

对于每次击键,都会触发一个高优先级更新和一个低优先级更新。

高优先级更新总是在低优先级更新之前处理的,因此,在第一次击键后,高优先级重新渲染开始,在此重新渲染中,只有高优先级更新中的修改生效,因此 filter 的值为 "t",但是 delayedFilter 保持不变,值为 ""。

在这种情况下,请注意,直到重新渲染 的低优先级重新渲染完成之前, 将保持过时。

delayedFilter 不在高优先级更新中被修改是保持输入框(UI中的快速部分)响应性的关键因素,因为 被记忆化了,它接收与之前渲染时相同的 delayedFilter,因此它不会重新渲染。

一旦高优先级重新渲染完成,然后它就会提交到虚拟DOM,并继续生命周期(插入效果 -> 修改DOM -> 布局 -> 布局效果 -> 绘制 -> 效果)。

之后,它开始低优先级重新渲染,在这个低优先级重新渲染中,filter 的值为 "t"(因为高优先级更新改变了它),而 delayedFilter 也具有值 "t",这个值来自低优先级更新。

在这个低优先级重新渲染期间,我们键入下一个字母 "a",这会导致另外两个更新,一个高优先级更新和一个低优先级更新。

因为高优先级更新被触发,低优先级重新渲染被中断,然后我们开始高优先级重新渲染。

在这个高优先级重新渲染中,filter 的新值为 "ta",但是 delayedFilter 仍然保持不变,值为 "",为什么?

由低优先级更新导致的状态修改只有在低优先级渲染完成时才会提交,而在此期间,高优先级更新会"看到"这些未修改的值,因为它们的修改从未被提交。

我们可以将低优先级重新渲染视为一种"草稿"重新渲染,在这种情况下,只要被高优先级更新中断,我们就会立即丢弃它。

由于在这个高优先级重新渲染中,delayedFilter 仍然具有与之前相同的值,因此 ,它是慢速组件,不会重新渲染。

当高优先级重新渲染完成后,我们回到低优先级重新渲染,并且这个过程会一直继续,直到我们停止与输入框的交互。

然后就不再有高优先级更新来中断低优先级更新,最后最新的低优先级渲染将能够完成。

我们刚才描述的内容可以通过查看上面的GIF并阅读控制台来看到。

此外,您会注意到有时我们会在未首先开始并中断低优先级重新渲染的情况下开始高优先级重新渲染,这是因为我们输入得太快,以至于在开始低优先级重新渲染之前,已经有一个高优先级更新计划了。

这整个过程可能起初有点混乱,但有一个非常适合的类比可以帮助我们理解这些概念。

并行渲染 - 分支工作流程

假设我们正在开发一个应用程序,并使用Git来跟踪其代码库。

有一个主分支,代表着当前正在生产环境中运行的代码。

当我们想要开发一个新功能时,我们会从主分支创建一个功能分支,比如 feature/awesome-feature,并在完成开发后将其合并回主分支。

当生产环境中出现一些紧急问题时,我们会从主分支创建一个热修复分支,比如 hotfix/fix-nasty-bug,并在完成修复后将其合并回主分支。

现在,当我们正在开发某个功能,突然需要发布一项热修复时,热修复的优先级更高,比交付功能更为紧急。因此(假设我们是项目中唯一的开发人员),我们必须中断对功能的开发,转而开始处理热修复,直到完成并将其合并。

只有在我们发布了热修复后,我们才能继续处理功能开发,但由于我们的功能是基于合并热修复之前的主分支的,为了继续开发功能,我们需要先通过合并或变基(rebase)将主分支与我们的功能分支"同步"。

在这个例子中,完成热修复后,我们可以回到功能开发工作,足够长的时间来完成它,但很可能我们的功能开发又因为生产环境中的其他错误而再次被打断,需要进行新的热修复。

如果您一直在关注,您可能已经注意到功能开发类似于低优先级更新,因为在功能分支上进行开发,类似于低优先级渲染,可能随时被需要发布热修复打断,而这又相当于高优先级更新。

此外,在完成热修复后,我们需要将热修复引入的更改合并到功能分支,然后才能继续功能开发(这里类比有点不太准确),这大致相当于当我们恢复低优先级渲染时,我们需要从头开始重新启动渲染,以整合高优先级更新所做的修改。

现在我们对React的并发渲染有了很好的了解,接下来我们将深入探讨并发特性。

Concurent 特性

在发布时,只有两个并发特性,也就是只有两种"激活"并发渲染的方式,它们分别是转换(useTransition或独立的startTransition)和延迟值(useDeferredValue)。

这两个特性都遵循相同的原理:它们允许我们将某些更新标记为低优先级,然后,正如我们之前所看到的,并发渲染器会处理其余的事情。

转换(useTransition)

转换API为我们提供了一种以命令式方式将某些更新标记为低优先级的方法,它暴露了一个startTransition函数,该函数接收一个函数作为参数,函数内部的任何状态更新都被标记为低优先级更新。

我们已经看到转换被用于处理缓慢的过滤列表,但值得再次查看:

实时演示

javascript 复制代码
export default function App() {
  // This state will be updated by
  // HIGH priority updates
  const [filter, setFilter] = useState("");
  // This state will be updated by
  // LOW priority updates
  const [delayedFilter, setDelayedFilter] = useState("");
  const [isPending, startTransition] = useTransition();

  // Ignore this for now, it's just
  // a hook to help us debug concurrent
  // features, later I'll explain how it works
  useDebug({ filter, delayedFilter });

  return (
    <div className="container">
      <input
        value={filter}
        onChange={(e) => {
          setFilter(e.target.value);
          startTransition(() => {
            // Here we're triggering the low
            // priority update that will
            // change `delayedFilter`'s value
            setDelayedFilter(e.target.value);
          });
        }}
      />

      {/*
       * We can use isPending to signal
       * that we have a transition pending
       */}

      {isPending && "Recalculating..."}

      <List filter={delayedFilter} />
    </div>
  );
}

// Notice we're memoing List now
const List = memo(({ filter }) => {
  const filteredList = list.filter((entry) =>
    entry.name.toLowerCase().includes(filter.toLowerCase())
  );

  sleep(100);

  return (
    <ul>
      {filteredList.map((item) => (
        <li key={item.id}>
          {item.name} - ${item.price}
        </li>
      ))}
    </ul>
  );
});

这个例子与我们之前看到的例子唯一的区别是,我们使用了isPending来向用户显示"正在重新计算"的反馈信息。

详细行为

Transitions API有一些值得注意的行为:

startTransition总是会触发高优先级更新和低优先级更新 即使我们在startTransition内部没有触发任何更新,它仍会触发高优先级更新(是的,您读对了,是高优先级更新)和低优先级更新。

说实话,我真的不太理解这种行为的目的,但无论如何,这是一条在调试并发渲染时可能会派上用场的信息。

以下是一个例子:

实时演示

javascript 复制代码
export default function App() {
  const [isPending, startTransition] = useTransition();

  useDebug();

  return (
    <button
      onClick={() => {
        startTransition(() => {});
      }}
    >
      Start Transition
    </button>
  );
}

startTransition的回调立即运行 我们传递给startTransition的函数作为参数会立即且同步地运行。

这对于调试目的非常重要,同时也提示我们不应在回调内部执行昂贵的工作,否则将阻塞渲染。

在startTransition的回调内触发更新是可以的(也是预期的),因为这些更新将在后台运行,但在回调本身内部进行昂贵的工作是不可取的。

以下是一个例子:

实时演示

javascript 复制代码
export default function App() {
  const [isPending, startTransition] = useTransition();

  useDebug();

  return (
    <button
      onClick={() => {
        console.log("Clicked!");
        startTransition(() => {
          console.log("Callback ran!");
        });
      }}
    >
      Start Transition
    </button>
  );
}

在startTransition的回调中,状态更新必须在与回调本身相同的调用栈中,才能被标记为低优先级,否则不会生效。

实际上,这意味着以下情况不会按预期工作:

javascript 复制代码
startTransition(() => {
  setTimeout(() => {
    // By the time setTimeout's callback is called
    // we're already in another call stack
    // This will be marked as a high priority update
    // instead
    setCount((count) => count + 1);
  }, 1000);
});
javascript 复制代码
startTransition(async () => {
  await asyncWork();

  // Here we're inside a different call stack
  setCount((count) => count + 1);
});
javascript 复制代码
startTransition(() => {
  asyncWork().then(() => {
    // Different call stack
    setCount((count) => count + 1);
  });
});

如果我们需要使用这些结构,应该采取以下方法代替:

javascript 复制代码
setTimeout(() => {
  startTransition(() => {
    setCount((count) => count + 1);
  });
}, 1000);
javascript 复制代码
await asyncWork();

startTransition(() => {
  setCount((count) => count + 1);
});
javascript 复制代码
asyncWork().then(() => {
  startTransition(() => {
    setCount((count) => count + 1);
  });
});

所有的转换都会在单个重渲染中批处理 实际上,这是低优先级更新的一个特性,正如我们之前提到的,多个低优先级更新会被批处理在单个重渲染中。

然而,我认为在转换的上下文中再次强调这一点是值得的。

当有一个低优先级更新挂起时,如果其他低优先级更新在第一个低优先级更新得到处理之前被触发,所有低优先级更新将会在同一个重渲染中一次性执行。

此外,目前存在一种全有或全无的策略,即即使这些低优先级更新会重新渲染组件树中完全无关的部分(例如兄弟组件),并且其中一个部分已经完成了重新渲染,中断也会使重新渲染从头开始。

演示链接

javascript 复制代码
export default function App() {
  return (
    <div
      style={{
        display: "flex",
      }}
    >
      <Component name="First" />
      <Component name="Second" />
    </div>
  );
}

export const Component = ({ name }) => {
  const [filter, setFilter] = useState("");
  const [delayedFilter, setDelayedFilter] = useState("");
  const [isPending, startTransition] = useTransition();

  useDebug({ name });

  return (
    <div className="container">
      <input
        value={filter}
        onChange={(e) => {
          setFilter(e.target.value);
          startTransition(() => {
            setDelayedFilter(e.target.value);
          });
        }}
      />
      {isPending && "Recalculating..."}

      <List filter={delayedFilter} />
    </div>
  );
};

const List = memo(({ filter }) => {
  const filteredList = list.filter((entry) =>
    entry.name.toLowerCase().includes(filter.toLowerCase())
  );

  sleep(500);

  return (
    <ul>
      {filteredList.map((item) => (
        <li key={item.id}>
          {item.name} - ${item.price}
        </li>
      ))}
    </ul>
  );
});

转换(Transitions)只适用于状态(state),而不适用于引用(refs) 尽管您可以在startTransition的回调中做几乎任何事情,包括修改引用(ref),但唯一可以将更新标记为低优先级的方式是调用setState。

以下是一个展示这一点的例子:

演示链接

javascript 复制代码
export default function App() {
  const [filter, setFilter] = useState("");
  const delayedFilterRef = useRef(filter);
  const [isPending, startTransition] = useTransition();

  const delayedFilter = delayedFilterRef.current;

  useDebug({ filter, delayedFilter });

  return (
    <div className="container">
      <input
        value={filter}
        onChange={(e) => {
          setFilter(e.target.value);
          startTransition(() => {
            delayedFilterRef.current = e.target.value;
          });
        }}
      />
      {isPending && "Recalculating..."}

      <List filter={delayedFilter} />
    </div>
  );
}

const List = memo(({ filter }) => {
  const filteredList = list.filter((entry) =>
    entry.name.toLowerCase().includes(filter.toLowerCase())
  );

  sleep(100);

  return (
    <ul>
      {filteredList.map((item) => (
        <li key={item.id}>
          {item.name} - ${item.price}
        </li>
      ))}
    </ul>
  );
});

注意在高优先级的重新渲染中,filter和delayedFilter始终保持同步。

尽管您可以在startTransition的回调中修改引用(ref),但对于引用(ref),它的行为与您在没有使用转换时修改它的行为相同。换句话说,引用的更新不会被标记为低优先级更新,因此它的更新行为与正常情况下的更新相同。

延迟值(Deferred Values):useDeferredValue

与以命令式方式工作的转换(Transitions)API相比,useDeferredValue采用声明式方式工作。

useDeferredValue返回一个状态,该状态是低优先级更新的结果,并且设置为我们作为参数传递给它的值。

更新上述状态的低优先级更新是在传递给useDeferredValue的当前值与先前接收到的值不同时触发的。

在进一步了解之前,让我们再次使用useDeferredValue来解决过滤列表的问题:

演示链接

javascript 复制代码
export default function App() {
  // This state will be updated by
  // HIGH priority updates
  const [filter, setFilter] = useState("");
  // This state will be updated by
  // LOW priority updates
  const deferredFilter = useDeferredValue(filter);

  useDebug({ filter, deferredFilter });

  return (
    <div className="container">
      <input
        value={filter}
        onChange={(e) => {
          setFilter(e.target.value);
        }}
      />

      <List filter={deferredFilter} />
    </div>
  );
}

const List = memo(({ filter }) => {
  const filteredList = list.filter((entry) =>
    entry.name.toLowerCase().includes(filter.toLowerCase())
  );

  sleep(100);

  return (
    <ul>
      {filteredList.map((item) => (
        <li key={item.id}>
          {item.name} - ${item.price}
        </li>
      ))}
    </ul>
  );
});

首次渲染应用程序时,它总是会触发一个高优先级渲染,正如我们之前所看到的,而且由于第一次调用useDeferredValue时不会触发低优先级更新,它只会返回初始化的值。

因此,在第一次渲染时,filter和deferredFilter都有相同的值,即""。

在第一次用户交互(在输入框中键入"t")后,由于调用了setFilter,会触发高优先级更新。

在第一次高优先级重新渲染中,filter的值是"t",因此useDeferredValue也是使用"t"调用的,但在其对应的低优先级重新渲染完成之前,它将继续返回之前的值(在这种情况下为"")。

然后,在第一次高优先级重新渲染完成后,因为我们向useDeferredValue传递了一个不同的值,触发了一个低优先级重新渲染。

在低优先级重新渲染中,useDeferredValue的返回值总是最新的,并且被设置为useDeferredValue在低优先级渲染中收到的最后一个值,这实际上就是它在低优先级重新渲染期间接收到的值。

从此时开始,它的工作与转换(Transitions)API的例子类似,您可以通过查看日志来验证,它们几乎是相同的。

详细行为:

在低优先级更新期间向useDeferredValue传递新值不会触发另一个更新 如前所述,useDeferredValue在低优先级重新渲染期间总是会返回它收到的最后一个值,即使在低优先级重新渲染期间它接收到的值与在前一次高优先级重新渲染中接收到的值不同,从而导致了首次触发低优先级更新的值也不同。

因此,即使在低优先级更新期间useDeferredValue收到新值,也不需要进行另一个低优先级更新,因为这个新接收的值会直接"穿过"这个钩子,并立即返回,从而使最新的值在重新渲染中可用。

演示链接

javascript 复制代码
export default function App() {
  const [filter, setFilter] = useState("");
  const [delayedFilter, setDelayedFilter] = useState("");
  const [isPending, startTransition] = useTransition();
  // useDeferredValue will receive different values
  // during the high priority and the low priority
  // rerenders
  const deferredFilter = useDeferredValue(
    isPending ? filter.toUpperCase() : delayedFilter
  );

  useDebug({ filter, delayedFilter, deferredFilter });

  return (
    <div className="container">
      <input
        value={filter}
        onChange={(e) => {
          setFilter(e.target.value);
          startTransition(() => {
            setDelayedFilter(e.target.value);
          });
        }}
      />

      <List filter={deferredFilter} />
    </div>
  );
}

const List = memo(({ filter }) => {
  const filteredList = list.filter((entry) =>
    entry.name.toLowerCase().includes(filter.toLowerCase())
  );

  sleep(100);

  return (
    <ul>
      {filteredList.map((item) => (
        <li key={item.id}>
          {item.name} - ${item.price}
        </li>
      ))}
    </ul>
  );
});

注意即使我们在高优先级和低优先级重新渲染期间向useDeferredValue传递不同的值(因为在高优先级重新渲染期间isPending仅为true),一切都保持基本不变,因为在低优先级重新渲染期间它接收到的值是最重要的,并且不会触发额外的重新渲染。

由不同的useDeferredValue调用引起的更新都会被全部批处理在单个重新渲染中,这类似于所有的转换(Transitions)都会在单个重新渲染中进行批处理的事实。

因此,即使在组件树的不相关部分中有多个useDeferredValue调用,它们的更新都将被批处理,并在单个低优先级重新渲染中解决。

在这里也适用我们在转换(Transitions)中看到的全有或全无的策略。

暂停(Suspense)

到目前为止,我们已经探索了使用并发渲染来处理CPU密集型组件,然而,我们也可以使用它来处理IO密集型组件。

由于IO操作可以在JavaScript中异步执行,即使没有并发渲染,IO密集型组件也不会像CPU密集型组件那样产生问题,因为IO是非阻塞的,因此当我们等待IO时,通常的做法是渲染一个加载动画,直到IO完成。

然而,当处理IO密集型组件时,我们还有其他一些问题,比如在使用暂停(Suspense)来获取数据时。

使用暂停(Suspense),我们可以在渲染过程中开始获取数据,而不是在仅在渲染后运行的effect中进行数据获取。

尽管这非常有趣,因为我们无需等待渲染完成才能开始获取数据,但在数据加载期间,我们在协调加载状态方面失去了一些控制。

例如,如果我们希望在获取新数据时继续显示旧数据,而不是显示加载状态,我们可以通过useEffect来实现:

演示链接

javascript 复制代码
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));

const createEntry = () => ({
  id: Math.random(),
  name: Math.random(),
});

// Simulates data fetching
// Returns a list of 10 entries
const fetchData = async () => {
  await wait(600);
  return Array.from({ length: 10 }).map(() => createEntry());
};

export default function App() {
  const [data, setData] = useState();
  const [page, setPage] = useState(1);

  useEffect(() => {
    // To avoid race conditions
    let ignore = false;

    fetchData(page).then((data) => {
      if (!ignore) {
        setData(data);
      }
    });

    return () => {
      ignore = true;
    };
  }, [page]);

  return (
    <div>
      <div>
        <button onClick={() => setPage((page) => page - 1)}>
          Previous Page
        </button>
        <button onClick={() => setPage((page) => page + 1)}>Next Page</button>
      </div>

      {data
        ? data.map((entry) => <div key={entry.id}>{entry.name}</div>)
        : "Loading ..."}
    </div>
  );
}

当我们重新加载页面并首次渲染组件时,我们没有任何数据,所以我们渲染一个加载状态。

然后,当切换页面时,我们不会回退到加载状态,而是使用stale-while-revalidate策略,即在新数据可用之前,我们会继续显示旧数据。

然而,当使用暂停(Suspense)进行数据获取时,每当我们尝试渲染一个数据尚不可用的组件时,它会暂停并显示一个回退状态。

如果我们想在使用暂停(Suspense)时采用相同的stale-while-revalidate策略,我们需要使用并发特性。

总体思想是使用低优先级更新来修改导致数据重新获取的状态,这样虽然组件仍然会暂停,但会在后台进行暂停,同时我们会在高优先级重新渲染中继续显示旧数据。

以下是两个使用并发特性实现使用暂停(Suspense)进行数据获取的stale-while-revalidate策略的示例,一个使用转换(Transitions),另一个使用延迟值(Deferred Values):

备注:我们省略了suspenseFetchData的实现,因为目前没有稳定的用于数据获取的Suspense API,我不希望人们根据它来进行相关操作。但是,如果您真的想看它是如何实现的,可以在演示链接中查看。

演示链接

javascript 复制代码
export default function App() {
  const [page, setPage] = useState(1);
  const [delayedPage, setDelayedPage] = useState(page);
  const [isPending, startTransition] = useTransition();

  return (
    <div>
      <div>
        <button
          onClick={() => {
            setPage((page) => page - 1);
            startTransition(() => {
              setDelayedPage((page) => page - 1);
            });
          }}
        >
          Previous Page
        </button>
        <button
          onClick={() => {
            setPage((page) => page + 1);
            startTransition(() => {
              setDelayedPage((page) => page + 1);
            });
          }}
        >
          Next Page
        </button>
        Page: {page}
      </div>

      <Suspense fallback="Loading...">
        <Component page={delayedPage} />
      </Suspense>
    </div>
  );
}

const Component = ({ page }) => {
  const data = suspenseFetchData(page);

  return (
    <>
      {data
        ? data.map((entry) => <div key={entry.id}>{entry.name}</div>)
        : "Loading ..."}
    </>
  );
};

演示链接

javascript 复制代码
export default function App() {
  const [page, setPage] = useState(1);
  const deferredPage = useDeferredValue(page);

  return (
    <div>
      <div>
        <button
          onClick={() => {
            setPage((page) => page - 1);
          }}
        >
          Previous Page
        </button>
        <button
          onClick={() => {
            setPage((page) => page + 1);
          }}
        >
          Next Page
        </button>
        Page: {page}
      </div>

      <Suspense fallback="Loading...">
        <Component page={deferredPage} />
      </Suspense>
    </div>
  );
}

const Component = ({ page }) => {
  const data = suspenseFetchData(page);

  return (
    <>
      {data
        ? data.map((entry) => <div key={entry.id}>{entry.name}</div>)
        : "Loading ..."}
    </>
  );
};

附加注意事项

现在我们了解了React中并发渲染的工作原理,以及如何通过并发特性来利用并发渲染,接下来我想与您分享一些其他的注意事项。

暂停点(Preemption)

每当我们在没有并行性的情况下使用并发性(在我们的情况下,由于并发渲染是在单个线程中进行的),都需要进行抢占,即在切换任务之前,我们需要停止当前正在执行的任务。

允许我们进行中断的"位置"就是我所说的暂停点(请注意,这里的"暂停"与Suspense关系不大)。

在多线程环境中,如果我们不使用锁定,每行代码都是一个暂停点,这意味着在任何时候,无论正在执行什么,线程都可以被暂停以允许其他线程执行。

在React中,暂停点位于组件的渲染之间。

这是一个关键的信息,因为这意味着单个组件的渲染不能被中断,因此一旦组件开始渲染,它将一直渲染直到达到return语句。

仅在此之后,在移动到下一个组件的渲染之前,它将检查是否有高优先级的更新。

以下是一个演示:

演示链接

javascript 复制代码
export default function App() {
  const [filter, setFilter] = useState("");
  const [delayedFilter, setDelayedFilter] = useState("");
  const [isPending, startTransition] = useTransition();

  useDebug({ filter, delayedFilter });

  return (
    <div className="container">
      <input
        value={filter}
        onChange={(e) => {
          setFilter(e.target.value);
          startTransition(() => {
            setDelayedFilter(e.target.value);
          });
        }}
      />
      {isPending && "Recalculating..."}

      <List filter={delayedFilter} />
    </div>
  );
}

const List = memo(({ filter }) => {
  const filteredList = list.filter((entry) =>
    entry.name.toLowerCase().includes(filter.toLowerCase())
  );

  return (
    <ul>
      {filteredList.map((item) => (
        <li key={item.id}>
          {item.name} - ${item.price}
        </li>
      ))}

      {Array.from({ length: 10 }).map((_, index) => (
        <Slow key={index} index={index} />
      ))}
    </ul>
  );
});

const Slow = ({ index }) => {
  console.log(`Before sleep: ${index}`);

  sleep(50);

  console.log(`After sleep: ${index}`);

  return null;
};

这个例子是使用转换(transitions)进行并发渲染的列表过滤示例,但进行了一些微小的修改。

现在,我们不再让本身成为慢速组件,而是渲染一个由这个新的组件构成的列表,正如你根据它的名字可能已经猜到的那样,现在我们的瓶颈在这里。

有两个重要的注意事项:

首先,取决于我们如何快速中断低优先级渲染,有更多或更少的实例会在中断发生之前被渲染。

其次,的控制台日志总是成对出现,这意味着一旦我们开始渲染,我们就无法中断它。

如果我们能够中断它,在的渲染过程中,最终会出现在第一个日志条目之后但在第二个日志条目之前发生的中断,因此我们会看到一个不成对的条目。

因此,我们应该避免在一个单独的组件中出现CPU密集型的任务,而是将它分散到多个组件中。

如果发生这种情况,甚至React的并发模式也无法做太多事情,因为一旦我们开始渲染这个慢速组件,我们将无法处理高优先级的更新,直到它完成渲染。

低优先级和高优先级的更新是单层的

尽管React在并发渲染中有很多内部优先级,但更新要么是高优先级,要么是低优先级,没有中间状态。

所有高优先级的更新都有相同的优先级,对低优先级更新也是如此。

这也意味着低优先级更新,无论是由组件树中不相关的部分触发,还是来自转换(transitions)或延迟值(deferred value),它们都有相同的优先级,将在同一个重新渲染中批处理,并遵循我们之前看到的"一刀切"政策。

请参考下面的示例:

演示链接

javascript 复制代码
export default function App() {
  const [filter, setFilter] = useState("");
  const [delayedFilter, setDelayedFilter] = useState("");
  const deferredFilter = useDeferredValue(filter);
  const [isPending, startTransition] = useTransition();

  useDebug({ filter, delayedFilter, deferredFilter });

  return (
    <div className="container">
      <input
        value={filter}
        onChange={(e) => {
          setFilter(e.target.value);
          startTransition(() => {
            setDelayedFilter(e.target.value);
          });
        }}
      />
      {isPending && "Recalculating..."}

      <List filter={delayedFilter} />
    </div>
  );
}

const List = memo(({ filter }) => {
  const filteredList = list.filter((entry) =>
    entry.name.toLowerCase().includes(filter.toLowerCase())
  );

  sleep(100);

  return (
    <ul>
      {filteredList.map((item) => (
        <li key={item.id}>
          {item.name} - ${item.price}
        </li>
      ))}
    </ul>
  );
});

注意,delayedFilter 和 deferredFilter 始终保持同步,并且没有触发额外的低优先级更新。

调试并发渲染

调试使用并发渲染的组件是棘手的,因为它们会渲染两次,一次是由于高优先级更新,另一次是由于低优先级更新。

这可能会让我们在想要检查(例如,在渲染时记录到控制台)值时感到非常困惑,特别是因为一些值在高优先级和低优先级渲染之间会有所不同。

在这种情况下,一个自然而然的问题是,我们如何知道我们是在高优先级渲染还是低优先级渲染中,或者我们如何知道低优先级渲染已被中断。

有一些指示可以帮助我们识别这一点:

第一个指示来自于观察 useDeferredValue 的返回值。

在 useDeferredValue 的第一次渲染(挂载)时,它将返回接收到的相同值,但在此后,如果我们传递给它的值与之前传递的值不同,它只会在低优先级重新渲染时返回新值。

所以我们可以做这样的事情:

javascript 复制代码
// We create a new reference in every render,
// so probe will always be different from
// the previous render
const probe = {};
const deferredProbe = useDeferredValue(probe);

// If we're not on the first render
const isLowPriority = probe === deferredProbe;

对于组件的第一次渲染,没有简单的方法来检测我们是在高优先级还是低优先级渲染中,因为组件的第一次渲染可能是由低优先级更新触发的。

javascript 复制代码
const App = () => {
  const [show, setShow] = useState(false);
  const deferredShow = useDeferredValue(show);

  return (
    <>
      <button onClick={() => setShow(true)}>Show</button>
      {/* 
        It's first render will be triggered 
        by a low priority update to deferredShow 
      */}
      {deferredShow && <Component />}
    </>
  );
};

其次,所有特效(useInsertionEffect、useLayoutEffect、useEffect)都只在渲染阶段之后运行,这意味着当它们运行时,不仅被调用的组件已经完成渲染,而且已经提交,这意味着渲染计划的整个组件子树已经完成重新渲染。

有了这些指标,我们就可以构建一个钩子,帮助我们调试启用并发功能的组件:

javascript 复制代码
const useDebugConcurrent = ({
  onFirstRenderStart,
  onFirstRenderEnd,
  onLowPriorityStart,
  onLowPriorityEnd,
  onHighPriorityStart,
  onHighPriorityEnd,
}) => {
  const probe = {};
  const deferredProbe = useDeferredValue(probe);
  const isFirstRenderRef = useRef(true);
  const isFirstRender = isFirstRenderRef.current;

  const isLowPriority = probe === deferredProbe;

  if (isFirstRender) {
    isFirstRenderRef.current = false;
    onFirstRenderStart?.();
  } else {
    if (isLowPriority) {
      onLowPriorityStart?.();
    } else {
      onHighPriorityStart?.();
    }
  }

  useLayoutEffect(() => {
    if (isFirstRender) {
      onFirstRenderEnd?.();
    } else {
      if (isLowPriority) {
        onLowPriorityEnd?.();
      } else {
        onHighPriorityEnd?.();
      }
    }
  });
};

最终考虑因素

React 的并发呈现通过优雅地处理前端开发中长期存在的问题,将我们创建出色用户体验的能力提升到了一个新的水平。

我很好奇 React 团队未来会将并发呈现带向何方,尤其是借助 Web Workers 将后台呈现委托给其他线程的可能性。

对于前端开发来说,我们生活在一个非常激动人心的时代。

相关推荐
伍哥的传说26 分钟前
鸿蒙系统(HarmonyOS)应用开发之手势锁屏密码锁(PatternLock)
前端·华为·前端框架·harmonyos·鸿蒙
yugi98783828 分钟前
前端跨域问题解决Access to XMLHttpRequest at xxx from has been blocked by CORS policy
前端
浪裡遊39 分钟前
Sass详解:功能特性、常用方法与最佳实践
开发语言·前端·javascript·css·vue.js·rust·sass
旧曲重听11 小时前
最快实现的前端灰度方案
前端·程序人生·状态模式
默默coding的程序猿2 小时前
3.前端和后端参数不一致,后端接不到数据的解决方案
java·前端·spring·ssm·springboot·idea·springcloud
夏梦春蝉2 小时前
ES6从入门到精通:常用知识点
前端·javascript·es6
马特说2 小时前
React金融数据分析应用性能优化实战:借助AI辅助解决18万数据量栈溢出Bug
react.js·金融·数据分析
归于尽2 小时前
useEffect玩转React Hooks生命周期
前端·react.js
G等你下课2 小时前
React useEffect 详解与运用
前端·react.js
我想说一句2 小时前
当饼干遇上代码:一场HTTP与Cookie的奇幻漂流 🍪🌊
前端·javascript