React key究竟有什麼作用?深入原始碼不背概念,五個問題重新整理你對於key的認知

2022-07-03 18:00:51

壹 ❀ 引

我在【react】什麼是fiber?fiber解決了什麼問題?從原始碼角度深入瞭解fiber執行機制與diff執行一文中介紹了react對於fiber處理的協調提交兩個階段,而在介紹協調時又順帶解釋了另一個較為重要的概念diff。那既然提到了diff我們還會順帶問一問diff中另一個有趣的概念key,那麼現在我來問大家,你是如何理解key的,key又有什麼作用呢?請大家思考一會如何回答。

我想,超過一大半的人會說,keydiff時能起到標記的作用,比如往一個陣列前面新增一個元素,react通過key能清晰知道它只用新增一個節點,而另外兩個節點可以直接複用,從而極大優化效能。

正如官網在介紹key時的例子所言:

當子元素擁有 key 時,React 使用 key 來匹配原有樹上的子元素以及最新樹上的子元素。以下例子在新增 key 之後使得之前的低效轉換變得高效。

<!-- 更新前 -->
<ul>
  <li key="2015">Duke</li>
  <li key="2016">Villanova</li>
</ul>
<!-- 更新後 -->
<ul>
  <li key="2014">Connecticut</li>
  <li key="2015">Duke</li>
  <li key="2016">Villanova</li>
</ul>

現在 React 知道只有帶著 '2014' key 的元素是新元素,帶著 '2015' 以及 '2016' key 的元素僅僅移動了。

那麼這個回答有問題嗎?官方都這麼說了,那大概是沒問題的;但如果我是面試官,我會基於這個回答再丟擲如下幾個問題:

  1. 為什麼list渲染時我們不提供key react就會給出警告,而普通dom結構不提供key卻不會如此?說說你的理解。

  2. react中的key真的有這麼聰明嗎?在列表渲染時通過key react就能知道哪些是新增的哪些是可以直接複用(僅僅是移動了)?

  3. 我們知道reactdiff是逐層比較的,假設現在有一個陣列為:

    const list = [
      {key:2015,value:1},
      {key:2016,value:2},
    ]
    

    我們在更新後list為:

    const list = [
      {key:2014,value:0},
      {key:2015,value:1},
      {key:2016,value:2},
    ]
    

    按照逐層比較的概念,它應該是這樣:

    那豈不是每一次比較都會認為key不同?每一層對比後都得重新渲染?那所謂的優化又是怎麼做的呢?

  4. 按照diff逐層對比的邏輯,如果新舊節點的key相等,則證明這個舊節點還可以複用。而我們不提供key時,key將預設為null;既然你又是逐層對比,而此時null === null也為true,也能夠複用,那為什麼還要提供獨一無二的key

  5. 為什麼不推薦使用index作為key,原因是什麼?

通過這五個問題,其實你能發現react官方基於key的解釋其實是特別宏觀的角度,如果你稍微瞭解過原始碼,你甚至會發現官方這個結論還有點經不住推敲,那麼就讓我們帶著這幾個問題投身於react原始碼中,通過這幾個問題來重新理解react中的key

注意,本文的原始碼分析均基於react 17.0.2版本,那麼本文開始。

貳 ❀ 深入理解react中的key

如果你有留意react官方檔案,key的解釋是在介紹list結構時所強調的概念,這也證明了key對於非list結構並不重要(一般我們直接不加key),這也說明在原始碼層diff一定會對於是否是list做邏輯區分,簡單點來說,針對非list的原始碼邏輯處理,你加不加key一點也不重要。

老實說,上文我丟擲的五個問題的結論其實是彼此關聯和依賴的,所以在解釋這幾個問題之前,我先給出二個比較核心的結論(後面會從原始碼層解釋這個結論):

  • react對於list結構的的新舊節點對比確實是逐層對比,但對於list結構且假設新增了獨一無二key時並不一定如此。
  • diff對比是先對比key,若key不同直接重新建立節點,若key相同則再對比type(標籤型別),如果type不同同樣重新建立;因此只有key type都相同時,react才會基於舊節點結合新props生成新節點。

先記住這兩個結論,下文我會連著結論以及上文的問題依次給出解釋。

貳 ❀ 壹 為什麼非list 結構不提供key不會有警告?

站在react設計角度,結合我對於原始碼的理解,我來說說我的看法。

我們都知道list的節點始終是動態生成的,每次資料的變更都會導致list需要map生成一份新的列表(宏觀角度確實是重新遍歷生成),站在react的角度,它需要考慮list資料規模大小是否會造成效能問題,所以在diff原始碼層才有了當keytype都相同時,react會利用舊fiber節點的資料clone一個新的fiber節點,而不是重新建立一個全新的fiber節點。

// 當diff判斷新舊節點的key與type都相同時,會使用舊fiber節點以及新的props來clone生成一個全新的fiber
function useFiber(fiber, pendingProps) {
  var clone = createWorkInProgress(fiber, pendingProps);
  clone.index = 0;
  clone.sibling = null;
  return clone;
}

而對於list結構,在某些情況下react會使用key來快取舊fiber節點便於後續對比,快取的邏輯如下:

function mapRemainingChildren(returnFiber, currentFirstChild) {
	// 建立一個map
  var existingChildren = new Map();
  // 這個是舊fiber節點
  var existingChild = currentFirstChild;
  // 只要舊fiber節點不會空,就一直遍歷
  while (existingChild !== null) {
    if (existingChild.key !== null) {
      // 如果fiber節點的key不會null,那就通過key==>fiber的形式存起來
      existingChildren.set(existingChild.key, existingChild);
    } else {
      // 假設為null,那就用index==>fiber形式存起來
      existingChildren.set(existingChild.index, existingChild);
    }
    // 將existingChild賦予成當前fiber的兄弟節點,然後繼續while
    existingChild = existingChild.sibling;
  }
	// 返回快取後的map
  return existingChildren;
}

但需要注意的是,並不是只要是list結構 react就會利用key快取舊節點。經測試,只有當key獨一無二,且key不相同時才會觸發快取邏輯,比如如下情況:

<!-- 更新前 -->
<ul>
  <li key="2015">Duke</li>
  <li key="2016">Villanova</li>
</ul>
<!-- 更新後 -->
<ul>
  <li key="2014">Connecticut</li>
  <li key="2015">Duke</li>
  <li key="2016">Villanova</li>
</ul>

第一次對比時,由於2014 !== 2015react就會想,你小子是不是在陣列前或者陣列中間插入了新元素了,為了避免逐層對比導致接下來的每個節點都要重新建立,此時會跳出之前的diff邏輯來到mapRemainingChildren方法,然後把舊節點存在map中,之後再借用map + key來達到舊節點的對比與複用。

而如下例子是在陣列之後插入了一個元素,這就導致2015 === 2015,所以react並不會走到快取邏輯,畢竟你key對比就已經相同了,之後判斷type都是li,說明新舊節點可能就只有props不同,那就直接複用更新就好了,沒必要去快取:

<!-- 更新前 -->
<ul>
  <li key="2015">Duke</li>
  <li key="2016">Villanova</li>
</ul>
<!-- 更新後 -->
<ul>
  <li key="2015">Duke</li>
  <li key="2016">Villanova</li>
  <li key="2016">Connecticut</li>
</ul>

所以來到非list情況,dom結構基本上是穩定的,你很難遇到dom插入新節點的場景,更多變化的是模板語法中的變數或者其它樣式,所以react也根本沒必要利用key去儲存這些不怎麼變化的節點。

而且站在效能優化的角度,第一大忌就是提前的過度優化。你想想,是listkey還能用key,那些非list不提供key你拿null來存嗎?都是null的情況下react怎麼知道誰是誰,難道強硬規定所有dom都需要提供key?而且即便強制開發者都提供key存所有fiber節點,你還需要考慮map對於記憶體佔用以及是否會造成記憶體漏失的問題,所以想想就知道這樣的設計非常不合理。

一句話總結,對於非list結構很難出現dom經常變動的情況,逐層對比就已經滿足新舊節點對比的需求;而對於list結構資料會經常變動,當頭部或中部插入新資料時,逐層對比會因為對比錯位而失效,所以需要key來快取舊節點,從而借用map修正逐層對比。

貳 ❀ 貳 react的key真的有那麼聰明嗎?

針對官方所給的例子,假設陣列前新增了一個元素,通過key react能知道只用新增一個,其它都只是移動了位置的結論,我更傾向於react需要考慮react初學者,且為了凸顯key的作用,所以描述上顯得key非常智慧,但事實上並不是如此。

function reconcileSingleElement(returnFiber, currentFirstChild, element, lanes) {
  // 獲取新虛擬dom的key
  var key = element.key;
  // 舊有的div fiber節點
  var child = currentFirstChild;
	// 遍歷當前節點的以及它的所有兄弟節點,注意下面的child = child.sibling,不為空就一直遍歷對比
  while (child !== null) {
    // 對於非list這裡都是null,也相等
    if (child.key === key) {
      switch (child.tag) {
        default:
          {
            // 只有元素的type型別也相等時,才會走更新fiber的邏輯
            if (child.elementType === element.type || ( 
             isCompatibleFamilyForHotReloading(child, element) )) {
              deleteRemainingChildren(returnFiber, child.sibling);
							// 根據新的虛擬dom的props來更新舊有fiber節點
              var _existing3 = useFiber(child, element.props);
              // ....
            }
            break;
          }
      }
      deleteRemainingChildren(returnFiber, child);
      break;
    } else {
      // 如果key不相等,直接在父節點上把自己整個都刪掉
      deleteChild(returnFiber, child);
    }
    // 將兄弟節點賦予child,繼續走while遍歷
    child = child.sibling;
  }
}

react並不能根據key相同就能斷定舊有節點只是移動了,最簡單推翻這個結論的例子就是key相同但type不同,比如:

<!-- 更新前 -->
<ul>
  <li key="2015">Duke</li>
  <li key="2016">Villanova</li>
</ul>
<!-- 更新後 -->
<div>
  <span key="2014">Connecticut</span>
  <span key="2015">Duke</span>
  <span key="2016">Villanova</span>
</div>

在前面的diff過程我們也說了,因為list對比某些情況還會借用key來快取舊fiber節點,它起到一個標誌作用,比較完key還是需要比較type是否相同,即便type相同我們還不能保證props是否相同,只要你能走到diff這一步,必定是key、type或者props某一個變了,就一定得更新fiber節點,這是毋庸置疑的,所以根本就不存在diff過程中直接完整複用舊節點的說法。

官方的對於舊節點只是移動了其實具有一定的誤導性,原始碼層還是走了clone邏輯,只是相對重新建立代價更小

貳 ❀ 叄 list 頭部插入新元素的diff過程

針對第三個問題,前文也已經說過了,react對於listdiff不一定是逐層的,當你沒提供key,或者key提供的是index,這會導致前後節點的key始終相等,從而繼續判斷type來決定是否更新複用舊fiber節點。

而當list對比且key不同時(陣列頭部或者中間插入元素時),react會先宣告一個map然後以此利用key依次快取舊fiber節點,之後再根據新的虛擬dom節點的順序,通過key從這個map裡獲取舊fiber節點,如果能獲取到,那就看看type是否相同,依次判斷是否能用舊fiber節點進行更新;如果通過keymap獲取不到,那說明這個節點就是一個全新的,直接重新建立。

說到底,key確實起到了標記的作用,但它的標記更多針對的是陣列頭部或者陣列中間插入新資料的場景,只要key不同了,react就知曉不能繼續逐層對比了,不然接下來肯定的key肯定會全部不同導致全部重新建立,因此才能根據key的獨一無二建立舊fibermap,並以此更新那些因插入導致原有對比順序被打亂的舊節點

接下來給大家展示下當陣列頭部插入新元素list對比的部分原始碼,大家可以結合上文在陣列頭部插入key=2014的例子來理解:

// 當子元素是陣列時,會進入此方法進行diff
function reconcileChildrenArray(returnFiber, currentFirstChild, newChildren, lanes) {
  // mapRemainingChildren的原始碼上面解釋過了,定義map根據key依次快取舊節點,注意,只有頭部或者中部插入元素,才會觸發這裡的邏輯
  var existingChildren = mapRemainingChildren(returnFiber, oldFiber);
	// 遍歷新的虛擬dom節點
  for (; newIdx < newChildren.length; newIdx++) {
    // 通過遍歷新虛擬dom節點,依次更新舊map儲存的節點,具體定義如下
    var _newFiber2 = updateFromMap(existingChildren, returnFiber, newIdx, newChildren[newIdx], lanes);
		// 刪除部分不影響理解的邏輯
}

比如我們在陣列前塞了一個key=2014的新節點,react在第一次對比是,發現2014! == 2015,外加上這塊又是陣列diff的邏輯,所以react會猜測你是不是在陣列前面或者中間插入了元素,從而導致key不同,因此才會呼叫mapRemainingChildren提前把舊fiber存入map

結合例子,那麼此時的newChildren就是三個虛擬dom,然後依次遍歷,與mapRemainingChildren返回的map節點做對比更新。緊接著我們來看updateFromMap的實現:

// updateFromMap具體實現
function updateFromMap(existingChildren, returnFiber, newIdx, newChild, lanes) {
  // 如果新虛擬節點型別是數位或者字串,走updateTextNode更新文字的邏輯
  if (typeof newChild === 'string' || typeof newChild === 'number') {
    var matchedFiber = existingChildren.get(newIdx) || null;
    return updateTextNode(returnFiber, matchedFiber, '' + newChild, lanes);
  }
	// 如果新節點是物件型別
  if (typeof newChild === 'object' && newChild !== null) {
    switch (newChild.$$typeof) {
      case REACT_ELEMENT_TYPE:
        {
          // 利用key(可能是key也可能是index)從map中獲取對應的舊fiber節點
          var _matchedFiber = existingChildren.get(newChild.key === null ? newIdx : newChild.key) || null;
					// 更新舊fiber節點
          return updateElement(returnFiber, _matchedFiber, newChild, lanes);
        }
    }
  }

  return null;
}

這個方法做的事情也很簡單,判斷新節點的型別,是數位或者字串,那就走文字更新的方法,反之就走更新物件的方法。而在物件更新中,我們看到了existingChildren.get()的邏輯,react通過key來獲取舊的fiber節點,之後又通過updateElement來做進一步的更新:

function updateElement(returnFiber, current, element, lanes) {
  // 判斷舊fiber節點是否存在,存在就更新舊fiber節點,否則那就重新建立
  if (current !== null) {
    // 判斷元素型別是否相同,比如前後都是li節點,證明dom型別沒變,而
    if (current.elementType === element.type || (
     isCompatibleFamilyForHotReloading(current, element) )) {
      // 根據新的props更新舊有的fiber節點
      var existing = useFiber(current, element.props);
      existing.ref = coerceRef(returnFiber, current, element);
      existing.return = returnFiber;
      return existing;
    }
  } // Insert

	// 當舊fiber節點不存在時,既然對比不了,那就直接重新建立了
  var created = createFiberFromElement(element, returnFiber.mode, lanes);
  created.ref = coerceRef(returnFiber, current, element);
  created.return = returnFiber;
  return created;
}

updateElement中我們看到了針對是否能從map中獲取到舊節點的不同處理,比如key=2014map很顯然就找不到,這就導致了currentnull,於是就走了下面的createFiberFromElement方法完全重新建立。

而當key2015或者2016時,因為current就是之前的舊fiber節點,於是走了var existing = useFiber(current, element.props)舊節點更新邏輯,而不是重新建立。

貳 ❀ 肆 既然null===null,為什麼還需要key?

其實說到這裡,我想大家對於這個問題應該也有了一定的理解。對於非list結構而言,確實是否提供key並無重要,反正大家都是逐層對比;而對於list而言,當存在陣列頭部或中間插入元素時,假設大家提供index作為key或者不提供key,都會導致新舊節點的key全部相等。這就導致了已經錯位的節點強行逐層對比,本應該新建的節點因為key相同而走了更新,本應該更新的節點因為key相同結果走了新建。

貳 ❀ 伍 為什麼不推薦使用index做為key?

理由在第四個問題已經回答過了,而且核心問題是因為本應該新建的結果你只做了更新,這種情況甚至還能導致bug。官方在介紹key時也給了一個導致bug的例子,我們結合原始碼來深究為什麼使用index導致了這個bug

例子程式碼如下:

class Item extends React.Component {
  render() {
    return (
      <div>
        <div>
          <input type="text" />
        </div>
      </div>
    );
  }
}

class Example extends React.Component {
  constructor() {
    super();
    this.state = {
      list: [
        { name: "聽風是風", id: 1 },
        { name: "行星飛行", id: 2 },
      ],
    };
  }

  addItem = () => {
    const id = +new Date();
    this.setState({
      list: [{ name: "時間跳躍" + id + id, id }, ...this.state.list],
    });
  };

  render() {
    return (
      <div className="example">
        <button onClick={this.addItem}>clie me</button>
        <div className="form">
          <form>
            <h3>
              不好的做法 <code>key=index</code>
            </h3>
            {this.state.list.map((todo, index) => (
              <Item {...todo} key={index} />
            ))}
          </form>
          <form>
            <h3>
              更好的做法 <code>key=id</code>
            </h3>
            {this.state.list.map((todo) => (
              <Item {...todo} key={todo.id} />
            ))}
          </form>
        </div>
      </div>
    );
  }
}

簡單來說,我們分別使用index以及獨一無二的id作為key,然後我們分別在兩個form中的第一個input屬於一個值,之後點選按鈕,分別在陣列前插入了一個新資料,然後區別就出現了,index的例子並沒有按照預期完整重新建立一個input,這個1本應該屬於第二個input

那麼為什麼造成了這個bug呢?原因其實很簡單,當使用了index作為key時,我們前文也說了,這個input就應該重新建立,結果你用index0===0truetype又相同,所以diff直接認為這是一次更新而不是重新建立。

在虛擬dom一文中,我們強調了虛擬dom為區域性重新整理提供了可能性,因為原生dom屬性非常多,如果遞回去對比就格外複雜了,但虛擬dom設計直接將我們需要對比的屬性都聚焦在了props中,所以即便diff去更新props也只是更新虛擬domprops,像上文中的input本身就是一個原生dom,它的vaule根本就不在diff比較的範疇內。

而前面也說了,因為index的緣故diff會認為你只是更新,在fiber節點中有一個stateNode欄位儲存了對應真實dom的屬性,所以diffclone節點時,直接將之前的stateNode賦值給了更新後的fiber節點,這就導致了這個1依舊停留在了第一個input上。

上圖就是當第一個fiber更新完成之後,通過stateNode存取到inputvalue,這就是為啥導致這個bug的原因。

我們通過兩張圖來描述當陣列前插入元素時,使用index或者不提供key預設null時,與使用獨一無二keydiff差異:

叄 ❀ 總

那麼到這裡,我們解釋了文章開頭的五個問題,也通過原始碼解開了key的神祕面紗。簡單點來說,key並沒有大家所想的那麼聰明,但對於listdiff而言又極其重要,reactdiff始終遵守逐層對比,也正因為key的存在,不管list如何改變順序,只有key獨一無二,react總是能正確的去更新或者新建它們,這才是key存在的核心意義。

那麼到這裡,關於key的介紹到此結束。