react+vue2+vue3 diff算法分析及比較

此文內容包括如下:vue

介紹diff算法node

  1. react-diff: 遞增法

移動節點:移動的節點稱爲α,將α對應的真實的DOM節點移動到,α在新列表中的前一個VNode對應的真實DOM的後面react

添加節點:在新列表中有全新的VNode節點,在舊列表中找不到的節點須要添加(經過find這個布爾值來查找)面試

移除節點:當舊的節點不在新列表中時,咱們就將其對應的DOM節點移除(經過key來查找肯定是否刪除)算法

不足:從頭至尾單邊比較,容易增長比較次數數組

  1. vue2-diff: 雙端比較

DOM節點何時須要移動和如何移動,總結以下:markdown

  • 頭-頭:不移動
  • 尾-尾:不移動
  • 頭-尾: 插入到舊節點的尾節點的後面
  • 尾-頭:插入到舊列表的第一個節點以前
  • 以上4種都不存在(特殊狀況):在舊節點中找,若是找到,移動找到的節點,移動到開頭;沒找到,直接建立一個新的節點放到最前面

添加節點【oldEndIndex以及小於了oldStartIndex】:將剩餘的節點依次插入到oldStartNodeDOM以前post

移除節點【newEndIndex小於newStartIndex】:將舊列表剩餘的節點刪除便可學習

  1. vue3-diff: 最長遞增子序列

區別優化

  1. react和vue2的比較:
  • vue2雙端比較解決react單端比較致使移動次數變多的問題,react只能從頭至尾遍歷,增長了移動次數
  1. vue2和vue3的比較:都用了雙端指針

  2. vue3和react比較:vue3在判斷是否須要移動,使用了react的遞增法

幾個算法看下來,套路就是找到移動的節點,而後給他移動到正確的位置。把該加的新節點添加好,把該刪的舊節點刪了,整個算法就結束了。

1、react-diff —— 遞增法

實現原理

從頭至尾遍歷比較,新列表的節點在舊列表中的位置是不是遞增 若是遞增,不須要移動,不然須要移動。

經過key在舊節點中找到新節點的節點,因此key必定要表明惟一性。

移動節點:在舊節點中找到須要移動的VNode,咱們稱該VNode爲α

生成的DOM節點插入到哪裏?

將α對應的真實的DOM節點移動到,α在新列表中的前一個VNode對應的真實DOM的後面

image.png

將DOM-B移到DOM-D的後面

爲何這麼移動?

首先咱們列表是從頭至尾遍歷的。這就意味着對於當前VNode節點來講,該節點以前的全部節點都是排好序的,若是該節點須要移動,那麼只須要將DOM節點移動到前一個vnode節點以後就能夠,由於在新列表vnode的順序就是這樣的。

添加節點:在新列表中有全新的VNode節點,在舊列表中找不到的節點須要添加

如何發現全新的節點?

定義一個find變量值爲false。若是在舊列表找到了key 相同的vnode,就將find的值改成true。當遍歷結束後判斷find值,若是爲false,說明當前節點爲新節點

生成的DOM節點插入到哪裏?

分兩種狀況:

    1. 新的節點位於新列表的第一個,這時候咱們須要找到舊列表第一個節點,將新節點插入到原來第一個節點以前,這個很好理解,也就是最在最前面的新節點插入第一個節點以前。
    1. 將新的真實的DOM節點移動到,新列表中的前一個VNode對應的真實DOM的後面。移動原理同移動節點,也就是由於該節點以前已經排好序。

刪除節點:當舊的節點不在新列表中時,咱們就將其對應的DOM節點移除

實現代碼

function reactDiff(prevChildren, nextChildren, parent) {
    let lastIndex = 0
    for (let i = 0; i < nextChildren.length; i++) {
        let nextChild = nextChildren[i],
            find = false;
        for (let j = 0; j < prevChildren.length; j++) {
            let prevChild = prevChildren[j]
            if (nextChild.key === prevChild.key) {
                find = true
                patch(prevChild, nextChild, parent)
                if (j < lastIndex) {
                    // 移動節點:移動到前一個節點的後面
                    let refNode = nextChildren[i - 1].el.nextSibling;
                    parent.insertBefore(nextChild.el, refNode)
                } else {
                    // 不須要移動節點,記錄當前位置,與以後的節點進行對比
                    lastIndex = j
                }
                break
            }
        }
        if (!find) {
            // 定義了find變量,插入新節點
            let refNode = i <= 0
                            ? prevChildren[0].el
                            : nextChildren[i - 1].el.nextSibling
            mount(nextChild, parent, refNode);
        }
    }
    //移除節點
    for (let i = 0; i < prevChildren.length; i++) {
        let prevChild = prevChildren[i],
            key = prevChild.key,
            has = nextChildren.find(item => item.key === key);
        if (!has) parent.removeChild(prevChild.el)
    }
}

複製代碼

算法優化及不足

  1. 時間複雜度是O(m*n),有不足,可優化

咱們能夠用空間換時間,把keyindex的關係維護成一個Map,從而將時間複雜度下降爲O(n)

function reactdiff(prevChildren, nextChildren, parent) {
  let prevIndexMap = {},
    nextIndexMap = {};
  for (let i = 0; i < prevChildren.length; i++) {
    let { key } = prevChildren[i]
    //保存舊列表key和指引i的關係
    prevIndexMap[key] = i
  }
  let lastIndex = 0;
  for (let i = 0; i < nextChildren.length; i++) {
    let nextChild = nextChildren[i],
      nextKey = nextChild.key,
      // 經過新列表的key獲得舊列表的指引
      j = prevIndexMap[nextKey];

    //保存新列表key和指引i的關係
    nextIndexMap[nextKey] = i
    
    if (j === undefined) {
    //添加節點
      let refNode = i === 0
                    ? prevChildren[0].el
                    : nextChildren[i - 1].el.nextSibling;
      mount(nextChild, parent, refNode)
    } else {
      patch(prevChildren[j], nextChild, parent)
      if (j < lastIndex) {
      //移動節點:移動到前一個節點的後面
        let refNode = nextChildren[i - 1].el.nextSibling;
        parent.insertBefore(nextChild.el, refNode)
      } else {
       // 不須要移動節點,記錄當前位置,與以後的節點進行對比
        lastIndex = j
      }
    }
  }

//刪除節點
  for (let i = 0; i < prevChildren.length; i++) {
    let { key } = prevChildren[i]
    if (!nextIndexMap.hasOwnProperty(key)) parent.removeChild(prevChildren[i].el)
  }
}
複製代碼
  1. 移動次數有不足

image.png

根據reactDiff的思路,咱們須要先將DOM-A移動到DOM-C以後,而後再將DOM-B移動到DOM-A以後,完成Diff。可是咱們經過觀察能夠發現,只要將DOM-C移動到DOM-A以前就能夠完成Diff

這是由於react只能從頭至尾遍歷,增長了移動次數。因此這裏是有可優化的空間的,接下來咱們介紹vue2.x中的diff算法——雙端比較,該算法解決了上述的問題

vue2-diff —— 雙端比較

實現原理

雙端比較就是新列表舊列表兩個列表的頭與尾互相對比,,在對比的過程當中指針會逐漸向內靠攏,直到某一個列表的節點所有遍歷過,對比中止。

按照如下四個步驟進行對比

  1. 使用舊列表的頭一個節點oldStartNode新列表的頭一個節點newStartNode對比
  2. 使用舊列表的最後一個節點oldEndNode新列表的最後一個節點newEndNode對比
  3. 使用舊列表的頭一個節點oldStartNode新列表的最後一個節點newEndNode對比
  4. 使用舊列表的最後一個節點oldEndNode新列表的頭一個節點newStartNode對比

image.png

經過圖形記住1-4的比較順序,先先後雙豎再首尾兩交叉,記住這張圖就夠了

具體規則和移動規則,這裏是重中之重,必定要學習

  1. 舊列表的頭一個節點oldStartNode新列表的頭一個節點newStartNode對比時key相同。那麼舊列表的頭指針oldStartIndex新列表的頭指針newStartIndex同時向移動一位。

本來在舊列表中就是頭節點,在新列表中也是頭節點,該節點不須要移動,因此什麼都不須要作

  1. 舊列表的最後一個節點oldEndNode新列表的最後一個節點newEndNode對比時key相同。那麼舊列表的尾指針oldEndIndex新列表的尾指針newEndIndex同時向移動一位。

本來在舊列表中就是尾節點,在新列表中也是尾節點,說明該節點不須要移動,因此什麼都不須要作

  1. 舊列表的頭一個節點oldStartNode新列表的最後一個節點newEndNode對比時key相同。那麼舊列表的頭指針oldStartIndex移動一位;新列表的尾指針newEndIndex移動一位。

本來舊列表中是頭節點,而後在新列表中是尾節點。那麼只要在舊列表中把當前的節點移動到本來尾節點的後面,就能夠了

  1. 舊列表的最後一個節點oldEndNode新列表的頭一個節點newStartNode對比時key相同。那麼舊列表的尾指針oldEndIndex移動一位;新列表的頭指針newStartIndex移動一位。

本在舊列表末尾的節點,倒是新列表中的開頭節點,沒有人比他更靠前,由於他是第一個,因此只須要把當前的節點移動到本來舊列表中的第一個節點以前,讓它成爲第一個節點便可。

DOM節點何時須要移動和如何移動,總結以下:

  • 頭-頭:不移動
  • 尾-尾:不移動
  • 頭-尾: 插入到舊節點的尾節點的後面
  • 尾-頭:插入到舊列表的第一個節點以前

固然也有特殊狀況,下面繼續

當四次對比都沒找到複用節點

咱們只能拿新列表的第一個節點去舊列表中找與其key相同的節點

找節點的時候有兩種狀況:

  1. 一種在舊列表中找到了

移動找到的節點,移動到開頭

DOM移動後,由咱們將舊列表中的節點改成undefined,這是相當重要的一步,由於咱們已經作了節點的移動了因此咱們不須要進行再次的對比了。最後咱們將頭指針newStartIndex向後移一位。

  1. 另外一種狀況是沒找到

直接建立一個新的節點放到最前面就能夠了,而後後移頭指針newStartIndex

添加節點

oldEndIndex小於了oldStartIndex,可是新列表中還有剩餘的節點,咱們只須要將剩餘的節點依次插入到oldStartNodeDOM以前就能夠了。爲何是插入oldStartNode以前呢?緣由是剩餘的節點在新列表的位置是位於oldStartNode以前的,若是剩餘節點是在oldStartNode以後,oldStartNode就會先行對比,這個須要思考一下,其實仍是與第四步的思路同樣。

移除節點

新列表newEndIndex小於newStartIndex時,咱們將舊列表剩餘的節點刪除便可。這裏咱們須要注意,舊列表undefind。前面提到過,當頭尾節點都不相同時,咱們會去舊列表中找新列表的第一個節點,移動完DOM節點後,將舊列表的那個節點改成undefind。因此咱們在最後的刪除時,須要注意這些undefind,遇到的話跳過當前循環便可。

實現代碼

function vue2diff(prevChildren, nextChildren, parent) {
  let oldStartIndex = 0,
    newStartIndex = 0,
    oldStartIndex = prevChildren.length - 1,
    newStartIndex = nextChildren.length - 1,
    oldStartNode = prevChildren[oldStartIndex],
    oldEndNode = prevChildren[oldStartIndex],
    newStartNode = nextChildren[newStartIndex],
    newEndNode = nextChildren[newStartIndex];
    //循環結束條件
  while (oldStartIndex <= oldStartIndex && newStartIndex <= newStartIndex) {
    if (oldStartNode === undefined) {
      oldStartNode = prevChildren[++oldStartIndex]
    } else if (oldEndNode === undefined) {
      oldEndNode = prevChildren[--oldStartIndex]
    } else if (oldStartNode.key === newStartNode.key) {
    // 頭-頭:不移動
      patch(oldStartNode, newStartNode, parent)

      oldStartIndex++
      newStartIndex++
      oldStartNode = prevChildren[oldStartIndex]
      newStartNode = nextChildren[newStartIndex]
    } else if (oldEndNode.key === newEndNode.key) {
      // 尾-尾:不移動
      patch(oldEndNode, newEndNode, parent)

      oldStartIndex--
      newStartIndex--
      oldEndNode = prevChildren[oldStartIndex]
      newEndNode = nextChildren[newStartIndex]
    } else if (oldStartNode.key === newEndNode.key) {
    // 頭-尾: 插入到舊節點的尾節點的後面
      patch(oldStartNode, newEndNode, parent)
      parent.insertBefore(oldStartNode.el, oldEndNode.el.nextSibling)
      oldStartIndex++
      newStartIndex--
      oldStartNode = prevChildren[oldStartIndex]
      newEndNode = nextChildren[newStartIndex]
    } else if (oldEndNode.key === newStartNode.key) {
    // 尾-頭:插入到舊列表的第一個節點以前
      patch(oldEndNode, newStartNode, parent)
      parent.insertBefore(oldEndNode.el, oldStartNode.el)
      oldStartIndex--
      newStartIndex++
      oldEndNode = prevChildren[oldStartIndex]
      newStartNode = nextChildren[newStartIndex]
    } else {
    //特殊狀況
      let newKey = newStartNode.key,
        oldIndex = prevChildren.findIndex(child => child && (child.key === newKey));
      if (oldIndex === -1) {
        mount(newStartNode, parent, oldStartNode.el)
      } else {
        let prevNode = prevChildren[oldIndex]
        patch(prevNode, newStartNode, parent)
        parent.insertBefore(prevNode.el, oldStartNode.el)
        prevChildren[oldIndex] = undefined
      }
      newStartIndex++
      newStartNode = nextChildren[newStartIndex]
    }
  }
  if (newStartIndex > newStartIndex) {
    while (oldStartIndex <= oldStartIndex) {
      if (!prevChildren[oldStartIndex]) {
        oldStartIndex++
        continue
      }
      parent.removeChild(prevChildren[oldStartIndex++].el)
    }
  } else if (oldStartIndex > oldStartIndex) {
    while (newStartIndex <= newStartIndex) {
      mount(nextChildren[newStartIndex++], parent, oldStartNode.el)
    }
  }
}

複製代碼

vue3-diff —— 最長遞增子序列

雙端比較,while循環,兩端是向內靠攏的 頭-頭
尾-尾

j是頭向內靠攏指針;
prevEnd是尾向內靠攏指針

添加節點:j > prevEndj <= nextEnd【證實新列表有多餘的】

移除節點:j > nextEnd【證實舊列表有多餘的】

image.png

上圖,j > prevEndj <= nextEnd,只須要把新列表jnextEnd之間剩下的節點插入進去。

若是j > nextEnd【證實舊列表有多餘的】時,把舊列表jprevEnd之間的節點刪除

移動節點

image.png

根據新列表剩餘的節點數量,建立一個source數組,並將數組填滿-1

建立數組和對象創建關係:

  • 數組source【來作新舊節點的對應關係的,根據source計算出它的最長遞增子序列用於移動DOM節點】:新節點舊列表的位置存儲在該數組中,
  • 對象nextIndexMap【經過新列表的key去找舊列表的key】:存儲當前新列表中的節點key指引i的關係,再經過key去舊列表中去找位置

若是舊節點在新列表中沒有的話,直接刪除就好

let prevStart = j,
      nextStart = j,
      nextLeft = nextEnd - nextStart + 1,     // 新列表中剩餘的節點長度
      source = new Array(nextLeft).fill(-1),  // 建立數組,填滿-1
      nextIndexMap = {},                      // 新列表節點與index的映射
      patched = 0;                            // 已更新過的節點的數量
      
    // 保存映射關係 
    for (let i = nextStart; i <= nextEnd; i++) {
      let key = nextChildren[i].key
      nextIndexMap[key] = i
    } 
    
    // 去舊列表找位置
    for (let i = prevStart; i <= prevEnd; i++) {
      let prevNode = prevChildren[i],
      	prevKey = prevNode.key,
        nextIndex = nextIndexMap[prevKey];
      // 新列表中沒有該節點 或者 已經更新了所有的新節點,直接刪除舊節點
      if (nextIndex === undefind || patched >= nextLeft) {
        parent.removeChild(prevNode.el)
        continue
      }
      // 找到對應的節點
      let nextNode = nextChildren[nextIndex];
      patch(prevNode, nextNode, parent);
      // 給source賦值
      source[nextIndex - nextStart] = i
      patched++
    }
  }
複製代碼

在找節點時要注意,若是舊節點在新列表中沒有的話,直接刪除就好。除此以外,咱們還須要一個數量表示記錄咱們已經patch過的節點,若是數量已經與新列表剩餘的節點數量同樣,那麼剩下的舊節點就直接刪除

若是是全新的節點的話,其在source數組中對應的值就是初始的-1,經過這一步能夠區分出來哪一個爲全新的節點,哪一個是可複用的。

判斷是否要移動?遞增法,同react思路:若是找到的index是一直遞增的,說明不須要移動任何節點。咱們經過設置一個變量move來保存是否須要移動的狀態。

function vue3Diff(prevChildren, nextChildren, parent) {
  //...
  outer: {
  // ...
  }
  
  // 邊界狀況的判斷
  if (j > prevEnd && j <= nextEnd) {
    // ...
  } else if (j > nextEnd && j <= prevEnd) {
    // ...
  } else {
    let prevStart = j,
      nextStart = j,
      nextLeft = nextEnd - nextStart + 1,     // 新列表中剩餘的節點長度
      source = new Array(nextLeft).fill(-1),  // 建立數組,填滿-1
      nextIndexMap = {},                      // 新列表節點與index的映射
      patched = 0,
      move = false,                           // 是否移動
      lastIndex = 0;                          // 記錄上一次的位置
      
    // 保存映射關係 
    for (let i = nextStart; i <= nextEnd; i++) {
      let key = nextChildren[i].key
      nextIndexMap[key] = i
    } 
    
    // 去舊列表找位置
    for (let i = prevStart; i <= prevEnd; i++) {
      let prevNode = prevChildren[i],
      	prevKey = prevNode.key,
        nextIndex = nextIndexMap[prevKey];
      // 新列表中沒有該節點 或者 已經更新了所有的新節點,直接刪除舊節點
      if (nextIndex === undefind || patched >= nextLeft) {
        parent.removeChild(prevNode.el)
        continue
      }
      // 找到對應的節點
      let nextNode = nextChildren[nextIndex];
      patch(prevNode, nextNode, parent);
      // 給source賦值
      source[nextIndex - nextStart] = i
      patched++
      
      // 遞增方法,判斷是否須要移動
      if (nextIndex < lastIndex) {
      	move = false
      } else {
      	lastIndex = nextIndex
      }
    }
    
    if (move) {
    
    // 須要移動
    } else {
	
    //不須要移動
    }
  }
}

複製代碼

怎麼移動?

一旦須要進行DOM移動,咱們首先要作的就是找到source最長遞增子序列

從後向前進行遍歷source每一項。此時會出現三種狀況:

  1. 當前的值爲-1,這說明該節點是全新的節點,又因爲咱們是從後向前遍歷,咱們直接建立好DOM節點插入到隊尾就能夠了。
  2. 當前的索引爲最長遞增子序列中的值,也就是i === seq[j],這說說明該節點不須要移動
  3. 當前的索引不是最長遞增子序列中的值,那麼說明該DOM節點須要移動,這裏也很好理解,咱們也是直接將DOM節點插入到隊尾就能夠了,由於隊尾是排好序的。

image.png

function vue3Diff(prevChildren, nextChildren, parent) {
  //...
  if (move) {
	const seq = lis(source); // [0, 1]
    let j = seq.length - 1;  // 最長子序列的指針
    // 從後向前遍歷
    for (let i = nextLeft - 1; i >= 0; i--) {
      let pos = nextStart + i, // 對應新列表的index
        nextNode = nextChildren[pos],	// 找到vnode
      	nextPos = pos + 1// 下一個節點的位置,用於移動DOM
        refNode = nextPos >= nextChildren.length ? null : nextChildren[nextPos].el, //DOM節點
        cur = source[i];  // 當前source的值,用來判斷節點是否須要移動
    
      if (cur === -1) {
        // 狀況1,該節點是全新節點
      	mount(nextNode, parent, refNode)
      } else if (cur === seq[j]) {
        // 狀況2,是遞增子序列,該節點不須要移動
        // 讓j指向下一個
        j--
      } else {
        // 狀況3,不是遞增子序列,該節點須要移動
        parent.insetBefore(nextNode.el, refNode)
      }
    }
  } else {
    //不須要移動: 咱們只須要判斷是否有全新的節點【其在source數組中對應的值就是初始的-1】,給他添加進去
    for (let i = nextLeft - 1; i >= 0; i--) {
      let cur = source[i];  // 當前source的值,用來判斷節點是否須要移動
    
      if (cur === -1) {
       let pos = nextStart + i, // 對應新列表的index
          nextNode = nextChildren[pos],	// 找到vnode
          nextPos = pos + 1// 下一個節點的位置,用於移動DOM
          refNode = nextPos >= nextChildren.length ? null : nextChildren[nextPos].el, //DOM節點
      	mount(nextNode, parent, refNode)
      }
    }
  }
}

複製代碼

小結

  1. 須要建立數組和對象創建關係:
  • 數組source【來作新舊節點的對應關係的,根據source計算出它的最長遞增子序列用於移動DOM節點】:新節點舊列表的位置存儲在該數組中,
  • 對象nextIndexMap【經過新列表的key去找舊列表的key】:存儲當前新列表中的節點key指引i的關係,再經過key去舊列表中去找位置
  1. 移除節點知足如下任何一個條件:
  • j > nextEnd
  • 若是舊節點在新列表中沒有的話,直接刪除
  • 已經更新了所有的新節點,剩下的舊節點就直接刪除了【patch標記已更新過的節點的數量】
  1. 新增節點知足如下任何一個條件:
  • j > prevEndj <= nextEnd
  • 若是是全新的節點的話,其在source數組中對應的值就是初始的-1,新增
  1. 移動節點知足如下任何一個條件:
  • 當前的索引不是最長遞增子序列中的值,那麼說明該DOM節點須要移動
  1. 最長遞增子序列是爲了操做移動DOM

  2. 對比規則:

第一步:對比新老節點數組的頭頭和尾尾 在這一步將兩頭兩尾相同的進行 patch 第二步:頭尾 patch 結束以後,查看新老節點數組是否是有其中一方已經 patch 完了,假如是,那麼就多刪少補 第三步:遍歷老節點,看老節點是否在新節點裏面存在,假如不存在,就刪除。 // 假如新的子節點都被遍歷完了,那麼就表明說老的數組以後的,都是須要被刪除的 第四步:獲取最長遞增子序列

總結

介紹diff算法

  1. react-diff: 遞增法

移動節點:移動的節點稱爲α,將α對應的真實的DOM節點移動到,α在新列表中的前一個VNode對應的真實DOM的後面

添加節點:在新列表中有全新的VNode節點,在舊列表中找不到的節點須要添加(經過find這個布爾值來查找)

移除節點:當舊的節點不在新列表中時,咱們就將其對應的DOM節點移除(經過key來查找肯定是否刪除)

不足:從頭至尾單邊比較,容易增長比較次數

  1. vue2-diff: 雙端比較

DOM節點何時須要移動和如何移動,總結以下:

  • 頭-頭:不移動
  • 尾-尾:不移動
  • 頭-尾: 插入到舊節點的尾節點的後面
  • 尾-頭:插入到舊列表的第一個節點以前
  • 以上4種都不存在(特殊狀況):在舊節點中找,若是找到,移動找到的節點,移動到開頭;沒找到,直接建立一個新的節點放到最前面

添加節點【oldEndIndex以及小於了oldStartIndex】:將剩餘的節點依次插入到oldStartNodeDOM以前

移除節點【newEndIndex小於newStartIndex】:將舊列表剩餘的節點刪除便可

  1. vue3-diff: 最長遞增子序列

區別

  1. react和vue2的比較:
  • vue2雙端比較解決react單端比較致使移動次數變多的問題,react只能從頭至尾遍歷,增長了移動次數
  1. vue2和vue3的比較:都用了雙端指針

  2. vue3和react比較:vue3在判斷是否須要移動,使用了react的遞增法;react是單端比較,這樣移動效率下降,vue3是使用雙端比較

幾個算法看下來,套路就是找到移動的節點,而後給他移動到正確的位置。把該加的新節點添加好,把該刪的舊節點刪了,整個算法就結束了。

此文借鑑別人的文章,梳理成本身的筆記,分別分析了react、vue二、vue3的diff算法實現原理和具體實現,同時比較了這3種算法,應對面試確定不會懼怕。固然總結它不只僅爲了之後的面試,也爲了提高算法思想。

最長遞增子序列可使用動態規劃方法 juejin.cn/post/696278…

React、Vue二、Vue3的三種Diff算法 (juejin.cn)

相關文章
相關標籤/搜索