虚拟 DOM 结构的变化

React 16 中,虚拟 DOM(Fiber)的结构是链表结构。

React 15 中,虚拟 DOM(VDOM) 是树结构。 原来的 VDOM 是一颗由上至下的,很普通,通过深度优先遍历,层层递归直下。 然而,这个直下最大的毛病在于不可中断。 因此,我们在 diff + patch 又或者是 Mount 巨大节点的时候,会造成巨大的卡顿。

image.png

Fiber 的作用

Fiber 在 diff 阶段,做了如下的操作:

  1. 把可中断的工作拆分成小任务。
  2. 对正在做的工作调整优先次序、重做、复用上次(做了一半的)成果。
  3. diff 阶段任务调度优先级控制。

Fiber 是根据一个 fiber 节点(VDOM节点)来拆分,以 fiber node 为一个任务单元,一个组件实例都是一个任务单元。任务循环中,每处理完一个 fiber node,可以中断/挂起/恢复。

所以就是说,React16 的 diff 就是每个 Fiber 节点作为任务单元,进行 diff。

Fiber核心是实现了一个基于优先级和requestIdleCallback的循环任务调度算法:
通过浏览器提供的 requestIdleCallback API 来进行中断/挂起/恢复。

Fiber的关键特性:

  • 增量渲染(把渲染任务拆分成块,匀到多帧)
  • 更新时能够暂停,终止,复用渲染任务
  • 给不同类型的更新赋予优先级
  • 并发方面新的基础能力

    增量渲染用来解决掉帧的问题,渲染任务拆分之后,每次只做一小段,做完一段就把时间控制权交还给主线程,而不像之前长时间占用。这种策略叫做 cooperative scheduling(合作式调度),

Fiber 节点的属性

Fiber Tree 的一个节点如下图
image.png

  1. Alternate:一个极其重要的属性,保存着 fiber 对应的 旧的 fiber。

    1. 在新的 Fiber 架构中,我们同样是有两颗 Fiber 树,一颗是旧的,一颗是新的(当调用 setState)以后。
    2. 当我们的更新完毕以后,新的 Alternate 树就会变成我们的老树,以此进行新旧交替。
  2. child:要解决用户的调度问题,那么就要把每一个节点的 diff patch 过程都变成可控制的

    1. 因此我们需要将原来的递归,变成一个循环,以链表为链接,控制每一次的 diff 和 patch。
    2. 因此,一个 Fiber 都会有一个 child 、sibling、return 三大属性作为链接树前后的指针。
  3. return:是实现错误边界的极其关键的一个Fiber 属性。

    1. 得益于它,React 实现了每一个节点的完全跟踪,你可以看到当你组件报错的时候,一个清晰的 React 组件级别的堆栈就会出现。
  4. effectTag:一个很有意思的标记,用于记录 effect 的类型,

    1. effect 指的就是对 DOM 操作的方式,比如修改,删除等操作,用于到后面进行 Commit
  5. firstEffect 、lastEffect:是用来保存中断前后 effect 的状态,用户中断后恢复之前的操作。

  6. tag:用于标记,这个 Fiber 是什么 ```typescript export type TypeOfWork = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10;

export const IndeterminateComponent = 0; // 尚不知是类组件还是函数式组件 export const FunctionalComponent = 1; // 函数式组件 export const ClassComponent = 2; // Class类组件 export const HostRoot = 3; // 组件树根组件,可以嵌套 export const HostPortal = 4; // 子树. Could be an entry point to a different renderer. export const HostComponent = 5; // 标准组件,如地div, span等 export const HostText = 6; // 文本 export const CallComponent = 7; // 组件调用 export const CallHandlerPhase = 8; // 调用组件方法 export const ReturnComponent = 9; // placeholder(占位符) export const Fragment = 10; // 片段 ```

React 执行的两个阶段

  1. reconciliation 阶段:

    1. 包含的主要工作是对 current tree 和 new tree 做 diff 计算,找出变化部分。
    2. 进行遍历、对比等是可以中断,歇一会儿接着再来。
  2. commit 阶段:

    1. 对上一阶段获取到的变化部分应用到真实的 DOM 树中,是一系列的 DOM 操作。
    2. 由于要维护更复杂的 DOM 状态,如果中断后再继续,会对用户体验造成影响,所以是在 reconcilation 阶段执行中断/恢复。
    3. 在普遍的应用场景下,此阶段的耗时比 diff 计算等耗时相对短。

Reconcilation

reconciliation 具体过程如下:

  1. 如果当前节点不需要更新,直接把子节点 clone 过来,跳到 5。要更新的话打个 tag
  2. 更新当前节点状态(props,state,context等)
  3. 调用 shouldComponentUpdate(),false 的话,跳到 5
  4. 调用 render() 获得新的子节点,并为子节点创建 fiber(创建过程会尽量复用现有fiber,子节点增删也发生在这里)
  5. 如果没有产生 child fiber,该工作单元结束,把 effect list 归并到 return,并把当前节点的 sibling 作为下一个工作单元。否则把 child 作为下一个工作单元
  6. 如果没有剩余可用时间了,等到下一次主线程空闲时才开始下一个工作单元。否则,立即开始做
  7. 如果没有下一个工作单元了(回到了 workInProgress tree 的根节点),第1阶段结束,进入 pendingCommit状态



实际上是 1-6 的工作循环,7 是出口,工作循环每次只做一件事,做完看要不要喘口气。
工作循环结束时,workInProgress tree 的根节点身上的 effect list 就是收集到的所有 side effect(因为每做完一个都向上归并)

所以,构建 workInProgress tree 的过程就是 diff 的过程,通过 requestIdleCallback 来调度执行一组任务,每完成一个任务后回来看看有没有插队的(更紧急的),每完成一组任务,把时间控制权交还给主线程,直到下一次requestIdleCallback 回调再继续构建 workInProgress tree

Commit

  1. 处理 effect list(包括 3 种处理:更新DOM树、调用组件生命周期函数以及更新 ref 等内部状态)
  2. 出队结束,第 2 阶段结束,所有更新都 commit 到DOM树上了

这个阶段(同步执行)的实际工作量是比较大的(因为是一口气全部做完的),所以尽量不要在后 3 个生命周期函数里干重活儿

优先级策略

每个工作单元运行时有 6 种优先级:

  1. synchronous 与之前的 Stack reconciler 操作一样,同步执行
  2. task 在 next tick 之前执行
  3. animation 下一帧之前执行
  4. high 在不久的将来立即执行
  5. low 稍微延迟(100-200ms)执行也没关系
  6. offscreen 下一次 render 时或 scroll 时才执行

    高优先级的比如键盘输入(希望立即得到反馈),低优先级的比如网络请求,让评论显示出来等等

缺点:

  1. 生命周期函数怎么执行(可能被频频中断):触发顺序、次数没有保证了
  2. starvation(低优先级饿死):如果高优先级任务很多,那么低优先级任务根本没机会执行(就饿死了)

    参考资料

    《前端进阶之React 15的diff和React16的区别》