React16之前版本调度、渲染效率不高,新版本引入新机制进行全面优化。React框架内部的运作可以分为3层:
Reconciler层是调度任务的核心,旧版本的调度方式中,当我们调用setState更新页面的时候,React会用递归的方式遍历整颗组件数的所有节点,对比新旧虚拟DOM树,找出需要变动的节点,然后再更新UI。整个过程是一气呵成,不能被打断的。如果页面元素很多,整个过程占用的时机就可能超过16毫秒,就容易出现卡顿掉帧的现象。
React16中使用了Fiber,但是Vue是没有Fiber的,为什么呢?原因是二者的优化思路不一样:
Fiber可以理解为是一种数据结构,也可以理解为是一个执行单元。
Fiber可以理解为是一种数据结构,ReactFiber就是采用链表实现的,每个Fiber保存了节点处理的上下文信息,因为是手动实现的,所以更为可控,我们可以保存在内存中,随时中断和恢复。每个VirtualDOM都可以表示为一个fiber,如下图所示,每个节点都是一个fiber。一个fiber包括了child(第一个子节点)、sibling(兄弟节点)、return(父节点)等属性,ReactFiber机制的实现,就是依赖于以下的数据结构。
任务的拆分、执行、挂起、恢复以及高优先级任务插队是react更新任务的核心。
每一个dom元素就是一个Fiber,而一个Fiber可以理解为一个执行单元,所以一次更新任务被拆分成了以Fiber为单位的小任务。
requestIdleCallback调度fiber更新任务的伪代码如下:
例如,高优先级更新u1、低优先级更新u2的updatePriority分别为0、250,则
MAX_SIGNED_31_BIT_INT-(currentTime+0)>MAX_SIGNED_31_BIT_INT-(currentTime+200)//即u1.expirationTime>u2.expirationTime;代表u1优先级更高。
expirationTime算法的原理简单易懂:每次都选出所有更新中**「优先级最高的」**。
除此之外,还有个问题需要解决:如何表示批次?
批次是什么?考虑如下例子:
//定义状态numconst[num,updateNum]=useState(0);//...某些修改num的地方//修改的方式1updateNum(3);//修改的方式2updateNum(num=>num+1);两种修改状态的方式都会创建更新,区别在于:
由于第二种方式的存在,更新之间可能有连续性。所以expirationTime算法计算出一个优先级后,组件render时实际参与更新当前状态的值的是:
这些相互关联,有连续性的更新被称为一个批次(batch)。expirationTime算法计算批次的方式也简单粗暴:优先级大于某个值(priorityOfBatch)的更新都会划为同一批次。
constisUpdateIncludedInBatch=priorityOfUpdate>=priorityOfBatch;expirationTime算法保证了render异步可中断、且永远是最高优先级的更新先被处理。
ReactFiber与浏览器的核心交互流程如下
但是react并没有用requestIdleCallback来执行Fiber更新任务,主要原因有两点
出现空闲时段的场景:
requestAnimationFrame+MessageChannel实现requestIdleCallback代码如下
在React得到控制权后,应该优先处理高优先级的任务。也就是说正在处理的任务可能会被中断,在恢复时会让位给高优先级任务,原本中断的任务可能会被放弃或者重做。但是如果不按顺序执行任务,可能会导致前后的状态不一致。比如低优先级任务将a设置为0,而高优先级任务将a递增1,两个任务的执行顺序会影响最终的渲染结果。因此要让高优先级任务插队,首先要保证状态更新的时序。
解决办法是:所有更新任务按照顺序插入一个队列,状态必须按照插入顺序进行计算,但任务可以按优先级顺序执行,例如:
上面被跳过任务不会被移除,在执行完高优先级任务后它们还是会被执行的。因为不同的更新任务影响的节点树范围可能是不一样的,举个例子a、b可能会影响Foo组件树,而c会影响Bar组件树。所以为了保证视图的最终一致性,所有更新任务都要被执行。
道理讲起来都很简单,ReactFiber实际上非常复杂,不管执行的过程怎样拆分、以什么顺序执行,最重要的是保证状态的一致性和视图的一致性
从React16.3之后,React团队就对生命周期进行了调整,React16.3之前的生命周期如下:
React16.3之后的生命周期如下:
通过对比可以发现之前的生命周期钩子函数componentWillMount,componentWillReceiveProps,componentWillUpdate被废弃,新增了staticgetDerivedStateFromProps(newProps,prevState)和getSnapshotBeforeUpdate(prevProps,prevSteate),之所以废弃掉三个生命周期是因为原来(Reactv16.0前)的生命周期在Reactv16推出Fiber之后就不合适了,因为如果要开启asyncrendering,组件在更新过程中有可能会被暂停和恢复更新,所以执行时机在render函数之前的所有钩子函数,都有可能被执行多次。如果在这些钩子函数里做副作用操作,比如发起请求,事件监听等可能会导致内存泄漏,这三个生命周期方法在v17以前仍然保留,新增了带UNSAFE_前缀的3个方法,v16.x版本中,新旧的方法依旧都可以使用,但是使用不带UNSAFE_前缀的方法,将提示被弃用的警告。
新增了两个钩子函数具体使用如下:
当我们要学习一个新事物的时候,我们应该做的第一件事就是问自己两个问题:
我们最初在写类组件时一定遇到过如下的问题:
withAuth(withRouter(withUserStatus(UserDetail)))这种嵌套写法的高阶组件可能会导致很多问题,其中一个就是props丢失的问题,例如withAuth传递给UserDetail的某个prop可能在withUserStatus组件里面丢失或者被覆盖了,且容易发生wrapperhell(嵌套地狱)
hooks的出现就是用来解决以上类组件面临的问题,实际上就两句话:易维护,易复用。
talkischeap,我们先看两个代码片段:
在类组件中
再来看看函数组件中:
在第一个例子打印结果:12345
在第二个例子打印结果:00000
在讲解hooks原理前先提两个问题:
在组件初始化的时候,每一次hooks执行,如useState(),useRef(),都会调用mountWorkInProgressHook,这个函数会产生一个hook对象,并形成链表结构,绑定在当前组件对应的Fiber(也称为workInProgress)对象的memoizedState属性上,每个hook对象又有如下属性:
对于effect副作用钩子,会把当前需要执行的副作用以链表的形式绑定在当前组件对应的Fiber对象的updateQueue属性上,等组件更新完毕后依次执行副作用。
假设一个组件中调用了useState、useMemo、useRef、useEffect,那么最终形成的结构如下图:
如果我们将其中的一个useRef放入条件语句中,
首先会以当前current组件树为基础复制一份workInProgress树,并将current树上的hooks信息复制过来。各hooks工作如下:
至于其他的几个hook是类似的,例如useReducer和useState类似,useCallback和useMemo类似。
React17发布日志上说这次版本最大的特点就是无新特性,但是仔细研究后还是有很多东西值得学习的。
React16可中断更新可以解决以下问题:
这些问题统称为CPU密集型问题。
在前端,还有一类问题也会影响体验,那就是请求数据或懒加载造成的等待。这类问题被称为IO密集型问题。
为了解决IO密集型问题,React提出了Suspense。
React.Suspense可以指定加载指示器(loadingindicator),以防其组件树中的某些子组件尚未具备渲染条件。在未来,我们计划让Suspense处理更多的场景,如数据获取等。
考虑如下代码:
假设组件Sub三秒后被加载,理想情况下,在加载前后UI会依次显示为:
Suspense带来了多任务并发执行的直观感受。
那么Suspense对应更新的优先级是高还是低呢?
当加载成功后,合理的逻辑应该是尽快展示成功后的UI。所以Suspense对应更新应该是高优先级更新。那么,在示例中共有两类更新:
在expirationTime算法下:
//u0优先级远大于u1、u2、u3...u0.expirationTime>>u1.expirationTime>u2.expirationTime>…u0优先级最高,则u1及之后的更新都需要等待u0执行完毕后再进行。
而u0需要等待加载成功才能执行。所以,加载成功前后UI会依次显示为:
所以,只考虑CPU密集型场景的情况下,高优先级更新先执行的算法并无问题。
但考虑IO密集型场景的情况下,高优先级IO更新会阻塞低优先级CPU更新,这显然是不对的。
所以expirationTime算法并不能很好支持并发更新。
expirationTime算法最大的问题在于:expirationTime字段耦合了优先级与批次这两个概念,限制了模型的表达能力。
这导致高优IO更新不会与低优CPU更新划为同一批次。那么低优CPU更新就必须等待高优IO更新处理完后再处理。
如果不同更新能根据实际情况灵活划分批次,就不会产生这个bug。
新的调度算法被称为Lane(车道),他是如何定义优先级与批次呢?
对于优先级,一个lane就是一个32bitInterger,最高位为符号位,所以最多可以有31个位参与运算。
不同优先级对应不同lane,越低的位代表越高的优先级,比如:
//对应SyncLane,为最高优先级0b0000000000000000000000000000001//对应InputContinuousLane0b0000000000000000000000000000100//对应DefaultLane0b0000000000000000000000000010000//对应IdleLane0b0100000000000000000000000000000//对应OffscreenLane,为最低优先级0b1000000000000000000000000000000批次则由lanes定义,一个lanes同样也是一个32bitInterger,代表一到多个lane的集合,该整数所有二进制位为1对应的优先级任务都将被执行。例如lanes为17(10001)时,表示将并行更新SyncLane(值为1)和DefaultLane(值为16)的任务,这两个任务属于同一批次。
可以用位运算很轻松的将多个lane划入同一个批次:
//要使用的批次letlanesForBatch=0;constlaneA=0b0000000000000000000000001000000;constlaneB=0b0000000000000000000000000000001;//将laneA纳入批次中lanesForBatch|=laneA;//将laneB纳入批次中lanesForBatch|=laneB;//lanesForBatch=0b0000000000000000000000001000001//新的优先级为0b0000000000000000000000001000001//更新时会将各个位上的1对应的任务一同更新,也就是一个批次上文提到的Suspense的bug是由于expirationTime算法不能灵活划定批次导致的。
lanes就完全没有这种顾虑,任何想划定为同一批次的优先级(lane)都能用位运算轻松搞定。
React17以前,React中如果使用JSX,则必须像下面这样导入React,否则会报错,这是因为旧的JSX转换会把JSX转换为React.createElement(...)调用。
而React17带来了改变,可以让我们单独使用JSX而无需引入React。这是因为新的JSX转换不会将JSX转换为React.createElement,而是自动从React的package中引入新的入口函数并调用,开发者可以不依赖于React的导入。
另外此次升级不会改变JSX语法,旧的JSX转换也将继续工作。
在React16或更早版本中,React会由于事件委托对大多数事件执行document.addEventListener()。但是一旦你想要局部使用React,那么React中的事件会影响全局。尤其在微前端中,不同的子工程可能会相互影响。
React17不再将事件添加在document上,而是添加到渲染React树的根DOM容器中。
下图形象描述了这次的变更,图片来自React官网
在React17以前,如果想要用异步的方式使用事件e,则必须先调用e.persist()才可以,这是因为React在事件池中重用了不同事件的事件源对象,以提高性能,并将所有事件字段在它们之前设置为null。
React17之前的调用方式:
handerClick=(e)=>{console.log(e.target)//button//必需要加e.persist()e.persist()setTimeout(()=>{console.log(e.target)//button},0)}React17之后的调用方式:
handerClick=(e)=>{console.log(e.target)//buttonsetTimeout(()=>{console.log(e.target)//button},0)}React17实验版(并发更新的尝试)React17实验版本是一个过渡版本,为React18正式启用并发更新做铺垫,来看看React17做了哪些工作?它与正式发布的React18有什么不同?
可以从架构角度来概括下,当前一共有两种架构:
新架构可以选择是否开启并发更新,所以当前市面上所有React版本一定属于如下一种情况:
React团队的愿景是:
使用老版本的开发者可以逐步升级到新版,即从情况1、2、3向情况4升级。
但是这中间存在极大的阻力,因为情况4的React一些行为异于情况1、2、3。
比如如下三个生命周期函数在情况4的React下是“不安全的”:
贸然升级可能造成老代码不兼容。
渐进升级方案的第一步是规范代码。
v16.3新增了StrictMode,对开发者编写的不符合并发更新规范的代码作出提示,逐步引导开发者写出规范代码。
比如,使用上述不安全的生命周期函数时会产生如下报错信息:
下一步,React团队让不同情况的React可以在同一个页面共存,借此可以让情况4的React逐步渗入原有的项目。
具体做法是提供三种开发模式:
官网的一张图片很直观的说明了三种模式
在与社区进行大量沟通后,React团队意识到当前的渐进升级策略存在两方面问题。
首先,由于模式影响的是整个应用,所以无法在同一个应用中完成渐进升级。
举个例子,开发者将应用中ReactDOM.render改为ReactDOM.createBlockingRoot,从Legacy模式切换到Blocking模式,这会自动开启StrictMode。
此时,整个应用的并发不兼容警告都会上报,开发者还是需要修改整个应用。
从这个角度看,并没有起到渐进升级的目的。
其次,React团队发现:开发者从新架构中获益,更多是由于使用了并发特性(ConcurrentFeature)。
并发特性指开启并发更新后才能使用的特性,比如:
所以,可以默认情况下仍使用同步更新,在使用了并发特性后再开启并发更新。
在v18中运行如下代码:
如果updateCount没有作为startTransition的回调函数执行,那么updateCount将触发默认的同步更新。
所以,在v18中,不再有三种模式,而是以是否使用并发特性作为是否开启并发更新的依据。
具体来说,在v18中统一使用ReactDOM.createRoot创建应用。
当不使用并发特性时,表现如情况3。使用并发特性后,表现如情况4。
在上节React17实验版中我们已经得到了结论:在v18中统一使用ReactDOM.createRoot创建应用,当不使用并发特性时,更新仍然是同步更新(不可中断更新),且默认是批量更新,当使用并发特性后,为并发更新,下面聊聊React18的一些新特性。
批处理是react将多个状态更新分组到一个渲染中以获得更好的性能。react18之前只能在react事件处理程序中批处理更新。默认情况下,Promise、setTimeout、本机事件处理程序或任何其他事件内部的更新不会在React中批处理。
React18自动使用自动批处理,这些更新将自动批处理:
//示例一:react17会render两次,react18只需要render一次consthandleClick=()=>{Promise.resolve().then(()=>{setC1((c)=>c+1);});setC2((c)=>c+1);};那么,如果我不想要批处理呢?
官方提供了一个APIflushSync用于退出批处理
//会更新两次functionhandleClick(){flushSync(()=>{setC1((c)=>c+1);});setC2((c)=>c+1);}flushSync会以函数为作用域,函数内部的多个setState仍然为批量更新,这样可以精准控制哪些不需要的批量。
componentDidUpdate(){console.log('didmount');}handleClick=()=>{setTimeout(()=>{console.log('开始运行setTimeout')flushSync(()=>{this.setState({},()=>{console.log('更新1')})this.setState({},()=>{console.log('更新2')})});this.setState({},()=>{console.log('更新3')})this.setState({},()=>{console.log('更新4')})console.log('结束运行setTimeout')});console.log('结束运行')}//flushSync将四个setState()分为了两组:更新1和更新2为一组、更新3和更新4为一组//打印顺序如下://开始运行setTimeout//didmount//更新1//更新2//结束运行setTimeout//didmount//更新3//更新4实现原理自动批处理的实现在React18中是基于优先级的,用lane模型来进行优先级的控制。lane用来弥补expirationTime的缺陷,它首先说明这个任务是个什么任务(确定优先级,确定lane值),其次说明哪些任务应该被batching到一起做(lane相同即batching到一起做)。然后通过lanes确定哪些并行更新。关于lane模型可以参见React17正式版这一小节中的Lane优先级算法。
批处理实现的核心在于当相同优先级的更新发生时,并不会生成新的任务,而是复用上一次的任务,从而实现合并。
flushSync实现原理更简单,它将内部更新的优先级强制指定为SyncLane,即指定为同步优先级,具体效果就是每一次更新时都会同步的执行渲染。
过渡是React18中的一个新概念,用于区分紧急和非紧急更新。紧急更新反映了直接交互,例如键入、单击、按下等。非紧急(过渡)更新将UI从一个视图转换到另一个视图。
打字、点击或按下等紧急更新需要立即响应,以符合我们对物理对象行为方式的直觉。否则用户会觉得“不对劲”。但是,过渡是不同的,因为用户不希望在屏幕上看到每个中间值。
下面我们来看一个例子:当滑块滑动时,下方的图表会一起更新,然而图表更新是一个CPU密集型操作,比较耗时。由于阻塞了渲染导致页面失去响应,用户能够非常明显的感受到卡顿。
实际上,当我们拖动滑块的时候,需要做两次更新:
//紧急更新setSliderValue(input);//非紧急更新setGraphValue(input);startTransition包装在startTransition中的更新被视为非紧急更新,也就是它的优先级被降低,如果出现更紧急的更新(如点击或按键),则会中断。默认的更新被视为紧急更新,也就是没有开启并发特性时的更新为同步更新,是不可中断的,表现和React18之前一样。
import{startTransition}from'react';//紧急更新setSliderValue(input);//非紧急更新startTransition(()=>{//Transition:ShowtheresultssetGraphValue(input);});使用后效果:
上述的问题能够把setGraphValue的更新包装在setTimeout内部,像如下这样:
通过setTimeout确实可以让输入状态好一些,但是由于setTimeout本身也是一个宏任务,而每一次input触发onchange事件也是宏任务,所以setTimeout还是会影响页面的交互体验。相当于是将setGraphValue(input)更新任务对页面的阻塞推迟到了下一个事件循环。
通过对比,startTransition相比于setTimeout的优势在于:
那么我们再想一个问题,为什么不是节流和防抖。首先节流和防抖能够解决卡顿的问题吗?答案是一定的,在没有transition这样的api之前,就只能通过防抖和节流来处理这件事。接下来用防抖处理一下。
上面介绍了startTransition,又讲到了过渡任务,本质上过渡任务有一个过渡期,在这个期间当前任务本质上是被中断的,那么在过渡期间,应该如何处理呢,或者说告诉用户什么时候过渡任务处于pending状态,什么时候pending状态完毕。
为了解决这个问题,React提供了一个带有isPending状态的hooks——useTransition。useTransition执行返回一个数组。数组有两个状态值:
import{useTransition}from'react'/*使用*/const[isPending,startTransition]=useTransition()那么当任务处于悬停状态的时候,isPending为true,可以作为用户等待的UI呈现。比如:
useDeferredValue和上述useTransition本质上有什么异同呢?
相同点:
不同点:
举个例子来更好的理解useDeferredValue
SSR有点像“魔术”。它不能使你的应用程序更快地完全可交互。相反,它让你更快地展示你的应用程序的非交互式版本。
SSR的整个过程是一个串行的,总结一下就是:获取数据(服务器)→渲染成HTML(服务器)→加载代码(客户端)→hydration(客户端)。任何一个阶段都不能在前一个阶段结束之前开始。
在目前的API中,当你渲染到HTML时,你必须已经在服务器上为你的组件准备好所有的数据。这意味着你必须在服务器上收集所有的数据,然后才能开始向客户端发送任何HTML。这样是很低效的。
在JavaScript代码加载后,React将HTML“hydrate”并使其具有交互性。客户端在渲染组件时将“走”过服务器生成的HTML,并将事件处理程序绑定到该HTML上。为了使其发挥作用,组件在浏览器中生成的树必须与服务器生成的树相匹配。否则React就不能“匹配它们!”这样做的一个非常不幸的后果是,必须在客户端加载所有组件的JavaScript,才能开始对任何组件进行hydration
React一次性完成树的hydration。这意味着,一旦它开始hydrate,React就不会停止hydration的过程,直到它为整个树完成hydration。因此,必须等待所有的组件被hydrated,才能与任何组件进行交互。
SSR存在的问题的根源是因为整个过程是一个“瀑布”(流程):获取数据(服务器)→渲染成HTML(服务器)→加载代码(客户端)→hydration(客户端)。任何一个阶段都不能在前一个阶段结束之前开始。这就是为什么它的效率很低。
React18的解决方案是将工作分开,这样就可以为屏幕的一部分而不是整个应用程序做这些阶段的工作。
React18中,有两个主要的SSR功能是由Suspense解锁的。
流式HTML
选择性hydration
所有组件完成hydration之前与页面互动
React18为SSR提供了两个主要功能:
这些功能解决了React中SSR的三个长期存在的问题:
useId是一个新的hook,用于在客户端和服务器上生成唯一ID,同时避免hydrationmismatches。
当我们在使用React进行服务端渲染(SSR)时就会遇到一个问题:如果当前组件已经在服务端渲染过了,但是在客户端我们并没有什么手段知道这个事情,于是客户端还会重新再渲染一次(双端对比),这样就造成了冗余的渲染。
因此,react18提出了useId这个hook来解决这个问题,它使用组件的树状结构(在客户端和服务端都绝对稳定)来生成id。