likes
comments
collection
share

React 并发原理

作者站长头像
站长
· 阅读数 26

赛内卡说,折磨我们的,不是事实,而是恐惧

大家好,我是柒八九

前言

就在前几天,我们讲了两篇关于React 18性能优化React Server Componment的文章介绍。其中大部分篇幅,都是基于RSC的.

而,今天我们来讲点不一样的东西。React 并发原理

又很凑巧,最近在做一个需求,有一些操作也是比较耗时和影响页面响应,您猜怎么着,只从有了新useTransiton高钙片啊..一次吃一片..腰不疼啦,腿不痛啦..上六楼啊也有劲勒..我们瞧准啦...新useTransiton高钙片!!!

React 并发原理

你能所学到的知识点

  1. 前置知识点
  2. 丝滑般用户体验
  3. 在没有使用startTransition时,浏览器为什么会出现卡顿
  4. startTransition如何工作的
  5. 可视化并发渲染过程
  6. 耗时任务应该分割成组件,以便过渡正常工作

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

React 并发原理


1. 前置知识点

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

什么是 useTransition?

useTransition 是一个 React Hook,允许你在不阻塞用户界面的情况下更新状态

使用 useTransition

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

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

import { useTransition } from 'react';

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

参数

useTransition 不接受任何参数。

返回值

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

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

Run-to-completion VS Preemptive Multitasking

Run-to-completion

运行至完成(Run-to-completion) 是计算机科学中的一个概念,通常用于描述在单线程执行任务时的行为。具体来说,它表示一个任务或操作会一直执行,直到完成,而不会被中断或被其他任务打断。

特点

  1. 连续执行: Run-to-completion 意味着一个任务或操作在开始执行后将连续执行,不会在执行过程中被中断。

  2. 单线程环境: 这个概念通常用于描述单线程编程环境,其中只有一个执行线程,负责按照顺序执行任务和操作。

  3. 任务不被打断:Run-to-completion 模型中,一个任务的执行不会被其他任务或事件所打断。一旦开始执行,任务将一直执行,直到完成或返回结果

  4. 保证顺序性: 任务的执行顺序是按照它们被调度的顺序进行的。这意味着在执行任务期间,不会有其他任务插入或中断,从而确保了任务的有序执行。

  5. 避免竞态条件: 由于任务的连续执行性质,Run-to-completion 有助于避免竞态条件(Race Conditions)和并发问题,因为在单线程中没有多个任务可以同时访问共享资源。

像我们的老朋友JavaScript就是一个典型的单线程编程语言,所有代码都运行在一个主线程中。JavaScript 中的事件循环(Event Loop)遵循 Run-to-completion 模型,确保在同一时刻只有一个任务在执行。

像我们平时用不到的Ruby/Lua也属于Run-to-completion语音

上面的语言虽然采用 Run-to-completion 模型,但它们也支持异步编程模式,例如使用回调函数、Promiseasync/await 等,以在需要时引入非阻塞操作,确保响应性和性能。


Preemptive Multitasking

抢占式多任务处理Preemptive Multitasking)是一种多任务处理模型,其中操作系统具有能力中断当前正在执行的任务,并在需要时将控制权转移到其他任务。这种模型允许操作系统管理多个任务并有效地共享 CPU 时间,以实现更高的系统并发性和响应性。

特点

  1. 任务调度: 抢占式多任务处理依赖于任务调度器(Task Scheduler),它负责管理各个任务的执行。任务调度器按照一定的策略,如优先级、时间片轮转等,来决定哪个任务应该获得 CPU 时间。

  2. 中断机制: 抢占式多任务处理的核心是中断机制。当操作系统决定切换到另一个任务时,它会发送一个中断信号,将当前任务的执行状态保存起来,然后将控制权切换到另一个任务。这种切换是无缝的,用户通常不会察觉到。

  3. 优先级: 抢占式多任务处理支持任务的优先级,高优先级任务可以在低优先级任务之前获得执行时间。

  4. 并行性: 由于任务可以在任何时刻被中断和切换,多个任务可以并行执行,以提高系统的性能和响应速度。

常用的支持抢占式多任务处理的编程语言:

  1. C/C++: C 和 C++ 是支持抢占式多任务处理的流行编程语言。通过使用线程库(如POSIX线程库),开发人员可以创建和管理多个线程,每个线程代表一个任务,操作系统会在不同线程之间进行抢占式调度。

  2. Java: Java 提供了多线程支持,开发人员可以使用 Java 的 Thread 类来创建多个线程,而 Java 虚拟机(JVM)负责抢占式任务调度。

  3. Rust: Rust 是一门系统级编程语言,具有强大的并发和线程支持,可以用于创建高性能的多任务应用程序。

抢占式多任务处理对于需要实现高度并发、响应速度要求高的应用程序非常有用,它允许操作系统有效地管理和调度任务,确保任务能够及时响应外部事件和请求。


Web Workers 简介

Web Workers 是一项用于在浏览器中执行多线程 JavaScript 代码的技术,它们旨在改善 Web 应用程序的性能和响应性。Web Workers 允许我们在主线程之外创建一个或多个工作线程,这些线程可以并行运行,执行计算密集型任务而不会阻塞用户界面的响应。

  1. 类型: 浏览器中的 Web Workers 主要有三种类型:

    • 专用工作线程(Dedicated Web Worker)通常简称为工作者线程、Web WorkerWorker,是一种实用的工具,可以让脚本单独创建一个 JS 线程,以执行委托的任务。只能被创建它的页面使用
    • 共享工作线程(Shared Web Worker):可以被多个不同的上下文使用,包括不同的页面。任何与创建共享工作者线程的脚本同源的脚本,都可以向共享工作者线程发送消息或从中接收消息
    • 服务工作线程(Service Worker):主要用途是拦截、重定向和修改页面发出的请求,充当网络请求的仲裁者的角色
  2. 用途: Web Workers 可以用于各种用途,包括但不限于:

    • 计算密集型任务,如图像处理、数据加密、数学计算等。
    • 处理后台数据同步和定期轮询。
    • 加载和处理大型数据集,以减轻主线程的负担。
    • 处理网络请求以避免阻塞用户界面。
  3. 创建: 创建 Web Workers 非常简单。我们可以使用以下代码创建一个 Dedicated Worker:

    const worker = new Worker('worker.js');
    

    其中 'worker.js' 是 Worker 脚本的文件路径。在 Worker 脚本中,我们可以监听事件来处理消息和执行工作。

  4. 通信Web Workers 与主线程之间通过消息传递进行通信。我们可以使用以下方法在主线程和 Worker 之间发送和接收消息:

    • 在主线程中,使用 worker.postMessage(data) 来向 Worker 发送消息。
    • 在 Worker 中,使用 self.postMessage(data) 来向主线程发送消息。

    我们还可以在主线程和 Worker 中监听消息事件,以便处理接收到的消息。

    主线程中的监听方式:

    worker.addEventListener('message', (event) => {
      // 处理来自 Worker 的消息
      const data = event.data;
    });
    

    Worker 中的监听方式:

    self.addEventListener('message', (event) => {
      // 处理来自主线程的消息
      const data = event.data;
    });
    
  5. 限制和注意事项

    • Web Workers 不能访问 DOM,因为它们在独立的上下文中运行。
    • 由于数据传递是通过消息进行的,因此需要序列化和反序列化数据,这可能会导致性能开销。
    • Shared Workers 可能会引入竞态条件和同步问题,因此需要小心处理共享状态。

更过更详细的内容,翻看我们之前写的关于


MessageChannel的简览

MessageChannelHTML5 中的一个 API,它允许你在不同的 JavaScript 线程之间传递消息。这对于在主线程和 Web Workers 之间进行通信非常有用。

下面是一个使用 MessageChannel 用于主线程和worker之间数据通信的的示例代码:

// 创建一个新的 MessageChannel
const channel = new MessageChannel();

// 获取消息的两个端口
const mainPort = channel.port1;
const workPort = channel.port2;

// 在主线程中监听来自workPort的消息
mainPort.onmessage = (event) => {
  console.log(`主线程中接收到的消息: ${event.data}`);
};

// 在 Web Worker 中监听来自port1的消息
// 我们利用Blob 进行Web Worker的实例化处理
const workerCode = `
  self.onmessage = (event) => {
    const port = event.ports[0];
    console.log('在Web Worker中接收到信息:', event.data.message);
    port.postMessage('来自Web Worker的问候')
  };
`;

// 创建一个新的 Web Worker,并将端口workPort传递给它
const blob = new Blob([workerCode], { type: 'application/javascript' });
const worker = new Worker(URL.createObjectURL(blob), { type: 'module' });
worker.postMessage({message:'来自主线程的问候!'},[workPort]);

这段代码做了以下事情:

  1. 创建了一个新的 MessageChannel,它包含两个端口:mainPortworkPort
  2. 在主线程中,我们通过 mainPort.onmessage 事件监听来自 workPort 的消息,一旦有消息到达,就会触发回调函数,打印消息内容。
  3. Web Worker 中,我们利用Blob 进行Web Worker的实例化处理,它监听来自 self.onmessage 的消息,并在收到消息时打印出来。
  4. 我们创建了一个新的 Web Worker,并将上述代码传递给它。然后,我们使用 worker.postMessageWeb Worker 发送消息。这里需要注意第二个参数。

最终,你会在浏览器的控制台中看到类似以下内容的输出:

在Web Worker中接收到信息: 来自主线程的问候!
主线程中接收到的消息: 来自Web Worker的问候

这证明了通过 MessageChannel 实现了主线程和 Web Worker 之间的双向通信。


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

React 并发原理


2. 丝滑般用户体验

以下是该文章将基于的CodeSandbox应用程序链接。这部分代码是从React官网的useTransition文档的变种。

React 并发原理

这里存在三个标签页,About/Posts (slow)/Contact 这不就是典型的公司官网介绍页面。我们通过点击对应的Button进行内容的切换。(setTab(nextTab))。

App.js

import { useState, useTransition } from "react";
import TabButton from "./TabButton.js";
import AboutTab from "./AboutTab.js";
import PostsTab from "./PostsTab.js";
import ContactTab from "./ContactTab.js";

export default function TabContainer() {
  const [isPending, startTransition] = useTransition();
  const [tab, setTab] = useState("about");

  function selectTab(nextTab) {
    startTransition(() => {
      setTab(nextTab);
    });
  }

  return (
    <>
      <TabButton 
        isActive={tab === "about"} 
        onClick={() => selectTab("about")}
      >
        About
      </TabButton>
      <TabButton 
        isActive={tab === "posts"} 
        onClick={() => selectTab("posts")}
      >
        Posts (slow)
      </TabButton>
      <TabButton
        isActive={tab === "contact"}
        onClick={() => selectTab("contact")}
      >
        Contact
      </TabButton>
      <hr />
      {tab === "about" && <AboutTab />}
      {tab === "posts" && <PostsTab />}
      {tab === "contact" && <ContactTab />}
    </>
  );
}

PostTab.js: 在渲染时间方面较慢

const PostsTab = memo(function PostsTab() {
  // 只记录一次。真正的耗时任务发生在SlowPost内部。
  console.log("[ARTIFICIALLY SLOW] Rendering 500 <SlowPost />");

  let items = [];
  for (let i = 0; i < 1000; i++) {
    items.push(<SlowPost key={i} index={i} />);
  }
  return <ul className="items">{items}</ul>;
});

SlowPost.js:真正耗时的组件

function SlowPost({ index }) {
  console.log("rendering post " + index);
  let startTime = performance.now();
  while (performance.now() - startTime < 1) {
    // 每项等待1毫秒不执行任何操作,以模拟耗时操作。
  }

  return <li className="item">Post #{index + 1}</li>;
}

PostsTab 组件充当多个 SlowPost 组件的容器,每个 SlowPost 组件需要 1 毫秒进行渲染。因此,如果有 1000 篇帖子需要渲染,并且每篇帖子对应一个 SlowPost 组件,那么 PostsTab 组件的总渲染时间将为 1 秒。在这 1 秒的时间内,浏览器在用户交互方面可能会变得迟钝。然而,由于在 startTransition 回调中进行处理,通常会导致明显页面卡顿的现象,此时却销声匿迹

function selectTab(nextTab) {
  startTransition(() => {
    setTab(nextTab);
  });
}

为了能更直观的体验这种如德芙般丝滑的感觉,我们可以按照下面的步骤操作一下:

  1. About 页面上,选择Posts (slow)选项卡。
  2. 立即(即在页面未显示帖子页面时)点击Contact页面。

如果Posts页面显示得过快,我们可以将帖子数量从 1000(即 1 秒渲染时间)增加到更大的数量。

正如我们可以注意到的,选择Posts页面后立即选择Contact页面时,没有出现延迟。使用 startTransition 就是使这种流畅用户体验成为可能的关键。

为了感受 startTransition 的神奇之处,我们可以尝试注释掉 startTransition 部分,并按照上述步骤进行操作:

function selectTab(nextTab) {
//    startTransition(() => {
			//当nextTab ==='post'时,页面明显出现卡顿现象 
      setTab(nextTab);
//    });
  }

现在,如果需要渲染 2000 篇帖子,我们应该会注意到在点击Posts (slow)选项卡后会出现 2 秒的冻结时间。

这就是startTransition的魅力所在。接下来,我们将其抽丝剥茧。看看它到底用了何种魔法。


3. 在没有使用startTransition时,浏览器为什么会出现卡顿

这是一个来自底层Reacter的渴求真理的发问。

想找到这个答案的关键在于理解在 React 的上下文中渲染的真正含义。一个组件被渲染是什么意思? - 用非常简单的话来说

渲染意味着调用代表 React 组件的函数

关于React渲染机制的介绍,可以参考我们之前写的文章,这里也不再赘述。

让我们在回顾一下,刚才渲染卡顿部分的代码。

// ==========================================================
function SlowPost({ index }) {
  console.log("rendering post " + index);
  let startTime = performance.now();
  while (performance.now() - startTime < 1) {
     // 每项等待1毫秒不执行任何操作,以模拟耗时操作。
  }

  return <li className="item">Post #{index + 1}</li>;
}
// ==========================================================

// 省略部分代码
function PostsTab() {
  const items = [];
  for (let i = 0; i < 1000; i++) {
		items.push(<SlowPost index={i} />)
	}
}

因此,渲染 PostsTab 组件意味着执行 PostsTab() 函数。这意味着 SlowPost 函数将会被调用 1000 次,而且由于调用 SlowPost 需要 1 毫秒,总的渲染时间将会是 1 秒。

现在我们已经理解了渲染的含义,我们也得到了第一个提示:耗费时间的是渲染,而不是浏览器构建网页。或者换句话说,耗费时间的是渲染阶段,而不是将渲染的元素提交到实际 DOM 中的动作

渲染(即在确定新的页面变更时调用的函数,这些更改最终会显示在实际 DOM 中)与提交到 DOM 之间有明显的区别。

有趣的是,提交阶段不一定总是在渲染阶段之后发生。例如,可以渲染一组虚拟 DOM 节点,但它们对实际 DOM 的提交可以被延迟。--这一点,我们会有一篇文章介绍相关内容

当我们使用React的语法,来进行页面切换时,如下面的代码,在React底层到底发生了啥?

function selectTab(nextTab) {
//    startTransition(() => {
      setTab(nextTab);
//    });
  }

我们来用另外一段伪代码来解释上面的发生的处理逻辑。

当点击Posts (slow)后,React同步地渲染整个树。这类似于执行以下操作:

// 处理页面切换后的页面渲染逻辑
const selectSlowPostsTab = () => {
  // 这是一个耗时1分钟的函数调用
  renderPostsTab();
  
 // 该函数将在1秒后执行(也就是在上面的函数执行完成后,才会被触发执行)
  commitChangesToTheRealDOM();
}

// ============================================================

const renderPostsTab = (...args) => {
  for (let postIdx = 0; postIdx < 1000; postIdx++) {
    renderSlowPost();
  }
}

const renderSlowPost = (...args) => {
  const startTime = performance.now();
  while (performance.now() - startTime < 1) {
    // 每项等待1毫秒不执行任何操作,以模拟耗时操作。
  }
  return;
}

当然,在现实中,情况要比这复杂得多。但上述伪代码应该能够突显问题所在 - 渲染(即调用一些 JavaScript 函数)需要很多时间,因此用户会注意到延迟

到目前为止,我们已经理解了问题所在,而且不知何故,startTransition 函数通过包装设置状态的函数来神奇地解决了这个问题:

function selectTab(nextTab) {
    startTransition(() => {
      setTab(nextTab);
    });
  }

还有一些值得考虑的因素:JavaScript执行模型是Run-to-completion,这意味着一个函数在执行过程中不能被中断并在以后继续执行。这种语言特性对我们来说意味着 renderPostsTab 函数的执行,除非我们采取一些非常规手段,否则函数无法被停止,也就意味着即使现在有更高优先级的任务需要被执行,它也只能干瞪眼React 并发原理

我们之前在浏览器性能指标系列中,有过介绍,如果一个任务/函数一次处理太长时间,我们可以将其分成较小的块,并通过将它们与其他需要在主线程上花费时间的任务交错进行,定期处理它们。

既然,这是一个可行的方案,并且也是一种处理长任务的一种有力的工具,那我们可以大胆的做一个假设,是不是startTransition也是利用这种机制,将长任务变成短任务,然后利用其中的优化机制,适时的将主线程空出来,来处理优先级更高的任务。


4. startTransition如何工作的

通过上文分析,将一项庞大的任务分成较小的任务是解决浏览器因渲染需要太多时间而变得不响应用户交互的良好方法

重申一下我们关于startTransition 函数假设 - 将耗时的渲染任务分成块,并定期让出给浏览器的主线程,以使页面保持响应。换句话说,startTransition 将启动并发模式。然而要注意的是,startTransition 并不是负责将任务分解为较小的任务

首先,让我们测试一下上面所说的是否确实正确。为此,让我们再次打开 CodeSandbox 应用程序:

React 并发原理

大家额外多关注一下 console.log() 调用。最重要的是 SlowPost 组件中的那个调用。

在此之前,我们有几个概念,需要知晓一下:

让出主线程

JavaScript在单线程环境中运行。虽然可以利用其他附加线程(例如通过WebWorkerServiceWorker),但只有一个主线程,也称为UI线程。这个线程不仅负责执行开发人员编写的JavaScript代码(例如事件监听器)等任务,还负责渲染任务、解析CSS等任务。每当执行一个函数时,整个主线程都会在执行该函数时被阻塞,因为主线程一次只能运行一个任务。这是网页可能变得无响应的原因 - 主线程正在忙于执行某些逻辑。

React 并发原理

之前我们在介绍浏览器性能指标时提到过RAIL - 在其中,我们可以看到哪些延迟在不同情况下是可以接受的,任务应该花费多少毫秒等等。

React 并发原理

把控制权让给主线程意味着中断渲染过程,并让浏览器有机会执行其他任务,例如渲染、接收用户输入等。

React 如何将控制权让给主线程

有一些浏览器 API 允许 React 实现这一点。例如,window.setImmediate() 此方法用于打断长时间运行的操作,并在浏览器完成其他操作(例如事件和显示更新)后立即运行回调函数

但是,由于它性格有点问题,都不受各个内核的待见,被赐予了一丈红的待遇。 React 并发原理

React 并发原理

好消息是有其他方法可以达到相同的结果,其中之一就是 MessageChannel API。

这正是 React 如何使用 MessageChannel API 来安排在浏览器执行了一些基本任务后运行函数的方式:

// 创建一个新的 MessageChannel
const channel = new MessageChannel();

// 从 MessageChannel 中获取 port2,用于后续的通信
const port = channel.port2;

// 在 port1 上设置消息监听器,以便在消息到达时执行 performWorkUntilDeadline 函数
channel.port1.onmessage = performWorkUntilDeadline;

// 定义一个名为 schedulePerformWorkUntilDeadline 的函数
schedulePerformWorkUntilDeadline = () => {
  // 向 port 发送一个空消息,触发 port1 上的消息监听器
  port.postMessage(null);
};

调度是在调用 schedulePerformWorkUntilDeadline() 时进行的。

因此,通过调用 schedulePerformWorkUntilDeadline() 并在浏览器获得足够的时间接收用户交互和执行其他与浏览器相关的任务之后,将会调用 performWorkUntilDeadline(),这是 React 相关的预定任务将被执行的地方。


验证 startTransition 确实起作用

在前一节中,我们已经看到会调用 schedulePerformWorkUntilDeadline() 来安排在浏览器的基本任务后进行一些工作 - 次举有助于消除浏览器卡顿现象。

进而我们可以进一步联想到 startTransition 会导致 schedulePerformWorkUntilDeadline()周期性地调用。因此,不是所有的 SlowPost 组件都应该立即被渲染。

我们如何断定这一点?

让我们在 CodeSandbox 应用程序中打开开发者工具,并放置以下日志点:

React 并发原理

有几个值得注意的关键点:

  • 在最左边的面板中,我们添加了一个日志,以帮助我们理解何时渲染 SlowPost 组件。(在代码中的17行)

  • 在最右边的面板中,我们在 scheduler.development.js 文件的第 538 行添加了一个日志点

    • 这将让我们知道 React 何时中断渲染过程,并在浏览器执行其它任务后重新安排渲染过程。
  • 在最右边的面板中,在第 517 行,注意 performWorkUntilDeadline() 如何调用 schedulePerformWorkUntilDeadline(),后者将通过 MessageChannel API 安排 performWorkUntilDeadline() 的调度;以下是它的实现方式:

    const channel = new MessageChannel();
    const port = channel.port2;
    channel.port1.onmessage = performWorkUntilDeadline;
    schedulePerformWorkUntilDeadline = () => {
      port.postMessage(null);
    };
    
  • 正如我们注意到的,这里正在进行递归这是保证 React 定期将控制权让给主线程的机制

  • 最后,在最右边的面板中,调用 scheduledHostCallback 将导致(某些)预定任务被执行。

现在,是时候查看日志并观察其运行了。在 Console 面板可见的情况下,尝试点击Posts (slow)选项卡,然后迅速点击Contact选项卡。完成这些操作后,控制台中可能会显示类似以下的内容:

React 并发原理

正如我们所看到的,SlowPosts 组件不会一次性全部渲染,而是分批次进行,以便浏览器有足够的时间响应用户。


5. 可视化并发渲染过程

关于React最新架构-Fiber我们之前有文章介绍过,这里也不再赘述。

为了理解并发渲染的美妙之处,最首要的任务是要了解 React 如何渲染组件树。

React同步渲染过程大致如下:

while (workInProgress !== null) {
  performUnitOfWork(workInProgress);
}

其中,workInProgress 表示当前正在处理的虚拟 DOM 节点。调用 performUnitOfWork() 可以触发渲染组件。(例如,在 workInProgresscurrent属性分配给一个函数组件时,进行组件渲染)

我们继续以 PostsTab 组件来分析:

const PostsTab = memo(function PostsTab() {
  let items = [];
  for (let i = 0; i < 1000; i++) {
    items.push(<SlowPost key={i} index={i} />);
  }
  
  return <ul className="items">{items}</ul>;
});

function SlowPost({ index }) {
  let startTime = performance.now();
  while (performance.now() - startTime < 1) { }

  return <li className="item">Post #{index + 1}</li>;
}

PostsTab 渲染后,对应的虚拟 DOM 大致如下:

React 并发原理

渲染的结果是,PostsTab() 返回了一个包含其他 React 元素的数组(稍后将转换为虚拟 DOM 节点)。

之后,每个返回的 SlowPost 子组件都会一个接一个成为 workInProgress

所以,首先,workInProgress = PostsTabNode,然后调用 performUnitOfWork(workInProgress),然后 workInProgress = SlowPost0Node,然后调用 performUnitOfWork(workInProgress),然后 workInProgress = SlowPost1Node,以此类推。

并发渲染时,while 循环如下所示:

while (workInProgress !== null && !shouldYield()) {
  performUnitOfWork(workInProgress);
}

这里最核心的部分是!shouldYield() - 这是允许 React 中断渲染过程然后将控制权让给主线程的部分。这就是 shouldYield() 实现的相关内容:

const timeElapsed = getCurrentTime() - startTime;
if (timeElapsed < frameInterval) {
  // 主线程只被阻塞了很短的时间;
  // 小于一个帧的时间。暂时不放权。
  return false;
}
// 省略了一些代码
return true;

换句话说,shouldYield() 检查 React 是否已经在渲染上花费了足够的时间,

  • 如果是耗时很多,就允许浏览器执行高优先级任务。
  • 如果还有时间可以进行渲染,那么它会继续执行 performUnitOfWork(),直到 while 循环的下一次检查,再次咨询 shouldYield()

这就是并发渲染的本质。现在,让我们将问题中的示例可视化:

React 并发原理

上面的图表(几乎)对应于我们在控制台中注意到的行为: React 并发原理

让我们回顾一下正在发生的事情:React 通过遍历组件树来渲染它。当前正在被访问(即将被渲染)的节点由 workInProgress 表示。遍历发生在 while 循环中,这意味着在继续执行工作(例如渲染)之前,它会首先检查是否应该将控制权让给主线程(由 shouldYield() 函数进行判断)。

  • 当需要让出控制权时,while 循环将停止,将会安排一个任务在浏览器完成一些工作后运行,同时确保对当前 workInProgress 的引用将保留以便下次渲染时恢复。
  • 当还有时间进行渲染时,performUnitOfWork(workInProgress) 将被调用,之后 workInProgress 将被分配给下一个需要遍历的虚拟 DOM 节点。

此时,我们应该对并发渲染的工作原理有了至少一点了解。但是,仍然有一些东西缺失 - startTransition 如何激活并发渲染?简短的答案是,当调用该函数时,一些标志最终被添加到根节点上,这些标志告诉 React 可以以并发模式渲染该树


6.耗时任务应该分割成组件,以便过渡正常工作

这是一个演示 startTransition 变得无效的例子:

const PostsTab = memo(function PostsTab() {
  let items = [];

  // 页面应该在此时变得无响应 4 秒钟。
  for (let i = 0; i < 4000; i++) {
    // 不再将任务分成较小的部分!
    // items.push(<SlowPost key={i} index={i} />);

    let startTime = performance.now();
    while (performance.now() - startTime < 1) {
      // 为了模拟极慢的代码,每个项等待 1 毫秒。
    }

    items.push(<li className="item">Post #{i + 1}</li>);
  }
  return <ul className="items">{items}</ul>;
});

上面的代码,是我们刻意为之的。但是它能说明虽然了设置 startTransition 但是页面也会存在卡顿现象。

function selectTab(nextTab) {
  startTransition(() => {
    setTab(nextTab);
  });
}

点击Posts (slow)选项卡将导致网页变得无响应,因此点击Contact选项卡只有在 4 秒后才会生效(即 PostsTab 渲染所需的时间)。

为什么会发生这种情况,尽管已经使用了 startTransition?

最初的问题是多个每个都需要 1 毫秒的较小任务会同步渲染(总渲染时间为 1ms * 小任务总数)。通过 startTransition 处理后它能够中断树遍历(因此中断了渲染过程),以便浏览器可以处理高优先级任务。现在,问题是一个单一的任务需要 4 秒。基本上,并发模式变得无效,因为一个单独的单位需要实际上太长的时间。并发模式依赖于有多个需要遍历的 workInProgress 节点。

在初始示例中,有 1000workInProgress SlowPost 组件 - 它们可以轻松分成一批批次,例如,每个批次有 5 个 SlowPost 组件,意味着这样的批次将花费 5 毫秒。完成一批后,轮到浏览器在其他任务上工作,然后再次等待另一批次,如此循环重复,直到没有其他内容需要渲染。

但是,如果一个单个任务已经超过了浏览器一帧的渲染时间,那虽然设置了startTransition,但是也无能为力。如果存在这种情况,那就只能人为的将单个任务继续拆分或者利用Web Worker进行多线程处理了。


后记

分享是一种态度

参考资料:

  1. React 官网-useTransition
  2. Worker-postMessage
  3. MessageChannel
  4. Event_loop
  5. [asynchronous task](www.webperf.tips/tip/event-l… Event Loop%2C by design,to do an asynchronous task%3F)
  6. RAIL

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