一行 Object.keys() 引發(fā)的思考
大廠技術(shù) 高級前端 Node進階
點擊上方 程序員成長指北,關(guān)注公眾號
回復(fù)1,加入高級Node交流群
故事背景
有一天上線后大佬反饋了一個問題,他剛發(fā)的動態(tài)在生成分享卡片的時候,卡片底部的小程序碼丟失了,然而其他小伙伴都表示在自己手機上運行正常。事實上大佬也說除了這條動態(tài)以外,其它都是正常的。
說明這個 BUG 需要特定的動態(tài)卡片 + 特定的設(shè)備才能復(fù)現(xiàn),所幸坐我對面的小姐姐手機與大佬是同款,也能復(fù)現(xiàn) BUG,避免了作為社恐的我要去找大佬借手機測試的尷尬。
先交代一下項目背景,這是一個微信小程序項目,其中生成分享卡片功能用到的是一個叫 wxml2canvas[1] 的庫,然而該庫目前看上去已經(jīng)「年久失修」,上面所說的 BUG 就是因為這個庫,
本文分享一下排查該 BUG 的過程、以及如何從 ECMAScript 規(guī)范中找到關(guān)于 Object.keys() 返回順序的規(guī)范定義,最后介紹一下在 V8 引擎中是如何處理對象屬性的。
希望大家在閱讀本文后,不會再因為搞不懂 Object.keys() 輸出的順序而犯錯導(dǎo)致產(chǎn)生莫名其妙的 BUG。
TL;DR
本文很長,如果你不想閱讀整篇文章,可以閱讀這段摘要;如果你打算閱讀整篇文章,那么你完全可以跳過本段。
如果閱讀摘要時未能幫助你理解,可以跳轉(zhuǎn)到對應(yīng)章節(jié)進行詳細閱讀。
摘要:
這個 BUG 是如何產(chǎn)生的?
wxml2canvas在繪制的時候,會根據(jù)一個叫做sorted的對象對它的 keys 進行遍歷,該對象的 key 為節(jié)點的 top 值,value 為節(jié)點元素;問題就是出在這里,該庫作者誤以為Object.keys()總是會按照實際創(chuàng)建屬性的順序返回,然而當 key 為正整數(shù)的時候,返回順序就不符合原本的預(yù)期了,會出現(xiàn)了繪制順序錯亂,從而導(dǎo)致這個 BUG 的產(chǎn)生。源碼:src/index.js#L1146[2] 和 src/index.js#L829[3]
如何解決這個 BUG
由于對象的 key 是一個數(shù)字,那么 key 有可能會是整數(shù),也有可能是浮點數(shù)。但是預(yù)期行為是希望 Object.keys()按照屬性實際創(chuàng)建的順序返回,那只要將所有 key 都強制轉(zhuǎn)換為浮點數(shù)就好了。
Object.keys()是按照什么順序返回值的?
Object.keys()返回順序與遍歷對象屬性時的順序一樣,調(diào)用的[[OwnPropertyKeys]]()內(nèi)部方法。根據(jù) ECMAScript 規(guī)范[4],在輸出 keys 時會先將所有 key 為數(shù)組索引類型(正整數(shù))從小到大的順序排序,然后將所有字符串類型(包括負數(shù)、浮點數(shù))的 key 按照實際創(chuàng)建的順序來排序。
V8 內(nèi)部是如何處理對象屬性的?
V8 在存儲對象屬性時,為了提高訪問效率,會分為常規(guī)屬性(properties) 和 排序?qū)傩?elements) 排序?qū)傩?elements) ,就是數(shù)組索引類型的屬性(也就是正整數(shù)類型)。 常規(guī)屬性(properties) ,就是字符串類型的屬性(也包括負數(shù)、浮點數(shù))。 以上兩種屬性都會存放在線性結(jié)構(gòu)中,稱為快屬性。 然而這樣每次查詢都有一個間接層,會影響效率,所以 V8 引入對象內(nèi)屬性(in-object-properties) 。 V8 會為每一個對象關(guān)聯(lián)一個隱藏類,用于記錄該對象的形狀,相同形狀的對象會共用同一個隱藏類。 當對象添加、刪除屬性的時候,會創(chuàng)建一個新的對應(yīng)的隱藏類,并重新關(guān)聯(lián)。 對象內(nèi)屬性會將部分常規(guī)屬性直接放在對象第一層,所以它訪問效率是最高的。 當常規(guī)屬性的數(shù)量少于對象初始化時的屬性數(shù)量時,常規(guī)屬性會直接作為對象內(nèi)屬性存放。 雖然快屬性訪問速度快,但是從線性結(jié)構(gòu)中添加或刪除時執(zhí)行效率會非常低,因此如果屬性特別多、或出現(xiàn)添加和刪除屬性時,就會將常規(guī)屬性從線性存儲改為字典存儲,這就是慢屬性。
可以看一下這兩張圖幫助理解:

V8 常規(guī)屬性和排序?qū)傩?/p>
V8 對象內(nèi)屬性、快屬性和慢屬性
圖片出處:《圖解 Google V8》[5]
如何解決該 BUG
由于是特定的動態(tài) + 特定的設(shè)備才能復(fù)現(xiàn)問題,可以很輕易地排除掉網(wǎng)絡(luò)原因,通過在 wxml2canvas 輸出繪制的節(jié)點列表,也能看到小程序碼相關(guān)的節(jié)點。
既然 wxml2canvas 已經(jīng)接受到小程序碼的節(jié)點,卻沒有繪制出來,那么問題自然就出在 wxml2canvas 內(nèi)部,不過已經(jīng)見怪不怪了,在我加入項目以后就已經(jīng)多次因為這操蛋的 wxml2canvas 出現(xiàn)各種問題而搞得頭皮發(fā)麻,有機會一定要替換掉這個庫,但由于已經(jīng)有很多頁面在依賴這個庫,現(xiàn)在也只能硬著頭皮上。
首先懷疑是小程序碼節(jié)點的坐標位置不太對,通過對比,發(fā)現(xiàn)位置相差不大,排除該原因。
然后對比所有節(jié)點的繪制順序,發(fā)現(xiàn)了一個不太尋常的點,在復(fù)現(xiàn) BUG 的手機上,繪制小程序碼節(jié)點的時機是比較靠前的,但由于它在卡片底部,所以在正常情況下,應(yīng)該是比較靠后才對。
于是通過查看相關(guān)代碼,果然發(fā)現(xiàn)了其中的玄機:

在繪制的時候,通過遍歷 sorted 對象,從上往下、從左到右依次繪制,但是通過對比兩臺手機的 Object.keys(),發(fā)現(xiàn)了它們的輸出是不一樣的,這時候我就明白怎么回事了。
先來說說這個 sorted 對象,它是一個 key 為節(jié)點 top 值,value 為所有相同 top 值(同一行)的元素數(shù)組。
下面是生成它的代碼:

問題就發(fā)生在前面所說的 Object.keys() 這里,我們先來看個 ??:
const sorted = {}
sorted[300] = {}
sorted[200] = {}
sorted[100] = {}
console.log(Object.keys(sorted)) // 輸出什么呢?
相信大部分同學(xué)都知道答案是:[‘100', '200', '300’]。
如果在有浮點數(shù)的情況呢?
const sorted = {}
sorted[300] = {}
sorted[100] = {}
sorted[200] = {}
sorted[50.5] = {}
console.log(Object.keys(sorted)) // 這次又輸出什么呢?
會不會有同學(xué)以為答案是:['50.5', ‘100', '200', '300’] 呢?
但正確的答案應(yīng)該是:[‘100', '200', '300’,’50.5’]。
所以我合理地猜測 wxml2canvas 的作者就是犯了這樣的錯誤,他可能以為 Object.keys 會根據(jù) key 從小到大的順序返回,因此滿足從上往下繪制的邏輯。
但是他卻沒有考慮浮點數(shù)的情況,所以當某個節(jié)點 top 值為整數(shù)的時候,會比其他 top 值為浮點數(shù)的節(jié)點更早地繪制,導(dǎo)致繪制后面的節(jié)點時覆蓋了前面的節(jié)點。
于是,當我把代碼改成這樣后,分享卡片的小程序碼就正常繪制出來了:
Object
.keys(sorted)
+ .sort((a, b)=> a - b)
.forEach((top, topIndex) => {
// do something
}
OK,搞定收工。
測試小姐姐:慢著!影響到其它地方了。
我一看,果然。于是再次經(jīng)過對比,發(fā)現(xiàn)原來大部分情況下,top 值都會是浮點數(shù),而本次出 BUG 的卡片小程序碼只是非常湊巧地為整數(shù),導(dǎo)致繪制順序不對。
我才發(fā)現(xiàn) wxml2canvas 原本的邏輯是想根據(jù) sorted 創(chuàng)建的順序來繪制,但是沒有考慮 key 為整數(shù)的情況。
所以,最后通過這樣修改解決問題:
_sortListByTop (list = []) {
let sorted = {};
// 粗略地認為2px相差的元素在同一行
list.forEach((item, index) => {
- let top = item.top;
+ let top = item.top.toFixed(6); // 強制添加小數(shù)點,將整數(shù)轉(zhuǎn)為浮點數(shù)
if (!sorted[top]) {
if (sorted[top - 2]) {
top = top - 2;
}else if (sorted[top - 1]) {
top = top - 1;
} else if (sorted[top + 1]) {
top = top + 1;
} else if (sorted[top + 2]) {
top = top + 2;
} else {
sorted[top] = [];
}
}
sorted[top].push(item);
});
return sorted;
}
很顯然,是因為 wxml2canvas 作者對 Object.keys() 返回順序的機制不了解,才導(dǎo)致出現(xiàn)這樣的 BUG。
不知道是否也有同學(xué)犯過同樣的錯誤,為避免再次出現(xiàn)這樣的情況,非常有必要深入、全面地介紹一下 Object.keys() 的執(zhí)行機制。
所以接下來就跟隨我一探究竟吧。
深入理解 Object.keys()
可能會有同學(xué)說:Object.keys() 又不是什么新出的 API, Google 一下不就行了,何必大費周章寫一篇文章來介紹呢?
的確通過搜索引擎可以很快就能知道 Object.keys() 的返回順序是怎樣的,但是很多都只流于表面,甚至我還見過這樣片面的回答:數(shù)字排前面,字符串排后面。
所以這次我想試著追本溯源,通過第一手資料來獲取信息,輕易相信口口相傳得來的信息,都極有可能是片面的、甚至是錯誤的。
PS:其實不光技術(shù),我們在對待其它不了解的事物都應(yīng)保持同樣的態(tài)度。
我們先來看看在 MDN[6] 上關(guān)于 Object.keys() 的描述:
Object.keys()方法會返回一個由一個給定對象的自身可枚舉屬性組成的數(shù)組,數(shù)組中屬性名的排列順序和正常循環(huán)遍歷該對象時返回的順序一致 。
emmm... 并沒有直接告訴我們輸出順序是什么,不過我們可以看看上面的 Polyfill[7] 是怎么寫的:
if (!Object.keys) {
Object.keys = (function () {
var hasOwnProperty = Object.prototype.hasOwnProperty,
hasDontEnumBug = !({toString: null}).propertyIsEnumerable('toString'),
dontEnums = [
'toString',
'toLocaleString',
'valueOf',
'hasOwnProperty',
'isPrototypeOf',
'propertyIsEnumerable',
'constructor'
],
dontEnumsLength = dontEnums.length;
return function (obj) {
if (typeof obj !== 'object' && typeof obj !== 'function' || obj === null) throw new TypeError('Object.keys called on non-object');
var result = [];
for (var prop in obj) {
if (hasOwnProperty.call(obj, prop)) result.push(prop);
}
if (hasDontEnumBug) {
for (var i=0; i < dontEnumsLength; i++) {
if (hasOwnProperty.call(obj, dontEnums[i])) result.push(dontEnums[i]);
}
}
return result;
}
})()
};
其實就是利用 for...in 來進行遍歷,接下來我們可以再看看關(guān)于 for...in[8] 的文檔,然而里面也沒有告訴我們順序是怎樣的。
既然 MDN 上沒有,那我們可以直接看 ECMAScript 規(guī)范,通常 MDN 上都會附上關(guān)于這個 API 的規(guī)范鏈接,我們直接點開最新(Living Standard)的那個,下面是關(guān)于 Object.keys 的規(guī)范定義[9]:
When the keys function is called with argument O, the following steps are taken:
Let obj be ? ToObject[10](O). Let nameList be ? EnumerableOwnPropertyNames[11](obj, key). Return CreateArrayFromList[12](nameList).
對象屬性列表是通過 EnumerableOwnPropertyNames 獲取的,這是它的規(guī)范定義[9]:
The abstract operation EnumerableOwnPropertyNames takes arguments O (an Object) and kind (key, value, or key+value). It performs the following steps when called:
Let ownKeys be ? O.[OwnPropertyKeys].
Let properties be a new empty List.
For each element key of ownKeys, do a. If Type(key) is String, then
b. Else, 1. Let value be ? Get(O, key). 2. If kind is value, append value to properties. 3. Else i. Assert: kind is key+value. ii. Let entry be ! CreateArrayFromList(? key, value ?). iii. Append entry to properties.
Let desc be ? O.GetOwnProperty. If desc is not undefined and desc.[[Enumerable]] is true, then a. If kind is key, append key to properties. Return properties.
敲黑板!這里有個細節(jié),請同學(xué)們多留意,后面會考。
我們接著探索,OwnPropertyKeys 最終返回的 OrdinaryOwnPropertyKeys:
The [[OwnPropertyKeys]] internal method of an ordinary object O takes no arguments. It performs the following steps when called:
Return ! OrdinaryOwnPropertyKeys(O)[13].
重頭戲來了,關(guān)于 keys 如何排序就在 OrdinaryOwnPropertyKeys 的定義中:
The abstract operation OrdinaryOwnPropertyKeys takes argument O (an Object). It performs the following steps when called:
Let keys be a new empty List. For each own property key P of O such that P is an array index, in ascending numeric index order, do a. Add P as the last element of keys. For each own property key P of O such that Type(P) is String and P is not an array index, in ascending chronological order of property creation, do a. Add P as the last element of keys. For each own property key P of O such that Type(P) is Symbol, in ascending chronological order of property creation, do a. Add P as the last element of keys. Return keys.
到這里,我們已經(jīng)知道我們想要的答案,這里總結(jié)一下:
創(chuàng)建一個空的列表用于存放 keys 將所有合法的數(shù)組索引按升序的順序存入 將所有字符串類型索引按屬性創(chuàng)建時間以升序的順序存入 將所有 Symbol類型索引按屬性創(chuàng)建時間以升序的順序存入返回 keys
這里順便也糾正一個普遍的誤區(qū):有些回答說將所有屬性為數(shù)字類型的 key 從小到大排序,其實不然,還必須要符合 「合法的數(shù)組索引」 ,也即只有正整數(shù)才行,負數(shù)或者浮點數(shù),一律當做字符串處理。
PS:嚴格來說對象屬性沒有數(shù)字類型的,無論是數(shù)字還是字符串,都會被當做字符串來處理。
我們結(jié)合上面的規(guī)范,來思考一下下面這段代碼會輸出什么:
const testObj = {}
testObj[-1] = ''
testObj[1] = ''
testObj[1.1] = ''
testObj['2'] = ''
testObj['c'] = ''
testObj['b'] = ''
testObj['a'] = ''
testObj[Symbol(1)] = ''
testObj[Symbol('a')] = ''
testObj[Symbol('b')] = ''
testObj['d'] = ''
console.log(Object.keys(testObj))
請認真思考后,在這里核對你的答案是否正確:
查看結(jié)果 ??
['1', '2', '-1', '1.1', 'c', 'b', 'a', 'd']
是否與你想象的一致?你可能會奇怪為什么沒有 Symbol 類型。
還記得前面敲黑板讓同學(xué)們留意的地方嗎,因為在 EnumerableOwnPropertyNames 的規(guī)范中規(guī)定了返回值只應(yīng)包含字符串屬性(上面說了數(shù)字其實也是字符串)。
所以 Symbol 屬性是不會被返回的,可以看 MDN[14] 上關(guān)于 Object.getOwnPropertyNames() 的描述。
如果要返回 Symbol 屬性可以用 Object.getOwnPropertySymbols()[15]。
看完 ECMAScript 的規(guī)范定義,相信你不會再搞錯 Object.keys() 的輸出順序了。但是你好奇 V8 是如何處理對象屬性的嗎,下一節(jié)我們就來講講。
V8 是如何處理對象屬性的
在 V8 的官方博客上有一篇文章《Fast properties in V8》[16](中譯版[17]),非常詳細地向我們解釋了 V8 內(nèi)部如何處理 JavaScript 的對象屬性,強烈推薦閱讀。
本節(jié)內(nèi)容主要參考這兩個地方,下面我們來總結(jié)一下。
首先,V8 為了提高對象屬性的訪問效率,將屬性分為兩種類型:
排序?qū)傩?elements) ,就是符合數(shù)組索引類型的屬性(也就是正整數(shù))。
常規(guī)屬性(properties) ,就是字符串類型的屬性(也包括負數(shù)、浮點數(shù))。
所有的排序?qū)傩?/strong>都會存放在一個線性結(jié)構(gòu)中,線性結(jié)構(gòu)的特點就是支持通過索引隨機訪問,所以能加快訪問速度,對于存放在線性結(jié)構(gòu)的屬性都稱為快屬性。
常規(guī)屬性也會存放在另一個線性結(jié)構(gòu)中,可以看下面這張圖幫助理解:

V8 排序?qū)傩院统R?guī)屬性
但是常規(guī)屬性還需要做一些額外的處理,這里我們要先介紹一下什么是隱藏類。
由于 JavaScript 在運行時是可以修改對象屬性的,所以在查詢的時候會比較慢,可以看回上面那張圖,每次訪問一個屬性的時候都需要經(jīng)過多一層的訪問,而像 C++ 這類靜態(tài)語言在聲明對象之前需要定義這個對象的結(jié)構(gòu)(形狀),經(jīng)過編譯后每個對象的形狀都是固定的,所以在訪問的時候由于知道了屬性的偏移量,自然就會比較快。
V8 采用的思路就是將這種機制應(yīng)用在 JavaScript 對象中,所以引入了隱藏類的機制,你可以簡單的理解隱藏類就是描述這個對象的形狀、包括每個屬性對應(yīng)的位置,這樣查詢的時候就會快很多。
關(guān)于隱藏類還有幾點要補充:
對象的第一個字段指向它的隱藏類。 如果兩個對象的形狀是完全相同的,會共用同一個隱藏類。 當對象添加、刪除屬性的時候,會創(chuàng)建一個新的對應(yīng)的隱藏類,并重新指向它。 V8 有一個轉(zhuǎn)換樹的機制來創(chuàng)建隱藏類,不過本文不贅述,有興趣可以看這里[18]。
解釋完隱藏類,我們再回頭來講講常規(guī)屬性,通過上面那張圖我們很容易發(fā)現(xiàn)一個問題,那就是每次訪問一個屬性的時候,都需要經(jīng)過一個間接層才能訪問,這無疑降低了訪問效率,為了解決這個問題,V8 又引入了一個叫做對象內(nèi)屬性,顧名思義,它會將某些屬性直接存放在對象的第一層里,它的訪問是最快的,如下圖所示:

V8 對象內(nèi)屬性
但要注意,對象內(nèi)屬性只存放常規(guī)屬性,排序?qū)傩砸琅f不變。而且需要常規(guī)屬性的數(shù)量小于某個數(shù)量的時候才會直接存放對象內(nèi)屬性,那這個數(shù)量是多少呢?
答案是取決于對象初始化時的大小。
PS:有些文章說是少于 10 個屬性時才會存放對象內(nèi)屬性,別被誤導(dǎo)了。
除了對象內(nèi)屬性、快屬性以外,還有一個慢屬性。
為什么會有慢屬性呢?快屬性雖然訪問很快,但是如果要從對象中添加或刪除大量屬性,則可能會產(chǎn)生大量時間和內(nèi)存開銷來維護隱藏類,所以在屬性過多或者反復(fù)添加、刪除屬性時會將常規(guī)屬性的存儲方式從線性結(jié)構(gòu)變成字典,也就是降低到慢屬性,而由于慢屬性的信息不會再存放在隱藏類中,所以它的訪問會比快屬性要慢,但是可以高效地添加和刪除屬性。可以通過下圖幫助理解:

V8 慢屬性
寫到這里,我覺得自己對 V8 的快屬性、慢屬性這些知識已經(jīng)非常了解,簡直要牛逼到上天了。
但當我看到這段代碼的時候:
function toFastProperties(obj) {
/*jshint -W027*/
function f() {}
f.prototype = obj;
ASSERT("%HasFastProperties", true, obj);
return f;
eval(obj);
}
我的心情是這樣的:

關(guān)于這段代碼是如何能讓 V8 使用對象快屬性的可以看這篇文章:開啟 V8 對象屬性的“fast”模式[19]。
另外也可以看一下這段代碼:to-fast-properties/index.js[20]。
寫在最后
當在開發(fā)時遇到一個簡單的錯誤,通常可以很快地利用搜索引擎解決問題,但如果只是面向 Google 編程,可能在技術(shù)上很難會有進步,所以我們不光要能解決問題,還要理解這個產(chǎn)生問題的背后的原因到底是什么,也就是知其然更知其所以然。
真的非常建議每個 JavaScript 開發(fā)者都應(yīng)該去了解一些關(guān)于 V8 或其它 JavaScript 引擎的知識,無論你是通過什么途徑(真的沒有打廣告),這樣能保證我們在編寫 JavaScript 代碼時出現(xiàn)問題可以更加地得心應(yīng)手。
最后,本文篇幅有限,部分細節(jié)難免會有遺漏,非常建議有興趣深入了解的同學(xué)可以延伸閱讀下面的列表。
延伸閱讀
Fast properties in V8[16] 中譯版[17] How is data stored in V8 JS engine memory?[21] V8 中的快慢屬性與快慢數(shù)組[22] 開啟 V8 對象屬性的“fast”模式[23] ECMAScript? 2015 Language Specification[24] Does JavaScript guarantee object property order? —— stackoverflow[25]
參考資料
https://github.com/wg-front/wxml2canvas
[2]https://github.com/wg-front/wxml2canvas/blob/master/src/index.js#L1146
[3]https://github.com/wg-front/wxml2canvas/blob/master/src/index.js#L829
[4]https://262.ecma-international.org/6.0/#sec-ordinary-object-internal-methods-and-internal-slots-ownpropertykeys
[5]https://time.geekbang.org/column/intro/100048001
[6]https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Object/keys
[7]https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Object/keys#polyfill
[8]https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Statements/for...in
[9]https://tc39.es/ecma262/#sec-object.keys
[10]https://tc39.es/ecma262/#sec-toobject
[11]https://tc39.es/ecma262/#sec-enumerableownpropertynames
[12]https://tc39.es/ecma262/#sec-createarrayfromlist
[13]https://tc39.es/ecma262/#sec-ordinaryownpropertykeys
[14]https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Object/getOwnPropertyNames
https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/Object/getOwnPropertySymbols
[16]https://v8.dev/blog/fast-properties
[17]https://blog.crimx.com/2018/11/25/v8-fast-properties/
[18]https://v8.dev/blog/fast-properties#hiddenclasses-and-descriptorarrays
[19]https://zhuanlan.zhihu.com/p/25069272
[20]https://github.com/sindresorhus/to-fast-properties/blob/main/index.js
[21]https://blog.dashlane.com/how-is-data-stored-in-v8-js-engine-memory/
[22]https://z3rog.tech/blog/2020/fast-properties.html
[23]https://zhuanlan.zhihu.com/p/25069272
[24]https://262.ecma-international.org/6.0/
[25]https://stackoverflow.com/questions/5525795/does-javascript-guarantee-object-property-order
作者:4Ark
https://juejin.cn/post/7041049741458669576
Node 社群
我組建了一個氛圍特別好的 Node.js 社群,里面有很多 Node.js小伙伴,如果你對Node.js學(xué)習感興趣的話(后續(xù)有計劃也可以),我們可以一起進行Node.js相關(guān)的交流、學(xué)習、共建。下方加 考拉 好友回復(fù)「Node」即可。
如果你覺得這篇內(nèi)容對你有幫助,我想請你幫我2個小忙:
1. 點個「在看」,讓更多人也能看到這篇文章 2. 訂閱官方博客 www.inode.club 讓我們一起成長 點贊和在看就是最大的支持
