虛擬 DOM 只是純粹的額外負擔
讓我們一勞永逸地破除「虛擬 DOM 很快」的神話
如果你在過去幾年使用過 JavaScript 框架,你可能聽過「虛擬 DOM 很快」這句話,通常是指它比實際的 DOM 快。這是一個令人驚訝的頑強迷因——例如,有人問 Svelte 在不使用虛擬 DOM 的情況下如何能夠快速。
現在是仔細研究的時候了。
什麼是虛擬 DOM?
在許多框架中,你可以透過建立 render()
函數來建立應用程式,例如這個簡單的 React 元件
function function HelloMessage(props: any): div
HelloMessage(props: any
props) {
return <type div = /*unresolved*/ any
div className="greeting">Hello {props: any
props.name}</div>;
}
你可以在不使用 JSX 的情況下做到同樣的事情...
function function HelloMessage(props: any): any
HelloMessage(props: any
props) {
return React.createElement('div', { className: string
className: 'greeting' }, 'Hello ', props: any
props.name);
}
...但結果是一樣的——一個代表頁面現在應該如何呈現的物件。這個物件就是虛擬 DOM。每次你的應用程式的狀態更新時(例如當 name
屬性變更時),你都會建立一個新的物件。框架的工作是將新的物件與舊的物件進行調和,找出必要的變更並將它們應用到實際的 DOM 中。
這個迷因是如何開始的?
關於虛擬 DOM 效能的誤解可以追溯到 React 的推出。在 重新思考最佳實踐中,前 React 核心團隊成員 Pete Hunt 在 2013 年的開創性演講中,我們了解到以下內容
這實際上非常快,主要是因為大多數 DOM 操作都往往很慢。DOM 的效能做了很多工作,但大多數 DOM 操作都傾向於丟失幀。

但是等一下!虛擬 DOM 操作是額外於最終在實際 DOM 上的操作。它唯一可能更快的方式是,如果我們將它與效率較低的框架進行比較(2013 年有很多這樣的框架!),或是在反駁稻草人論證——也就是說,另一種選擇是做一些實際上沒有人會做的事情
onEveryStateChange(() => {
var document: Document
document.Document.body: HTMLElement
Specifies the beginning and end of the document body.
body.InnerHTML.innerHTML: string
innerHTML = renderMyApp();
});
Pete 很快澄清了...
React 並非魔法。就像你可以使用 C 語言進入組合語言並擊敗 C 編譯器一樣,如果你願意,你也可以進入原始 DOM 操作和 DOM API 呼叫並擊敗 React。但是,使用 C 或 Java 或 JavaScript 是一種數量級的效能改進,因為你不需要擔心...平台的細節。使用 React,你可以構建應用程式而無需考慮效能,並且預設狀態是快速的。
...但這並不是讓人們記住的部分。
所以...虛擬 DOM 慢嗎?
不完全是。它更像是「虛擬 DOM 通常夠快」,但有一些注意事項。
React 最初的承諾是,你可以在每次狀態變更時重新渲染整個應用程式,而無需擔心效能。實際上,我認為這並不準確。如果真是如此,就不需要像 shouldComponentUpdate
這樣的最佳化(這是一種告訴 React 何時可以安全跳過元件的方法)。
即使使用 shouldComponentUpdate
,一次更新整個應用程式的虛擬 DOM 也是一件繁重的工作。不久前,React 團隊推出了一個名為 React Fiber 的東西,它允許將更新分解成更小的區塊。這意味著(除此之外)更新不會長時間阻塞主執行緒,儘管它不會減少總工作量或更新所需的時間。
額外負擔來自哪裡?
最明顯的是,差異比對不是免費的。你不能在不先將新的虛擬 DOM 與之前的快照進行比較的情況下,將變更應用於實際的 DOM。以先前的 HelloMessage
範例來說,假設 name
屬性從「world」變更為「everybody」。
- 兩個快照都包含一個元素。在兩種情況下,它都是一個
<div>
,這表示我們可以保留相同的 DOM 節點 - 我們列舉舊的
<div>
和新的<div>
上的所有屬性,以查看是否需要變更、新增或移除任何屬性。在這兩種情況下,我們都有一個單一屬性——一個值為"greeting"
的className
- 深入到元素中,我們看到文字已變更,因此我們需要更新實際的 DOM
在這三個步驟中,只有第三個步驟在本例中具有價值,因為——正如絕大多數更新的情況一樣——應用程式的基本結構沒有改變。如果我們可以直接跳到步驟 3,效率會高得多
if (changed.name) {
text.data = const name: void
name;
}
(這幾乎完全是 Svelte 生成的更新程式碼。與傳統的 UI 框架不同,Svelte 是一個編譯器,它在建置時就知道應用程式中的內容可能會如何變更,而不是等待在執行時執行工作。)
不只是差異比對而已
React 和其他虛擬 DOM 框架使用的差異比對演算法速度很快。可以說,更大的額外負擔是在元件本身。你不會寫出像這樣的程式碼...
function function StrawManComponent(props: any): p
StrawManComponent(props: any
props) {
const const value: any
value = expensivelyCalculateValue(props: any
props.foo);
return <type p = /*unresolved*/ any
p>the const value: any
value is {const value: any
value}</p>;
}
...因為你會在每次更新時都粗心地重新計算 value
,而不管 props.foo
是否已變更。但是,以看起來更良性的方式執行不必要的計算和配置非常普遍
function function MoreRealisticComponent(props: any): div
MoreRealisticComponent(props: any
props) {
const [const selected: any
selected, const setSelected: any
setSelected] = useState(null);
return (
<type div = /*unresolved*/ any
div>
<type p = /*unresolved*/ any
p>Selected {const selected: any
selected ? const selected: any
selected.name : 'nothing'}</p>
<type ul = /*unresolved*/ any
ul>
{props: any
props.items.map((item: any
item) => (
<type li = /*unresolved*/ any
li>
<type button = /*unresolved*/ any
button onClick={() => const setSelected: any
setSelected(item)}>{item: any
item.name}</button>
</li>
))}
</ul>
</div>
);
}
在這裡,我們在每次狀態變更時都會產生一個新的虛擬 <li>
元素陣列——每個元素都有自己的內嵌事件處理常式——而不管 props.items
是否已變更。除非你對效能有不健康的痴迷,否則你不會最佳化它。沒有意義。它已經夠快了。但是你知道什麼會更快嗎?不做這些。
預設執行不必要的工作的危險,即使該工作微不足道,也是你的應用程式最終會屈服於「千刀萬剮」,而一旦到了最佳化的時候,也沒有明確的瓶頸可以針對。
Svelte 的設計明確旨在防止你陷入這種情況。
那麼,框架為什麼要使用虛擬 DOM?
重要的是要了解,虛擬 DOM 不是一項功能。它是一種達到目的的手段,目的是宣告式、狀態驅動的 UI 開發。虛擬 DOM 的價值在於,它允許你在不考慮狀態轉換的情況下構建應用程式,並且具有通常足夠好的效能。這表示更少的錯誤程式碼,以及更多時間花在創意任務上,而不是繁瑣的任務上。
但是,結果證明我們可以在不使用虛擬 DOM 的情況下實現類似的程式設計模型——而這就是 Svelte 的用武之地。