整体架构简述

  1. reactv16的整体架构和特点?
    React的整体架构分为 Scheduler(调度) — Reconciler(render) — Renderer(commit)
    Scheduler的工作主要是调度更新任务;Reconciler的工作是构造fiber树,并找出更新前后的不同;而Renderer的任务是将更新渲染到浏览器中,过程中包括调用生命周期和hooks方法。

另外,React16 的更新工作变成了可以中断的循环过程。Scheduler和Reconciler的工作可能因为这样的问题被中断:

  • 有其他更高优任务需要先更新
  • 当前帧没有剩余时间

整体流程

关于Fiber

  1. fiber node、 fiber tree是什么?
    fiber是包含节点信息的数据结构,fiber包含的数据包括:
  • 静态数据结构,每一个fiber节点对应一个react element,保存了类型、对应节点的DOM信息等;
  • 动态工作单元,每一个fiber节点,保存了本次更新中,该组件的改变的状态,要执行的工作…
    fiebr tree就是由fiber组合起来的树状结构;
  1. fiber tree的结构和遍历方式?
    fiber节点中和fiber tree结构相关的属性有:child、sibling、return;
    遍历方式是:
    简单说就是从根节点出发,依次遍历child、sibling,如果即没有child,也没有sibling的话就return到父节点;
    结合render阶段的话就是:
    1) 从rootFiber开始向后遍历;遍历child、sibling;
    2) 每遍历到一个节点,执行其beginWork方法;
    3) 当某一个节点,该节点没有child属性,执行其completeWork方法;
    4) 如果这个节点存在sibling,则继续遍历sibling;
    5) 如果不存在sibling,则return到父节点,执行父节点的completeWork;
  2. rootFiber和fiberRoot?
    rootFiber是一个fiber节点,是一颗fiber tree的根节点;
    fiberRoot是应用的根节点,fiberRoot有一个重要的属性current,指向当前页面对应的fiber tree的根节点。
  3. 双缓存结构,current fiber 和 workInProgress fiber?
    React中最多会存在两颗Fiber Tree:
  • current Fiber Tree: 当前屏幕上显示内容对应的;
  • workInProgress Fiber Tree: 正在构建中的;
    两颗树的交替发生在commit阶段的最后,由fiberRoot的current指针指向workInProgress Fiber Tree的根节点rootFiber,就算是完成交替过程。

render阶段

render阶段,总的来说是对fiber tree从上到下的遍历过程,在遍历的过程中,调用beginWorkcompleteWork,最终完成给需要更新的fiber节点打上标记,以及将所有要更新的fiber node有effectList串联起来的过程。

render过程是构建并遍历fiber 双缓存结构中workInProgress的那棵树的过程。
得益于fiber tree的结构:child、sibling、return,遍历过程其实可以分为遍历阶段和回归阶段,就是“递”和“归”。

  1. 在遍历阶段核心方法beginWork,试图建立一颗新的workInProgress,当然从跟节点开始,到child,或者再到child或者sibling…最终我们能得到一颗完整的workInProgerss Tree,同时在需要变化的节点上还会有EffectTag作为标志。
    这个过程之所以会区分mount以及update,是因为mount阶段是一定要新建立节点的。
    update的情况会复杂点,还要考虑旧的节点能不能复用以及其子树需要更新吗,如果不能复用我们还需要借助reconcileChildFiber函数,使用DIFF,当然,最终还是返回带有effect tag的新的节点。
  2. 在回归阶段处理了很多事情,核心处理函数是completeWork,比如需要mount的fiber节点,要在fiber node上挂上真实的dom,比如update的节点要检查事件监听等,另外,还要把这些变化的节点中需要变化的属性找出来(用数组的形式存放在属性中: workInProgress.updateQueue)。
  3. 上述过程看上去像是完全分离的两个步骤,就像对一颗树处理,先走所有节点的beginWork,再走completeWork。但是,实际上有可能是交替进行,站在fiber tree的视角上看可能是交错的,但是站在每一个fiber node的视角看,一定是先beginWork,completeWork。具体顺序是按照这样的原则:
    1)从rootFiber开始向后遍历;
    2)遍历child、sibling,每遍历到一个节点,执行其beginWork方法;
    3)当某一个节点,该节点没有child属性,执行其completeWork方法;
    4)如果这个节点存在sibling,则继续遍历sibling;如果不存在sibling,则return到父节点,执行父节点的completeWork;
  4. 总之,总会对每一个节点都走一遍这两个方法的,最终我们在workInProgress的rootFiber中会有这样的属性收集了所有需要更新的节点信息数据结构,就是,fristEffect(有effectTag的Fiber节点都会被追加在effectList中,最终形成一条以rootFiber.firstEffect为起点的单向链表)

commit阶段

之前render过程的最后我们拿到了结果:由effectTag fiberNode组成的effectList单向链表。
后续的事情都是围绕effectList上的节点展开:核心就是操作DOM,以及操作DOM前后的工作,所以,从逻辑上我们将整个过程分为:before mutation、mutation、layout三个部分。

  1. 在before mutation阶段:
  • 处理DOM节点渲染/删除后的 autoFocus、blur 逻辑;
  • 调用getSnapshotBeforeUpdate生命周期钩子;
  • 调度useEffect;没有调用。(具体useEffect和useLayoutEffect见hooks)
  1. 在mutation阶段:
  • 更新ref;
  • 根据effectTag分别处理,其中effectTag包括(Placement插入 | Update更新 | Deletion删除 …),更新DOM
  1. 在layout阶段:
  • 触发componentDidMountcomponentDidUpdate,useLayoutEffect以及调度useEffect;
  • 获取DOM实例,更新ref;

更新

更新流程

  1. 触发状态更新(根据场景调用不同方法)
  2. 创建Update对象
  3. 从fiber到root(markUpdateLaneFromFiberToRoot)
    从触发状态更新的fiber一直向上遍历到rootFiber,并返回rootFiber
  4. 调度更新(ensureRootIsScheduled)
    通知Scheduler根据优先级,决定以同步还是异步的方式调度本次更新
  5. render阶段(performSyncWorkOnRoot 或 performConcurrentWorkOnRoot)
    给需要更新的fiber节点打上标记
  6. commit阶段(commitRoot)
    更新dom等…

触发更新的方式

  • ReactDOM.render
  • this.setState
  • this.forceUpdate
  • useState
  • useReducer

Update对象

  1. 数据结构
    1. const update: Update<*> = {
    2. // 任务时间
    3. eventTime,
    4. // 优先级相关字段
    5. lane,
    6. suspenseConfig,
    7. // 更新的类型,包括UpdateState | ReplaceState | ForceUpdate | CaptureUpdate
    8. tag: UpdateState,
    9. // 更新挂载的数据,不同类型组件挂载的数据不同
    10. payload: null,
    11. callback: null,
    12. // 与其他Update连接形成链表
    13. next: null,
    14. };

next指针将多个Update对象链接成链表结构。
一个Fiber节点上的Update链表并被包含在fiber.updateQueue中。

优先级

每当需要调度任务时,React会调用Scheduler提供的方法runWithPriority,方法接收一个优先级常量,一个回调函数作为参数。回调函数会以优先级高低为顺序排列在一个定时器中并在合适的时间触发。

this.setState之后的流程

  1. this.updater.enqueueSetState;
  2. 在enqueueSetState方法中就是熟悉的从创建update到调度update;
    1)通过组件实例获取对应fiber
    2)获取优先级
    3)将update插入updateQueue
    4)调度update
  3. render阶段
  4. commit阶段

Scheduler的工作原理

基于任务优先级和时间片的概念,Scheduler围绕着它的核心目标 - 任务调度,衍生出了两大核心功能:任务队列管理 和 时间片下任务的中断和恢复。
任务优先级让任务按照自身的紧急程度排序,这样可以让优先级最高的任务最先被执行到。
时间片规定的是单个任务在这一帧内最大的执行时间,任务一旦执行时间超过时间片,则会被打断,有节制地执行任务。这样可以保证页面不会因为任务连续执行的时间过长而产生卡顿。

Scheduler管理着taskQueue和timerQueue两个队列,它会定期将timerQueue中的过期任务放到taskQueue中,然后让调度者通知执行者循环taskQueue执行掉每一个任务。执行者控制着每个任务的执行,一旦某个任务的执行时间超出时间片的限制。就会被中断,然后当前的执行者退场,退场之前会通知调度者再去调度一个新的执行者继续完成这个任务,新的执行者在执行任务时依旧会根据时间片中断任务,然后退场,重复这一过程,直到当前这个任务彻底完成后,将任务从taskQueue出队。taskQueue中每一个任务都被这样处理,最终完成所有任务,这就是Scheduler的完整工作流程。

hooks原理

hooks数据结构

当函数组件进入render阶段时,会被renderWithHooks函数处理。函数组件作为一个函数,它的渲染其实就是函数调用,而函数组件又会调用React提供的hooks函数。
mount和update,所用的hooks函数是不同的。

每调用一次hooks函数,都会产生一个hook对象与之对应。以下是hook对象的结构。hook1—>next—->hook2这个是一个链表结构,链表存储到函数组件fiber.memoizedState上, workInProgressHook是一个记录当前正在调用的指针:

  1. {
  2. baseQueue: null,
  3. baseState: 'hook1',
  4. memoizedState: null,
  5. queue: null,
  6. next: { // next指针
  7. baseQueue: null,
  8. baseState: null,
  9. memoizedState: 'hook2',
  10. next: null
  11. queue: null
  12. }
  13. }

mount(update)WorkInProgressHook函数

  • mountWorkInProgressHook:初始挂载调用,它会创建hook,连接成链表,同时更新workInProgressHook,最终返回新创建的hook,也就是hooks链表;
  • updateWorkInProgressHook:更新时,调用的是该函数,通过currentHook指针,拿到currentTree、workInProgress Tree之间对应的相同位置的hook对象,从而拿到先后memoizedState值;
  1. currentTree
  2. current.memoizedState = hookA -> hookB -> hookC
  3. ^
  4. currentHook
  5. |
  6. workInProgress Tree |
  7. |
  8. workInProgress.memoizedState = hookA -> hookB
  9. ^
  10. workInProgressHook

useState

比如mount阶段useState的实现大致就是这个结构:

  1. const HooksDispatcherOnMount: Dispatcher = {
  2. ...
  3. useState: mountState,
  4. ...
  5. };
  6. //
  7. function mountState<S>(
  8. initialState: (() => S) | S,
  9. ): [S, Dispatch<BasicStateAction<S>>] {
  10. // 获取hook对象
  11. const hook = mountWorkInProgressHook();
  12. // 对hook对象的处理
  13. ...
  14. return [hook.memoizedState, dispatch];
  15. }

useEffect和useLayoutEffect

effect就是上文中的hook,所以有函数式组件中调用的了几个use(layout)Effect,就会产生多上个effect形成链表,和上面介绍的一样,只不过链表中每个effect对象的数据结构是:

  • create: 传入use(Layout)Effect函数的第一个参数,即回调函数;
  • destroy: 回调函数return的函数,在该effect销毁的时候执行;
  • deps: 依赖项
  • next: 指向下一个effect
  • tag: effect的类型,区分是useEffect还是useLayoutEffect

调度:

  1. react在render阶段做的是,创建出新的hooks链表,挂在WorkInProgress Fiber 的memorizedState上,这时候链表中的effect和先前状态的effect的依赖项目对比,如果不同,就可以被处理;
  2. commit阶段,异步处理useEffect,等到layout阶段完成调度useEffect,而useLayuotEffect是在layout阶段同步处理的。
    这也就是他们的不同;

详细点说,useEffect的调度是通过调度器的函数scheduleCallback,

  1. scheduleCallback(NormalSchedulerPriority,
  2. () => {
  3. flushPassiveEffects(); // 调用这个回调时执行的函数
  4. return null;
  5. }
  6. );

这一步发生在commit的beforeMutation阶段,这只是去调度,回调函数在等待其调用时机,到那个时候才会真正执行effect;
而到了commit的layout阶段后期,调度useEffect是schedulePassiveEffects函数中,向EffectUnmount、EffectMount数组中填充数据,此前都是空的。
最后,当调度器调用flushPassiveEffects的时候,其实遍历的就是这两个数组,依次调用;

Diff

Diff算法的3个假设使得时间复杂度从o(n^3) ->o(n):

  1. 只对同级元素进行Diff。如果一个DOM节点在前后两次更新中跨越了层级,那么React不会尝试复用他;
  2. 两个不同类型的元素会产生出不同的树。如果元素由div变为p,React会销毁div及其子孙节点,并新建p及其子孙节点;
  3. 可以通过 key prop来暗示哪些子元素在不同的渲染下能保持稳定;

Diff的入口函数reconcileChildFibers,分了两种情况:

  1. 当newChild类型为object、number、string,代表同级只有一个节点;
  2. 当newChild类型为Array,同级有多个节点;

单节点Diff

React通过先判断key是否相同,如果key相同则判断type是否相同,只有都相同时一个DOM节点才能复用;

多节点Diff

多节点Diff算法的整体逻辑会经历两次for循环(但每个节点只会遍历到一次,直到超出newChildren长度结束)
第一次:处理更新的节点;
第二次:处理剩下的不属于更新的节点;

第一次:
判断如果可复用,继续遍历child、sibling

如果不可复用,分两种情况:

  • key不同导致不可复用,立即跳出整个遍历,第一次遍历结束;
  • key相同type不同导致不可复用,会将oldFiber标记为DELETION,并继续遍历;

如果newChildren遍历完 或 oldFiber遍历完,跳出遍历,第一次遍历结束

第二次四种情况:
1)newChildren与oldFiber同时遍历完;
只需在第一次遍历进行组件更新 (opens new window)。此时Diff结束;

2)newChildren没遍历完,oldFiber遍历完;
本次更新有新节点插入,我们只需要遍历剩下的newChildren为生成的workInProgress fiber依次标记Placement;

3)newChildren遍历完,oldFiber没遍历完;
有节点被删除了。所以需要遍历剩下的oldFiber,依次标记Deletion;

4)newChildren与oldFiber都没遍历完;
思路 借助lastPlacedIndex标记出需要移动的节点;lastPlacedIndex最后一个可复用的节点在oldFiber中的位置索引。比较lastPlacedIndex和newfiber遍历节点对应在oldfiber中的index,来决定是不是需要移动的一种方法。

demo:

  1. // 之前
  2. abcd
  3. // 之后
  4. acdb
  5. ===第一轮遍历开始===
  6. a(之后)vs a(之前)
  7. key不变,可复用
  8. 此时 a 对应的oldFiber(之前的a)在之前的数组(abcd)中索引为0
  9. 所以 lastPlacedIndex = 0;
  10. 继续第一轮遍历...
  11. c(之后)vs b(之前)
  12. key改变,不能复用,跳出第一轮遍历
  13. 此时 lastPlacedIndex === 0;
  14. ===第一轮遍历结束===
  15. ===第二轮遍历开始===
  16. newChildren === cdb,没用完,不需要执行删除旧节点
  17. oldFiber === bcd,没用完,不需要执行插入新节点
  18. 将剩余oldFiberbcd)保存为map
  19. // 当前oldFiber:bcd
  20. // 当前newChildren:cdb
  21. 继续遍历剩余newChildren
  22. key === c oldFiber中存在
  23. const oldIndex = c(之前).index;
  24. 此时 oldIndex === 2; // 之前节点为 abcd,所以c.index === 2
  25. 比较 oldIndex lastPlacedIndex;
  26. 如果 oldIndex >= lastPlacedIndex 代表该可复用节点不需要移动
  27. 并将 lastPlacedIndex = oldIndex;
  28. 如果 oldIndex < lastplacedIndex 该可复用节点之前插入的位置索引小于这次更新需要插入的位置索引,代表该节点需要向右移动
  29. 在例子中,oldIndex 2 > lastPlacedIndex 0
  30. lastPlacedIndex = 2;
  31. c节点位置不变
  32. 继续遍历剩余newChildren
  33. // 当前oldFiber:bd
  34. // 当前newChildren:db
  35. key === d oldFiber中存在
  36. const oldIndex = d(之前).index;
  37. oldIndex 3 > lastPlacedIndex 2 // 之前节点为 abcd,所以d.index === 3
  38. lastPlacedIndex = 3;
  39. d节点位置不变
  40. 继续遍历剩余newChildren
  41. // 当前oldFiber:b
  42. // 当前newChildren:b
  43. key === b oldFiber中存在
  44. const oldIndex = b(之前).index;
  45. oldIndex 1 < lastPlacedIndex 3 // 之前节点为 abcd,所以b.index === 1
  46. b节点需要向右移动
  47. ===第二轮遍历结束===
  48. 最终acd 3个节点都没有移动,b节点被标记为移动