React是前端开发中每天都会用到的前端框架,自然而然对其原理有深刻的理解。本人长期使用React,所以本文将总结一下自己对react原理的理解。react和vue都是基于vdom的前端框架。先说vdom:为什么react和vue都是基于vdom的?不能直接操作真实的dom?考虑这样一个场景:渲染就是使用domapi对真实的dom进行增删改查。如果一个dom已经渲染好了,后面需要更新,就需要遍历它所有的属性,重新设置,比如id,clasName,onclick等。而且dom的属性有很多:有很多属性是没有的完全使用,但更新时必须重置它们。我们只能比较我们关心的属性吗?单独提取这些并用JS对象表示它们是否就足够了?这就是为什么有一个vdom,它的第一大好处。而且有了vdom,与dom没有强绑定,可以渲染到其他平台,比如native,canvas等。这是vdom的第二个好处。我们知道vdom使用JS对象来表示最终渲染的dom,例如:{type:'div',props:{id:'aaa',className:['bbb','ccc'],onClick:function(){}},children:[]}并用渲染器渲染它。但是开发人员应该编写这样的vdom吗?那肯定不行,太麻烦了,html的方式大家都很熟悉了,所以需要引入编译的手段。dsl的compileddslisdomainspecificlanguage,即domainspecificlanguage,html和css都是web域中的dsl。直接写vdom太麻烦了,所以前端框架会设计一套dsl,然后编译成render函数,执行后生成vdom。vue和react都是这样的:这套dsl怎么设计?前端领域熟悉的描述dom的方式就是html,最好的方式自然是这样设计。所以vue的template和react的jsx就是这样设计的。Vue的模板编译器是自己实现的,而react的jsx编译器是babel实现的,这是两个团队合作的结果。比如我们可以这样写:编译成render函数然后执行就是我们需要的vdom。然后渲染器渲染出来。渲染器是如何渲染vdom的?渲染vdom渲染vdom就是通过domapi对dom进行增删改查。例如,对于一个div,您需要document.createElement来创建元素,然后setAttribute来设置属性,addEventListener来设置事件监听器。如果是文本,需要通过document.createTextNode来创建。所以根据vdom类型的不同,写一个ifelse,分别做不同的处理即可。switch(vdom.tag){caseHostComponent://创建或更新domcaseHostText://创建或更新domcaseFunctionComponent://创建或更新domcaseClassComponent://创建或更新dom}可以,不管vue还是React,renderer中这个ifelse是必不可少的:在react中,标签是用来区分vdom类型的。比如HostComponent是一个元素,HostText是文本,FunctionComponent和ClassComponent分别是函数组件和类组件。那么问题来了,如何渲染组件呢?这就涉及到组件的原理了:我们组件的目标是通过vdom来描述界面,在react中会用到jsx。这样的jsx有时是根据state动态生成的。如何将状态与jsx相关联?以函数、类或选项对象的形式封装。然后在渲染的时候执行它们得到vdom。组件是这样实现的:switch(vdom.tag){caseFunctionComponent:constchildVdom=vdom.type(props);渲染(childVdom);//...caseClassComponent:constinstance=newvdom.type(props);constchildVdom=instance.render();渲染(childVdom);//...}如果是函数组件,传入props执行,拿到vdom后递归渲染。如果是类组件,则创建它的实例对象,调用render方法获取vdom,然后递归渲染。那么,你猜到Vue的option对象的组件描述是怎么渲染的了吗?{数据:{},道具:{}渲染(h){返回h('div',{},'');}}是的,执行render方法即可:constchildVdom=option.render();渲染(childVdom);你可能平时会写一个sfc形式的单文件组件,里面会有专门的编译器把模板编译成render函数,然后挂在option对象的render方法上:所以组件本质上只是为了生成vdom逻辑的封装可以是函数、选项对象或类的形式。就像vue3也有函数组件一样,组件的形式无所谓。基于vdom的前端框架的渲染过程是类似的,vue和react的很多方面都是相同的。但是管理状态的方式是不同的。Vue是响应式风格,而react是setState的api方式。说实话,vue和react最大的不同就是状态管理方式的不同,因为这种不同导致了后续架构演化方向的不同。状态管理react通过setStateAPI触发状态更新,更新后重新渲染整个vdom。而Vue则是作为state的代理,在获取的时候收集起来,然后在state修改的时候触发相应组件的render。可能有同学会问,为什么react不直接渲染对应的组件呢?想象这样一个场景:父组件将自己的setState函数传递给子组件,子组件调用它。这个时候更新是由子组件触发的,但是需要渲染的只有那个组件吗?显然不是,还有它的父组件。出于同样的原因,对一个组件的更新实际上可以触发对任何地方的其他组件的更新。所以必须重新渲染整个vdom。那么为什么vue可以准确的更新和改变组件呢?因为有了响应式代理,无论是子组件、父组件,还是其他位置的组件,只要使用了对应的state,就会被收集为一个依赖,当state出现时就可以触发它们的render变化,无论是组件在哪里。这就是为什么React需要重新渲染整个vdom而vue不需要。这个问题也导致了两者的架构逐渐不同。react15时react架构的演进还是很像vue的渲染过程,都是vdom的递归渲染,就是增删改dom。但是,状态管理方式的差异逐渐导致了架构的差异。React的setState会渲染整个vdom,一个应用的所有vdom可能会非常大,计算量可能会很大。如果浏览器中的js计算时间过长,会阻塞渲染,会占用每一帧的动画,重绘重排时间,使动画卡顿。作为一个有抱负的前端框架,动画卡顿是肯定不能接受的。但是由于setState这个方法只能渲染整个vdom,大量的计算是不可避免的。能不能拆分计算量,每帧计算一部分,不至于阻塞动画的渲染?按照这个思路,react被改造成了fiber架构。fiber架构优化的目标是打断计算,多次执行,但是现在递归渲染不能打断。原因有两个:渲染时直接操作dom,此时中断,更新到dom的部分怎么办?现在直接渲染的是vdom,vdom里面只有children的信息。如果中断了,如何找到它的父节点呢?第一个问题的解决方法很容易想到:渲染的时候不要直接更新dom,只要找到变化的部分,标记增删改,创建dom,毕竟一次性更新到dom计算完成。.所以React将渲染过程分为两部分:render和commit。render阶段会找到vdom变化的部分,创建dom,标记增删改查。这叫做调和,调和。协调可以中断,也可以按计划安排。全部计算完成后,一次性更新到dom中,称为commit。这样一来,react就把之前和vue很像的递归渲染,变成了render(reconcile+schdule)+commit的两阶段渲染。从那以后,react和vue架构之间的区别就变大了。第二个问题,中断后如何找到父节点和其他兄弟节点?现有的vdom不起作用,需要重新记录parent和silbing信息。所以React创建了fiber数据结构。除了children信息,还有额外的siblings和returns,分别记录了兄弟节点和父节点的信息。这种数据结构也称为纤程。(Fiber不仅仅是一个数据结构,也代表了render+commit的渲染过程)React会先把vdom转成fiber,然后reconcile,所以可以打断。为什么可以这样中断?因为不再是递归,而是循环:functionworkLoop(){while(wip){performUnitOfWork();}if(!wip&&wipRoot){commitRoot();react中有一个workLoop循环,每次在循环中做一个fiberreconcile,当前处理的fiber会放在全局变量workInProgress中。当循环结束,即wip为空时,再执行commit阶段,将reconcile的结果更新到dom中。每种纤维的协调根据类型进行不同处理。当当前fiber节点处理完成后,将wip指向sibling,返回切换到下一个fiber节点。:functionperformUnitOfWork(){const{tag}=wip;switch(tag){caseHostComponent:updateHostComponent(wip);休息;caseFunctionComponent:updateFunctionComponent(wip);休息;caseClassComponent:updateClassComponent(wip);:updateFragmentComponent(wip);休息;caseHostText:updateHostTextComponent(wip);休息;默认值:中断;}如果(wip.child){wip=wip.child;返回;}让next=wip;while(next){if(next.sibling){wip=next.sibling;返回;}next=next.return;}wip=null;}函数组件和类组件的reconcile和之前一样,就是调用render获取vdom,然后继续处理Renderedvdom:functionupdateClassComponent(wip){const{type,props}=wip;常量实例=新类型(道具);constchildren=instance.render();reconcileChildren(wip,children);}functionupdateFunctionComponent(wip){renderWithHooks(wip);常量{类型,道具}=wip;constchildren=type(props);reconcileChildren(wip,children);}循环执行reconcile,然后在每次处理之前判断是否有更高优先级的任务,可以打断,所以我们每次处理fiber节点的reconcile之前,调用shouldYield方法首先:functionworkLoop(){while(wip&&shouldYield()){performUnitOfWork();}if(!wip&&wipRoot){commitRoot();}}shouldYieled方法是判断任务队列中是否有更高优先级的任务待处理?如果有,就先处理那边的光纤,先把这里的光纤暂停。这就是光纤架构的调和可以中断的原理。通过纤程的数据结构和循环处理前每次判断是否中断来实现。说完render阶段(reconcile+schedule),接下来就是进入commit阶段。前面说了,为了变得可中断,reconcile阶段并不真正操作dom,只是创建dom并标记effectTag的增删改查。在提交阶段,只需根据标记更新dom。但是在commit阶段,需要再次遍历fiber,找到有effectTag的节点。你想更新dom吗?那当然很好,但这不是必需的。完全可以在reconcile时将带有effectTag的节点收集到队列中,然后在commit阶段直接遍历队列。这个队列叫做effectList。React在commit阶段会遍历effectList,根据effectTag增删改dom。在创建dom前后执行useEffect、useLayoutEffect以及函数组件的一些生命周期函数。useEffect设计为在dom操作前异步调用,useLayoutEffect在dom操作后同步调用。为什么?因为我们要对dom进行操作,如果此时同步执行一个effect,计算量会非常巨大,岂不是破坏了fiber架构带来的优势?所以效果是异步的,不会阻塞渲染。而useLayoutEffect,顾名思义,就是在这个阶段想要获取一些布局信息。dom操作完成后就可以了,都是渲染出来的,自然就可以同步调用了。实际上,React将提交阶段分为3个小阶段。在突变、突变、布局之前。Mutation就是遍历effectList来更新dom。在mutation之前,会异步派发useEffect的回调函数。之后就是布局阶段,因为这个阶段已经可以获取布局信息,会同步调用useLayoutEffect的回调函数。并且在这个阶段,可以获得新的dom节点,ref会被更新。至此,我们已经明确了我们在react新架构的render和commit两个阶段做了什么。总结一下,react和vue都是基于vdom的前端框架。之所以用vdom,是因为它可以准确比较感兴趣的属性,也可以跨平台渲染。不过开发不会直接写vdom,而是使用jsx这种接近html语法的DSL编译生成render函数,执行后生成vdom。vdom的渲染就是根据不同的类型,使用不同的domAPI来操作dom。渲染组件时,如果是函数组件,则执行得到vdom。class组件创建一个实例,然后调用render方法获取vdom。对于vue的option对象,调用render方法获取vdom。组件本质上是对一段vdom的逻辑封装,可以是函数、类、选项对象甚至其他形式。react和vue最大的区别在于状态管理的方式,vue是通过响应式,react是通过setStateapi。我认为这是最大的不同,因为它会导致后面的React架构发生变化。React的setState方法导致它不知道哪些组件发生了变化,需要渲染整个vdom。但是计算量会比较大,会阻塞渲染,导致动画卡顿。所以react后来改造成fiber架构,目标是可中断计算。为了这个目标,从change到change是不可能更新dom的,所以渲染分为两个阶段:render和commit。render阶段通过schedule调度reconcile,即找到变化的部分,创建dom,标记增删改等。所有计算完成后,commit阶段一次性更新到dom。中断之后,需要找到父节点和兄弟节点,所以vdom也转化为一个fiber数据结构,有父节点和兄弟节点信息。所以fiber不仅仅指这个链表的数据结构,还指render和commit的过程。在reconcile阶段,对每一个fiber节点进行处理,处理前判断shouldYield。如果有更高优先级的任务,则先执行其他任务。在commit阶段,不需要再次遍历fiber树。React为了优化,将所有带effectTag的fiber都放入effectList队列中,只需要遍历更新即可。在dom操作之前会异步调用useEffect的回调函数,异步是因为无法阻塞渲染。dom操作完成后,会同步调用useLayoutEffect的回调函数,更新ref。因此,commit阶段分为三个小阶段:beforemutation、mutation、layout,分别对应上面提到的三个部分。我想如果理解了vdom、jsx、componentessence、fiber、render(reconcile+schedule)+commit(beforemutation、mutation、layout)的渲染过程,我对react的原理就会有更深的理解。
