一篇 antfu 推薦的互動式優質性能優化博文,作者有 20 年經驗,真的很多細節,試著翻譯一下加一點自己的潤色,強烈建議閱讀英文原文,體驗互動式,這裡就先用截圖替代了。
第一次翻譯,不足之處歡迎指正!
本譯文博客鏈接:https://ysx.cosine.ren/optimizing-javascript-translate
原文鏈接:https://romgrk.com/posts/optimizing-javascript
原文作者:romgrk
我經常覺得一般的 javascript 代碼運行起來比原來慢得多,原因很簡單,就是沒有進行適當的優化。以下是我發現的常用優化技術的總結。需要注意的是,性能與可讀性之間的權衡往往是可讀性,因此何時選擇性能,何時選擇可讀性,這個問題留給讀者自己去解決。我還想說的是,談論優化必然需要談論基準測試。如果一個函數一開始的運行時間只占實際總運行時間的一小部分,那麼對該函數進行數小時的微優化以使其運行速度提高 100 倍是毫無意義的。如果要優化,最重要的第一步就是基準測試。我將在後面的內容中介紹這一主題。還要注意的是,微基準測試(microbenchmarks)通常存在缺陷,這裡介紹的可能也包括在內。我已經盡力避免這些陷阱,但在沒有基準測試的情況下,不要盲目應用這裡介紹的任何要點。
我已經為所有可能的情況提供了可運行的示例。它們默認顯示我在我的機器上得到的結果(archlinux 上的 brave 122),但是你可以自己運行它們。儘管我不想這麼說,但 Firefox 在優化遊戲中已經落後了一點,目前只占流量的一小部分,所以我不建議使用 Firefox 上的結果作為有用的指標。
0. 避免不必要的工作#
這可能聽起來很明顯,但它需要在這裡,因為不可能有另一個優化的第一步:如果你試圖優化,你應該首先考慮避免不必要的工作。 這包括了諸如記憶化(memoization)、延遲計算(laziness)和增量計算(incremental computation)等概念。具體應用會根據上下文有所不同。例如,在 React 中,這意味著應用 memo()
、useMemo()
以及其他適用的原語。
1. 避免字符串比較#
JavaScript 很容易隱藏字符串比較的真實開銷。如果你需要在 C 語言中比較字符串,你會使用 strcmp(a, b)
函數。而 JavaScript 使用 ===
進行比較,因此你看不到 strcmp
。但它是存在的,strcmp
通常(但不總是)需要將一個字符串中的每個字符與另一個字符串中的字符進行比較;字符串比較的時間複雜度是 O(n)
。一種常見的要避免的 JavaScript 模式是將字符串用作枚舉(strings-as-enums)。但是,隨著 TypeScript 的出現,這應該很容易避免,因為枚舉類型默認是整數。
// No
enum Position {
TOP = 'TOP',
BOTTOM = 'BOTTOM',
}
// Yeppers
enum Position {
TOP, // = 0
BOTTOM, // = 1
}
以下是成本比較:
// 1. string compare
const Position = {
TOP: 'TOP',
BOTTOM: 'BOTTOM',
}
let _ = 0
for (let i = 0; i < 1000000; i++) {
let current = i % 2 === 0 ?
Position.TOP : Position.BOTTOM
if (current === Position.TOP)
_ += 1
}
// 2. int compare
const Position = {
TOP: 0,
BOTTOM: 1,
}
let _ = 0
for (let i = 0; i < 1000000; i++) {
let current = i % 2 === 0 ?
Position.TOP : Position.BOTTOM
if (current === Position.TOP)
_ += 1
}
2. 避免不同的形狀( Shapes )#
JavaScript 引擎嘗試通過假設對象具有特定的形狀,並且函數將接收相同形狀的對象來優化代碼。這允許它們為該形狀的所有對象一次性存儲鍵,並在一個單獨的扁平數組中存儲值。用 JavaScript 代碼表示的話如下例:
// 引擎接受的對象
const objects = [
{
name: 'Anthony',
age: 36,
},
{
name: 'Eckhart',
age: 42
},
]
// 優化後的內部存儲結構類似
const shape = [
{ name: 'name', type: 'string' },
{ name: 'age', type: 'integer' },
]
const objects = [
['Anthony', 36],
['Eckhart', 42],
]
Note
我使用了 “shape” 這個詞來描述這個概念,但要注意,您可能也會發現 “hidden class” 或 “map” 用於描述它,這取決於引擎。
例如,在運行時,如果下面的函數接收到兩個具有形狀 { x: number, y: number }
的對象,則引擎將推測未來的對象將具有相同的形狀,並生成針對該形狀優化的機器代碼。
function add(a, b) {
return {
x: a.x + b.x,
y: a.y + b.y,
}
}
如果我們傳遞的對象不是形狀 {x, y} 而是形狀 { y, x } ,那麼引擎將需要撤銷其推測,函數將突然變得相當慢。我在這裡的解釋會有所保留,因為如果你想了解更多細節,應該閱讀 mraleph 的優秀文章 。但我要強調的是,V8 引擎特別有 3 種模式,用於訪問:單態(monomorphic,1 種形狀)、多態(polymorphic,2-4 種形狀)和巨態(megamorphic,5 種以上形狀)。假設你真的想保持單態,因為性能下降是很劇烈的:
譯者注:
在保持代碼性能的單態模式下,你需要確保傳遞給函數的對象保持相同的形狀。在 JavaScript 和 TypeScript 的開發過程中,這意味著當你定義和操作對象時,你需要保證屬性的添加順序一致,避免隨意添加或刪除屬性,這樣可以幫助 V8 引擎優化對象屬性的訪問。
為了提高性能,儘量避免在運行時改變對象的形狀。這涉及到避免添加或刪除屬性,或者以不同的順序創建相同屬性的對象。通過維持對象屬性的一致性,可以幫助 JavaScript 引擎保持對對象的高效訪問,從而避免由於形狀變化導致的性能下降。
// setup
let _ = 0
// 1. monomorphic
const o1 = { a: 1, b: _, c: _, d: _, e: _ }
const o2 = { a: 1, b: _, c: _, d: _, e: _ }
const o3 = { a: 1, b: _, c: _, d: _, e: _ }
const o4 = { a: 1, b: _, c: _, d: _, e: _ }
const o5 = { a: 1, b: _, c: _, d: _, e: _ } // all shapes are equal
// 2. polymorphic
const o1 = { a: 1, b: _, c: _, d: _, e: _ }
const o2 = { a: 1, b: _, c: _, d: _, e: _ }
const o3 = { a: 1, b: _, c: _, d: _, e: _ }
const o4 = { a: 1, b: _, c: _, d: _, e: _ }
const o5 = { b: _, a: 1, c: _, d: _, e: _ } // this shape is different
// 3. megamorphic
const o1 = { a: 1, b: _, c: _, d: _, e: _ }
const o2 = { b: _, a: 1, c: _, d: _, e: _ }
const o3 = { b: _, c: _, a: 1, d: _, e: _ }
const o4 = { b: _, c: _, d: _, a: 1, e: _ }
const o5 = { b: _, c: _, d: _, e: _, a: 1 } // all shapes are different
// test case
function add(a1, b1) {
return a1.a + a1.b + a1.c + a1.d + a1.e +
b1.a + b1.b + b1.c + b1.d + b1.e }
let result = 0
for (let i = 0; i < 1000000; i++) {
result += add(o1, o2)
result += add(o3, o4)
result += add(o4, o5)
}
那我該怎麼辦呢?
說來容易做來難:用完全相同的形狀創建所有對象。即使是像以不同的順序編寫 React 組件 props 這樣微不足道的事情也會觸發這種情況。
例如,這裡是我在 React 的代碼庫中發現的一些簡單案例,但幾年前它們已經對同一問題產生了更高的影響,因為它們用整數初始化了一個對象,然後存儲了一個浮點數。 是的,改變類型也會改變形狀。 是的,整數和浮點數類型隱藏在 number 後面。處理它。
Note
引擎通常可以將整數編碼為值。例如,V8 表示 32 位的值,整數作為緊湊的 Smi(SMall)值,但浮點數和大整數作為指針傳遞,就像字符串和對象一樣。JSC 使用 64 位編碼,雙標記,按值傳遞所有數字,就像 SpiderMonkey 一樣,其餘的作為指針傳遞。
譯者注:
這種編碼方式允許 JavaScript 引擎在不犧牲性能的前提下,高效地處理各種類型的數字。對於小整數,Smi 的使用減少了需要為其分配堆內存的情況,提高了操作的效率。對於更大的數字,雖然需要通過指針來訪問,但這種方式依然能夠保證 JavaScript 在處理各種數字類型時的靈活性和效能。
這樣的設計也反映了 JavaScript 引擎作者在數據表示和性能優化之間權衡的智慧,尤其是在動態類型語言中,這種權衡尤為重要。通過這種方式,引擎能夠在執行 JavaScript 代碼時,盡可能地減少內存使用和提高運算速度。
3. 避免使用數組 / 對象方法#
我和其他人一樣喜歡函數式編程,但是除非你在 Haskell / OCaml / Rust 中工作,函數式代碼被編譯成高效的機器代碼,否則函數式總是比命令式慢。
const result =
[1.5, 3.5, 5.0]
.map(n => Math.round(n))
.filter(n => n % 2 === 0)
.reduce((a, n) => a + n, 0)
這些方法的問題在於:
- 它們需要製作數組的完整副本,這些副本稍後需要由垃圾收集器釋放。我們將在第 5 節中更詳細地探討內存 I/O 的問題。
- 它們為 N 個操作循環 N 次,而 for 循環只允許循環一次。
// setup:
const numbers = Array.from({ length: 10_000 }).map(() => Math.random())
// 1. functional
const result =
numbers
.map(n => Math.round(n * 10))
.filter(n => n % 2 === 0)
.reduce((a, n) => a + n, 0)
// 2. imperative
let result = 0
for (let i = 0; i < numbers.length; i++) {
let n = Math.round(numbers[i] * 10)
if (n % 2 !== 0) continue
result = result + n
}
像 Object.values()
、 Object.keys()
和 Object.entries()
這樣的對象方法也會遇到類似的問題,因為它們也會分配更多的數據,而內存訪問是所有性能問題的根源。我發誓,我會在第五節給你看的。
4. 避免間接來源#
另一個尋找優化收益的地方是任何間接來源,我可以看到 3 個主要來源:
const point = { x: 10, y: 20 }
// 1. 代理對象更難優化,因為它們的 get/set 函數可能正在運行自定義邏輯,因此引擎無法做出通常的假設。
const proxy = new Proxy(point, { get: (t, k) => { return t[k] } })
// 有些引擎可以使代理成本消失,但這些優化的成本很高,而且很容易損壞。
const x = proxy.x
// 2. 通常會被忽略,但通過 `.` 或 `[]` 訪問對象也是一種間接訪問。在簡單的情況下,引擎很可能可以優化成本:
const x = point.x
// 但每次額外的訪問都會增加成本,並使引擎更難對“點”的狀態做出假設:
const x = this.state.circle.center.point.x
// 3.最後,函數調用也會產生成本。引擎一般都善於內聯這些函數:
function getX(p) { return p.x }
const x = getX(p)
// 但也不能保證一定可以。特別是如果函數調用不是來自靜態函數,而是來自參數等:
function Component({ point, getX }) {
return getX(point)
}
代理基準測試目前在 V8 上尤其殘酷。上次我檢查的時候,代理對象總是從 JIT 返回到解釋器,從這些結果來看,情況可能仍然如此。
// 1. 代理訪問
const point = new Proxy({ x: 10, y: 20 }, { get: (t, k) => t[k] })
for (let _ = 0, i = 0; i < 100_000; i++) { _ += point.x }
// 2. 直接訪問
const point = { x: 10, y: 20 }
const x = point.x
for (let _ = 0, i = 0; i < 100_000; i++) { _ += x }
我還想展示訪問深嵌套對象與直接訪問的對比,但引擎很擅長在存在熱循環和常量對象時通過轉義分析來優化對象訪問 。為了防止這種情況,我插入了一些間接方法。
譯者注:
“熱循環”(hot loop) 指的是在程序執行過程中頻繁運行的循環,即被大量重複執行的代碼部分。因為這部分代碼執行次數非常多,所以它們對程序的性能影響尤為顯著,成為了性能優化的關鍵點。後文也有出現。
// 1. 嵌套訪問
const a = { state: { center: { point: { x: 10, y: 20 } } } }
const b = { state: { center: { point: { x: 10, y: 20 } } } }
const get = (i) => i % 2 ? a : b
let result = 0
for (let i = 0; i < 100_000; i++) {
result = result + get(i).state.center.point.x }
// 2. 直接訪問
const a = { x: 10, y: 20 }.x
const b = { x: 10, y: 20 }.x
const get = (i) => i % 2 ? a : b
let result = 0
for (let i = 0; i < 100_000; i++) {
result = result + get(i) }
5. 避免緩存未命中#
這一點需要一點底層知識,但即使在 JavaScript 中也有影響,所以我將解釋一下。從 CPU 的角度來看,從 RAM 中獲取內存的速度很慢。為了加快速度,它主要使用了兩種優化方法。
5.1 預取( Prefetching )#
第一種是預取:它會提前獲取更多內存,希望這些內存是你感興趣的。它總是猜測,如果你請求一個內存地址,你就會對緊隨其後的內存區域感興趣。因此,按順序訪問數據是關鍵。在下面的示例中,我們可以觀察到按隨機順序訪問內存的影響。
// setup:
const K = 1024
const length = 1 * K * K
// 這些點是一個接一个地創建的,因此它們在內存中是按順序分配的。
const points = new Array(length)
for (let i = 0; i < points.length; i++) {
points[i] = { x: 42, y: 0 }
}
// 該數組包含與上相同的數據,但隨機打亂。
const shuffledPoints = shuffle(points.slice())
// 1. 順序的
let _ = 0
for (let i = 0; i < points.length; i++) { _ += points[i].x }
// 2. 隨機的
let _ = 0
for (let i = 0; i < shuffledPoints.length; i++) { _ += shuffledPoints[i].x }
那我該怎麼辦呢?
將這一概念應用於實踐可能是最困難的,因為 JavaScript 沒有一種方法可以指定對象在內存中的位置,但你可以像上面的例子一樣,利用這些知識來發揮優勢,例如在重新排序或排序之前對數據進行操作。你不能假設按順序創建的對象在一段時間後會保持在同一位置,因為垃圾回收器可能會移動它們。但有一個例外,那就是數字數組,最好是 TypedArray
實例:
有關更詳細的示例,請參閱此鏈接 *。
- 請注意,它包含一些現已過時的優化,但總體上仍然準確。
譯者注:
JavaScript 中的 TypedArray 實例提供了一種高效的方式來處理二進制數據。與常規數組相比,TypedArray 不僅在內存使用上更高效,而且它們訪問的是連續的內存區域,這使得它們在進行數據操作時可以實現更高的性能。這與預取的概念密切相關,因為處理連續的內存塊通常比隨機訪問內存更快,尤其是在涉及大量數據時。
例如,如果你在處理大量數值數據,比如圖像處理或科學計算,使用 Float32Array 或 Int32Array 等 TypedArray 可以讓你獲得更好的性能。由於這些數組直接操作內存,它們可以更快地讀取和寫入數據,尤其是在進行順序訪問時。這樣做不僅減少了 JavaScript 運行時的開銷,還可以利用現代 CPU 的預取和緩存機制來加速數據處理。
5.2 緩存在 L1/2/3#
CPU 使用的第二種優化方式是 L1/L2/L3 緩存:這些緩存就像速度更快的 RAMs,但也更昂貴,因此它們的容量要小得多。它們包含 RAM 數據,但起到 LRU 緩存的作用。數據在 "hot"(正在處理)時進入,當新的工作數據需要空間時再寫回主 RAM。因此,這裡的關鍵是使用盡可能少的數據,將工作數據集保留在快速緩存中。在下面的示例中,我們可以觀察到破壞每個連續緩存的效果。
// setup:
const KB = 1024
const MB = 1024 * KB
// 這些是適合這些緩存的近似大小。如果您在計算機上沒有得到相同的結果,可能是因為您的 sizes 不同。
const L1 = 256 * KB
const L2 = 5 * MB
const L3 = 18 * MB
const RAM = 32 * MB
// 我們將為所有測試用例訪問相同的緩衝區,但我們只會訪問第一個用例中的前 0 到 “L1” 條目,第二個用例中的 0 到 “L2” 條目,依此類推。
const buffer = new Int8Array(RAM)
buffer.fill(42)
const random = (max) => Math.floor(Math.random() * max)
// 1. L1
let r = 0; for (let i = 0; i < 100000; i++) { r += buffer[random(L1)] }
// 2. L2
let r = 0; for (let i = 0; i < 100000; i++) { r += buffer[random(L2)] }
// 3. L3
let r = 0; for (let i = 0; i < 100000; i++) { r += buffer[random(L3)] }
// 4. RAM
let r = 0; for (let i = 0; i < 100000; i++) { r += buffer[random(RAM)] }
那我該怎麼辦呢?
無情地消除每一個可以消除的數據或內存分配。數據集越小,程序運行的速度就越快。內存 I/O 是 95% 程序的瓶頸。另一個好的策略是將您的工作分成塊( chunks ),並確保您一次處理一個小數據集。
有關 CPU 和內存的更多詳細信息,請參閱此鏈接。
Note
關於不可變數據結構 —— 不可變性對於清晰性和正確性來說是很好的,但是在性能方面,更新不可變的數據結構意味著複製容器,這將導致更多的內存 I/O 刷新緩存。你應該盡可能避免不可變的數據結構。
關於 ...
擴展運算符 —— 它非常方便,但每次使用它都要在內存中創建一個新對象。更多的內存 I/O,更慢的緩存!
6. 避免大型 Objects#
如第 2 節所述,引擎使用 shapes 來優化對象。然而,當 shapes 變得太大時,引擎別無選擇,只能使用常規的散列表(如 Map 對象)。正如我們在第 5 節中看到的,緩存未命中會顯著降低性能。散列表很容易出現這種情況,因為它們的數據通常是隨機的 & 均勻地分佈在它們所佔用的內存區域中。讓我們看看這個通過用戶 ID 索引的用戶映射是如何表現的。
// setup:
const USERS_LENGTH = 1_000
// setup:
const byId = {}
Array.from({ length: USERS_LENGTH }).forEach((_, id) => {
byId[id] = { id, name: 'John'}
})
let _ = 0
// 1. [] access
Object.keys(byId).forEach(id => { _ += byId[id].id })
// 2. direct access
Object.values(byId).forEach(user => { _ += user.id })
我們還可以觀察到性能如何隨著對象大小的增加而不斷下降:
// setup:
const USERS_LENGTH = 100_000
那我該怎麼辦呢?
如上所述,應避免頻繁對大型對象進行索引。最好事先將對象轉化為數組。將 ID 組織在模型上可以幫助您組織數據,因為您可以使用 Object.values()
,而不必引用鍵映射來獲取 ID。
7. 使用 eval#
有些 JavaScript 模式很難針對引擎進行優化,而通過使用 eval () 或其衍生工具,你可以讓這些模式消失。在本例中,我們可以觀察到使用 eval () 如何避免了使用動態對象鍵創建對象的成本:
// setup:
const key = 'requestId'
const values = Array.from({ length: 100_000 }).fill(42)
// 1. without eval
function createMessages(key, values) {
const messages = []
for (let i = 0; i < values.length; i++) {
messages.push({ [key]: values[i] })
}
return messages
}
createMessages(key, values)
// 2. with eval
function createMessages(key, values) {
const messages = []
const createMessage = new Function('value',
`return { ${JSON.stringify(key)}: value }`
)
for (let i = 0; i < values.length; i++) {
messages.push(createMessage(values[i]))
}
return messages
}
createMessages(key, values)
譯者注:
在使用 eval 的版本中(通過 Function 構造函數實現),示例首先創建了一個函數,該函數動態生成一個具有動態鍵和給定值的對象。然後,這個函數在循環中被調用以創建消息數組。這種方式通過預先編譯生成對象的函數,減少了運行時的計算和對象創建開銷。
雖然使用 eval 和 Function 構造函數可以避免某些運行時開銷,但它們也引入了潛在的安全風險,因為它們可以執行任意代碼。此外,動態評估的代碼可能難以調試和優化,因為它在運行時生成,JavaScript 引擎可能無法提前進行有效的優化。
因此,雖然這個例子展示了一種性能優化技巧,但在實際應用中,推薦尋找其他方法來優化性能,同時保持代碼的安全性和可維護性。在大多數情況下,避免大量動態對象創建的需求,通過靜態分析和重構代碼來提高性能,會是更好的選擇。
eval 的另一個很好的用例是編譯一個過濾謂詞函數,在這個函數中,你可以丟棄那些你知道永遠不會被執行的分支。一般來說,任何要在一個非常熱的循環中運行的函數都適合進行這種優化。
顯然,關於 eval () 的常見警告也適用:不要相信用戶輸入,對傳遞到 eval () 代碼中的任何內容進行清理,不要產生任何 XSS 可能性。還要注意的是,有些環境不允許訪問 eval (),例如帶有 CSP 的瀏覽器頁面。
8. 小心使用字符串#
我們已經在上面看到了字符串比它們看起來更昂貴。好吧,我這裡有一個好消息和一個壞消息,我將按照唯一的邏輯順序宣布(先壞,後好):字符串比它們表面看起來更複雜,但它們也可以非常高效地使用。
字符串操作因其上下文而成為 JavaScript 的核心部分。為了優化大量使用字符串的代碼,引擎必須具有創造力。我的意思是,他們必須根據使用情況,用 C++ 中的多種字符串表示法來表示字符串對象。有兩種一般情況值得您擔心,因為它們適用於 V8(迄今為止最常見的引擎),一般也適用於其他引擎。
首先,使用 +
連接的字符串不會創建兩個輸入字符串的副本。該操作創建指向每個子字符串的指針。如果是 typescript 的話,應該是這樣的:
class String {
abstract value(): char[] {}
}
class BytesString {
constructor(bytes: char[]) {
this.bytes = bytes
}
value() {
return this.bytes
}
}
class ConcatenatedString {
constructor(left: String, right: String) {
this.left = left
this.right = right
}
value() {
return [...this.left.value(), ...this.right.value()]
}
}
function concat(left, right) {
return new ConcatenatedString(left, right)
}
const first = new BytesString(['H', 'e', 'l', 'l', 'o', ' '])
const second = new BytesString(['w', 'o', 'r', 'l', 'd'])
// 看吧,沒有數組副本!
const message = concat(first, second)
其次,字符串切片( string slices )也不需要創建副本:它們可以簡單地指向另一個字符串中的範圍。繼續上面的例子:
class SlicedString {
constructor(source: String, start: number, end: number) {
this.source = source
this.start = start
this.end = end
}
value() {
return this.source.value().slice(this.start, this.end)
}
}
function substring(source, start, end) {
return new SlicedString(source, start, end)
}
// This represents "He", but it still contains no array copies.
// It's a SlicedString to a ConcatenatedString to two BytesString
const firstTwoLetters = substring(message, 0, 2)
但問題是:一旦你需要開始改變這些字節,那就是你開始付出拷貝代價的時刻。假設我們回到我們的 String 類,並嘗試添加一個 .trimEnd 方法:
class String {
abstract value(): char[] {}
trimEnd() {
// `.value()` 這裡可能調用我們的 Sliced->Concatenated->2*Bytes 字符串!
const bytes = this.value()
const result = bytes.slice()
while (result[result.length - 1] === ' ')
result.pop()
return new BytesString(result)
}
}
因此,讓我們跳轉到一個例子,在這個例子中,比較使用突變操作( mutation )和只使用串聯操作( concatenation ):
// setup:
const classNames = ['primary', 'selected', 'active', 'medium']
// 1. mutation
const result =
classNames
.map(c => `button--${c}`)
.join(' ')
// 2. concatenation
const result =
classNames
.map(c => 'button--' + c)
.reduce((acc, c) => acc + ' ' + c, '')
那麼我該怎麼辦呢?
一般來說,盡最大可能避免突變。這包括 .trim()
、.replace()
等方法。請考慮如何避免使用這些方法。在某些引擎中,字符串模板也可能比 +
慢。目前在 V8 中就是這種情況,但將來可能不會,所以還是要以基準為依據。
關於上面的 SlicedString
需要注意的是,如果一個大字符串的小子串還存在內存中,可能會妨礙垃圾回收器收集大字符串!所以如果您處理大文本並從中提取小字符串,可能會泄漏大量內存。
const large = Array.from({ length: 10_000 }).map(() => 'string').join('')
const small = large.slice(0, 50)
// ^ will keep alive
這裡的解決方案是使用對我們有利的突變方法。如果我們在 small 上使用其中一種方法,它將強制複製,而指向 large 的舊指針就會丟失:
// replace a token that doesn't exist
const small = small.replace('#'.repeat(small.length + 1), '')
有關更多詳細信息,請參見 V8 上的 string. h 或 JavaScriptCore 上的 JSString. h。
Note
關於字符串複雜性 —— 我快速瀏覽了一些東西,但還是有很多實現細節增加了字符串的複雜性。每種字符串表示法通常都有最小長度。例如,對於非常小的字符串,可能並不會使用連接字符串。有時也有限制,例如避免指向子串的子串。閱讀上面鏈接的 C++ 文件,即使只是閱讀註釋,也能很好地了解實現細節。
9. 進行專業化( specialization )#
性能優化的一個重要概念是專業化( specialization ):調整邏輯以適應特定用例的限制。這通常意味著要弄清哪些情況_可能_會發生,並針對這些情況進行編碼。
假設我們是一個有時需要在產品列表中添加標籤的商家。根據經驗,我們知道標籤通常是空的。了解了這些信息,我們就可以針對這種情況專門設計我們的函數:
// setup:
const descriptions = ['apples', 'oranges', 'bananas', 'seven']
const someTags = {
apples: '::promotion::',
}
const noTags = {}
// 將產品轉化為字符串,如果適用的話還包括其標籤
function productsToString(description, tags) {
let result = ''
description.forEach(product => {
result += product
if (tags[product]) result += tags[product]
result += ', '
})
return result
}
// Specialize it now
function productsToStringSpecialized(description, tags) {
// 我們知道 `tags` 很可能是空的,所以我們提前檢查一次,然後就可以從內循環中移除 `if` 檢查了
if (isEmpty(tags)) {
let result = ''
description.forEach(product => {
result += product + ', '
})
return result
} else {
let result = ''
description.forEach(product => {
result += product
if (tags[product]) result += tags[product]
result += ', '
})
return result
}
}
function isEmpty(o) { for (let _ in o) { return false } return true }
// 1. not specialized
for (let i = 0; i < 100; i++) {
productsToString(descriptions, someTags)
productsToString(descriptions, noTags)
productsToString(descriptions, noTags)
productsToString(descriptions, noTags)
productsToString(descriptions, noTags)
}
// 2. specialized
for (let i = 0; i < 100; i++) {
productsToStringSpecialized(descriptions, someTags)
productsToStringSpecialized(descriptions, noTags)
productsToStringSpecialized(descriptions, noTags)
productsToStringSpecialized(descriptions, noTags)
productsToStringSpecialized(descriptions, noTags)
}
這種優化可以為你帶來適度的改進,但這些改進會累積起來。它們是對更重要的優化(如 shapes 和內存 I/O )的一个很好的補充。但要注意的是,如果你的條件發生變化,專業化可能會對你不利,所以在應用時一定要小心。
Note
分支預測和無分支預測代碼 —— 從代碼中移除分支可以極大地提高性能。有關分支預測器的更多詳細信息,請閱讀 stackoverflow 的經典回答:為什麼處理排序數組更快?
10. 數據結構#
關於數據結構的細節,我就不多說了,因為這需要單獨寫一篇文章。但請注意,使用不正確的數據結構對您的用例造成的影響可能比上述任何優化都要大。我建議您熟悉 Map 和 Set 等本地數據結構,並了解鏈表、優先隊列、樹(RB 和 B+)和 tries。
但是作為一個快速的例子,讓我們比較一下 Array.includes
和 Set.has
在一個小列表中的表現:
// setup:
const userIds = Array.from({ length: 1_000 }).map((_, i) => i)
const adminIdsArray = userIds.slice(0, 10)
const adminIdsSet = new Set(adminIdsArray)
// 1. Array
let _ = 0
for (let i = 0; i < userIds.length; i++) {
if (adminIdsArray.includes(userIds[i])) { _ += 1 }
}
// 2. Set
let _ = 0
for (let i = 0; i < userIds.length; i++) {
if (adminIdsSet.has(userIds[i])) { _ += 1 }
}
正如你所看到的,數據結構的選擇會產生非常大的影響。
作為一個真實的例子,我曾遇到過這樣一個場景:通過將數組換成鏈表,我們將一個函數的運行時間從 5 秒縮短到了 22 毫秒。
11. 基準測試( Benchmarking )#
我把這一部分留到最後,原因只有一個:我需要通過上面有趣的部分建立可信度。現在,我已經掌握了它(希望如此),讓我告訴你們,基準測試是優化工作中最重要的部分。它不僅最重要,而且也很難。即使有了 20 年的經驗,我有時仍然會創建有缺陷的基準測試,或者錯誤地使用分析工具。因此,無論如何,請盡最大努力正確創建基準測試。
11.0 自頂向下#
你的首要任務始終應該是優化占運行時間最大部分的函數 / 代碼段。如果你把時間花在優化最重要的部分之外,那就是在浪費時間。
11.1 避免微基準( micro-benchmarks )#
在生產模式下運行代碼,並根據這些觀察結果進行優化。JS 引擎非常複雜,在微基準測試中的表現往往不同於實際應用場景。例如,看看這個微基準測試:
const a = { type: 'div', count: 5, }
const b = { type: 'span', count: 10 }
function typeEquals(a, b) {
return a.type === b.type
}
for (let i = 0; i < 100_000; i++) {
typeEquals(a, b)
}
如果你稍加留意,就會發現引擎會將形狀 { type: string, count: number }
的函數特殊化。但在實際應用中,這是否成立呢?a 和 b 是否總是這種形狀,還是會收到任何形狀?如果您在生產過程中會收到很多形狀,那麼這個函數的行為就會有所不同。
11.2 懷疑你的結果#
如果你剛剛優化了一個函數,而它現在的運行速度比以前快了 100 倍,那就懷疑它。試着推翻你的結果,試着在生產模式下運行,對它進行壓力測試 。同樣,也要懷疑你的工具。僅僅使用 devtools 觀察基準測試就能改變其行為。
11.3 選擇您的目標#
不同的引擎對某些模式的優化效果有好有壞。您應該針對與您相關的引擎制定基準測試,並優先考慮哪個引擎更重要。這是一個 Babel 中的實際例子,在這個例子中,提高 V8 意味著降低 JSC 的性能。
12. 分析和工具#
關於分析和開發工具的各種討論。
12.1 瀏覽器陷阱#
如果您在瀏覽器中進行分析,請確保您使用的瀏覽器配置文件是乾淨、空白的。為此,我甚至會使用單獨的瀏覽器。如果您在進行分析時啟用了瀏覽器擴展,它們會擾亂測量結果。特別是 React devtools 會顯著影響結果,使得代碼的渲染速度看起來比實際上呈現給用戶的要慢。
12.2 Sample vs Structural 分析#
瀏覽器的性能分析工具是基於採樣的分析器,它們會定期對你的調用堆棧進行採樣。這有一個很大的缺點:一些非常小且頻繁被調用的函數可能在這些採樣間隙中被調用,而在你看到的堆棧圖表中可能會被嚴重低報。使用 Firefox devtools 自定義採樣間隔或使用具有 CPU 节流功能的 Chrome devtools 來緩解此問題。
12.3 性能優化中的常用工具#
除了常規的瀏覽器 devtools 外,了解這些設置選項可能會有所幫助:
- Chrome devtools 中有許多實驗標誌,可以幫助你找出速度慢的原因。當你需要調試瀏覽器中的樣式 / 佈局重新計算時,樣式失效跟蹤器非常有用。
https://github.com/iamakulov/devtools-perf-features - deoptexplorer-vscode 擴展允許你加載 V8/chromium 的日誌文件,以理解你的代碼何時觸發去優化,當你向函數傳遞不同 shapes 時。你不需要這個擴展就能閱讀日誌文件,但它讓體驗變得更加愉快。
https://github.com/microsoft/deoptexplorer-vscode - 你也可以為每個 JS 引擎編譯 debug shell ,以更詳細地了解它是如何工作的。這樣就可以運行 perf 和其他底層工具,還可以檢查每個引擎生成的字節碼和機器碼。
V8 示例 | JSC 示例 | SpiderMonkey 示例(缺失)