react学习(八) diff 算法实现

2022-05-22 20:26:32 浏览数 (2)

前面几节我们学习了解了 react 的渲染机制和生命周期,本节我们正式进入基本面试必考的核心地带 -- diff 算法,了解如何优化和复用 dom 操作的,还有我们常见的 key 的作用。

diff 算法使用在子都是数组的情况下,这点和 vue 是一样的。如果元素是其他类型的话直接替换就好。

事例分析

按照之前的 diff 写法,如果元素不同我们是直接删了 a 再插入的:

按照上面图的结构,我们需要知道那个元素变化了,其实右边相对左边只是把 A做了移动,没有 dom 元素的删除和新增。

diff 特点

  • 同级对比 On
  • 类型不一样销毁老的,创建新的
  • 通过 key 标识

key 这里需要标识,主要是为了列表中有删除新增时有优化效果,如果纯静态列表,只是展示作用,key 意义不大。

diff 思路

  1. 使用 map 存储节点状态,格式如下:
代码语言:txt复制
let map = {
  keyA: ADOM,
  keyB: BDOM
}
  1. 定义 lastPlacedIndex 记录上一个不需要移动的老节点

默认 lastPlacedIndex = 0 ,上一个不需要移动的节点,在循环新的子虚拟 dom 时,如果老节点的挂载索引小于当前值,则改变 lastPlacedIndex。这里有点类似 vue 的最长递增子序列,最大的保证不变的 dom 元素,只是判断方式不同。

  1. 循环新数组
  2. 先出 Amap 中如果有 A,表示可以复用
    • 判断 A 的老挂载索引和 lastPlacedIndex 对比,如果索引值大,A 节点不需要移动,更新 lastPlacedIndex 的值;否则循环到 B,挂载索引小,需要移动 B;循环到 Gmap 中没有值,需要新增;新的数组节点循环完,未用到的老节点全部删除。

实现 diff 算法

修改入口文件

代码语言:txt复制
// src/index.js
class Counter extends React.Component {
  constructor(props) {
    super(props)
    this.state = {list: ['A','B', 'C', 'D', 'E', 'F']}
  }
  handleClick = () => {
    this.setState({
      list: ['A', 'C', 'E', 'B', 'G']
    })
  }
  render() {
    // 使用空标签
    return <React.Fragment>
      <ul>
      {this.state.list.map(item => {
        // 这里使用 key 标识
        return <li key={item}>{item}</li>
      })}
      </ul>
      <button onClick={this.handleClick}>add 1</button>
    </React.Fragment>
  }
}

实现 React.Fragment

Fragment 就是代码片段,不占用 dom 结构。简写 <></>,对应 dom 操作为 createDocumentFragment

  1. 是用原生库打印,看结构

可以发现就是一个简单的 Symbol,所以需要定义新的类型:

为什么一个简单的 Symbol 可以被渲染成片段呢?依赖于 babel 解析。

代码语言:txt复制
// src/constants.js
export const REACT_FRAGMENT = Symbol("react.fragment") // React.Fragment 标签

// 备用,diff 时做 patch 的 type 定义
// 新的插入
export const PLACEMENT = 'PLACEMENT'
// 复用的移动
export const MOVE = 'MOVE'

在创建元素的时候进行类型判断,记得 react.js 中导出

代码语言:txt复制
// src/react-dom.js  

// createDOM 方法
else if (type === REACT_FRAGMENT) {
  // fragment 片段
  dom = document.createDocumentFragment()
}

// updateElement 方法
else if (oldVdom.type === REACT_FRAGMENT) {
  // fragment 不需要对比,直接对比 子 就可以了
  const currentDOM = newVdom.dom = findDOM(oldVdom)
    updateChildren(currentDOM, oldVdom.props.children, newVdom.props.children)
}

我们需要修改 children 对比

之前逻辑:

代码语言:txt复制
// src/react-dom.js

// diff  没有做复用,直接做的替换
function updateChildren(parentDOM, oldVChildren, newVChildren) {
  // 拿到最长的
  let maxLength = Math.max(oldVChildren.length, newVChildren.length);
  for (let i = 0; i < maxLength; i  ) {
  // 不能直接 appendChild 进父,需要找到当前操作的节点的下一个节点。在其前面插入
    const nextVdom = oldVChildren.find((item, index) => index > i && item && findDOM(item))
    compareTwoVdom(parentDOM, oldVChildren[i], newVChildren[i], findDOM(nextVdom));
  }
}

新的逻辑(参考上面的流程):

代码语言:txt复制
// diff
function updateChildren(parentDOM, oldVChildren, newVChildren) {
  oldVChildren = Array.isArray(oldVChildren) ? oldVChildren : [oldVChildren];
  newVChildren = Array.isArray(newVChildren) ? newVChildren : [newVChildren];

 // 1.循环老结构, 构建map存储  key: dom
  const keydOldMap = {}
  let lastPlacedIndex = 0
  oldVChildren.forEach((oldVChild, index) => {
    let oldKey = oldVChild?.key || index //  写key 了就用key,没写默认 index
    keydOldMap[oldKey] = oldVChild
  })
  // 2. 创建 dom 补丁包,收集 dom 操作
  const patch = []
  newVChildren.forEach((newVChild, index) => {
    newVChild.mountIndex = index // 为新元素每个添加索引标识
    const newKey = newVChild?.key || index
    const oldVChild = keydOldMap[newKey] // 看有没有存
    if(oldVChild) {
      // 如果有老的,就去更新老节点 这里直接可以复用
      updateElement(findDOM(oldVChild).parentNode, oldVChild, newVChild)
      if(oldVChild.mountIndex < lastPlacedIndex) {
        patch.push({
          type: MOVE,
          oldVChild,
          newVChild,
          mountIndex: index // 旧的移动到新的的位置
        })
      }
      // 复用过了 删除掉
      delete keydOldMap[newKey]
      lastPlacedIndex = Math.max(lastPlacedIndex, oldVChild.mountIndex)// 取最大
    } else {
      // 新的
      patch.push({
        type: PLACEMENT,
        newVChild,
        mountIndex: index
      })
    }
  })
  // 找到需要移动的老节点
  const moveVChildren = patch.filter(action => action.type === MOVE).map(action => action.oldVChild)
  // 把要删除的节点 和  要移动的节点先全删除     (页面里没有了,但是内存中还存在  patch 中有存)
  Object.values(keydOldMap).concat(moveVChildren).forEach(oldVdom => {
    let currentDOM = findDOM(oldVdom)
    currentDOM.remove()
  })
  patch.forEach(action => {
    const {type, oldVChild, newVChild, mountIndex} = action
    // 老的真实子节点
    const childNodes = parentDOM.childNodes
    // 新的插入
    if (type === PLACEMENT) {
      let newDOM = createDOM(newVChild)
      let childNode = childNodes[mountIndex] // 老真实节点
      if (childNode) {
        // 往 老的父对应位置插入
        parentDOM.insertBefore(newDOM, childNode)
      } else {
        parentDOM.appendChild(newDOM)
      }
    } else if (type === MOVE) {
      // 移动不用创建 新 dom,复用
      let oldDOM = findDOM(oldVChild)
      let childNode = childNodes[mountIndex] // 老真实节点
      if (childNode) {
        // 往 老的父对应位置插入
        parentDOM.insertBefore(oldDOM, childNode)
      } else {
        parentDOM.appendChild(oldDOM)
      }
    }
  })
}

实现如下跟原生一致,可以看到,三个节点实现了复用,即 A, C, E

如果没有写 key,我们在看效果:

可以看到只有第一个节点实现了复用,因为默认索引都使用的 0。所以这也是为什么不建议我们使用索引当 key 的原因。动态列表 key 意义不大。

本节代码不是很多,主要是 diff 算法的思路和实现原理。如果了解了 vuediff 算法,相信理解起来更好,也能更好的对比。下一小节我们学习下 react 新的生命周期。

0 人点赞