React diff 算法

前言

好久之前写过一篇了解虚拟DOM 的文章,主要讲解了vue为何会使用虚拟 DOM 以及 VUE 的 diff 算法。最近技术栈迁移到了 React,就好好研究了一下 React diff 算法的实现。前端

React 版本号 16.9.0

React Fiber

在了解 React diff 算法以前,先了解 React Fiber 相关的知识,将有助于后面对 diff 算法中比对的理解。React 16版本以后推出了 Fiber 的概念,React Fiber是对核心算法的一次从新实现,本文主要是讲 diff 算法,所以忽略分片、更新优先级这些概念,能够简单的将 Fiber理解为 DOM 结构的 JS 映射。vue

例如以下的 React 代码react

class App extends React.Component {

  state = {
    list: [{
      key: 'A',
      value: '我是 A'
    }, {
      key: 'B',
      value: '我是 B'
    }, {
      key: 'C',
      value: '我是 C'
    }, {
      key: 'D',
      value: '我是 D'
    }, {
      key: 'E',
      value: '我是 E'
    }]
  };


  btn2Click = () => {
    //
  }

  render () {
    const { list } = this.state;
    return (
    <div className="App">
      <span className="btn-2" onClick={this.btn2Click}>
        点击调换顺序
      </span>
      {
        list.map((item) => (<div key={item.key}>{item.value}</div>))
      }
    </div>
    )};
}

映射为 React Fiber 的简略表示为算法

{ // FiberNode
  memoizedProps: {},
  memoizedState: {
    list: [{
      // 此处省略,是定义在 state 中的 list
    }]
  },
  // 在Fiber树更新的过程当中,每一个Fiber都会有一个跟其对应的Fiber
  // 咱们称他为`current <==> workInProgress`
  // 在渲染完成以后他们会交换位置
  alternate: Fiber | null,
  child: { // FiberNode,为div.App的第一个子节点
    elementType: "span",
    memoizedProps: {
      children: "点击调换顺序",
      className: "btn-2",
      onClick: () => { }
    },
    memoizedState: null,
    return: { // FiberNode 对应其父节点的FiberNode
      // ... 省略其它
      stateNode: 'div.App'
    },
    sibling: { // FiberNode,span.btn-2的下一个兄弟节点,此处为一个虚拟的节点
      child: {
        elementType: "div",
        child: null,
        index: 0,
        key: "A",
        memoizedProps: {
          children: "我是 A",
        },
        return: {
          // 虚拟父节点
        },
        sibling: {
          elementType: "div",
          child: null,
          index: 0,
          key: "B",
          memoizedProps: {
            children: "我是 B",
          },
          return: {
            // 同 A,虚拟父节点
          },
          sibling: {
            // 省略,key 为 C,其 sibing key 为 D;D的 Sibing 为 E,结束
          }
        }
      },
      elementType: null,
      memoizedProps: [{
        $$typeof: Symbol(react.element),
        key: "A",
        props: {
          children: "我是 A"
        },
        type: "div"
      }, {
        // 省略其它
      }],
      memoizedState: null,
      return: { // FiberNode 对应其父节点的FiberNode
        // ... 省略其它
        stateNode: 'div.App'
      }
    },
    stateNode: 'span.btn-2' // 实际是真正的span.btn-2 元素
  },
  sibling: null,
  stateNode: 'div.App' // 实际是真正的div.App 元素
}

其中截取了 span.btn-2的完整 FiberNode的表示
Xnip2020-05-26_18-45-33.jpgsegmentfault

使用图来表示:app

Xnip2020-05-27_16-15-21.jpg

即:oop

  • 每一个 Fiber的 child 指向其第一个孩子节点,没有孩子节点则为 null
  • 每一个 Fiber的sibling 指向其下一个兄弟节点,没有则为 null
  • 每一个 Fiber的return 指向其父节点
  • 每一个 Fiber有一个 index 属性表示其在兄弟节点中的排序
  • 每一个 Fiber的 stateNode 指向其原生节点
  • 每一个 Fiber有一个 key 属性

React diff 算法

给定任意两棵树,找到最少的转换步骤。可是标准的的Diff算法复杂度须要O(n^3)。考虑到前端操做的状况--咱们不多跨级别的修改节点,虚拟DOM在比较时只比较同层次节点,其复杂度下降到了O(n). 并且比较时只比较其key和type是否相同,相同即为相同节点ui

// 节选自 updateSlot
if (newChild.key === key) {
    // 节选自 updateElement
    if (current.elementType === element.type) {
       
    } else {
      // 直接建立新节点
    }
} else {
return null;
}

例子:下图节点从左图变为右图this

虚拟DOM的作法是spa

A.destroy(); 
A = new A(); 
A.append(new B()); 
A.append(new C()); 
D.append(A);

而不是

A.parent.remove(A);
D.append(A);

示例1

对于例子中的state 由[A, B, C, D, E] 变为[A, F, B, C, D]的操做,diff 算法如何进行的处理?key 在这其中又扮演什么以为呢?

btn2Click = () => {
    this.setState({
      list: [{
        key: 'A',
        value: '我是 A'
      }, {
        key: 'F',
        value: '我是 F'
      }, {
        key: 'B',
        value: '我是 B'
      }, {
        key: 'C',
        value: '我是 C'
      }, {
        key: 'D',
        value: '我是 D'
      }]
    })
  }
  
 ...
 <span className="btn-2" onClick={this.btn2Click}>
    点击调换顺序
  </span>

若是按照常规的从头至尾比较,第一个元素 A 相同,后面的元素依次比较,key 都不相同, 须要删除 A 后面的 BCDE,再添加上FBCD. 原来的 BCD 节点彻底没有被复用。React 是怎么作的呢?

首先经过reconcileChildrenArray对节点进行组装。

function placeChild(
    newFiber: Fiber,
    lastPlacedIndex: number,
    newIndex: number,
  ): number {
    newFiber.index = newIndex;
    const current = newFiber.alternate;
    // 标记 1-1
    if (current !== null) {
      // 标记 1-2
      const oldIndex = current.index;
      if (oldIndex < lastPlacedIndex) {
        // 标记 1-3 This is a move.
        newFiber.effectTag = Placement;
        return lastPlacedIndex;
      } else {
        // 标记 1-4 This item can stay in place.
        return oldIndex;
      }
    } else {
      // 标记 1-5 This is an insertion.
      newFiber.effectTag = Placement;
      return lastPlacedIndex;
    }
  }

function reconcileChildrenArray(
    returnFiber: Fiber,
    currentFirstChild: Fiber | null,
    newChildren: Array<*>,
    expirationTime: ExpirationTime,
  ): Fiber | null {
    let resultingFirstChild: Fiber | null = null;
    let previousNewFiber: Fiber | null = null;

    let oldFiber = currentFirstChild;
    let lastPlacedIndex = 0;
    let newIdx = 0;
    let nextOldFiber = null;
    // 标记 1
    for (; oldFiber !== null && newIdx < newChildren.length; newIdx++) {
       // 标记 2
      if (oldFiber.index > newIdx) {
        nextOldFiber = oldFiber;
        oldFiber = null;
      } else {
        nextOldFiber = oldFiber.sibling;
      }
      // 标记 3
      // updateSlot 判断newChildren[newIdx]与oldFiber的 key 和type 是否一致。若是不一致,返回 null,若是一致,返回return指向returnFiber的newFiber
      const newFiber = updateSlot(
        returnFiber,
        oldFiber,
        newChildren[newIdx],
        expirationTime,
      );
      // 标记 4
      if (newFiber === null) {
        // 没有 children
        // 标记 5
        if (oldFiber === null) {
          oldFiber = nextOldFiber;
        }
        // 遍历到 F 节点的时候,命中 break
        // 标记 6
        break;
      }
      // 标记 7
      lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx);
      // 标记 8
      if (previousNewFiber === null) {
        // 将新节点经过sibling进行链接
        resultingFirstChild = newFiber;
      } else {
        // 将新节点经过sibling进行链接
        previousNewFiber.sibling = newFiber;
      }
      previousNewFiber = newFiber;
      oldFiber = nextOldFiber;
      // 标记 9
    }


    // 标记 10
    if (newIdx === newChildren.length) {
      // 删除剩余的旧的节点
      deleteRemainingChildren(returnFiber, oldFiber);
      return resultingFirstChild;
    }

    // 标记 11
    if (oldFiber === null) {
      // 建立新节点
      for (; newIdx < newChildren.length; newIdx++) {
        const newFiber = createChild(
          returnFiber,
          newChildren[newIdx],
          expirationTime,
        );
        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;
      }
      return resultingFirstChild;
    }

    // 标记 12  Add all children to a key map for quick lookups.
    const existingChildren = mapRemainingChildren(returnFiber, oldFiber);

    //  标记 13 Keep scanning and use the map to restore deleted items as moves.
    for (; newIdx < newChildren.length; newIdx++) {
    //  标记 14
      const newFiber = updateFromMap(
        existingChildren,
        returnFiber,
        newIdx,
        newChildren[newIdx],
        expirationTime,
      );
      if (newFiber !== null) {
          // 省略代码,将 newFiber 从existingChildren中删除掉
          //  标记 15
        lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx);
        //  标记 16
        if (previousNewFiber === null) {
          resultingFirstChild = newFiber;
        } else {
          previousNewFiber.sibling = newFiber;
        }
        previousNewFiber = newFiber;
      }
    }
    // 省略代码,删除剩余的existingChildren
    //  标记 17
    return resultingFirstChild;
  }

按照以下流程:
image.png

image.png

image.png
image.png
Xnip2020-06-02_21-10-51.jpg
image.png

组装为如下结构:

// 新的第一个节点节选
{
    alternate: { // 旧的位置
        index: 0,
        key: "A",
        sibling: {
            //... 旧的 B 节点,sibling 为 C 节点 等等
        }
    },
    index: 0,
    key: "A",
    effectTag: 0,
    sibling: {
        alternate: null, // 没有旧的对应的节点,说明是新增
        index: 1,
        key: "F",
        effectTag: 2,
        sibling: {
            alternate: {
                index: 1,
                key: "B"
            },
            index: 2,
            key: "B",
            effectTag: 0, // 不须要挪位置
            sibling: {
                alternate: {
                    index: 2,
                    key: "C"
                },
                index: 3,
                key: "C",
                effectTag: 0,
                sibling: {
                    alternate: {
                        index: 3,
                        key: "D"
                    },
                    index: 4,
                    key: "D",
                    effectTag: 0,
                }
            }
        }
    }
  }
  
  // 更新节点,commitMutationEffects 中
nextEffect = {
    effectTag: 8, // 删除
    key: E,
    nextEffect: {
        effectTag: 2, // 新增
        key: F
    }
}

所以,节点渲染的顺序为

  1. ABCD保持不动
  2. 删除 E 节点
  3. 将 F 节点添加到 B 以前

总结来讲:

  • 维护一个 lastPlacedIndexnewIndex
  • lastPlacedIndex = 0, newIndex为按顺序第一个与 old 节点 key 或者 type 不一样的节点
  • 依次遍历新节点。取出旧节点对应的oldIndex(若是旧节点中存在), oldIndex < lastPlacedIndex,则newFiber.effectTag = Placement,且lastPlacedIndex不变;不然,节点位置保持不变,将lastPlacedIndex = oldIndex

示例2

若是从[A, B, C, D, E]变为[B, C, D, E, A], 则

nextEffect = {
    effectTag: 2, // 移动或添加。调用 appendChild,若是是已有节点,原生会操做移动
    key: A,
    nextEffect: null
}
  1. lastPlacedIndex = 0, newIndex = 0
  2. newFiber 遍历 B 节点,oldIndex = 1 > lastPlacedIndex 不作操做,且 lastPlacedIndex = oldIndex = 1
  3. lastPlacedIndex = 1, newIndex = 1
  4. newFiber 遍历 C 节点, oldIndex = 2 > lastPlacedIndex 不作操做,且 lastPlacedIndex = oldIndex = 2`
  5. lastPlacedIndex = 2, newIndex = 2
  6. newFiber 遍历 D 节点, oldIndex = 3 > lastPlacedIndex 不作操做,且 lastPlacedIndex = oldIndex = 3
  7. E 同理...
  8. lastPlacedIndex = 3, newIndex = 3
  9. newFiber 遍历 A 节点, oldIndex = 0 < lastPlacedIndex`,移动 A 节点

所以,节点渲染的顺序为

  1. 移动 A

示例3

若是从[A, B, C, D, E]变为[E, A, B, C, D], 则

  1. lastPlacedIndex = 0, newIndex = 0
  2. newFiber 遍历 E 节点,oldIndex = 4 > lastPlacedIndex 不作操做,且 lastPlacedIndex = oldIndex = 4
  3. lastPlacedIndex = 4, newIndex = 1
  4. newFiber 遍历 A 节点,oldIndex = 0 < lastPlacedIndex 移动之, lastPlacedIndex保持不变
  5. lastPlacedIndex = 4, newIndex = 2
  6. B 节点 oldIndex 为 1 < lastPlacedIndex, 同理,移动之
  7. lastPlacedIndex = 4, newIndex = 3
  8. C 节点 oldIndex 为 2 < lastPlacedIndex, 同理,移动之
  9. D节点同理...
nextEffect = {
    effectTag: 2, // 移动或添加。调用 appendChild,若是是已有节点,原生会操做移动
    key: A,
    nextEffect: {
      effectTag: 2,
      key: B,
      nextEffect: {
          effectTag: 2,
          key: C,
          nextEffect: {
              effectTag: 2,
              key: D,
              nextEffect: null
          }
      }
    }
}
  1. 移动 A
  2. 移动 B
  3. 移动 C
  4. 移动 D

总结

  • diff 算法将 O(n3) 复杂度的问题转换成 O(n)
  • key 的做用是为了尽量的复用节点
  • 虚拟DOM的优点并不在于它操做DOM比较快,而是可以经过虚拟DOM的比较,最小化真实DOM操做,参考文档
相关文章
相关标签/搜索