React Diffing 自顶向下

React Diffing 自顶向下

站在巨人肩旁上,本片文章主要解决下面的问题

  1. Diff 算法是怎么来的?
  2. Diff 算法是如何工作的?

Diffing算法的由来

远古时代我们都是使用的jQuery原生JS,每次更新都是梭哈

1. 梭哈更新会有什么问题?

我们需要简单了解一下浏览器是如何渲染的,下图是 WebKit 渲染的主流程

浏览器渲染原理

从图中我们可以了解到,远古时代每次更新都会重新执行一下这个流程(重排重绘),毫无疑问这样做性能太差。

2. 如何优化浏览器的更新这个流程呢?

核心思路:减少没有必要的更新,能复用即复用

好处:减少重复DOM节点的生成,减少重排,甚至能够减少重绘

React是怎么做的?

  1. 用虚拟dom做一个真实DOM映射
  2. 每次提交后,比较更新虚拟dom(Scheduler + Reconciler
  3. 将虚拟dom中的改变同步到真实dom中(Renderer

为了做到比较更新,所以需要设计一个Diffing算法

缺点:

  1. 第一次渲染会较慢
  2. 极端场景下性能不是最优解

Diffing算法

在开始之前先补充下一个小知识点
小拓展:双缓存技术

“双缓存”技术:在内存中构建并直接替换的技术

当我们用canvas绘制动画,每一帧绘制前都会调用ctx.clearRect清除上一帧的画面。

如果当前帧画面计算量比较大,导致清除上一帧画面到绘制当前帧画面之间有较长间隙,就会出现白屏。

为了解决这个问题,我们可以在内存中绘制当前帧动画,绘制完毕后直接用当前帧替换上一帧画面,由于省去了两帧替换间的计算时间,不会出现从白屏到出现画面的闪烁情况。

一、Fiber 架构下Diff都干了什么

react 内部的虚拟Dom叫current Fiber (页面中正在渲染的树的映射),每次向虚拟dom提交更新的阶段,其实都是比较 JSXCurrent Fiber 的阶段,这个阶段的产物就是 workInProgress Fiber

在renderer阶段,React就会把workInProgress Fiber的更新一次性同步到Dom中,此时workInProgress变成了current。

二、Diffing 的对象
Fiber

让我们看看 React 是如何实现的

Fiber节点的部分属性

function FiberNode(
  tag: WorkTag,
  pendingProps: mixed,
  key: null | string,
  mode: TypeOfMode,
) {
  // Fiber对应组件的类型 Function/Class/Host...
  this.tag = tag;
  // key属性
  this.key = key;
  // 大部分情况同type,某些情况不同,比如FunctionComponent使用React.memo包裹
  this.elementType = null;
  // 对于 FunctionComponent,指函数本身,对于ClassComponent,指class,对于HostComponent,指DOM节点tagName
  this.type = null;
  // Fiber对应的真实DOM节点
  this.stateNode = null;

  // 用于连接其他Fiber节点形成Fiber树
    
  // 指向父级Fiber节点
  this.return = null;
  // 指向子Fiber节点  
  this.child = null;
  // 指向右边第一个兄弟Fiber节点
  this.sibling = null;
  // 索引
  this.index = 0;
}

一个个Fiber节点构建成fiber树,图片来源于《React技术揭秘》

Fiber树

JSX

解析前

function List () {
  return (
    <ul>
      <li key="0">0</li>
      <li key="1">1</li>
      <li key="2">2</li>
      <li key="3">3</li>
    </ul>
  )
}

解析后

{
  $$typeof: Symbol(react.element),
  key: null,
  props: {
    children: [
      {$$typeof: Symbol(react.element), type: "li", key: "0", ref: null, props: {},}
      {$$typeof: Symbol(react.element), type: "li", key: "1", ref: null, props: {},}
      {$$typeof: Symbol(react.element), type: "li", key: "2", ref: null, props: {},}
      {$$typeof: Symbol(react.element), type: "li", key: "3", ref: null, props: {},}
    ]
  },
  ref: null,
  type: "ul"
}

实际上就是Fiber解析后的JSX对象对比

三、Diffing 的规则
同层比较

众所周知,树的diff算法的时间复杂度是O(n^3),而真实的场景跨层级复用节点的情况很少,所以React提出了一套 O(n) 的启发式算法:

  1. 两个不同类型的元素会产生出不同的树;
  2. 开发者可以通过设置 key 属性,来告知渲染哪些子元素在不同的渲染下可以保存不变

这样的好处就是,不会每一层的对比都是相对独立的,不会影响到下一层的对比;

在同层对比的过程中有这样几种情况:

  1. 节点类型变了 销毁老节点和它的子节点,生成新节点
  2. 节点类型一样,属性或者属性值发生变化 复用老节点 更新
  3. 删除/新增/改变 节点 Diffing算法的精髓
  4. 文本变化 只会触发文本的改变

源码位置

节点Diff分为两种:

  1. 单节点Diff —— ElementPortalstringnumber
  2. 多节点Diff —— ArrayIterator
单节点Diff

单节点Diff比较简单,只有key相同并且type相同的情况才会尝试复用节点,否则会返回新的节点。

源码位置

// 当子节点不为null,则复用子节点并删除其兄弟节点
  // 当子节点为null,则创建新的fiber节点
  function reconcileSingleElement(
    returnFiber: Fiber,
    // 旧
    currentFirstChild: Fiber | null,
    //新
    element: ReactElement,
    lanes: Lanes,
  ): Fiber {
    const key = element.key;
    let child = currentFirstChild;
    //从当前已有的所有子节点中,找到可以复用的 fiber 对象,并删除它的 兄弟节点
    while (child !== null) {
      // TODO: If key === null and child.key === null, then this only applies to
      // the first item in the list.
      if (child.key === key) {
        //如果节点类型未改变的话
        const elementType = element.type;
        if (elementType === REACT_FRAGMENT_TYPE) {
          if (child.tag === Fragment) {
            //复用 child,删除它的兄弟节点
            //因为旧节点它有兄弟节点,新节点只有它一个
            deleteRemainingChildren(returnFiber, child.sibling);
            // 复制 fiber 节点,并重置 index 和 sibling
            // existing就是复用的节点
            const existing = useFiber(child, element.props.children);
            existing.return = returnFiber;
            if (__DEV__) {
              existing._debugSource = element._source;
              existing._debugOwner = element._owner;
            }
            return existing;
          }
        } else {
          if (
            child.elementType === elementType ||
            // Keep this check inline so it only runs on the false path:
            (__DEV__
              ? isCompatibleFamilyForHotReloading(child, element)
              : false) ||
            // Lazy types should reconcile their resolved type.
            // We need to do this after the Hot Reloading check above,
            // because hot reloading has different semantics than prod because
            // it doesn't resuspend. So we can't let the call below suspend.
            (enableLazyElements &&
              typeof elementType === 'object' &&
              elementType !== null &&
              elementType.$$typeof === REACT_LAZY_TYPE &&
              resolveLazy(elementType) === child.type)
          ) {
            deleteRemainingChildren(returnFiber, child.sibling);
            // 复用当前child fiber
            const existing = useFiber(child, element.props);
            //设置正确的 ref
            existing.ref = coerceRef(returnFiber, child, element);
            existing.return = returnFiber;
            if (__DEV__) {
              existing._debugSource = element._source;
              existing._debugOwner = element._owner;
            }
            return existing;
          }
        }
        // 不匹配删除节点
        deleteRemainingChildren(returnFiber, child);
        break;
      } else {
        // key不同直接删除节点
        deleteChild(returnFiber, child);
      }
      child = child.sibling;
    }
		// 返回 新的Fiber节点
    if (element.type === REACT_FRAGMENT_TYPE) {
      const created = createFiberFromFragment(
        element.props.children,
        returnFiber.mode,
        lanes,
        element.key,
      );
      created.return = returnFiber;
      return created;
    } else {
      const created = createFiberFromElement(element, returnFiber.mode, lanes);
      created.ref = coerceRef(returnFiber, currentFirstChild, element);
      created.return = returnFiber;
      return created;
    }
  }

多节点Diff(精髓)

React团队发现,在日常开发中,相较于新增删除更新组件发生的频率更高。所以Diff会优先判断当前节点是否属于更新

首先我们需要明白比较对象的基本性质:Fiber 的children(链表),JSX的children(数组)

怎么比较比较好呢?顺序比?还是两端比?

顺序比!

思考一个问题,为什么不采用两端比较?

顺序比会遇到什么场景?

  • 好的情况: 位置没有发生改变的情况下,只进行更新

  • 坏的情况:有位置发生改变了,或者下轮更新删除该节点了,移动+更新

上面两种情况都会遇到 节点新增和删除

好的情况

优先先处理好的情况,因为我们是顺序比,所以只需要按照顺序把节点位置没有发生变化的更新一下就好了

我们还将遇到三种情况:

  1. 情况一: 老的新的同时遍历完,只更新
  2. 情况二: 老的遍历完了,新的没有遍历完,添加剩下新增的节点
  3. 情况三:新的遍历完了,老的没有遍历完,删除剩下老的节点

比如:

// 情况一
<!-- 更新前 -->
<div key='a'>a</div>
<div key='b'>c</div>
<div key='c'>c</div>
<!-- 更新后 -->
<div key='a'>b</div>
<div key='b'>c</div>
<div key='c'>a</div>

// 情况二
<!-- 更新前 -->
<div key='a'>a</div>
<div key='b'>c</div>
<div key='c'>c</div>
<!-- 更新后 -->
<div key='a'>b</div>
<div key='b'>c</div>

// 情况三
<!-- 更新前 -->
<div key='a'>a</div>
<div key='b'>c</div>
<!-- 更新后 -->
<div key='a'>b</div>
<div key='b'>c</div>
<div key='c'>a</div>
坏的情况

如何检查出现了坏的情况呢?

出现节点的顺序对不上了,也就是比较到不一样的节点了(Key不同)

比如:

<!-- 更新前 -->
<div key='a'>a</div>
<div key='b'>b</div>
<div key='c'>c</div>
<!-- 更新后 -->
<div key='a'>a</div>
<div key='c'>c</div> // 和b对不上
<div key='e'>e</div>

我们可以把前面的节点作为好的情况处理,后面的作为坏的情况处理。

如何处理呢?(难点)

还是先分情况:

  1. 情况一: 新节点可以复用老节点,移动
  2. 情况二: 新节点不能复用老节点,新增
  3. 情况三:复用完毕后,还存在老节点,删除

对于链表增删移动是非常方便的

我们怎么快速找到需要更新的节点呢?

我们需要使用key

但是DIff是发在Reconciler阶段,不涉及Dom更新,所以要为每一种操作打上一个标记,以便Render阶段调用对应的API

ReconcilerRenderer不再是交替工作。当Scheduler将任务交给Reconciler后,Reconciler会为变化的虚拟DOM打上代表增/删/更新的标记.

// 放置(替换/新增)
export const Placement = /*                    */ 0b00000000000000000000000010;// 2
// 更新
export const Update = /*                       */ 0b00000000000000000000000100;//4
// 放置 + 更新 = 移动
export const PlacementAndUpdate = /*           */ Placement | Update;//6
// 删除
export const Deletion = /*                     */ 0b00000000000000000000001000;//8

insertBefore removeChild creatElemen appendChild 等等就可以实现标记对应的操作

增删比较好处理,就是老的节点中有或者没有的问题。关键是我们如何确定移动的元素

如何处理元素的是否移动

我们怎么知道节点是否移动

根据常识,需要有一个参照物才能确定是否移动。
我先告诉你答案,我们采用上一次放置节点的位置(lastPlacedIndex)作为移动的参照物。
我知道大家懵了,接着往下看,我讲带你推出来为什么选择lastPlacedIndex作为参照物。

为什么是采用lastPlacedIndex作为参照物?

我们发现比较移动和位置变化有关,因此我穷举了各种情况

  • 绝对位置
    • 头部作为参照物可行么?不可行,因为在坏的情况位置相对头部一定发生改变了。❌
  • 相对位置,下面说的位置都是相对位置
    • 相对前一个节点作为参照物可行么?不可行,因为不确定前一个节点的位置是否发生变化。❌
    • 相对后一个节点作为参照物可行么? 不可行,因为后一个不知道前一个位置是否发生变化。❌
    • 相对位置发生改变的节点作为参照物可行么? 不可行,如果可以的话第一个跟谁比?❌
    • 相对位置没有发生变化的节点是否可行么?可行✅
      • 之前,位置一定改变,打上移动的标签
      • 之后,位置一定没变

因此我给lastPlacedIndex下了个定义 : 上一个相对位置没变的元素索引

此外: 相对位置改变会调用Dom Api Node.insertBefore(),思考一下为什么?

什么时候更新lastPlacedIndex ?

上个元素相对位置没变,就把它的索引更新为lastPlacedIndex

至此,我们已经为每个元素打上对应的标签,Diff完毕,剩下的交给调度器处理把。

Demo

比如 abcdefg => acebg

<!-- 更新前 -->
<div key='a'>a</div>
<div key='b'>b</div>
<div key='c'>c</div>
<div key='d'>d</div>
<div key='e'>e</div>
<div key='f'>f</div>
<!-- 更新后 -->
<div key='a'>a</div>
<div key='c'>c</div>
<div key='e'>e</div>
<div key='b'>b</div>
<div key='g'>g</div>

让我们看着图走一遍流程吧!
图片来自网络侵删
DiffDemo
用newChildren进行遍历
好的情况

  • A节点:比较key和type,发现A的位置没有变,属于好的情况,打上Update标签并且更新lastPlacedIndex = 0。
    执行两个判断操作:
  1. 判断 oldFiber是否遍历完,如果遍历完,则将newChildren中剩下的元素依次创建新Fiber然后打上新增标签。
  2. 判断newChildren是否遍历完,如果遍历完,则将剩余的元素打上删除标签。

坏的情况
下一次个元素的key 不同,好的情况结束,出现坏的情况。且下面的都按照坏的情况对比。将oldFibers 用key做一个map。

  • C节点:
    1. 从map中判断是否能被复用,C节点发现能复用
    2. 判断是否移动,发现C还是在A(上一个相对位置没有发生变化的元素)后面,所以没有移动,打上Update标签并更新lastPlacedIndex = 1
  • E节点:
    1. 从map中判断是否能被复用,E节点发现能复用
    2. 判断是否移动,发现E还是在C(上一个相对位置没有发生变化的元素)后面,所以没有移动,打上Update标签并更新lastPlacedIndex = 4
  • B节点:
    1. 从map中判断是否能被复用,B节点发现能复用
    2. 判断是否移动,发现B从E的后面变成前面了,所以位置移动,打上PlacementAndUpdate标签,上个相对位置没变的元素还是E
  • G节点:
    1. 从map中判断是否能被复用,发现不能复用,创建新Fiber,打上Placement标签

React 把diff 算法分成两轮比较

第一轮遍历:处理更新的节点,规则是 顺序相同且key也相同的节点。

第二轮遍历:处理剩下的不属于更新的节点,需要做新增、移动或删除操作。

前置变量定义

function reconcileChildrenArray(
		// 父
    returnFiber: Fiber,
    // 第一个旧的节点
    currentFirstChild: Fiber | null,
    // 新的孩子节点
    newChildren: Array<*>,
    // 优先级
    lanes: Lanes,
): Fiber | null 

// 函数返回的Fiber节点
let resultingFirstChild: Fiber | null = null;
// 上一个兄弟节点
let previousNewFiber: Fiber | null = null;
// 当前节点
let oldFiber = currentFirstChild;
// 上一次放置更新节点的位置
let lastPlacedIndex = 0;
// 当前newChildren遍历的位置
let newIdx = 0;
// 下一个兄弟节点
let nextOldFiber = null;

第一轮遍历:

		
    /**
     * 第一轮遍历
     * 第一轮遍历的是顺序相同且key也相同的节点,这些节点需要做更新操作
     */
    for (; oldFiber !== null && newIdx < newChildren.length; newIdx++) {
      // 如果 oldFiber比新的元素多,那剩下的就不用比了,下一次直接跳出循环
      if (oldFiber.index > newIdx) {
        nextOldFiber = oldFiber;
        oldFiber = null;
      } else {
        // 每次循环旧的fiber节点都会指向兄弟元素也就是下次循环的fiber节点
        nextOldFiber = oldFiber.sibling;
      }
      // key相同返回fiber节点,key不同返回null
      // 如果type相同复用节点,不同返回新节点
      const newFiber = updateSlot(
        returnFiber,
        oldFiber,
        newChildren[newIdx],
        lanes,
      );
      // newFiber为null表示key不同,跳出循环
      if (newFiber === null) {
        // 老fiber可能已经删了,需要重新指定为兄弟节点,以作为第二次遍历的头节点
        if (oldFiber === null) {
          oldFiber = nextOldFiber;
        }
        break;
      }
      // 第一次渲染不需走删除老节点的逻辑
      if (shouldTrackSideEffects) {
        // newFiber.alternate为null就是新节点,说明type不同创建了新fiber节点
        if (oldFiber && newFiber.alternate === null) {
          // key相同 type不同删除老的type节点
          deleteChild(returnFiber, oldFiber);
        }
      }
      // 放置节点,更新lastPlacedIndex
      lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx);
      // 组成新fiber节点链,常规链表操作
      if (previousNewFiber === null) {
        resultingFirstChild = newFiber;
      } else {
        previousNewFiber.sibling = newFiber;
      }
      // 本轮更新完,上个节点要变成prev
      previousNewFiber = newFiber;
      // 下个节点变成当前节点
      oldFiber = nextOldFiber;
    }

	// 刚好newChildren遍历完,剩下的不用比了直接删掉老的节点
    if (newIdx === newChildren.length) {
      // We've reached the end of the new children. We can delete the rest.
      deleteRemainingChildren(returnFiber, oldFiber);
      if (getIsHydrating()) {
        const numberOfForks = newIdx;
        pushTreeFork(returnFiber, numberOfForks);
      }
      return resultingFirstChild;
    }
    // 把剩下的新节点都加入到fiber中
    if (oldFiber === null) {
      // If we don't have any more existing children we can choose a fast path
      // since the rest will all be insertions.
      for (; newIdx < newChildren.length; newIdx++) {
        const newFiber = createChild(returnFiber, newChildren[newIdx], lanes);
        if (newFiber === null) {
          continue;
        }
        lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx);
        if (previousNewFiber === null) {
          // TODO: Move out of the loop. This only happens for the first run.
          resultingFirstChild = newFiber;
        } else {
          previousNewFiber.sibling = newFiber;
        }
        previousNewFiber = newFiber;
      }
      if (getIsHydrating()) {
        const numberOfForks = newIdx;
        pushTreeFork(returnFiber, numberOfForks);
      }
      return resultingFirstChild;
    }

第二轮遍历:

    // Add all children to a key map for quick lookups.
    // 用剩余的oldFiber创建一个key->fiber节点的Map,方便用key来获取对应的旧fiber节点
    const existingChildren = mapRemainingChildren(returnFiber, oldFiber);

    // Keep scanning and use the map to restore deleted items as moves.
    // 第二轮遍历,继续遍历剩余的节点,这些节点可能是需要移动或者删除的
    for (; newIdx < newChildren.length; newIdx++) {
      // 从map中获取对应对应key的旧节点,返回更新后的新节点
      const newFiber = updateFromMap(
        existingChildren,
        returnFiber,
        newIdx,
        newChildren[newIdx],
        lanes,
      );
      if (newFiber !== null) {
        if (shouldTrackSideEffects) {
          // 复用的新节点,从map里删除老的节点,对应的情况可能是位置的改变
          if (newFiber.alternate !== null) {
            // The new fiber is a work in progress, but if there exists a
            // current, that means that we reused the fiber. We need to delete
            // it from the child list so that we don't add it to the deletion
            // list.
            // 复用的节点要移除map,因为map里剩余的节点都会被标记Deletion删除
            existingChildren.delete(
              newFiber.key === null ? newIdx : newFiber.key,
            );
          }
        }
        // 放置节点同时节点判断是否移动
        lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx);
        if (previousNewFiber === null) {
          resultingFirstChild = newFiber;
        } else {
          previousNewFiber.sibling = newFiber;
        }
        previousNewFiber = newFiber;
      }
    }

    if (shouldTrackSideEffects) {
      // Any existing children that weren't consumed above were deleted. We need
      // to add them to the deletion list.
      //删除剩余的
      existingChildren.forEach(child => deleteChild(returnFiber, child));
    }
		// 
    if (getIsHydrating()) {
      const numberOfForks = newIdx;
      pushTreeFork(returnFiber, numberOfForks);
    }
    return resultingFirstChild;

思考题:

  1. 为什么Fiber Tree 的children采用的是链表?有可能优化么?
  2. 怎么写JSX性能更好?

参考资料

  1. React中Diff算法源码浅析
  2. React技术揭秘
  3. 图解Diff算法-- Vue
  4. 深入理解React源码 - 界面更新(DOM树)IX
  5. React源码解析之Commit第二子阶段
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值