深层次拷问React面试题,答懵面试官 🌹🌹🌹

608 阅读33分钟

前言

笔者在翻完React源码后,发现不足之处依然很多,归根结底是React源码之庞大,而且笔者之前是针对源码进行分析,这很容易分不清主次,所以笔者将收集大部分的React相关问题,「笔者能保证的是下面的任何一个问题经得起推敲,并具有含金量」针对问题来透析源码.😊

下文需要了解Fiber架构和React渲染过程,否则下文就是天书,并希望你手握源码进行调试,对于这种大型架构而言,绝不是拿着文章背八股文就能记住的.


如果下文对您有帮助,请点赞关注一波,您的支持就是笔者最大的动力.

  • React.js、Redux核心开发者Dan神镇楼~~~~ 1.webp

React Class 组件有哪些周期函数?分别有什么作用?

  • 周期函数:
    • mount阶段
      • static.getDerivedStateFromProps,
        • 以静态函数的形式存在,作用与componentWillReceiveProps类似,getDerivedStateFromProps(nextProps, prevState),React会根据该函数的返回值重定义定义初始化的state.
        • 我们模拟一种场景,一个受控子组件,那么保证复用性,我们将该值维护在当前子组件中,当提交表单等情况时,才执行父组件的回调函数并将值传出去,但是,组件存在复用,所以初始化的值需要由父组件传入,但我们的受控组件值又是维护在子组件内的,那我们就需要一个函数,该函数的作用是当父向子传参时,该参数需要影响到组件内部state的初始值.
        • 重点: 该API是React16+新增,并将多个API列为UNSAFE,笔者看了许多文章,但是很遗憾,没有找到笔者对该API满意的答案「还是得自己来😁」,看了源码后,笔者认为,其实React一直在追求纯函数的概念,在组件没有挂载完成时,不应该产生副作用,笔者认为这也是React将其设置为静态函数的原因 --- 所有值的输出仅依赖当前输入「拿不到this」,低耦合实例对象,并且存在classComponent的所有更新阶段(mount和update),其值会以解构赋值的形式和实例的state组合,并不调起新的更新任务该API不与下列UNSAFE_API同时存在,否则UNSAF_API不予执行
      • componentWillMount(UNSAFE)
        • 组件挂载之前执行,无法操作dom(拿不到ref-dom引用).
        • 可以发起请求,但是React推荐在didMount中发起,因为在当前发起的请求可能由于服务端渲染的原因触发两次.并且当前API并不能保证数据能够被正确处理,可能会产生在concurrent模式下产生InCompleteClassComponent,从而多次执行
        • 可以对state的值进行修改,并且该次更新直接覆盖initState,由于执行在mount阶段,并且React有预见的执行processUpdateQueue之前,并不发生重渲染,而是直接更新state.
        • 重点: 该API透露了副作用的特性,即便React在其执行完后,立刻解开更新队列,试图将存在该API上的更新吸纳进本次更新,但是在concurrent模式下下存在的不同lane决定了不属于本次更新lane的update依然会发起调度,「上述内容结合源码,仅发散读者思维,可以不用深入」
      • componentDidMount
        • 组件以Dom的形式被挂载后触发(意味着ref引用已经完成),React推荐在此处发起请求,此处发起的setState会触发冲渲染.
        • React依然在biginWork中处理该API,
    • update阶段
      • componentWillReceiveProps(UNSAFE)
        • props更新时触发,可以处理依赖props的state更新,但是此处可以调起props传递的函数,并可能因此调起一次新的更新,循环执行当前API,陷入死循环,排除该可能,该API发起的调度,依然可能因React的异步调度产生性能缺陷.所以React将其设为了UNSAFE_API
      • shouldComponentUpdate
        • 依赖该API可以决定组件是否更新,从而优化性能,对于props不变的情况或者isPureReactComponent组件,React会判断shouldCOmponentUpdate的返回值和PureComponent下的props和state是否改变决定是否触发的回调.
      • componentWillUpdate(UNSAFE)
        • 当shouldUpdate通过,便会执行componentWillUpdate.
      • getSnapshotBeforeUpdate
        • React将要操作dom之前执行的生命周期函数,函数传入更新前的props和state,该函数返回的参数会被作为componentDidUpdate的第三个参数,
      • componentDidUpdate
        • DOM挂载完后执行生命周期,与componentDidMount执行时机一致.

        React判断是否存在DidUpdate和DidMount函数,从而标记Update,并且在commit阶段判断标记并且和currentFiberTree是否存在「不存在表示mount」,从而发起执行生命周期,值得注意的是其执行时机和useLayOutMount一致,同步执行,这意味着,React渲染会滞后于该函数

    • 销毁
      • componentWillUnmount
        • 组件销毁之前执行,一般用于处理解绑.

React Class 组件中请求可以在 componentWillMount 中发起吗?为什么?

  • 可以发起,但是React推荐在componentDidMount中执行,因为它执行时DOM已加载完毕,部分依赖更新dom而发起的请求在它内部写更加合理,并且在服务端渲染时,此函数会被执行两次,而DidMount并不会在服务端执行.

React Class 组件和 React Hook 的区别有哪些?

  • 1、ClassComponent更加注重依据数据类型分类,所以数据被存在this.state上,所有方法存在组件内部,而FunctionComponent更加注重依赖功能分类,对于复杂组件而言,FunctionComponent逻辑更加清晰.

  • 2、React Hook对于功能复用更加友好,自定义Hook可以实现单独的状态并实现高可用性的Hook.

  • 3、ReactComponent的副作用函数依赖于组件的生命周期,这意味着对于复杂组件而言,所有处理更新的操作都被写在同一个生命周期「ComponentDidUpdate」,当组件庞大时,函数可读性下降,在处理依赖关系时,也没有Hook更为友好.

  • 4、React的更新(挂载)生命周期函数为同步执行,这会导致脚本执行占用线程,使绘制滞后从而可能导致掉帧,React为此产生了两个Hook: useEffect,useLayOutEffect(前者会被创建一个新的任务执行从容不阻塞解析,后者与ClassComponent更新执行实际一致,可以解决页面数据闪烁.)

  • 5、**“FunctionComponent捕获render时的值”,这也是FunctionCOmponent产生的原因,下面让我们展开来讨论这个关键点

    • 下面时一段很经典的代码,应该是您是小白时会被很多视频提到,那么造成报错的原因就是this的与预期不符.
      class App extends React.Component {
        constructor() {
          super();
          this.state = {
            a: 123
          }
        }
        click() {
          console.log(this.state.a);
        }
        render() {
        return <div onClick={this.click}>{this.state.a}</div>
        }
      }
      
    • 好,下面我们再看一段
      • 如果您有较为好的js基础,那么结果应该是您可以预见的,但是,这合理吗?,结果是并不合理的,可以看到的是我们在定时器中打印的值并不是在执行函数阶段时的a值,「而造成该结果的原因是---我们并没有保留或者说捕获在执行该函数阶段时的state中的值,那么这就增加不可以预测性」 --- 举个🌰 ,我拍了拍我的同事,和他说,楼下放在A位置的面包很甜,过了一会儿,他去楼下吃了告诉我面包是咸的,而造成这一切的原因是店员将A位置的面包替换了,造成这一切的结果就是店员替换面包先于同事买面包.这就是所谓的不可预测性.而解决这个问题的方法就是,我告诉店员,这个面包留着,我同事等下要来买(闭包)
      class App extends React.Component {
         constructor() {
           super();
           this.state = {
             a: 123
           }
         }
         click() {
           setTimeout(() => {
             console.log(this.state.a); // 333
           },2000);
           this.setState({a: 333});
         }
         // 解决方案
         correctclick() {
             const a = this.state.a;
           setTimeout(() => {
             console.log(a); // 123
           },2000);
           this.setState({a: 333});
         }
         render() {
         return <div onClick={this.click.bind(this)}>{this.state.a}</div>
         }
       }
      
  • OK,看了上述的两段片段,下面是大总结了,实际上对于classComponent而言, 造成这种不可预测的原因是this的引用,在函数内执行时this始终指向当前实例,不断变化的是this之下的变量,而比较理想的实现是我创造的变量始终附着于本次更新阶段,functionComponent解决问题的方式是每次变量存在于当前Function中,那么即使每次更新要重写执行Component(),但是由于闭包,我们访问值在声明阶段就被确定,并且该值为独立个体,不存在会修改引用的问题,「props也是类似,在classComponent中依然需要依赖this去取值,这依然会导致捕获不到理想值的问题,而hook中,通过args的形式传入从而形成闭包,解决该问题」到这里你应该明白了functionComponent作出的改变,它解决了classComponent因this指向不变,而更新了this之下的状态,导致访问时,造成引用断开,而没有捕获到理想值的问题


React 中高阶函数和自定义 Hook 的优缺点?

  • 优点:
    • 1、逻辑与UI分离,可复用性强,低耦合.
    • 2、语法简洁易懂,相较于classComponent通过HOC和renderProps实现复用,复杂度更低.
    • 3、hook之间可以实现组合拆分,实现更小的颗粒度,
  • 缺点:
    • 1、学习成本增加.
    • 2、由于React特性,hook不允许写在块级作用域.
    • 3、因为状态以链表的形式存在,没有了classComponent的浅比较,每次父组件重渲染就会使子组件也重渲染.

简要说明 React Hook 中 useState 和 useEffect 的运行原理?

  • 1、useState.
    • useState是functionComponent中用于存储状态的,ReactHook维护了三张dispatcher列表,分别对应mount,update,其它三个阶段的执行.

    • 执行functionComponent时机是在beginWork创建workInProgressiberTree,React判断当前Fiber的类型,当匹配到FnCompnent时,执行RenderwithHook,并且根据是否存在上一次的FiberTree判断是mount阶段或者update阶段,从而设置当前dispatcher列表.

    • 初始化阶段....(下面问题中详细展开)

    • 初始化之后React返回两个参数,分别是「计算出来本来更新的状态值,用于更新状态的方法」.

    • 那么在接下来的函数执行中,所访问的该变量都会因为闭包的缘故,捕获正确的值无论执行执行时机如何.

    • 而React更新该状态的方法(第二个参数),实际是向初始化完的hook.queue中添加一次更新,并且发起一次任务调度选择(我们在这里并不展开),之后React会在合适的时机发起一次重渲染,当再次执行到当前Component()时,React发现当前的FiberTree存在current(上次更新结果),将currentDispatch匹配update表,在update表中,会解开存在当前hook上的更新队列,然后循环去执行符合条件的更新,生成结果值后返回

    这其中其实并没有那么简短,中间衍生了一系列React源码问题--循环链表,优先级,位运算,更新方式,调度时机等问题,笔者暂时不展开,更多源码建议您一步笔者的私房源码.


  • 2、useEffect
    • 笔者前面说到React维护了三张表mount,update,error,分别是挂载、更新、非函数组件执行三种.

    • 但是相较于useState,这确是一种大相径庭的hook,它和ComponentDidMount和DidUpdate类似,但是执行时机晚于它们,并且灵活性也更高,它的执行依赖它的第二个依赖参数数组,换句话说,当React在执行Component时,对于该hook,会判断它的依赖是否发生变化.

    • 依赖改变时,该副作用函数不会立刻执行,而是像setState一样,被丢进Fiber.updateQueue中(循环链表)注意: 不同于state维护的Queue,它存在于Fiber上

    • 而它的解开是依赖于本次effect.tag和fiber.flags,React将在funct,inComponent中需要触发的effect,依据effect类型(layOutEffect, effect, insertEffect),标记在fiber.flags上,用于在遍历FiberTree的副作用队列时提高性能,当在commit阶段找到fiber.flags有副作用函数需要执行时,遍历Fiber.updateQueue,再根据每个effect.tag去判断指定类型,从而执行它,React为了提升性能,将useEffect的回调函数以新的宏任务形式执行

React 如何发现重渲染、什么原因容易造成重渲染、如何避免重渲染

  • 对于整个FiberTree而言,当我们执行setState,React依据当前更新优先级调度一个任务时,整个FiberTree会发生一次重渲染(「React做了许多比较复用.」).

  • 下面我们说一下两种重渲染的场景

    • 1、当前组件触发渲染更新是怎么在创建workInprogressFiberTree中不触发它的父组件和兄弟组件的更新的?
    • 2、当前组件触发的更新,而子组件仅Props被带动更新更新时,是如何带动子组件更新的.
  • 对于某个Component而言,React依据车道和props来判断当前Fiber是否需要重渲染.

    • 当Props变化时,毋庸置疑需要重渲染,classComponent还会进行「isPureComponent」和「shouldComponentUpdate」判断

    • 而当我们执行一次setState时,React会将本次更新的更新车道附着到当前Fiber节点上,表示当前Fiber需要进行更新,在我们进行beginWork的时候便会利用该lane与本次更新的renderLane比较,如果在本次更新中,进行重渲染该Fiber,当然React对页面级的渲染还做了diff,提高性能.

    • 笔者到了这里思考了一种场景,那就是对于被更新FiberA,当进行beginWork时,执行到没有更新的FiberA父节点FiberParent时,是怎么进行操作的呢.

      • 答案是,我们在进行setState时,不仅对当前Fiber设置了lane,还对当前Fiber的所有祖先Fiber设置了childLane,那么我们就可以通过这个childLane,来决定是否需要继续DFS.
      • 1、当当前Fiber的pendingprops或lane存在,我们执行Component()并且返回workInprogress.child.
      • 2、当当前Fiber的pendingprops或lane不存在,判断childLane,childLane中不存在当前更新车道,return null,结束本次“递”的过程,开始执行complete,当存在childLane时,我们选择cloneFiber,并继续深入workInProgress.child.「笔者在上面的描述中有一些漏洞,我们在下面讲Diff的时候会补上这个漏洞」
    • 避免重渲染:

      • 1、对于Dom而言,我们需要对以组件列表形式存在的JSX.element添加“正确”的key,以便于避免ReactDiff错误的判断.

      • 2、对于classComponent而言,利用好pureComonent和shouldUpdateComponent,并且应该避免在UNSAFE_API中发起任务调度,造成死循环.

      • 3、对于functionComponent而言,避免renderPhaseUpdate.造成死循环,避免在书写循环式副作用.


React Hook 中 useEffect 有哪些参数,如何检测数组依赖项的变化?

  • create和deps,分别对应副作用依赖数组和组件初始化或依赖变化执行的副作用函数.

  • 结合lane,执行renderWithHook(),重渲染时,判断current.memoizedStates上对应的hook.deps和本次渲染的deps是否isEqual(同Object.is效果),当依赖变化时,标记当前effect.tag(HookHasEffect),并push进入Fiber.updateQueue,并且commit阶段指定时机执行.


React Hook 和闭包有什么关联关系?

  • ReactHook宗旨在捕获本次render时的props和内置state,结合闭包特性(作用域由函数声明阶段决定),而每次Rerender,我们又会重新执行renderWithHook,所有我们在渲染阶段所拿到的状态值不会因重渲染而断开或修改捕获,这都更加符合思考模式,这也是区别classComponent的地方,并且ReactHook还通过特性,产生了useMemo,useCallback等hook,当依赖不发生改变时,不GC或新建新的函数,提升性能.

React 中 useState 是如何做数据初始化的?

  • 在首次执行FunctionComponent时,依赖useState中的参数,将它作为当前hook.baseState,之后的更新依赖该值进行链式更新.

  • 并且在renderPhaseUpdate的值(函数执行过程中进行setState),可能会影响该值的初始化,但是在strictLacyMode下,React通过重复执行的方式,避免了render阶段进行setState而影响初始值的情况. ```js let m = 123; function App() { const [a,setA] = useState(123); if(m === 123) { setA(456); m = 111; } return

    {a}
    // 渲染值为123; }

// 源码
value = renderWithHooks(
    null,
    workInProgress,
    Component,
    props,
    context,
    renderLanes,
);
// 执行两次, 因为如果render阶段没有限制那么会循环执行,React会直接throwError
// 否则在严格模式下,React也禁止了因renderPhaseUpdate而引起的意外值.
if (workInProgress.mode & StrictLegacyMode) {
try {
  value = renderWithHooks( 
    null,
    workInProgress,
    Component,
    props,
    context,
    renderLanes,
  );
 } .... 
```

React中的Diff细节可以讲一下吗,是所有Fiber都需要Diff吗,React做了什么优化?又可能存在什么缺陷.

  • React的Diff是React提升性能的手段,但是实际是React在diff之前就存在一套前置优化.其实这在我们前面讨论 重渲染时有简短的说明了.下面我们需要补上上述的漏洞,也是回答上面是所有FIber都需要Diff?
    • 1、React会依据JSX -> ReactElement(包含props,$$typeof,type) -> Fiber.
    • 2、笔者在这里解析的过程中有三个疑惑,困扰很久.
      • 1、创建FiberTree,如何做到复用未更新Fiber? 这点我们在前面讲过,对于props和state未变化的Fiber,React依赖childLane决定结束当次DFS,或者cloneFiber,继续深入.
      • 2、React的props到底是什么组成的,其实这是JSX做的事情,JSX在解析时,将标签上的属性「keyValue存储」和标签内部的内容「children: (递归解析)」,作为props.其实这就产生了一个问题,我们在解析一个FunctionComponent或者ClassComponent时,会再解析它render出来的新的Fiber,这意味着这个Fiber和它之下的子Fiber上的props全部更新了,所以自它之下的Fiber全部需要重执行.「注意这里是Fiber层的执行,要区别与渲染」,我们将性能消耗由低到高排名是 「直接复用上次Fiber,不执行脚本」,「Props或状态变化,执行脚本,但是不触发DOM层的变化」,「由于key的变化或因状态或Props的变化而触发DOM层的变动」,其实state->dom的变化是极其合理的,这就是所谓的状态驱动,我们不需要关注DOM的变化,因为React已经为我们做好了铺垫,但是「面试官希望你关注DOM的变化😭」,而这里对DOM变化的控制就被称为DIFF算法
      • 3、那么是不是只要一个FunctionComponent或者ClassComponent状态或者props变化,它对应的dom结构就要做Dom层的重绘呢? 答案是否定的,Fiber层的重执行并不意味着Dom重绘,Dom重绘有一个标准就是当前Fiber极大可能是不存在Dom可复用对象.而这层判断就是diff为我们做的.

      上面的内容笔者可以说总结了一个礼拜,因为真的大多数的文章不知其所以然,他们只不过解释了一遍函数的翻译,当然,笔者也是以个人的思考模式和不断地打断点去思考它的目的,希望对您有帮助.

  • 下面我们来切入源码视角.(全程仅关注Diff重点, 笔者仅将diff中疑重点展开,不大篇幅阐述源码diff“算法”实现,参考移步 笔者的私房源码.)
     // 注意执行到这里的时候一定意味着,当前Fiber存在state或者props的变化
     // 拿到render返回的ReactElement并将它转化为Fiber
     nextChildren = renderWithHooks(
      current,
      workInProgress,
      Component,
      nextProps,
      context,
      renderLanes,
    );
    
    // 该函数中React判断mount和update并设置一个全局变量来标识.
    reconcileChildren(current, workInProgress, nextChildren, renderLanes);
    
    // reconcileChildFibers
    // 取出babel解析后的elementObject,和上次update或mount的Fiber进行Diff
    
    // React判断顶层无键代码片段,直接将它作为子片段.
    const isUnkeyedTopLevelFragment =
      typeof newChild === 'object' &&
      newChild !== null &&
      newChild.type === REACT_FRAGMENT_TYPE &&
      newChild.key === null;
    if (isUnkeyedTopLevelFragment) {
      newChild = newChild.props.children;
    }
    
    • 🌹 --- React会扁平化顶层无键片段,但是不会扁平化在子项中的Fragment,那能这么实现的原因是顶层无键片段总是被FunctionComponent或ClassComponent包裹,等于在Diff的时候它的returnFiber这关要先过,但是子项的Fragment是不可以被扁平化的,因为子项的Fragment总是以一个整体的形式存在,如果将它扁平化了那么,它实际是跨层了,这种跨层会极大降低Fiber重用率.
    • 我们看到的以迭代器数组形式存在的DomList实际也是被Fragment包裹的,🌹 --- 这种包裹的目的总是表示: 它们属于同一类型的Dom结构
      • 看下面代码,让我们来看一下React中key的作用有多强大.您可以调适下面片段.
        • 我们首先点击第一个BBB组件的<div>标签,此时<div>内的值变化,之后点击外层123213131,我们交换了两个标签的位置,但实际是两个标签位置并未变化「从两个div中值未交换顺序看出」
      function BBB() {
        const [a, setA] = useState('BBB');
        return <div onClick={(e) => {setA('111'); e.stopPropagation()}}>{a}</div>
      }
      function App() {
        const [v, setV] = useState([<BBB name="12314"></BBB>,<BBB></BBB>]);
        return <div onClick={() => setV((v) => {const m = [...v]; m.reverse(); return m})}>
          123213131
          {v}
          <BBB key="123"></BBB>
        </div>
      }
      
    • 这里笔者不重新讲一遍DIFF了,但是笔者想讲一下DIFF中,很多“笨蛋”博主没有讲到的.下面希望您基本了解Diff算法
      for (; oldFiber !== null && newIdx < newChildren.length; newIdx++) {
        // Diff遍历
        if (oldFiber.index > newIdx) {
        // 这里的判断一直困扰笔者,笔者认为oldFiber应该是有序的,并且newIdx也是等差递增的
        // 最近笔者发现了这里的意义,实际就是我们很可能存在占位符的概念
        // 我们将Dom写入变量中,并且将变量初始值为null,使Fiber在进行“递”“归”时忽略又不忽略该变量
        // 忽略是我们在DOM无视该无效值,不忽略是我们依然将其作为一个单元Unit,并不无视它的index
        // 当该Fiber更新时,currentFiber可能就因占位问题,导致index无序
        // 为了保证正确的复用,我们就需要暂停该oldFiber的移动.
          nextOldFiber = oldFiber;
          oldFiber = null;
        } else {
          nextOldFiber = oldFiber.sibling;
        }
      }
      
    • React在DIFF时,会给出复用或者新建的Fiber,并用返回的Fiber.alternate(上次更新的Fiber)判断是否为复用,若非复用Fiber,标记PlaceMent,那么需要进行Dom层重绘.
    • 看下面一段代码,你能猜出答案吗?
    function BCC() {
      console.log('BC'); // 打印
      return <div>123</div>
    }
    function BAA() {
      return <div>
        BAA
        <BCC></BCC>
      </div>
    }
    function App() {
      const [a,setA] = useState('123');
      return <div>
        // 点击
        <div onClick={setA.bind(null, Math.random())}>12131313</div>
        <BAA></BAA>
      </div>
    }
    
    • 好吧,虽然笔者之前已经阐述过了,但是还是要强调一下,一旦触发某个Component(),那么它创建出来的ReactElement上所有对应Fiber全部需要更新,因为props全部新建
    • 所以我们应该尽量实现组件封装,多用状态,因为一旦某一层的Fiber开始开启调度,那么它的子树全部需要重执行.

额,很不好意思的是笔者这里没有展开Diff算法,因为在另一篇文章中或者大多数前端er的文章中已经有相关阐述了,笔者这里只是将Diff的前置和后置做基本的阐述.不得不说的是React的递归思路和大量的变量真的很绕.

setState是异步的吗?给出理由?

  • setState的执行是同步的,而我们讨论的一直是调度是同步还是异步的,如果一个人敢说自己精通源码,而直接说setState是异步的,那他可以走了.
  • 其实setState是操作极为简单,它不过是向当前Hook中增加了一条更新,并开启一次调度(这才是关键),而决定调度条件的是React.mode执行环境调度优先级.
  • React.mode主要有legacyMode「React.render()」和concurrentMode「React.createRoot().render()」.
  • React确定一次任务的调度,是通过ensureRootIsScheduled
// 截取一段
if (newCallbackPriority === SyncLane) {
 if (root.tag === LegacyRoot) {
   // 比起else中的函数,设置一个flag,这个flag非常关键,
   scheduleLegacySyncCallback(performSyncWorkOnRoot.bind(null, root));
 } else {
   scheduleSyncCallback(performSyncWorkOnRoot.bind(null, root));
 }
 .....将执行syncQueue的任务放入微任务队列
}

else { ...根据优先级放入异步调度队列. }

// 依赖该flag决定是否同步执行的函数.
// 该函数存在schedulerUpdateOnFiber和batchUpdate中.
flushSyncCallbacksOnlyInLegacyMode() 
  • 1、legacyMode下,任务不存在优先级,全部为紧急任务(SyncLane).
  • 2、所有紧急任务被放在syncQueue中.
  • 3、React在一次事件执行完成后(batchUpdate),会执行flushSyncCallbacksOnlyInLegacyMode,那么所有SyncLane的调度任务需要被清空(值得注意的是: 该情况的满足条件为syncLanelegacyRoot)
  • 4、React在scheduleUpdateOnFiber中也存在该函数.这就是为什么定时器内调度会同步执行的原因.
 ensureRootIsScheduled(root, eventTime); // 存放调度任务,可能设置flag
 if (lane === SyncLane && 
 // 判断当前上下文,非render(执行Component)或commit(useLayOutEffect)
 executionContext === NoContext &&  
 (fiber.mode & ConcurrentMode) === NoMode && // 判断是否为并发模式) {
   flushSyncCallbacksOnlyInLegacyMode();
 }
  • 5、React在concurrentMode下实现了一套控制任务优先级调度的优先队列机制,在笔者的私房源码中,笔者有详细讲解,这里不多赘述.

下面我们来总结一下.1、React对紧急任务,或者legacyMode下的所有任务都放置在紧急车道,并且一般情况下会以微任务的形式调度,但在某些特定情况下会同步执行. --- 2、React对于concurrentMode下的非紧急任务会根据优先级给予不同的过期时间,从而依赖任务的紧急程度设定执行顺序. --- 3、我们所说的更新从来不是设置一个变量而已,而是发起一次FiberTree的重执行,从而更新那些被标记了的状态或Node.

说一下HOC、render props?

  • 1、HOC(高阶组件)是一种实现组件复用模式. -如果你知道高阶函数,那么高阶组件就可以同理得出了,是一种以组件为入参出参的函数,实现功能间和数据间的解耦和复用.
    • 这种复用对于复杂组件而言可能存在深层的嵌套关系,提高复杂性和可读性.
    • 在数据方面,可能由于使用HOC模式,无法正确的判别props的传入是上层组件还是封装组件内部.并且props可能存在覆盖性,后传入的props会将传入的props覆盖.
  • 2、render props
    • 以props参数的形式传入需要渲染的函数组件,复用组件的抽离逻辑和生命周期后将复用逻辑以参数形式传给函数组件.实现复用.
    • 相比较HOC,更加灵活,但是本身依然存在复杂性.
  • 3、将比较上两种围绕类组件实现复用的方式,React团队重构Hook,实现了更符合UI一致性和正确逻辑的开发模式.但是这也提高了学习的成本.

说一下React中的时间分片?

React的时间分片也是一个人云亦云的话题了,那要是我来问,那就直接“时间分片什么时候触发” , “什么时候中断脚本” “脚本执行时间多久,请说一下各种情况下的允许时间”,你能回答出来吗😊

  • 1、时间分片存在workLoopConcurrent「创建FierTree」和workLoop「调度任务」
  • 2、说到时间分片,笔者就需要提到任务调度浏览器原理.笔者会较为简短的描述它.
    • 任务调度: React有两类任务单元,紧急任务数组和单次任务调度.之前讲到过,对于紧急任务而言会被放入一个syncQueue,React将执行这个数组作为一个任务单元,并且这些任务单元会被React放入优先队列中根据过期时间「由任务优先级决定」和开始时间以宏任务的方式决定执行时机
    • 浏览器原理: 在浏览器中会将所有任务放入一个任务队列「满足先进先出」中,这里的任务指的是「执行脚本」,「绘制渲染」,「messageChannelReact调度的方式」,「rAF」,「setImmidatenode」等,除此之外还有延时队列存放定时器任务,在开始执行宏任务时,会检测到期定时任务,如果过期则执行.并且每个宏任务又有一个微任务队列,在执行完每个宏任务后,会清空微任务队列.
    • 那么时间分片就是防止执行脚本占满一帧,导致渲染来不及执行而造成掉帧.
    • 请看下面代码片段,两次弹出为父子关系,我们以流水线的形式模拟下面的任务,我们加工一个零件需要经历多个环节workLoopConcurrent,这个任务可能因车间停电而暂停,那么这个暂停就会导致所有加工都暂停shouldYieldToHost,这个暂停就让出了渲染主线程.那么下个阶段我们就看一下,React是如何判断退出的.
    function workLoopConcurrent() {
      while (workInProgress !== null && !shouldYield()) {
        performUnitOfWork(workInProgress);
      }
    }
    
    function workLoop(hasTimeRemaining, initialTime) {
      let currentTime = initialTime;
      // 当前时间下需要执行的任务,被放入到taskQueue中
      advanceTimers(currentTime);
      // 根据执行时机循环
      currentTask = peek(taskQueue);
      while (currentTask !== null &&!(enableSchedulerDebugging && isSchedulerPaused)) {
        if (
          currentTask.expirationTime > currentTime &&
          (!hasTimeRemaining || shouldYieldToHost()) // 任务分片.
        ) {
          break;
        }
        ...
        const callback = currentTask.callback;
        // 在执行这次调度时的返回值.
        const continuationCallback = callback(didUserCallbackTimeout);
        if(typeof continuationCallback === 'function')
          // 不弹出该任务,任务被中断,上述workLoopConcurrent的时间分片将造成中断
          currentTask.callback = continuationCallback; 
          ...
    
    • 看下面的代码.
      • 首先React根据Date.now()和在执行开始时存在的闭包变量获取脚本执行时间.
      • 接下来React检测了帧间隔「5ms」.
      • 判断是否支持isInputPending API
        • 支持则再次判断continuousInputInterval,如果小于该时间,我们判断离散事件,并跳过持续输入事件「例如 mouseMove等」
        • 再判断最大间隔,如果存在事件发生,则直接退出.否则继续执行
        • 不支持该API直接暂停.
    function shouldYieldToHost() {
      const timeElapsed = getCurrentTime() - startTime;
      // 给5ms时间,如果没到允许执行.
      if (timeElapsed < frameInterval) {
        return false;
      }
      if (enableIsInputPending) {
        // 需要重绘直接退出,暂停
        if (needsPaint) {
          return true;
        }
        // 对于当前过期时间比持续输入输入间隔短的
        if (timeElapsed < continuousInputInterval) {
          // 暂停离散事件
          if (isInputPending !== null) {
            return isInputPending();
          }
        } else if (timeElapsed < maxInterval) {
          // 对于离散和持续输入直接暂停
          if (isInputPending !== null) {
            return isInputPending(continuousOptions);
          }
        } else {
          return true;
        }
      }
      return true;
    }
    

详细描述一下React-redux和redux和React自带跨组件传参?

  • React自带的跨组件传参为createContext_API,可以做简单的跨组件传参.这里笔者撕不动了,所有没有写useContext、Provider的相关文章,如果后面有时间的话,可能会回过头来看撕一下该文章.
    const reducer = (preState, action) {
      switch(action.type) {
          case('a'): return {...preState, a: '124'}
          default: return {...preState}
      }
    }
    const context = React.creatContext();
    const {
      Provider,
      Consumer
    } = context;
    
    function Detail() {
        // 方式二
        const context = useContext(context);
        return <div>{context.state.a}</div>
    }
    
    function App() {
      const [state, dispatch] = useReducer(reducer, {a: '123'});
      return <Provider value={{state, dispatch}}>
      // 方式一
        <Consumer>{(val) => <div>{val.state.a}</div>}</Consumer>
        <Detail></Detail>
      </Provider>
    }
    
  • redux一种做状态管理的JS库,遵循单项数据流.
    • state,action,reducer,dispatch构成数据流的顺序为 --- dispatch执行action行为 -> reducer处理对应行为 -> 返回state.
    • 当然作为处理大型项目状态管理的工具,那么就意味着大量复杂状态需要维护,redux为我们维护了合并多个reducer的APIcombineReducers,如果您看过源码,就知道这里的combine其实是将您的action放到reducers中遍历,更新每一个reducer,并执行所有subscribe回调,其实这就会产生一个缺陷,那就是需要保证reducer中action.type的唯一性,否则不同reducer中,判断相同type会导致数据意外变化,产生的不可追踪.不可思议的是,Redux做了一层判断,来确定每个Redux的state是否变化,笔者原先以为这层判断是Redux在做性能优化,当所有redux的state都不变化时便不通知监听触发回调,但是Redux也并没有这么做.只要您触发dispatch,那么一定会遍历所有subScribe回调并执行,当然这么做的好处是我们不必关心因引用未发生改变而导致未重渲染的问题
  • React-redux,React相关绑定库,在Redux的基础上做了自动绑定监听,并利用装饰器的方式实现高阶组件.以组合的方式向组件添加Props.在Hooks到来以后,出现了useSelectoruseDispatch,简化了获取状态管理数据的方式.笔者大概看了一下源码,其实际也是利用redux + 原生Provider进行封装,重定义了Provider的value,内部自动实现组件的通知更新,「笔者不太想翻这一块了,😭,就偷个懒吧,其实React-redux就是通过HOC和Hooks两种模式来控制状态驱动视图.」

React中绑定的事件是原生的吗?不是的话和原生事件有什么差别?React是如何做到平台统一?

  • React为消除浏览器间的差异,并且为了控制不同事件的优先级,自定义了一类合成事件,并且大部分的事件都绑定到了root上,对于部分无法授权的事件 --- 类似onScroll、video等上的事件,React还是将事件绑定到了元素本身,这些事件无法冒泡,无法被上层root控制执行,其余原生事件React都将其绑定到了Root上,然后让事件自动冒泡到Root上后批量执行.

  • 这就衍生出了一些问题.

    • 1、React如何保证事件能冒泡到Root上呢,如果我们在事件中将它阻止冒泡,那么React不是没法控制了吗?

    • 2、React又是如何控制冒泡和捕获的呢?

    • 3、即使事件可以冒泡到Root上,那么在某次冒泡函数执行中,执行e.stopPropagation(),又是如何阻止执行的呢?

  • 下面我们来解析一下React是如何解决的吧,首先你要明白的是props上的事件「onClick为例」,并不是在元素上绑定原生事件,你在里面可以拿到e,完全是React给你的,这就意味着即使你在事件中写了阻止冒泡,依然会冒泡到Root上,因为它不代表原生事件监听,当然对于无法授权的事件,也就是说不会冒泡的事件,React还是采取了直接绑定到当前元素上,而在你触发事件之前,RootFiber就已经预处理了一系列事件,这些事件根据事件类型被分配不同优先级触发「discretePriority,ContinuousEventPriority,message内含多个,defaultEventPriority」「这个message可以不是什么事件哦,还记得我们说的创建调度宏任务吗?MessageChannel就是通过message事件触发」,我们在事件篇就不多介绍优先级了.

  • 这些事件由于冒泡触发时,我们根据冒泡的e.target和Fiber架构下元素实例instance和Fiber之间的映射关系,拿到对应Fiber,之后我们便可以依赖FiberTree,拿到上层props上对应EventName的执行事件数组「React的冒泡」,并且对应EventName会合成不同的事件「e上的属性不同,执行的优先级不同」,那现在您应该懂了吧,React机智的前置合成了事件与执行数组这里的执行数组笔者没有想到原因,笔者认为,这里只需要一个执行单元,「合成事件e, 因冒泡或捕获,从FiberTree上拿到与EventName相同的祖先事件数组」而React则是执行单元数组,那么剩下的就是将e传入祖先事件数组,遍历执行祖先事件.「而冒泡和捕获,我就可以通过事件名称| 冒泡和捕获不同 |,来判断,从而决定正序遍历还是逆序遍历.」

  • 「batchUpdate」: 笔者这里顺带提一下React下的批处理,笔者在前文有讲过上下文的概念executionContext,有renderContext, commitContext.实际还有一种batchContext,对应批处理,React的事件执行之前,是被包裹在batchUpdate中的,实际这个函数就是标记了这个执行上下文,笔者前文在讲到setState的调度时,就说过,setState同步执行的情况,就是在「执行上下文为NoContext,并且为LegacyMode下」,React在这里利用这个batchContext,就是避免了在LegacyMode下,我们在事件中多次调用setState,而此时不设置BatchContext,则executionContext为NoContext,造成本次setState发起同步调度,因为执行本次事件函数的优先级一致「根据EventName就已经确定」,所有这些任务会被放在同一车道上,从而批量更新,这就是著名的批量更新


描述一下React的受控组件?能说一下受控组件的原理吗?

  • 受控组件指的是对于Input而言,当我们设置将value与状态相绑定时,它的值仅依赖状态的变化而变化,并不依赖在Input中输入值而改变Input中的值.
  • 笔者老样子,还是源码级分析,上文中,笔者提到,事件由React控制,并且被batchUpdate所包裹,下面我们还是来看一下源码.
function batchedUpdates(fn, a, b) {
  ... 忽略所有无关信息
  try {
    // 执行批处理
    return batchedUpdatesImpl(fn, a, b);
  } finally {
    // 完成事件句柄 ~~~ 重点函数.
    finishEventHandler();
  }
}
function finishEventHandler() {
    // 判断是否存在受控组件
  var controlledComponentsHavePendingUpdates = needsStateRestore();
  if (controlledComponentsHavePendingUpdates) {
    // ~~~~ 关键操作. 我们解开了同步优先级的事件调度数组.
    flushSyncImpl();
    // ~~~ 这里我们进行重制操作.
    restoreStateIfNeeded();
  }
}
  • 光看上面的代码也许您有点无法理解,笔者这里来总结一下.
    • 1、任何事件的执行由React控制.
    • 2、事件被冒泡到Root上时,Root根据事件类型、FiberTree上的冒泡函数等合成一个dispatchQueue单元数组,以合成事件和被执行函数列表作为一个单元,在合成这个数组过程中,React判断了受控组件「依据元素标签,主要由input、texteara、select」,合成执行单元,并将此元素推入「还原队列」(实现受控组件的关键一步)
    • 3、如上所示,我们在finishEventHandler中,执行flushSyncImpl,此函数会调度所有紧急任务[sync],而离散事件「change,click等」的优先级便是sync,那么就表示在此处我们执行所有离散调度,我们不多考虑优先级问题,此处只要知道onChange函数体会在此处被执行
    • 4、onChange中的所有状态设置[setState]以及触发的调度,都以同步的方式被执行,然后我们再看restoreStateIfNeeded,它遍历还原队列,拿到所有target和对应的fiber映射,那么此时我们便可以依赖fiber.props更新对应target元素的[checked]和[value],能这么做的原因完全是因为「我们已经执行了在onChange中的离散更新(类似setValue的调度),此时元素对应FiberTree得到更新,所以在restoreState中拿到的props都是最新的,如果没有[setState],fiber.props为输入之前的值,否则为[setState]后更新的props,这就做到了重制与更新,实现了受控.」

好了,笔者讲完了受控组件,笔者也有很大的启发,其实受控组件,也沾了批量更新的红利,依赖批量更新完的状态,实现视图控制.


结语

源码翻到这里已经经过了三个多月了,笔者感受到了源码的“魅力”😢 这两篇文章也是让笔者下了一番功夫,笔者尽量贴近React团队初衷,由浅入深的去感受源码设计,希望这两篇文章对您有较好的帮助.最后,笔者想说的是如果您真的想理解源码,必须亲自手撕,他和您看文章是一定有很大区别的.届时,我想笔者的文章应该是能更好的帮到你.