React DOM Diff

背景

React 组件是一个返回 Virtual DOM Tree 的方法

function SomeComponent() {
  return (
  	<div>
      <span>..</span>
      <Button>..</Button>
    </div>
  )
}

//* 上述代码会转换为一个 React Virtual DOM,就像如下所示
class VirtualDOMNode {
  type: SomeComponent // 如果是内置的就会描述成 "div" 等等
  children
  update() {
    const newVirtualDOM = this.type()
    // ...
  }
}

更新策略有很多,而最划算的更新策略就是在原来的基础上进行新增、删除等操作,而不是对整个DOM进行替换。

大体过程

1. React 渲染函数执行时会生成一个树状结构

<div style={xxx}>...</div>

// 上面的jsx会转换为
React.createElement('div', {
  style: {xxx},
  ...
})

createElement() 被调用之后将生成一个 Virtual DOM 节点

class ReactElement {
  type: SomeComponent
  props: {
    children
  }
}

Element 本身可以看做是对“数据”的描述,也就是元数据,它本身是没有行为的。

Element 可以看做是虚拟 DOM,因为它代表了真实的 DOM 结构。但是又因为它本身没有行为,所以要使它拥有像更新的能力,就得对该 Element 再进行一次封装 (FiberNode)。

class FiberNode {
  type: SomeComponent // 函数组件本身,调用它的时候可以生成新的 Element,复制于 ElementNode 
  props: {},
  update() {}
}

2. 更新

对于某个给定组件

function SomeComponent() {
 	return <div>...</div>
}

当组件 SomeComponent 触发更新时,React 会这样处理

// Fiber Context
{
  let vDOMOld // 上一次调用 SomeComponent 产生的 VirtualDOM
  //...
  
  update() {
    const vDOMNext = SomeComponent()
    const updates = domDiff(vDOMOld, vDOMNext)
    vDOMOld = vDOMNext
    apply(updates)
  }
}

React 更新产生虚拟 DOM 节点,然后通过 diff 算法比较两个 DOM 节点的差异,然后决定更新步骤,最后再向 DOM 应用这些更新。

从上述伪代码中可以看到所有的更新都依赖 diff,这就要求 diff 算法的效率必须足够高才能很好地支撑起整个项目。

FAQ

问:为什么不把更新方法放到 ElementNode 当中

答:因为组件的更新需要在特定的场景下,它可能是在浏览器端、Native等等;另外组件的更新涉及到特殊的算法,像 Fiber。在具体的场景下再封装具体的方法有利于代码的设计。

细节

对于相同类型的节点

if (vDOMOld.type === vDOMNext.type) {
  // ...
}

比如下述组件发生变化时

function Button({text}) {
  return <button>{text}</button>
}

只需要替换属性即可

<Button text="点击" />
// 转换为
<Button text="click" />

对于不同类型的节点

当遇到不同类型的节点时,React 会直接替换而不是继续往下比

<div>
	<Counter />
</div>

// 转换为

<span>123</span>

对于子节点的处理

<ul>
  <li>first</li>
  <li>second</li>
</ul>

// 转换为

<ul>
  <li>first</li>
  <li>second</li>
  <li>third</li>
</ul>

对于上述的节点,React 只会插入第三个 li

但是对于下述这种乱序的情况,React 会进行逐个替换

<ul>
  <li>first</li>
  <li>second</li>
</ul>

// 转换为

<ul>
  <li>third</li>
  <li>first</li>
  <li>second</li>
</ul>

因为在对比第一个 li 时,React 发现(文本)属性不一样了,所以按照上文提到的属性不一样时进行替换。

对于第二个 li 的对比同理。

这是因为 DOM-Diff 会用简单的算法 —— 顺序比较,而不是动态规划(时间复杂度有O(n2))。由于直接替换元素不会明显影响速度(人对其目前的表现感觉还不错),因此这里没有必要使用更加复杂但可能高效的算法。

为了解决更新比较慢的问题,React 引用了 key 来解决

<ul>
  <li key="first">first</li>
  <li key="second">second</li>
</ul>

// 转换为

<ul>
  <li key="third">third</li>
  <li key="first">first</li>
  <li key="second">second</li>
</ul>

然而,只有在列表很大的时候优化效果才会体现出来,因为更新过程的成本更多是在渲染而不是计算上面。

具体的 diff 细节如下面伪代码所示

// Diff 算法入口
function *domDiff(vDOM1, vDOM2) {
  // 原来没有现在有,就是添加
  if (!vDOM1) {
    yield new InsertUpdate(vDOM1, vDOM2)
    return
  }
  
  if (vDOM1.type === vDOM2.type) {
    // 类型相同
    if (vDOM1.key === vDOM2.key) {
      // 类型相同而且 key 相同时就是对属性进行 update
      yield new AttributeUpdate(vDOM1, vDOM2)
      // 此时要继续 diff 它们的子节点
      yield *domDiffArray(vDOM1.children, vDOM2.children)
    } else {
      // 类型相同但是 key 不同就会执行替换操作
      yield new ReplaceUpdate(vDOM1, vDOM2)
    }
  } else {
    // 类型不同
    yield new ReplaceUpdate(vDOM1, vDOM2)
  }
}

// 对子节点进行diff
function *domDiffArray(arr1, arr2) {
  // 子节点也是原来的属性,如果新旧节点中有一个没有子节点,则对新旧节点进行替换
  if (!arr1 || !arr2) {
    yield new ReplaceUpdate(vDOM1, vDOM2)
    return
  }
  
  const m1 = toMap(arr1)
  const m2 = toMap(arr2)
  
  // 1.查看是否存在需要删除的 VDOM
  const deleted = arr1.filter((item, i) => {
    // 没有 key 走的是顺序比较
    // 当 arr1 的当前元素存在 key 时,如果 m2 中不存在这个 key(更新后没有了)时,就说明这个元素要删掉的
    // 当 arr1 的当前元素不存在 key 而且更新后的节点数量比当前数量少时,说明当前节点是多余的,需要删掉
    return item.key ? !m2.has(item.key) : i >= arr2.length
  })
  // 将需要删除的节点替换为 null
  for (const item of deleted) {
    yield new ReplaceUpdate(item, null)
  }
  
  // 2.查看是否存在要被替换的节点
  for (let i = 0; i < arr1.length; i++) {
    const a = arr1[i]
    if (a.key) {
      // 对于有 key 的节点,如果 m2 中也有相同的 key,则通过递归 diff 进行替换的判断
      if (m2.has(a.key)) yield *domDiff(a, m2.get(a.key))
    } else {
      // 如果当前遍历节点没有 key,则对相同位置上的节点进行递归 diff 替换
      if (i < arr2.length) yield *domDiff(a, arr2[i])
    }
  }
  
  // 3.查看是否存在需要增加的节点 ( arr2 中有且 arr1 中没有)
  for (let i = 0; i < arr2.length; i++) {
    const b = arr2[i]
    
    if (b.key) {
      // arr2 的当前元素的 key 不存在于 m1,说明当前元素是新增的
      if (!m1.has(b.key)) yield new InsertUpdate(i, b)
    } else {
      // arr2 的当前元素没有 key,但是当前元素的序号已经大于 arr1 的长度了
      if (i >= arr1.length) yield new InsertUpdate(i, arr[2])
    }
  }

}

// 将数组转换为字典的方法
function toMap(arr) {
  const map = new Map()
  arr.forEach(item => {
    if (item.key) map.set(item.key, item)
  })
  return map
}

class InsertUpdate {
  constructor(pos, to) {
    this.pos = pos
    this.to = to
  }
}

class ReplaceUpdate {
  constructor(from ,to) {
    this.from = from
    this.to = to
  }
}

简单整理一下:

diff 算法可以分为两大块,diff 算法入口子节点比较方法

diff 算法入口

diff 算法入口 处,即上述的 function *domDiff(vDOM1, vDOM2) {},对传入的两个虚拟DOM节点进行比较 ( Current Fiber 和 WorkInProgress Fiber ) :

  • 当出现原来不存在节点但是现在有时,执行 添加操作
    // 原来没有现在有,就是添加
     if (!vDOM1) {
       yield new InsertUpdate(vDOM1, vDOM2)
       return
     }
    
  • 当两个节点均存在且出现 类型不同类型相同时 key 不同 的情况时,进行替换操作
    if (vDOM1.type === vDOM2.type) {
      // 类型相同
      if (vDOM1.key === vDOM2.key) {
      	// ...
      } else {
        // 类型相同但是 key 不同就会执行替换操作
        yield new ReplaceUpdate(vDOM1, vDOM2)
      }
    } else {
      // 类型不同
      yield new ReplaceUpdate(vDOM1, vDOM2)
    }
    
  • 当两个节点 类型相同且key相同时,执行属性更新操作并且对它们的子节点调用子节点比较方法
    if (vDOM1.type === vDOM2.type) {
      // 类型相同
      if (vDOM1.key === vDOM2.key) {
        // 类型相同而且 key 相同时就是对属性进行 update
        yield new AttributeUpdate(vDOM1, vDOM2)
        // 此时要继续 diff 它们的子节点
        yield *domDiffArray(vDOM1.children, vDOM2.children)
      } else {
        // 类型相同但是 key 不同就会执行替换操作 ...
      }
    } else {
      // 类型不同 ...
    }
    

对子节点进行diff

  1. 当两个父节点存在其中一个节点没有子节点时,直接进行替换操作,后面的比较不再进行
    // 子节点也是原来的属性,如果新旧节点中有一个没有子节点,则对新旧节点进行替换
    if (!arr1 || !arr2) {
      yield new ReplaceUpdate(vDOM1, vDOM2)
      return
    }
    
  2. 将二者的子节点由数组转换成字典 ( key 能够不按顺序比较的原因 )
    const m1 = toMap(arr1)
    const m2 = toMap(arr2)
    
    // 将数组转换为字典的方法
    function toMap(arr) {
      const map = new Map()
      arr.forEach(item => {
        if (item.key) map.set(item.key, item)
      })
      return map
    }
    
  3. 查看是否存在需要删除的节点
    // 查看是否存在需要删除的 VDOM
    const deleted = arr1.filter((item, i) => {
      // 没有 key 走的是顺序比较
      // 当 arr1 的当前元素存在 key 时,如果 m2 中不存在这个 key(更新后没有了)时,就说明这个元素要删掉的
      // 当 arr1 的当前元素不存在 key 而且更新后的节点数量比当前数量少时,说明当前节点是多余的,需要删掉
      return item.key ? !m2.has(item.key) : i >= arr2.length
    })
    // 将需要删除的节点替换为 null
    for (const item of deleted) {
      yield new ReplaceUpdate(item, null)
    }
    
  4. 查看是否存在需要被替换的节点
    // 查看是否存在要被替换的节点
    for (let i = 0; i < arr1.length; i++) {
      const a = arr1[i]
      if (a.key) {
        // 对于有 key 的节点,如果 m2 中也有相同的 key,则通过递归 diff 进行替换的判断
        if (m2.has(a.key)) yield *domDiff(a, m2.get(a.key))
      } else {
        // 如果当前遍历节点没有 key,则对相同位置上的节点进行递归 diff 替换
        if (i < arr2.length) yield *domDiff(a, arr2[i])
      }
    }
    
  5. 查看是否存在需要增加的节点 ( arr2 中有且 arr1 没有 )
    // 查看是否存在需要增加的节点 ( arr2 中有且 arr1 中没有)
    for (let i = 0; i < arr2.length; i++) {
       const b = arr2[i]
       
       if (b.key) {
         // arr2 的当前元素的 key 不存在于 m1,说明当前元素是新增的
         if (!m1.has(b.key)) yield new InsertUpdate(i, b)
       } else {
         // arr2 的当前元素没有 key,但是当前元素的序号已经大于 arr1 的长度了
         if (i >= arr1.length) yield new InsertUpdate(i, arr[2])
       }
     }
    
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值