React 并发原理(二)

本文涉及的产品
日志服务 SLS,月写入数据量 50GB 1个月
简介: React 并发原理(二)

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 函数的执行,除非我们采取一些非常规手段,否则函数无法被停止,也就意味着即使现在有更高优先级的任务需要被执行,它也只能干瞪眼

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

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


4. startTransition如何工作的

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

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

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

image.png

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

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

让出主线程

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

image.png

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

image.png

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

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

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

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

image.png

好消息是有其他方法可以达到相同的结果,其中之一就是 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 应用程序中打开开发者工具,并放置以下日志点:

image.png

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

  • 在最左边的面板中,我们添加了一个日志,以帮助我们理解何时渲染 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选项卡。完成这些操作后,控制台中可能会显示类似以下的内容:

image.png


正如我们所看到的,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 大致如下:


image.png

渲染的结果是,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()

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

image.png

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

image.png

让我们回顾一下正在发生的事情: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 节点。

在初始示例中,有 1000workInProgressSlowPost 组件 - 它们可以轻松分成一批批次,例如,每个批次有 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

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

相关实践学习
日志服务之使用Nginx模式采集日志
本文介绍如何通过日志服务控制台创建Nginx模式的Logtail配置快速采集Nginx日志并进行多维度分析。
相关文章
|
7月前
|
算法 前端开发 JavaScript
React的diff算法原理
React的diff算法原理
133 0
|
7月前
|
JSON 缓存 前端开发
【React】React原理面试题集锦
本文集合一些React的原理面试题,方便读者以后面试查漏补缺。作者给出自认为可以让面试官满意的简易答案,如果想要了解更深刻,可以点击链接查看对应的详细博文。在此对链接中的博文作者非常感谢🙏。
147 0
|
2月前
|
监控 前端开发 UED
在 React 18 中利用并发渲染提高应用性能
【10月更文挑战第12天】利用并发渲染需要综合考虑应用的特点和需求,合理运用相关特性和策略,不断进行优化和调整,以达到最佳的性能提升效果。同时,要密切关注 React 的发展和更新,以便及时利用新的技术和方法来进一步优化应用性能。你还可以结合具体的项目实践来深入理解和掌握这些方法,让应用在 React 18 的并发渲染机制下发挥出更好的性能优势。
123 59
|
2月前
|
存储 前端开发 测试技术
React Hooks 的工作原理
【10月更文挑战第1天】
|
4月前
|
前端开发 算法 JavaScript
React原理之Diff算法
【8月更文挑战第24天】
|
4月前
|
前端开发 JavaScript 算法
如何学习react原理
【8月更文挑战第9天】 如何学习react原理
48 6
|
4月前
|
开发者 安全 UED
JSF事件监听器:解锁动态界面的秘密武器,你真的知道如何驾驭它吗?
【8月更文挑战第31天】在构建动态用户界面时,事件监听器是实现组件间通信和响应用户操作的关键机制。JavaServer Faces (JSF) 提供了完整的事件模型,通过自定义事件监听器扩展组件行为。本文详细介绍如何在 JSF 应用中创建和使用事件监听器,提升应用的交互性和响应能力。
42 0
|
4月前
|
前端开发 Java UED
瞬间变身高手!JSF 与 Ajax 强强联手,打造极致用户体验的富客户端应用,让你的应用焕然一新!
【8月更文挑战第31天】JavaServer Faces (JSF) 是 Java EE 标准的一部分,常用于构建企业级 Web 应用。传统 JSF 应用采用全页面刷新方式,可能影响用户体验。通过集成 Ajax 技术,可以显著提升应用的响应速度和交互性。本文详细介绍如何在 JSF 应用中使用 Ajax 构建富客户端应用,并通过具体示例展示 Ajax 在 JSF 中的应用。首先,确保安装 JDK 和支持 Java EE 的应用服务器(如 Apache Tomcat 或 WildFly)。
47 0
|
4月前
|
缓存 JavaScript 前端开发
【React生态进阶】React与Redux完美结合:从原理到实践全面解析构建大规模应用的最佳策略与技巧分享!
【8月更文挑战第31天】React 与 Redux 的结合解决了复杂状态管理的问题,提升了应用性能。本文详细介绍了在 React 应用中引入 Redux 的原因、步骤及最佳实践,包括安装配置、状态管理、性能优化等多方面内容,并提供了代码示例,帮助你构建高性能、易维护的大规模应用。
72 0
|
4月前
|
前端开发 JavaScript 中间件
【前端状态管理之道】React Context与Redux大对决:从原理到实践全面解析状态管理框架的选择与比较,帮你找到最适合的解决方案!
【8月更文挑战第31天】本文通过电子商务网站的具体案例,详细比较了React Context与Redux两种状态管理方案的优缺点。React Context作为轻量级API,适合小规模应用和少量状态共享,实现简单快捷。Redux则适用于大型复杂应用,具备严格的状态管理规则和丰富的社区支持,但配置较为繁琐。文章提供了两种方案的具体实现代码,并从适用场景、维护成本及社区支持三方面进行对比分析,帮助开发者根据项目需求选择最佳方案。
75 0