跳至主要內容

虛擬 DOM 只是純粹的額外負擔

讓我們一勞永逸地破除「虛擬 DOM 很快」的神話

如果你在過去幾年使用過 JavaScript 框架,你可能聽過「虛擬 DOM 很快」這句話,通常是指它比實際的 DOM 快。這是一個令人驚訝的頑強迷因——例如,有人問 Svelte 在不使用虛擬 DOM 的情況下如何能夠快速。

現在是仔細研究的時候了。

什麼是虛擬 DOM?

在許多框架中,你可以透過建立 render() 函數來建立應用程式,例如這個簡單的 React 元件

function function HelloMessage(props: any): divHelloMessage(props: anyprops) {
	return <type div = /*unresolved*/ anydiv className="greeting">Hello {props: anyprops.name}</div>;
}

你可以在不使用 JSX 的情況下做到同樣的事情...

function function HelloMessage(props: any): anyHelloMessage(props: anyprops) {
	return React.createElement('div', { className: stringclassName: 'greeting' }, 'Hello ', props: anyprops.name);
}

...但結果是一樣的——一個代表頁面現在應該如何呈現的物件。這個物件就是虛擬 DOM。每次你的應用程式的狀態更新時(例如當 name 屬性變更時),你都會建立一個新的物件。框架的工作是將新的物件與舊的物件進行調和,找出必要的變更並將它們應用到實際的 DOM 中。

這個迷因是如何開始的?

關於虛擬 DOM 效能的誤解可以追溯到 React 的推出。在 重新思考最佳實踐中,前 React 核心團隊成員 Pete Hunt 在 2013 年的開創性演講中,我們了解到以下內容

這實際上非常快,主要是因為大多數 DOM 操作都往往很慢。DOM 的效能做了很多工作,但大多數 DOM 操作都傾向於丟失幀。

Pete Hunt at JSConfEU 2013
擷取自 重新思考最佳實踐,在 2013 年 JSConfEU 的演講

但是等一下!虛擬 DOM 操作是額外於最終在實際 DOM 上的操作。它唯一可能更快的方式是,如果我們將它與效率較低的框架進行比較(2013 年有很多這樣的框架!),或是在反駁稻草人論證——也就是說,另一種選擇是做一些實際上沒有人會做的事情

onEveryStateChange(() => {
	var document: Documentdocument.Document.body: HTMLElement

Specifies the beginning and end of the document body.

MDN Reference

body
.InnerHTML.innerHTML: stringinnerHTML = 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」。

  1. 兩個快照都包含一個元素。在兩種情況下,它都是一個 <div>,這表示我們可以保留相同的 DOM 節點
  2. 我們列舉舊的 <div> 和新的 <div> 上的所有屬性,以查看是否需要變更、新增或移除任何屬性。在這兩種情況下,我們都有一個單一屬性——一個值為 "greeting"className
  3. 深入到元素中,我們看到文字已變更,因此我們需要更新實際的 DOM

在這三個步驟中,只有第三個步驟在本例中具有價值,因為——正如絕大多數更新的情況一樣——應用程式的基本結構沒有改變。如果我們可以直接跳到步驟 3,效率會高得多

if (changed.name) {
	text.data = const name: void
@deprecated
name
;
}

(這幾乎完全是 Svelte 生成的更新程式碼。與傳統的 UI 框架不同,Svelte 是一個編譯器,它在建置時就知道應用程式中的內容可能會如何變更,而不是等待在執行時執行工作。)

不只是差異比對而已

React 和其他虛擬 DOM 框架使用的差異比對演算法速度很快。可以說,更大的額外負擔是在元件本身。你不會寫出像這樣的程式碼...

function function StrawManComponent(props: any): pStrawManComponent(props: anyprops) {
	const const value: anyvalue = expensivelyCalculateValue(props: anyprops.foo);

	return <type p = /*unresolved*/ anyp>the const value: anyvalue is {const value: anyvalue}</p>;
}

...因為你會在每次更新時都粗心地重新計算 value,而不管 props.foo 是否已變更。但是,以看起來更良性的方式執行不必要的計算和配置非常普遍

function function MoreRealisticComponent(props: any): divMoreRealisticComponent(props: anyprops) {
	const [const selected: anyselected, const setSelected: anysetSelected] = useState(null);

	return (
		<type div = /*unresolved*/ anydiv>
			<type p = /*unresolved*/ anyp>Selected {const selected: anyselected ? const selected: anyselected.name : 'nothing'}</p>

			<type ul = /*unresolved*/ anyul>
				{props: anyprops.items.map((item: anyitem) => (
					<type li = /*unresolved*/ anyli>
						<type button = /*unresolved*/ anybutton onClick={() => const setSelected: anysetSelected(item)}>{item: anyitem.name}</button>
					</li>
				))}
			</ul>
		</div>
	);
}

在這裡,我們在每次狀態變更時都會產生一個新的虛擬 <li> 元素陣列——每個元素都有自己的內嵌事件處理常式——而不管 props.items 是否已變更。除非你對效能有不健康的痴迷,否則你不會最佳化它。沒有意義。它已經夠快了。但是你知道什麼會更快嗎?不做這些。

預設執行不必要的工作的危險,即使該工作微不足道,也是你的應用程式最終會屈服於「千刀萬剮」,而一旦到了最佳化的時候,也沒有明確的瓶頸可以針對。

Svelte 的設計明確旨在防止你陷入這種情況。

那麼,框架為什麼要使用虛擬 DOM?

重要的是要了解,虛擬 DOM 不是一項功能。它是一種達到目的的手段,目的是宣告式、狀態驅動的 UI 開發。虛擬 DOM 的價值在於,它允許你在不考慮狀態轉換的情況下構建應用程式,並且具有通常足夠好的效能。這表示更少的錯誤程式碼,以及更多時間花在創意任務上,而不是繁瑣的任務上。

但是,結果證明我們可以在不使用虛擬 DOM 的情況下實現類似的程式設計模型——而這就是 Svelte 的用武之地。