前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >react学习(八) diff 算法实现

react学习(八) diff 算法实现

原创
作者头像
测不准
发布2022-05-22 20:26:32
1K0
发布2022-05-22 20:26:32
举报
文章被收录于专栏:与前端沾边

前面几节我们学习了解了 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 新的生命周期。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 事例分析
    • diff 特点
      • diff 思路
      • 实现 diff 算法
        • 修改入口文件
          • 实现 React.Fragment
          • 我们需要修改 children 对比
          领券
          问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档