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 应用程序:
大家额外多关注一下 console.log()
调用。最重要的是 SlowPost
组件中的那个调用。
在此之前,我们有几个概念,需要知晓一下:
让出主线程
JavaScript
在单线程环境中运行。虽然可以利用其他附加线程(例如通过WebWorker
、ServiceWorker
),但只有一个主线程,也称为UI线程。这个线程不仅负责执行开发人员编写的JavaScript代码(例如事件监听器)等任务,还负责渲染任务、解析CSS等任务。每当执行一个函数时,整个主线程都会在执行该函数时被阻塞,因为主线程一次只能运行一个任务。这是网页可能变得无响应的原因 - 主线程正在忙于执行某些逻辑。
之前我们在介绍浏览器性能指标时提到过RAIL
- 在其中,我们可以看到哪些延迟在不同情况下是可以接受的,任务应该花费多少毫秒等等。
把控制权让给主线程意味着中断渲染过程,并让浏览器有机会执行其他任务,例如渲染、接收用户输入等。
React 如何将控制权让给主线程
有一些浏览器 API 允许 React
实现这一点。例如,window.setImmediate()
此方法用于打断长时间运行的操作,并在浏览器完成其他操作(例如事件和显示更新)后立即运行回调函数。
但是,由于它性格有点问题,都不受各个内核的待见,被赐予了一丈红的待遇。
好消息是有其他方法可以达到相同的结果,其中之一就是 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 应用程序中打开开发者工具,并放置以下日志点:
有几个值得注意的关键点:
- 在最左边的面板中,我们添加了一个日志,以帮助我们理解何时渲染
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
选项卡。完成这些操作后,控制台中可能会显示类似以下的内容:
正如我们所看到的,
SlowPosts
组件不会一次性全部渲染,而是分批次进行,以便浏览器有足够的时间响应用户。
5. 可视化并发渲染过程
关于React
最新架构-Fiber
我们之前有文章介绍过,这里也不再赘述。
为了理解并发渲染的美妙之处,最首要的任务是要了解 React
如何渲染组件树。
React
的同步渲染过程大致如下:
while (workInProgress !== null) { performUnitOfWork(workInProgress); }
其中,workInProgress
表示当前正在处理的虚拟 DOM 节点。调用 performUnitOfWork()
可以触发渲染组件。(例如,在 workInProgress
的current
属性分配给一个函数组件时,进行组件渲染)
我们继续以 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 大致如下:
渲染的结果是,
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
通过遍历组件树来渲染它。当前正在被访问(即将被渲染)的节点由 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
节点。
在初始示例中,有 1000
个 workInProgress
SlowPost
组件 - 它们可以轻松分成一批批次,例如,每个批次有 5 个 SlowPost 组件,意味着这样的批次将花费 5 毫秒。完成一批后,轮到浏览器在其他任务上工作,然后再次等待另一批次,如此循环重复,直到没有其他内容需要渲染。
但是,如果一个单个任务已经超过了浏览器一帧的渲染时间,那虽然设置了startTransition
,但是也无能为力。如果存在这种情况,那就只能人为的将单个任务继续拆分或者利用Web Worker
进行多线程处理了。
后记
分享是一种态度。
参考资料:
- React 官网-useTransition
- Worker-postMessage
- MessageChannel
- Event_loop
- [asynchronous task](www.webperf.tips/tip/event-l… Event Loop%2C by design,to do an asynchronous task%3F)
- RAIL
全文完,既然看到这里了,如果觉得不错,随手点个赞和“在看”吧。