大家好,我是Kason。我的女朋友是一个喜欢做饭和玩耍的硬汉。铁憨憨:卡卡,最近很多同事都在讨论React18,能不能介绍一下?我要你用最通俗的语言讲解最基础的知识。我妈妈的时间非常宝贵。我:好吧,难得你想学,18的新功能都在这里了,你想先看哪个?然后,我把屏幕转向她。铁憨憨:“这个名字最长,一串英文,一看就很厉害。”我看到的时候,她指着Automaticbatching(自动批处理)什么是批处理。铁憨憨:“批量加工,跟批发市场有关系吗?批发是什么意思?”虽然我对这个比喻无语,但不得不承认:挺像的!在React中,开发者通过调用this.setState(或useState的dispatch方法)来触发状态更新。状态更新可能最终反映为视图更新(取决于是否有DOM更改)。开发者早就接受了一个明显的设定:“state”和“view”是一一对应的。但是,我们站在React团队的角度思考一个问题:从this.setState的调用到最后的视图更新,需要在源码内部做一系列的工作。这一系列的工作应该是同步的还是异步的?在下面的例子中,a的初始状态为0。当onClick被触发时,this.setState被调用了两次://...省略无关信息state={a:0}onClick(){this.setState({a:1});console.log('ais:',this.state.a);this.setState({a:2});}render(){const{a}=this.state;return{a}
;}如果进程是异步的(即console.log打印ais:0),会存在两个潜在的问题:问题1:中间视图状态当状态更新是异步的,例子中页面上的数字会先从0变成1,再变成2。显然更希望的行为是:数字直接从0变成2。问题2:竞争问题状态更新。{a:1}和{a:2}的状态变化应该谁先反映到viewupdate?毕竟在异步的情况下,即使先触发this.setState({a:1}),也可能先完成this.setState({a:2})的过程。开发人员不希望数字在用户单击时从0变为2,有时变为1。铁憨憨:“这么复杂,还是改成同步吧,可以同时解决这两个问题,而且简单!”确实,如果状态更新全部同步,那么:同步过程发生在同一个任务(宏任务)中,视图的中间状态更新之间不会有明确的顺序,也就不会出现“竞争问题”。但是,同步过程也意味着当更新发生时,浏览器会一直被JS线程(执行更新过程)阻塞。如果更新过程复杂(应用非常大),或者同时触发多个更新,浏览器会丢帧,表现为“浏览器卡顿”。那我们该怎么办呢?React团队给出的解决方案是:“批处理”(batchedUpdates)。批处理:React会尝试将在同一上下文中触发的更新合并到一个更新中在我们刚才的示例中:onClick(){this.setState({a:1});console.log('ais:',this.state.A);this.setState({a:2});}this.setState改变两次的状态会按顺序保存,最后只会触发一次状态更新。这样做的好处是显而易见的:合并不必要的更新,减少更新过程的调用次数,并按顺序保存状态,所以更新时不会出现“竞争问题”。最终触发的更新是一个异步过程,减少了浏览器掉帧的可能性。批发市场拉货。如果老板派几辆小货车去,可能路上耽误了,先走的车不一定先回来(竞争问题)。最好提前清点好要拉的货,派个大货车去,拉一次就返回(批量处理)。铁憨憨:“我明白了!不过为什么叫‘自动批处理’?难不成手枪之类的也有手动和半自动的?”是的,v18的“批处理”是自动的。在v18之前的React使用半自动“批处理”。同时,React提供了一个API——unstable_batchedupdates,就是手动“批处理”。半自动批处理要说“自动批处理”,就必须先说“半自动批处理”。在v18之前,只有事件回调和生命周期回调中的更新才会被批量处理,比如上例中的onClick。但是在promise、setTimeout等异步回调中不会进行批处理。原因,我们看批处理源码(变量的意义不用懂,不重要):exportfunctionbatchedUpdates
(fn:A=>R,a:A):R{constprevExecutionContext=executionContext;executionContext|=BatchedContext;try{returnfn(a);}finally{executionContext=prevExecutionContext;//如果有遗留同步更新,flushthemattheendoftheouter//mostbatchedUpdates-likemethod.if(executionContext===NoContext){resetRenderTimer();flushOnlycCallback}s}可以看出传入了一个回调函数fn,通过“位运算”将BatchedContext状态添加到代表当前执行上下文状态的变量executionContext中。具有此状态位表示当前执行上下文需要批处理。fn在执行过程中,其获取到的全局变量executionContext中会包含BatchedContext。最后的fn执行完后,进入try...finally逻辑,将executionContext恢复到之前的context。在React源码中,执行onClick时的逻辑类似于:batchedUpdates(onClick,e);在onClick里面的this.setState中,获取到的executionContext中包含BatchedContext,不会立即进入更新流程。退出上下文后统一执行更新过程,即“半自动批处理”。铁憨憨:“既然batchedUpdates是React自动调用的,那为什么是‘半自动批处理’呢?”原因是同步调用了batchedUpdates方法。如果fn有一个异步过程,比如下面的例子:onClick(){setTimeout(()=>{this.setState({a:3});this.setState({a:4});})}那么其实执行this.setState的时候,batchedUpdates已经执行过了,executionContext中不包含BatchedContext。此时触发的更新不会遵循批处理逻辑。所以这种“只在同步过程中批量处理this.setState”只能说是“半自动”。手动批处理为了弥补“半自动批处理”的不灵活,ReactDOM导出了unstable_batchedUpdates方法供开发者手动调用。比如上面的例子可以这样修改:onClick(){setTimeout(()=>{ReactDOM.unstable_batchedUpdates(()=>{this.setState({a:3});this.setState({a:4});})})}那么当两次调用this.setState时,context中的全局变量executionContext就会包含BatchedContext。铁憨憨:“你这么一说,批处理的实现我就明白了。但是v18是怎么实现各种上下文的批处理的?有点神奇!”自动批处理v??18实现“自动批处理”的关键有两点:添加调度的过程不是基于全局变量executionContext,而是基于更新的“优先级”铁憨憨:“怎么会有一个“优先级”?这是什么鬼?我:“那我给大家介绍一下什么是更新,什么是优先级。"优先级是指调用this.setState后,源码会依次执行:根据当前环境选择一个"优先级"创建一个代表本次更新的更新对象,赋予其第1步的优先级,并将更新挂载到当前组件对应的组件进入fiber(虚拟DOM)上的调度流程举个例子:onClick(){this.setState({a:3});this.setState({a:4});}创建this.setState第一次更新数据结构如下:第二次执行this.setState创建的更新数据结构如下:其中lane表示更新的优先级,在v18中,更新触发在不同场景有不同的“优先级”。例如:在上面的例子中,事件回调中的this.setState会产生同步优先级的更新,是最高优先级(lane为1)。为了对比,我们把上面的代码放到setTimeout中:onClick(){setTimeout(()=>{this.setState({a:3});this.setState({a:4});})}第一次执行这个.setState创建的更新数据结构如下:执行创建的更新数据结构第二次this.setState如下:lane为16,代表Normal(即一般优先级)。铁憨憨:“所以每次调用this.setState,都会产生一个update对象,它会根据调用场景有不同的lane(优先级)对吧?”我:“没错!”。铁憨憨:“那这和‘批处理’有什么关系?”我:“别着急,这是下一步进入调度流程。”调度流程fibermounts对应的组件更新后,会进入“调度流程”。试想一下,一个大型应用程序,在某个时刻,应用程序的不同组件触发更新。然后在对应不同组件的纤程中会有不同优先级的更新。“调度进程”的作用是在这些更新中选择优先级最高的一个,进入该优先级的更新进程。我们选取“调度流程”的部分源码:functionensureRootIsScheduled(root,currentTime){//获取当前所有优先级中最高的优先级varnextLanes=getNextLanes(root,root===workInProgressRoot?workInProgressRootRenderLanes:NoLanes);//this二级调度的优先级varnewCallbackPriority=getHighestPriorityLane(nextLanes);//现有调度的优先级varexistingCallbackPriority=root.callbackPriority;if(existingCallbackPriority===newCallbackPriority){return;}//调度更新进程newCallbackNode=scheduleCallback(schedulerPriorityWorkLevel,RoformConform.bind(null,root));root.callbackPriority=newCallbackPriority;root.callbackNode=newCallbackNode;}摘录的调度流程大致是:获取当前所有优先级中最高的优先级,将第1步的优先级作为本次的优先级二次调度取决于是否已经有调度。如果已经有一个调度,并且和当前调度的优先级一致,那么返回不一致,就会进入调度流程。可以看到调度的最终目的是在一定时间后执行performConcurrentWorkOnRoot。正式进入更新过程。还是拿上面的例子:onClick(){this.setState({a:3});this.setState({a:4});}第一次调用this.setState,进入“调度流程”后,没有existingCallbackPriority。所以调度会被执行:newCallbackNode=scheduleCallback(schedulerPriorityLevel,performConcurrentWorkOnRoot.bind(null,root));第二次调用this.setState,进入“调度流程”后,已经有了existingCallbackPriority,这是第一次调用产生的。此时对比一下两者的优先级:if(existingCallbackPriority===newCallbackPriority){return;}由于两个更新都是在onClick中触发的,优先级相同,return。按照这个逻辑,即使多次调用this.setState,如:onClick(){this.setState({a:3});this.setState({a:4});this.setState({a:5});this.setState({a:6});}只有第一次调用会执行调度,后面几次执行都会返回,因为优先级和第一次一致。一定时间后,第一次调度的回调函数performConcurrentWorkOnRoot会被执行,进入更新流程。因为每次执行this.setState都会创建一个更新并将其挂载到纤程上。所以即使更新过程只执行一次,状态仍然可以更新到最新。这就是基于“优先级”的“自动批处理”逻辑。小结通过这次讲解,闺蜜不仅学会了“批处理”的含义。还学习了“手动/半自动/自动”三种形式的批处理。最后,我们还讲了批处理的源码实现逻辑。