最近发布的Reactv17.0不包含任何新功能。原因是v17.0的主要工作在于源代码中对并发模式的支持。所以v17版本也被称为“敲门砖”版本。本文将详细介绍ConcurrentMode的来龙去脉,以及从底层架构到上层API的这个系统的实现。由于跨度比较大,细节难免有所遗漏。关于文中提到的细节的进一步补充,欢迎关注我的工作号——魔术师卡松,给你完整的源码学习计划。它是什么?什么是并发模式?其基本概念可以从官网[1]的ConcurrentMode介绍中了解。简而言之:并发模式是一组新的React功能,可帮助应用程序保持响应并根据用户的设备性能和网络速度进行适当调整。为了保持应用程序的响应性,我们需要了解是什么限制了应用程序保持响应性?当我们每天使用App浏览网页时,有两种场景会限制应用的响应速度:当遇到大规模操作或设备性能不足时,页面Frame下降,导致卡顿。发送网络请求后,无法快速响应,需要等待数据返回才能进行下一步操作。这两类场景可以概括为:CPU瓶颈IO瓶颈CPU瓶颈当项目变得庞大,组件数量多时,很容易遇到CPU瓶颈。考虑以下Demo,我们将3000lis渲染到视图:functionApp(){constlen=3000;return(
{Array(len).fill(0).map((_,i)=>- {i}
)}
);}constrootEl=document.querySelector("#root");ReactDOM.render(
,rootEl);主流浏览器的刷新频率是60Hz,即每(1000ms/60Hz)16.6ms浏览器刷新一次。我们知道JS可以操作DOM,而GUI渲染线程和JS线程是互斥的。所以JS脚本执行和浏览器布局绘制不能同时执行。每16.6ms需要完成以下任务:JS脚本执行-----样式布局-----样式绘制当JS执行时间过长,超过16.6ms时,就没有时间执行了此刷新的样式布局和绘制的样式。Demo中,由于组件数量较多(3000个),JS脚本执行时间过长,导致页面掉帧,导致卡顿。从打印的执行栈图可以看出,JS的执行时间为73.65ms,比一帧要长很多。如何解决这个问题呢?答案是:在浏览器的每一帧中,都会为JS线程预留一些时间,React利用这段时间来更新组件(可以看到源码[2]中预留的初始时间是5ms)。当预留时间不够时,React将线程控制返回给浏览器,使其有时间渲染UI,React等待下一帧时间继续中断的工作。像蚂蚁搬家一样,将长任务拆分成每一帧,一次执行一个短任务的操作称为时间片。所以,解决CPU瓶颈的关键是实现时间片,而时间片的关键是:把同步更新变成可中断的异步更新。IO的瓶颈网络延迟不是前端开发者能解决的。在网络延迟客观存在的情况下,如何降低用户对网络延迟的感知?React给出的答案是将人机交互研究的成果融入到真实的UI中[3]。这里我们以业界顶尖的人机交互苹果为例。IOS系统中:点击“设置”面板中的“通用”,进入“通用”界面:对比,点击“设置”面板中的“Siri和搜索”,进入“Siri和搜索”界面:可以感受一下两者的体验差异?其实点击“通用”后的交互是同步的,直接显示后续界面,点击“Siri和搜索”后的交互是异步的,需要等待在显示后续界面之前请求返回。但是从用户感知的角度来看,两者之间的差异可以忽略不计。这里的技巧是:点击“SiriandSearch”后,它会在当前页面停留一小段时间,而这段短时间是用来请求数据的,当“这段短时间”足够短时,用户是察觉不到的,如果请求时间超过一个范围,则显示加载的效果。想象一下,如果我们一点击“SiriandSearch”就显示加载效果,即使数据请求时间很短,加载效果也会一闪而过。用户也能感知到。为此,React实现了Suspense[4],useDeferredValue[5]。在源码内部,为了支持这些特性,还需要将同步更新改成可中断的异步更新。ConcurrentMode自下而上决定了上层API的实现。接下来,让我们从下往上了解ConcurrentMode为了实现上述功能,到底包含了哪些组件。底层架构——FiberArchitecture从上面我们了解到,要解决CPU和IO瓶颈,最关键的一点就是实现异步和可中断的更新。基于这个前提,React花了2年的时间重构完成了Fiber架构。Fiber组织的意义在于将单个组件视为一个工作单元,使得组件粒度的“异步可中断更新”成为可能。架构的驱动力——调度器如果我们同步运行Fiber架构(通过ReactDOM.render),Fiber架构与重构之前没有什么不同。但是配合时间分片,我们可以根据宿主环境的性能,为每个工作单元分配一个可运行的时间,实现“异步可中断更新”。于是,scheduler[6](调度器)就被创建了。Scheduler可以确保我们的长任务在每一帧都被拆分成不同的任务。当我们为上面提到的渲染3000lis的Demo开启ConcurrentMode时:应用/>);可以看出每个JS脚本的执行时间在5ms左右。这样浏览器就有剩余时间进行样式布局和样式绘制,减少掉帧的可能性。Fiber架构配合Scheduler实现ConcurrentMode的底层需求——“异步可中断更新”。架构运行策略-车道模型至此,React通过Scheduler可以控制更新运行/中断/继续运行在Fiber架构中。基于目前的架构,当一个更新在运行过程中被中断,并在一段时间后继续运行,这就是“异步可中断更新”。当一个更新在运行过程中被打断,又开始新的更新时,我们可以说:上次更新打断了上次更新。这就是优先级的概念:后面的更新有更高的优先级,它会打断前面正在进行的更新。多个优先级如何相互干扰?可以提高或降低优先级吗?应给予此更新什么优先级?这就需要一个模型来控制不同优先级之间的关系和行为,于是车道模型诞生了。lane模型为一个位分配不同的优先级,通过31位的位操作来操作优先级,如下:*/0b0000000000000000000000000000001;exportconstSyncBatchedLane:Lane=/**/0b0000000000000000000000000000010;exportconstInputDiscreteHydrationLane:Lane=/**/0b0000000000000000000000000000100;constInputDiscreteLanes:Lanes=/**/0b0000000000000000000000000011000;//省略...上层实现现在,Wecansay:Fromthe源代码层面,ConcurrentMode是一套可控的“多优先级更新架构”。那么基于这个架构可以实现哪些有趣的功能呢?举几个例子:batchedUpdates如果我们在一个事件回调中触发多个更新,它们将被合并为一个更新来处理。下面的代码执行只会触发一次更新:onClick(){this.setState({stateA:1});this.setState({stateB:false});this.setState({stateA:2});}这种合并多次更新的优化方式称为batchedUpdates。batchedUpdates在很早的版本中就存在,但是之前的实现非常有限(当前上下文之外的更新不会被合并)。在ConcurrentMode中,更新根据优先级进行合并,使用范围更广。SuspenseSuspense[7]可以在组件请求数据时显示挂起状态。请求成功后渲染数据。本质上,Suspense中的组件子树的优先级低于组件树的其余部分。useDeferredValueuseDeferredValue[8]返回一个延迟响应值,该值可能被“延迟”最长时间timeoutMs。示例:constdeferredValue=useDeferredValue(value,{timeoutMs:2000});useState将在useDeferredValue内部调用,并触发更新。本次更新的优先级很低,所以如果有正在进行的更新,不会受到useDeferredValue产生的更新的影响。所以useDeferredValue可以返回延迟值。当useDeferredValue产生的update超过timeoutMs还没有执行时(因为优先级太低已经中断),会触发另一个高优先级的update。总结除了上面介绍的实现方式,可以预见的是,当v17完美支持ConcurrentMode时,v18将迎来一大波基于ConcurrentMode的库。