react diff 算法實現思路及原理解析

前面幾節我們學習瞭解瞭 react 的渲染機制和生命周期,本節我們正式進入基本面試必考的核心地帶 — diff 算法,瞭解如何優化和復用 dom 操作的,還有我們常見的 key 的作用。

diff 算法使用在子都是數組的情況下,這點和 vue 是一樣的。如果元素是其他類型的話直接替換就好。

事例分析

按照之前的 diff 寫法,如果元素不同我們是直接刪瞭 a 再插入的:

按照上面圖的結構,我們需要知道那個元素變化瞭,其實右邊相對左邊隻是把 A 做瞭移動,沒有 dom 元素的刪除和新增。

diff 特點

  • 同級對比 On
  • 類型不一樣銷毀老的,創建新的
  • 通過 key 標識

key 這裡需要標識,主要是為瞭列表中有刪除新增時有優化效果,如果純靜態列表,隻是展示作用,key 意義不大。

diff 思路

  • 使用 map 存儲節點狀態,格式如下:
let map = {
  keyA: ADOM,
  keyB: BDOM
}
  • 定義 lastPlacedIndex 記錄上一個不需要移動的老節點

默認 lastPlacedIndex = 0 ,上一個不需要移動的節點,在循環新的子虛擬 dom 時,如果老節點的掛載索引小於當前值,則改變 lastPlacedIndex。這裡有點類似 vue 的最長遞增子序列,最大的保證不變的 dom 元素,隻是判斷方式不同。

  • 循環新數組
  • 先出 Amap 中如果有 A,表示可以復用
    • 判斷 A 的老掛載索引和 lastPlacedIndex 對比,如果索引值大,A 節點不需要移動,更新 lastPlacedIndex 的值;否則循環到 B,掛載索引小,需要移動 B;循環到 Gmap 中沒有值,需要新增;新的數組節點循環完,未用到的老節點全部刪除。

實現 diff 算法

修改入口文件

// 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

  • 是用原生庫打印,看結構

可以發現就是一個簡單的 Symbol,所以需要定義新的類型:

為什麼一個簡單的 Symbol 可以被渲染成片段呢?依賴於 babel 解析。

// 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 中導出

// 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 對比

之前邏輯:

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

新的邏輯(參考上面的流程):

// 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 新的生命周期。

到此這篇關於react diff 算法實現思路及原理解析的文章就介紹到這瞭,更多相關react diff 算法內容請搜索WalkonNet以前的文章或繼續瀏覽下面的相關文章希望大傢以後多多支持WalkonNet!

推薦閱讀: