從一道前端面試題引發的原理性探究

王老闆的前端發表於2020-05-22

Vue 和 React 中的 key 到底有什麼用?

key 是給每一個 vnode 的唯一 id,依靠 key,我們的 diff 操作可以更準確、更快速。 對於簡單列表頁渲染來說 diff 節點也更快,但會產生一些隱藏的副作用,比如可能不會產生過渡效果,或者在某些節點有繫結資料(表單)狀態,會出現狀態錯位。)

diff 演算法的過程中,先會進行新舊節點的首尾交叉對比,當無法匹配的時候會用新節點的 key 與舊節點進行比對,從而找到相應舊節點。

你以為這樣回答,面試官就能放過你。Too young,To simple。下面是面試官的反問三連擊:

為什麼更準確?

因為帶 key 就不是就地複用了,在 sameNode 函式 a.key === b.key 對比中可以避免就地複用的情況。所以會更加準確,如果不加 key,會導致之前節點的狀態被保留下來,會產生一系列的 bug。

為什麼更快速?

key 的唯一性可以被 Map 資料結構充分利用,相比於遍歷查詢的時間複雜度 O(n),Map 的時間複雜度僅僅為 O(1)。

為什麼 Map 資料結構會更快?

Map / Set / WeakSet / WeakMap 就是使用隱藏的 Hash code 優化雜湊表

ECMAScript 2015 引入了幾個新的資料結構,例如 Map,Set,WeakSet和 WeakMap,所有這些結構都在後臺使用雜湊表。下面詳細介紹了V8 v6.3+ 如何將 key 儲存在雜湊表中的最新進展。

雜湊碼 Hash code

雜湊函式用於將給定的 key 對映到雜湊表中的特定位置。一個雜湊碼是給定的 key 執行此雜湊函式的運算結果。

hashCode = hashFunc(key)

在 V8 中,雜湊碼只是一個隨機數,與物件值無關。因此,我們無法重新計算它,這意味著我們必須儲存它。

以前,對於那些把 JavaScript 物件作為 key 的情況,V8 將雜湊碼作為私有符號(private symbol)儲存在物件上。V8 中的私有符號類似於Symbol,只是它不可列舉,也不會不會洩漏到使用者空間 JavaScript 中。也就是說這個 symbol 只在 V8 引擎內部使用,使用者的 JavaScript 程式碼訪問不到。

function GetObjectHash(key) {
  let hash = key[hashCodeSymbol];
  if (IS_UNDEFINED(hash)) {
    hash = (MathRandom() * 0x40000000) | 0;
    if (hash === 0) hash = 1;
    key[hashCodeSymbol] = hash;
  }
  return hash;
}

之所以行之有效,是因為在將物件新增到雜湊表之前,我們不必為雜湊碼欄位保留記憶體。當物件被新增到雜湊表時,才把新的私有符號儲存在物件上。

與使用內聯快取(IC)系統進行的任何其他屬性查詢一樣,V8 還可以優化雜湊碼符號查詢,從而為雜湊碼提供非常快速的查詢。當鍵具有相同的隱藏類時,這對於單態內聯快取查詢非常有效。但是,大多數現實世界的程式碼都不遵循這種模式,並且鍵通常具有不同的隱藏類,導致雜湊碼的復態內聯快取查詢變慢。

私有符號方法的另一個問題是,它在儲存雜湊碼時觸發了金鑰的隱藏類轉換。這導致不僅對雜湊程式碼查詢也為上鍵和其他財產查詢差的多形態程式碼去優化從優化程式碼。

JavaScript 物件支援儲存

V8 的 JavaScript 物件(JSObject)使用 2 個 word(除了它的頭部):一個 word 用於儲存指向元素儲存的指標,另一個 word 用於儲存指向屬性儲存的指標。

  • word (computer architecture)

元素儲存用於像陣列索引的屬性,而屬性儲存用於其鍵為字串或符號的屬性。有關這些的更多資訊,請參見 Camillo Bruni 的 V8 部落格文章。

const x = {};
x[1] = 'bar';      // ← stored in elements
x['foo'] = 'bar';  // ← stored in properties

隱藏雜湊碼 Hiding the hash code

儲存雜湊碼最簡單的方法是將 JavaScript 物件的大小擴充套件一個字,並將雜湊碼直接儲存在物件上。但是,對於那些沒有新增到雜湊表中的物件,這會浪費記憶體。相反,我們可以嘗試將雜湊碼儲存在元素儲存或屬性儲存中。

元素儲存是一個包含其長度和所有元素的陣列。在這裡沒有太多的工作要做,因為可以把雜湊碼儲存在一個保留的槽中(比如第 0 個索引),不過,當我們不使用這個物件作為雜湊表中的關鍵字時,仍然會浪費記憶體。

讓我們看看屬性儲存。有兩種資料結構用作屬性儲存:陣列字典

與元素儲存中使用的陣列不同,元素儲存不具有上限,而屬性儲存中使用的陣列的上限為 1022 個值。由於效能原因,V8 在超過此限制時則轉換為使用字典模式。(我略微簡化了這一點 - V8 也可以在其他情況下使用字典,但是可以儲存在陣列中的值的數量有一個固定的上限。)

因此,屬性儲存有三種可能的狀態:

  • 空(沒有屬性)
  • 陣列(最多可以儲存 1022 個值)
  • 字典

1、屬性儲存是空的

對於空的情況,我們可以直接在 JSObject 的偏移量上儲存雜湊碼。

2、屬性儲存是一個陣列

V8 表示小於 231 的整數(在 32 位系統上)更加高效,如 Smi。在一個 Smi 中,最低有效位是用來區別指標的 tag,而其餘的 31 位儲存實際的整數值。

通常,陣列將它們的長度儲存為 Smi。既然我們知道這個陣列的最大容量只有 1022 個,我們只需要 10 個位元就可以儲存這個長度。我們可以使用剩下的 21 位來儲存雜湊碼!

3、屬性支援儲存是一個字典

對於字典的情況,我們將字典大小增加1個字,以便將雜湊碼儲存在字典起始位置的專用槽中。在這種情況下,我們可能會浪費掉一個字的儲存空間,因為這個比例增長的大小並不像陣列那麼大。

通過這些更改,雜湊碼查詢不再需要經過複雜的 JavaScript 屬性查詢機制。

效能改進

SixSpeed 對 Map 和 Set 的基準測試,這些變化導致了 5〜50% 的效能提升。

這一變化也導致 ARES6 中的基準測試提高了 5%。

這也導致 Emberperf 基準測試套件中測試的 Ember.js 提高了 18%。

Emberperf

探究總結

掌握一門技術併合理使用它的最好辦法就是深入理解這項技術背後的工作原理

參考資料

v8.dev/blog/hash-code

本作品採用《CC 協議》,轉載必須註明作者和本文連結

相關文章