【React】Concurrent 的奥秘

2,366 阅读8分钟

随着 React 18 的正式发布,等了很久的 Concurrent 终于在正式版中和大家见面了。借着这个机会,希望和大家来一起剖析一下 Concurrent 这项技术背后的东西。

这篇文章的主要内容包括:

  • Concurrent 是为解决什么问题而出现的

  • 它又是通过怎么样的方式来了解决这个问题

  • 探索 Concurrent 的设计思路

Concurrent 为什么场景而生

Concurrent 早已不是什么新鲜概念了,这几年也被大家拿来各种解读。今天从 Concurrent 诞生的背景出发,也是想要找一个真实的角度来切入 Concurrent

其实呢,笔者个人认为深入了解一项技术之前,去真实地去面对它所面临的场景,这样对理解技术本身会更加有帮助,甚至没准你会有更好思路来解决问题。

计算密集型的“渲染”

JSConf Iceland 2018 上,React 团队关于 Beyond React 16 的分享中就提到了,在构建更好用户体验时所面临的两类场景:

其中一点就是:计算密集型(CPU)的 “渲染”

React 是将我们的组件转化成了 **VDOM** 进行维护,当我们修改组件时,React 需要刷新全部的 VDOM ,而这个过程,我们就把它叫做 “渲染”。

这个 “渲染” 过程就是一个典型的计算密集型场景,它会因为 **VDOM** 的节点过多等因素,变成一个耗时的任务。

就像大家所熟知的那样,当 JS 长时间执行时,页面无法进行任何操作,会完全地卡在那个地方,直到 JS 执行结束。这样带来的用户体验无疑是非常糟糕的。

下面就是一个非常糟糕的例子:耗时缓慢的 “渲染” 让我们的输入感觉非常卡顿。

当我们在输入框中输入内容时,由于大量列表的缓慢 “渲染” ,从而拖慢了整个 “渲染” 的完成时间。

如此一来,我们在输入框中输入时,感觉是响应不够及时(可以看到输入框中的内容是跳跃的,而非连续的),会让人感觉到页面卡顿。

并发渲染:更流畅的用户体验

让我们在 “并发模式” 下,使用 “并发特性”来优化上面的例子。请看下面的演示:

可以明显地发现,用户在输入内容的时候,没有再出现之前例子中输入框内容跳跃的情况,而是可以一直跟随我们输入而实时变化,会感受到应用非常的流畅。

感兴趣的同学,欢迎在 Sandbox 上体验在线的 DEMO

这个案例的灵感也来自于 React 团队关于 Concurrent UI 的分享

我们是如何使用并发特性的呢

首先我们看一下,在 “同步渲染” 的案例中我们是如何编码的:

在同步渲染的案例中,我们实现非常简单,InputSlowList 组件渲染同样的一份数据即可,这也是我们绝大所属情况下的做法。

In React 18 we’re introducing a new API that helps keep your app responsive even during large screen updates. This new API lets you substantially improve user interactions by marking specific updates as “transitions”.

React 18 提供了新的 API **startTransition** 来帮助我们实现并发渲染:

API 使用很简单,但是区别也是非常明显,主要有两点:

  • 一份数据变更为两份数据inputValuetext
  • 之前的调用 hooks 更新数据,和嵌套在 **startTransition** 中更新数据

如果大家去调试代码的话,很容易发现:**setInputValue**嵌套在 **startTransition** 中的 setText同步执行**startTransition 的回调函数**并没有延迟触发。

尽管数据更新是同步,可是组件却重新渲染了两次

  • 第一次:**inputValue: 新值 text: 旧值**
  • 第二次:**inputValue: 新值 text: 新值**

所以问题来了:

  • 组件的第一次渲染,为什么 text 的值没有更新
  • 为什么我们的应用就更流畅了呢 

我们带着这两个问题进入到下一部分。

关于更多并发特性 **startTransition** 的演示可以参考:Real world example: adding startTransition for slow renders

Concurrent 背后的奥秘

这一部分呢,我们通过探寻 Concurrent 背后机制的方式,来解答上一部分留下的问题。

首先,让我们借助 Chrome DevTool Performance 来直观地感受一下两个例子背后的运行差异。

同步渲染

我们录制一段同步渲染下的页面性能表现,如下图:

可以看到,由于大量的 SlowList 组件需要更新,导致 React 的整个处理过程非常的慢,需要耗时 50 毫秒才能完成。在此期间,浏览器无法响应我们交互动作,也无法进行页面的重新渲染。

并发渲染

我们来录制一段并发渲染下的页面性能表现,如下图:

很明显的可以发现,它和同步渲染是截然不同的。

它不存在一个执行了很久的任务,取而代之代之的是很多个“微型”任务。

每个微任务执行完,就让浏览器重新掌握 “控制权” ,这样我们应用可以决定是继续其他微任务的执行,还是执行其他任务(例如响应用户交互),或者重新渲染页面。

揭秘

为什么并发渲染会表现的如此不一致呢?我们可以从关于 startTranstion介绍中找到答案:

Until React 18, all updates were rendered urgently. This means that the two state states above would still be rendered at the same time, and would still block the user from seeing feedback from their interaction until everything rendered. What we’re missing is a way to tell React which updates are urgent, and which are not.

在 React 18 之前,所有更新都是紧急的。 这意味着,在 “渲染” 时,React 需要无差别地处理完 VDOM 上的所有更新。当然,在此期间,页面就会卡住,直到 “渲染” 完成。

如果我们认为有一些组件的更新是不那么紧急的,是可以延后的。

我们就可以在 “渲染” 时,先处理那些紧急的更新,完成本次 “渲染” 后,再进行不紧急的 “渲染” 处理不紧急的更新,这样就相当于实现了 “局部的优先渲染”。

OK,现在我们需要**一种机制来创建非紧急的更新(**或者不同优先级的更新)。

Updates wrapped in startTransition are handled as non-urgent and will be interrupted if more urgent updates like clicks or key presses come in. If a transition gets interrupted by the user (for example, by typing multiple characters in a row), React will throw out the stale rendering work that wasn’t finished and render only the latest update.

新 API **startTransition** 可以为我们创建不紧急的更新。同时 React 会有不同优先级的 “渲染” 来处理这些不同优先的更新。

另外很重要的一点是:不紧急的 “渲染” 也会被拆分成很多 “微任务” ,避免了一次 “渲染” 耗时很久,而且在每个 “微任务” 的间隙,可以插队执行紧急的 “渲染” 或者让浏览器完成 Paint 。这也就是被大家谈到的 “渲染中断” 。

小结

回看我们的例子:在输入框输入内容,列表内容会同时跟随变化。

在这样的场景下,很明显,我们需要保证的是输入框的 “实时渲染”,以此来保证我们体验是流畅的;至于列表的更新,在这个场景下它 “即时性” 显得没那么重要,不应该由于它的拖累导致我们的体验产生卡顿。

所以我使用 **startTransition** 来声明列表的更新是不紧急的,而输入框的更新是紧急的(默认)。

回答

问:并发渲染时,组件的第一次渲染,为什么 text 的值没有更新?

答:因为我们实际上创建了两个更新:输入框的紧急更新 和 列表的非紧急更新(这也是为什么有两次渲染的原因)。第一次渲染,React 需要保证紧急更新优先处理,并呈现给用户,所以此时是仅处理了输入框的紧急更新,而未处理列表的非紧急更新,所以 text 的值没有更新。

问:为什么并发渲染会让应用感觉更流畅了呢?

答:在我们的例子中,如果 “渲染” 仅处理输入框的紧急更新是非常快的,同时,列表的非紧急 “渲染” 又总会被新的紧急 “渲染” 所中断,所以我们的应用可以保持一个流畅的帧率,并在每一帧中输入框都保持最新的状态。

Concurrent 的设计思路

所以总结来看,Concurrent 的设计思路无非就是下面两点的相互结合:

区分优先级的更新:当拥有优先级之后,React 就有了依据可以先 “渲染” 哪些更新,从而实现了一定程度上的 “局部优先渲染”。对这一部分有兴趣的同学,可以翻阅:《“车道模型” - 区分优先级》

可被中断的 “渲染”:对于高优先级的更新,React 会同步完成全部的 “渲染” ;对于低优先级的更新,React 则采取 Concurrent 的方式进行 “渲染” 。在每一个 “微任务” 到期时,都可以去检查是否进行其他工作。对这一部分有兴趣的同学,可以翻阅:《调度系统 - Scheduler》《Fiber - 实现可中断的渲染》