虚拟DOM和Diff算法以及Key值的作用

为什么需要虚拟DOM

真实DOM和其解析流程

​ 浏览器渲染引擎工作流程都差不多,大致分为5步,创建DOM树——创建StyleRules——创建Render树——布局Layout——绘制Painting

  • 第一步,用HTML分析器,分析HTML元素,构建一颗DOM树(标记化和树构建)。
  • 第二步,用CSS分析器,分析CSS文件和元素上的inline样式,生成页面的样式表。
  • 第三步,将DOM树和样式表,关联起来,构建一颗Render树(这一过程又称为Attachment)。每个DOM节点都有attach方法,接受样式信息,返回一个render对象(又名renderer)。这些render对象最终会被构建成一颗Render树。
  • 第四步,有了Render树,浏览器开始布局,为每个Render树上的节点确定一个在显示屏上出现的精确坐标。
  • 第五步,Render树和节点显示坐标都有了,就调用每个节点paint方法,把它们绘制出来。

DOM树的构建是文档加载完成开始的?

构建DOM数是一个渐进过程,为达到更好用户体验,渲染引擎会尽快将内容显示在屏幕上。它不必等到整个HTML文档解析完毕之后才开始构建render树和布局。

Render树是DOM树和CSSOM树构建完毕才开始构建的吗?

这三个过程在实际进行的时候又不是完全独立,而是会有交叉。会造成一边加载,一遍解析,一遍渲染的工作现象。

CSS的解析是从右往左逆向解析的(从DOM树的下-上解析比上-下解析效率高),嵌套标签越多,解析越慢。
虚拟DOM和Diff算法以及Key值的作用_第1张图片

JS操作真实DOM的代价!

  • 用我们传统的开发模式,原生JS或JQ操作DOM时,浏览器会从构建DOM树开始从头到尾执行一遍流程。在一次操作中,我需要更新10个DOM节点,浏览器收到第一个DOM请求后并不知道还有9次更新操作,因此会马上执行流程,最终执行10次。例如,第一次计算完,紧接着下一个DOM更新请求,这个节点的坐标值就变了,前一次计算为无用功。计算DOM节点坐标值等都是白白浪费的性能。即使计算机硬件一直在迭代更新,操作DOM的代价仍旧是昂贵的,频繁操作还是会出现页面卡顿,影响用户体验。

它有什么好处?

Web界面由DOM树(树的意思是数据结构)来构建,当其中一部分发生变化时,其实就是对应某个DOM节点发生了变化。
虚拟DOM就是为了 解决浏览器性能问题而被设计出来的。 如前,若一次操作中有10次更新DOM的动作,虚拟DOM不会立即操作DOM,而是将这10次更新的diff内容保存到本地一个JS对象中,最终将这个JS对象一次性attch到DOM树上,再进行后续操作,避免大量无谓的计算量。 所以用JS对象模拟DOM节点的好处是,页面的更新可以先全部反映在JS对象(虚拟DOM)上,操作内存中的JS对象的速度显然要更快,等更新完成后,再将最终的JS对象映射成真实的DOM,交由浏览器去绘制。

一个真实的DOM节点:
虚拟DOM和Diff算法以及Key值的作用_第2张图片
我们用JS来模拟DOM节点实现虚拟DOM:
虚拟DOM和Diff算法以及Key值的作用_第3张图片
其中的Element方法具体怎么实现的呢?

虚拟DOM和Diff算法以及Key值的作用_第4张图片

Element方法实现:

  • 第一个参数是节点名(如div),第二个参数是节点的属性(如class),第三个参数是子节点(如ul的li)。除了这三个参数会被保存在对象上外,还保存了key和count。其相当于形成了虚拟DOM树。

虚拟DOM树:虚拟DOM和Diff算法以及Key值的作用_第5张图片
有了JS对象后,最终还需要将其映射成真实DOM:
虚拟DOM和Diff算法以及Key值的作用_第6张图片
虚拟DOM对象映射成真实DOM(实际上是递归的方法实现的)

我们已经完成了创建虚拟DOM并将其映射成真实DOM,这样所有的更新都可以先反应到虚拟DOM上,如何反应?需要用到Diff算法

Diff算法

Diff算法是基于虚拟DOM的出现,在更新节点时,事件复杂度过高所产生的。
一个DOM节点在某一时刻最多会有4个节点和他相关。 current Fiber。如果该DOM节点已在页面中,current Fiber代表该DOM节点对应的Fiber节点。 workInProgress Fiber。如果该DOM节点将在本次更新中渲染到页面中,workInProgress Fiber代表该DOM节点对应的Fiber节点。 DOM节点本身。 JSX对象。即ClassComponent的render方法的返回结果,或FunctionComponent的调用结果。JSX对象中包含描述DOM节点的信息。 Diff算法的本质是对比1和4,生成2。

Diff的瓶颈以及React如何应对

两棵树如果完全比较时间复杂度是O(n^3),但参照《深入浅出React和Redux》一书中的介绍,React的Diff算法的时间复杂度是O(n)。要实现这么低的时间复杂度,意味着只能平层的比较两棵树的节点,放弃了深度遍历。这样做,似乎牺牲掉了一定的精确性来换取速度,但考虑到现实中前端页面通常也不会跨层移动DOM元素,这样做是最优的。虚拟DOM和Diff算法以及Key值的作用_第7张图片
如果在React中使用了该算法,那么展示1000个元素所需要执行的计算量将在十亿的量级范围。这个开销实在是太过高昂。
为了降低算法复杂度,React的diff会预设三个限制:

  1. 只对同级元素进行Diff。如果一个DOM节点在前后两次更新中跨越了层级,那么React不会尝试复用他。

  2. 两个不同类型的元素会产生出不同的树。如果元素由div变为p,React会销毁div及其子孙节点,并新建p及其子孙节点。

  3. 开发者可以通过 key prop来暗示哪些子元素在不同的渲染下能保持稳定。考虑如下例子:

// 更新前
<div>
  <p key="ka">ka</p>
  <h3 key="song">song</h3>
</div>

// 更新后
<div>
  <h3 key="song">song</h3>
  <p key="ka">ka</p>
</div>

如果没有key,React会认为div的第一个子节点由p变为h3,第二个子节点由h3变为p。这符合限制2的设定,会销毁并新建。

但是当我们用key指明了节点前后对应关系后,React知道key === "ka"p在更新后还存在,所以DOM节点可以复用,只是需要交换下顺序。

这就是React为了应对算法性能瓶颈做出的三条限制。

Diff是如何实现的

我们从Diff的入口函数reconcileChildFibers出发,该函数会根据newChild(即JSX对象)类型调用不同的处理函数。

// 根据newChild类型选择不同diff函数处理
function reconcileChildFibers(
  returnFiber: Fiber,
  currentFirstChild: Fiber | null,
  newChild: any,
): Fiber | null {

  const isObject = typeof newChild === 'object' && newChild !== null;

  if (isObject) {
    // object类型,可能是 REACT_ELEMENT_TYPE 或 REACT_PORTAL_TYPE
    switch (newChild.$$typeof) {
      case REACT_ELEMENT_TYPE:
        // 调用 reconcileSingleElement 处理
      // // ...省略其他case
    }
  }

  if (typeof newChild === 'string' || typeof newChild === 'number') {
    // 调用 reconcileSingleTextNode 处理
    // ...省略
  }

  if (isArray(newChild)) {
    // 调用 reconcileChildrenArray 处理
    // ...省略
  }

  // 一些其他情况调用处理函数
  // ...省略

  // 以上都没有命中,删除节点
  return deleteRemainingChildren(returnFiber, currentFirstChild);
}

我们可以从同级的节点数量将Diff分为两类:

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

单节点Diff

对于单个节点,我们以类型object为例,会进入reconcileSingleElement

  const isObject = typeof newChild === 'object' && newChild !== null;

  if (isObject) {
    // 对象类型,可能是 REACT_ELEMENT_TYPE 或 REACT_PORTAL_TYPE
    switch (newChild.$$typeof) {
      case REACT_ELEMENT_TYPE:
        // 调用 reconcileSingleElement 处理
      // ...其他case
    }
  }

这个函数会做如下事情:
虚拟DOM和Diff算法以及Key值的作用_第8张图片
第二步判断DOM节点是否可以复用是如何实现的。

function reconcileSingleElement(
  returnFiber: Fiber,
  currentFirstChild: Fiber | null,
  element: ReactElement
): Fiber {
  const key = element.key;
  let child = currentFirstChild;
  
  // 首先判断是否存在对应DOM节点
  while (child !== null) {
    // 上一次更新存在DOM节点,接下来判断是否可复用

    // 首先比较key是否相同
    if (child.key === key) {

      // key相同,接下来比较type是否相同

      switch (child.tag) {
        // ...省略case
        
        default: {
          if (child.elementType === element.type) {
            // type相同则表示可以复用
            // 返回复用的fiber
            return existing;
          }
          
          // type不同则跳出循环
          break;
        }
      }
      // 代码执行到这里代表:key相同但是type不同
      // 将该fiber及其兄弟fiber标记为删除
      deleteRemainingChildren(returnFiber, child);
      break;
    } else {
      // key不同,将该fiber标记为删除
      deleteChild(returnFiber, child);
    }
    child = child.sibling;
  }

  // 创建新Fiber,并返回 ...省略
}

从代码可以看出,React通过先判断key是否相同,如果key相同则判断type是否相同,只有都相同时一个DOM节点才能复用。
这里有个细节需要关注下:

  • 当child !== null且key相同且type不同时执行deleteRemainingChildren将child及其兄弟fiber都标记删除。

  • 当child !== null且key不同时仅将child标记删除。

多节点Diff

现在考虑我们有一个FunctionComponent:

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>
  )
}

他的返回值JSX对象的children属性不是单一节点,而是包含四个对象的数组

{
  $$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"
}

这种情况下,reconcileChildFibers的newChild参数类型为Array,在reconcileChildFibers函数内部对应如下情况:

  if (isArray(newChild)) {
    // 调用 reconcileChildrenArray 处理
    // ...省略
  }

首先归纳下我们需要处理的情况:

我们以之前代表更新前的JSX对象,之后代表更新后的JSX对象

情况1:节点更新

// 之前
<ul>
  <li key="0" className="before">0<li>
  <li key="1">1<li>
</ul>

// 之后 情况1 —— 节点属性变化
<ul>
  <li key="0" className="after">0<li>
  <li key="1">1<li>
</ul>

// 之后 情况2 —— 节点类型更新
<ul>
  <div key="0">0<li>
  <li key="1">1<li>
</ul>

情况2:节点新增或减少

// 之前
<ul>
  <li key="0">0<li>
  <li key="1">1<li>
</ul>

// 之后 情况1 —— 新增节点
<ul>
  <li key="0">0<li>
  <li key="1">1<li>
  <li key="2">2<li>
</ul>

// 之后 情况2 —— 删除节点
<ul>
  <li key="1">1<li>
</ul>

情况3:节点位置变化

// 之前
<ul>
  <li key="0">0<li>
  <li key="1">1<li>
</ul>

// 之后
<ul>
  <li key="1">1<li>
  <li key="0">0<li>
</ul>

Diff的思路

该如何设计算法呢?如果让我设计一个Diff算法,我首先想到的方案是:

判断当前节点的更新属于哪种情况

  1. 如果是新增,执行新增逻辑
  2. 如果是删除,执行删除逻辑
  3. 如果是更新,执行更新逻辑
  4. 按这个方案,其实有个隐含的前提——不同操作的优先级是相同的

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


在我们做数组相关的算法题时,经常使用双指针从数组头和尾同时遍历以提高效率,但是这里却不行。

虽然本次更新的JSX对象 newChildren为数组形式,但是和newChildren中每个组件进行比较的是current fiber,同级的Fiber节点是由sibling指针链接形成的单链表,即不支持双指针遍历。

即 newChildren[0]与fiber比较,newChildren[1]与fiber.sibling比较。

所以无法使用双指针优化。

基于以上原因,Diff算法的整体逻辑会经历两轮遍历:

第一轮遍历:处理更新的节点。

第二轮遍历:处理剩下的不属于更新的节点。

第一轮遍历

第一轮遍历步骤如下:

let i = 0,遍历newChildren,将newChildren[i]与oldFiber比较,判断DOM节点是否可复用。

如果可复用,i++,继续比较newChildren[i]与oldFiber.sibling,可以复用则继续遍历。

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

key不同导致不可复用,立即跳出整个遍历,第一轮遍历结束。

key相同type不同导致不可复用,会将oldFiber标记为DELETION,并继续遍历

如果newChildren遍历完(即i === newChildren.length - 1)或者oldFiber遍历完(即oldFiber.sibling === null),跳出遍历,第一轮遍历结束。
你可以从这里 (opens new window)看到这轮遍历的源码

当遍历结束后,会有两种结果:

步骤3跳出的遍历

此时newChildren没有遍历完,oldFiber也没有遍历完。

// 之前
<li key="0">0</li>
<li key="1">1</li>
<li key="2">2</li>
            
// 之后
<li key="0">0</li>
<li key="2">1</li>
<li key="1">2</li>

第一个节点可复用,遍历到key === 2的节点发现key改变,不可复用,跳出遍历,等待第二轮遍历处理。

此时oldFiber剩下key === 1、key === 2未遍历,newChildren剩下key === 2、key === 1未遍历。

步骤4跳出的遍历

可能newChildren遍历完,或oldFiber遍历完,或他们同时遍历完。

举个例子,考虑如下代码:

// 之前
<li key="0" className="a">0</li>
<li key="1" className="b">1</li>
            
// 之后 情况1 —— newChildren与oldFiber都遍历完
<li key="0" className="aa">0</li>
<li key="1" className="bb">1</li>
            
// 之后 情况2 —— newChildren没遍历完,oldFiber遍历完
// newChildren剩下 key==="2" 未遍历
<li key="0" className="aa">0</li>
<li key="1" className="bb">1</li>
<li key="2" className="cc">2</li>
            
// 之后 情况3 —— newChildren遍历完,oldFiber没遍历完
// oldFiber剩下 key==="1" 未遍历
<li key="0" className="aa">0</li>

带着第一轮遍历的结果,我们开始第二轮遍历
对于第一轮遍历的结果,我们分别讨论:

newChildrenoldFiber同时遍历完
那就是最理想的情况:只需在第一轮遍历进行组件更新 (opens new window)。此时Diff结束。

#newChildren没遍历完,oldFiber`遍历完
已有的DOM节点都复用了,这时还有新加入的节点,意味着本次更新有新节点插入,我们只需要遍历剩下的newChildren为生成的workInProgress fiber依次标记Placement。

你可以在这里 (opens new window)看到这段源码逻辑

newChildren遍历完,oldFiber没遍历完
意味着本次更新比之前的节点数量少,有节点被删除了。所以需要遍历剩下的oldFiber,依次标记Deletion。

你可以在这里 (opens new window)看到这段源码逻辑

newChildrenoldFiber都没遍历完
这意味着有节点在这次更新中改变了位置。

这是Diff算法最精髓也是最难懂的部分。我们接下来会重点讲解。

处理移动的节点

由于有节点改变了位置,所以不能再用位置索引i对比前后的节点,那么如何才能将同一个节点在两次更新中对应上呢?

我们需要使用key

为了快速的找到key对应的oldFiber,我们将所有还未处理的oldFiber存入以keykeyoldFibervalueMap中。

const existingChildren = mapRemainingChildren(returnFiber, oldFiber);

接下来遍历剩余的newChildren,通过newChildren[i].key就能在existingChildren中找到key相同的oldFiber`。

标记节点是否移动

既然我们的目标是寻找移动的节点,那么我们需要明确:节点是否移动是以什么为参照物?

我们的参照物是:最后一个可复用的节点在oldFiber中的位置索引(用变量lastPlacedIndex表示)。

由于本次更新中节点是按newChildren的顺序排列。在遍历newChildren过程中,每个遍历到的可复用节点一定是当前遍历到的所有可复用节点中最靠右的那个,即一定在lastPlacedIndex对应的可复用的节点在本次更新中位置的后面。

那么我们只需要比较遍历到的可复用节点在上次更新时是否也在lastPlacedIndex对应的oldFiber后面,就能知道两次更新中这两个节点的相对位置改变没有。

我们用变量oldIndex表示遍历到的可复用节点在oldFiber中的位置索引。如果oldIndex < lastPlacedIndex,代表本次更新该节点需要向右移动。

lastPlacedIndex初始为0,每遍历一个可复用的节点,如果oldIndex >= lastPlacedIndex,则lastPlacedIndex = oldIndex

参考文献

<>

你可能感兴趣的:(算法,js,vue,react)