高中生打破React性能極限,將React性能提升70%!
React 是當今最受歡迎的 JavaScript 框架之一,它的創(chuàng)新之一就是引入了虛擬 DOM,但很多現(xiàn)代框架已經(jīng)不再采用這種方案,其在某些情況下會影響應用的性能。Svelte 的創(chuàng)建者 Rich Harris 曾將其稱作純粹的開銷。
一位名為 Aidenybai 的高中生開發(fā)了一個名為 million.js 的輕量級(小于 4KB)虛擬 DOM 庫,其可將 React 組件的性能提高多達 70%。
那 million.js 到底是什么?又是如何讓 React 的速度提高 70% 的呢?下面就來一探究竟!
本文目錄:
- 基本概念
- 使用步驟
- 打包體積
- 工作原理
- 使用場景
- 總結
基本概念
Million.js 提供了一個極致優(yōu)化的虛擬 DOM,可以與 React 兼容。使用 Million 創(chuàng)建 Web 應用程序就像使用 React 組件一樣簡單(它只是一個包裝 React 組件的高階組件),但加載和渲染速度更快。Million.js 使用經(jīng)過微調和優(yōu)化的虛擬 DOM 減少了 React 的開銷,就像 React 組件以純 JavaScript 的速度運行一樣。
一個高中生就這么超越了Meta 的整個頂級工程師團隊?帶著懷疑看了看 JavaScript 框架性能基準測試對比結果:
數(shù)據(jù)不言自明,在第二張表中,內存消耗的差異更加顯著,它清楚的顯示了 Million 如何在內方面得到更好的優(yōu)化。
那為什么 million.js 會如此之快呢?
React 默認的虛擬 DOM 是實際 DOM 的一種內存抽象。組件被存儲在一個樹形結構中,當狀態(tài)發(fā)生變化時,React 會創(chuàng)建一個新的虛擬 DOM。接下來,將新的虛擬 DOM 樹與舊的虛擬 DOM 樹進行比較,找出兩者之間的差異。最后,使用這些差異更新實際 DOM 樹。這就是所謂的協(xié)調過程。但是,創(chuàng)建全新的虛擬 DOM 代價很大。
Million 通過使用塊虛擬 DOM,采用了更加精簡的方式。將應用程序中的動態(tài)部分提取出來并進行跟蹤,當狀態(tài)發(fā)生變化時,只對變化的部分進行 diff 操作。相比默認的虛擬 DOM,不需要對整個樹進行 diff。由于 Million 跟蹤了動態(tài)部分的位置,因此可以精確地找到并更新它們,這種方法與 Svelte 很相似。
后面會詳細介紹 Million.js 的工作原理。
使用步驟
在使用 million 之前,首先需要創(chuàng)建一個 React 項目在,這里略過創(chuàng)建項目的過程。
或者也可以直接克隆官方提供的 React + Vite 項目模板(https://github.com/aidenybai/million-react),打開項目根目錄,依次執(zhí)行 npm install 和 npm run dev 命令來啟動項目,啟動完成之后在瀏覽器輸入 localhost:3000 就可以看到以下界面:
可以通過以下命令來安裝 million 庫:
npm install million
使用方式很簡單,引入 million,并在組件中使用:
import React, { useState } from 'react';
import { block } from 'million/react';
import './App.css';
function App() {
const [count, setCount] = useState(0);
return (
<div className="App">
<h1>Million + React</h1>
<button onClick={() => setCount((count) => count + 1)}>
count: {count}
</button>
</div>
);
}
const AppBlock = block(App)
export default AppBlock
可以看到,組件從 million 中引入了 block(),并使用 block() 包裹 App 組件。Million.js 可以讓我們創(chuàng)建塊(block),塊是一種特殊的高階組件,可以像 React 組件一樣使用,但具有更快的渲染速度。
塊的一個用例是高效地渲染數(shù)據(jù)列表。下面在 React 中構建一個數(shù)據(jù)網(wǎng)格。可以在組件中分別定義 <Table />(用于展示數(shù)據(jù)列表) 和 <Input />(用于輸入展示列表的行數(shù)) 組件,使用 useState() hook 存儲要顯示的行數(shù)。
import React, { useState } from 'react';
import './App.css';
function App() {
const [rows, setRows] = useState(1);
return (
<div>
<Input value={rows} setValue={setRows} />
<Table>
// ...
</Table>
</div>
);
}
export default App;
假設我們通過一個名為 buildData(rows) 的函數(shù)獲取任意數(shù)據(jù)數(shù)組:
const data = buildData(100);
// [{ adjective: '...', color: '...', noun: '...' }, ... x100]
現(xiàn)在可以使用 Array.map() 在表格中渲染數(shù)據(jù):
import React, { useState } from 'react';
import './App.css';
function App() {
const [rows, setRows] = useState(1);
const data = buildData(rows);
return (
<div>
<Input value={rows} setValue={setRows} />
<Table>
{data.map(({ adjective, color, noun }) => (
<tr>
<td>{adjective}</td>
<td>{color}</td>
<td>{noun}</td>
</tr>
))}
</Table>
</div>
);
}
export default App;
頁面效果如下:
它的性能表現(xiàn)非常好。從 0 到 100,幾乎沒有延遲,但一旦超過 500 左右,渲染時就會有明顯的延遲。
這時引入 million 來看看:
import React, { useState } from 'react';
import { block } from 'million/react';
import './App.css';
function App() {
const [rows, setRows] = useState(1);
const data = buildData(rows);
return (
<div>
<Input value={rows} setValue={setRows} />
<Table>
{data.map(({ adjective, color, noun }) => (
<tr>
<td>{adjective}</td>
<td>{color}</td>
<td>{noun}</td>
</tr>
))}
</Table>
</div>
);
}
const AppBlock = block(App)
export default AppBlock
此時,再添加超過 500 條數(shù)據(jù)時,頁面渲染就會快很多。
除此之外,Million 還提供了其他實用工具,特別是用于高效地渲染列表。Million 并不推薦將傳統(tǒng)的列表包裝在block HOC 中進行渲染,而是推薦使用內置的 For 組件:
<For each={data}>
({ adjective, color, noun }) => (
<tr>
<td>{adjective}</td>
<td>{color}</td>
<td>{noun}</td>
</tr>
)
</For>
打包體積
頁面的執(zhí)行性能非常重要,其初始加載也非常重要,其中一個重要因素就是項目的打包體積。這里我們使用 Million 和不使用它構建了相同的應用。
使用純 React 的打包體積:
使用 Million 的打包體積:
可以看到,gzip 捆綁包大小的差異小于 5 kB,這意味著對于多數(shù) React 應用來說,Million 對項目的體積影響可以忽略不計。
工作原理
最后,我們來看看 million 的工作原理。
React 的虛擬 DOM
虛擬 DOM 的產(chǎn)生是為了解決頻繁操作真實 DOM 帶來的性能問題。它是真實 DOM 的輕量級、內存中的表示形式。當一個組件被渲染時,虛擬 DOM 會計算新狀態(tài)和舊狀態(tài)之間的差異(稱為 diff 過程),并對真實 DOM 進行最小化的變化,使它與更新后的虛擬 DOM 同步(這個過程稱為協(xié)調)。下面來看一個例子,假設有一個 React 組件 <Numbers />:
function Numbers() {
return (
<foo>
<bar>
<baz />
</bar>
<boo />
</foo>
);
}
當 React 渲染此組件時,它將經(jīng)過檢查更改的 diff 和更新 DOM 的協(xié)調過程。這個過程看起來像這樣:
- 我們得到了兩個虛擬 DOM:current(當前的),代表當前 UI 的樣子,和 new(新的),代表想要看到的樣子。
- 比較第一個節(jié)點,發(fā)現(xiàn)沒有差異,繼續(xù)比較下一個。
- 比較第二個節(jié)點,發(fā)現(xiàn)有一個差異,在 DOM 中進行更新。
- 比較第三個節(jié)點,發(fā)現(xiàn)它在新的虛擬 DOM 中已經(jīng)不存在了,在 DOM 中將其刪除。
- 比較第四個節(jié)點,發(fā)現(xiàn)它在新的虛擬 DOM 中已經(jīng)不存在了,在 DOM 中將其刪除。
- 比較第五個節(jié)點,發(fā)現(xiàn)有差異,在 DOM 中進行更新并完成了整個過程。
diff 過程取決于樹的大小,最終導致虛擬 DOM 的性能瓶頸。組件的節(jié)點越多,diff 所需要的時間就越長。
隨著像 Svelte 這樣的新框架的出現(xiàn),由于性能開銷的問題,甚至不再使用虛擬 DOM。相反,Svelte 使用一種稱為 "臟檢查" 的技術來確定哪些內容已經(jīng)發(fā)生了改變。類似 SolidJS 這樣的精細響應式框架更進一步,精確定位于 DOM 中哪些部分發(fā)生了變化,并僅更新這部分內容。
Million 的虛擬 DOM
2022 年,Blockdom 發(fā)布了 ?;诓煌乃悸罚珺lockdom 引入了“塊虛擬DOM”的概念。塊虛擬 DOM 采用不同的 diff 方法,可以分為兩部分:
- 靜態(tài)分析:對虛擬 DOM 進行分析,將樹的動態(tài)部分提取到“Edit Map”中,即虛擬DOM的動態(tài)部分到狀態(tài)的“Edit”(Map)列表中。
- 臟檢查:對比狀態(tài)(而不是虛擬 DOM 樹)以確定發(fā)生了什么變化。如果狀態(tài)發(fā)生了變化,通過 Edit Map 直接更新DOM。
簡而言之,就是對比數(shù)據(jù)而不是 DOM,因為數(shù)據(jù)的大小通常比 DOM 的大小小得多。而且對比數(shù)據(jù)值可能比對比完整的 DOM 節(jié)點更簡單。
由于 Million.js 采用了與 Blockdom 類似的方法,因此下面將使用 Million.js 的語法。
下面來看一個簡單的計數(shù)器應用以及它如何使用 Million.js 處理:
import { useState } from 'react';
import { block } from 'million/react';
function Count() {
const [count, setCount] = useState(0);
const node1 = count + 1;
const node2 = count + 2;
return (
<div>
<ul>
<li>{node1}</li>
<li>{node2}</li>
</ul>
<button
onClick={() => {
setCount(count + 1);
}}
>
Increment Count
</button>
</div>
);
}
const CountBlock = block(Count);
這個程序很簡單,顯示效果如下:
(1)靜態(tài)分析
靜態(tài)分析可以在編譯時或運行時的第一步完成,具體取決于是否使用了 Million.js 的實驗性編譯器。
此步驟負責將虛擬 DOM 的動態(tài)部分提取到“編輯映射”中。
- 這里沒有使用 React 來渲染 JSX,而是使用 Million.js 來渲染它,它將占位符節(jié)點(用“?”表示)傳遞到虛擬DOM。這些節(jié)點將充當動態(tài)內容的占位符,并在靜態(tài)分析過程中使用。
- 現(xiàn)在開始靜態(tài)分析,檢查第一個節(jié)點是否有占位符,沒有找到,繼續(xù)下一步。
- 在第二個節(jié)點中檢查占位符,沒有找到,繼續(xù)下一步。
- 檢查第三個節(jié)點的占位符并找到“?”。將占位符添加到“Edit Map”,它將prop1關聯(lián)到占位符節(jié)點。然后從塊中刪除占位符。
- 檢查第四個節(jié)點的占位符并找到“?”。將占位符添加到“Edit Map”,它將 prop2 關聯(lián)到占位符節(jié)點。然后從塊中刪除占位符。
- 檢查第五個節(jié)點是否有占位符,沒有找到,完成檢測。
(2)臟檢查
創(chuàng)建 Edit Map 后,就可以開始臟檢查了。這一步負責確定狀態(tài)發(fā)生了什么變化,并相應地更新 DOM。
- 可以只區(qū)分 prop1 和 prop2,而不是按元素進行區(qū)分。由于兩者都與在靜態(tài)分析期間創(chuàng)建的“Edit Map”相關聯(lián),因此一旦確定差異,就可以直接更新 DOM。
- 比較當前的 prop1 和新的 prop1 值,由于它們不同,因此更新了 DOM。
- 比較當前的 prop2 和新的 prop2 值,由于它們不同,因此更新了 DOM。
可以看到,臟檢查比 diff 步驟需要更少的計算。這是因為臟檢查只關心狀態(tài),而不關心虛擬 DOM,因為每個虛擬節(jié)點可能需要許多級別的遞歸來確定它是否已經(jīng)改變,狀態(tài)只需要一個淺層相等檢查。
使用場景
Million.js 具有相當高的性能,并且能夠在 JavaScript 框架基準測試中勝過 React。
JavaScript 框架基準測試通過渲染一個包含行和列的大型表格來測試框架的性能。該基準測試旨在測試高度不切實際的性能測試(如添加/替換 1000 行),并不一定代表真實的應用。
那 Million.js 或塊虛擬 DOM 可以用在什么地方呢?
靜態(tài)內容多,動態(tài)內容少
當有很多靜態(tài)內容而動態(tài)內容很少時,最好使用塊虛擬 DOM。塊虛擬 DOM最大的優(yōu)勢就是不需要考慮虛擬 DOM 的靜態(tài)部分,所以如果能跳過很多靜態(tài)內容,速度會非常快。
例如,在這種情況下,塊虛擬 DOM 將比常規(guī)虛擬 DOM 快得多:
// ?
<div>
<div>{dynamic}</div>
很多靜態(tài)內容...
</div>
如果有很多動態(tài)內容,可能看不出塊虛擬 DOM 和常規(guī)虛擬 DOM 的區(qū)別:
// ?
<div>
<div>{dynamic}</div>
<div>{dynamic}</div>
<div>{dynamic}</div>
<div>{dynamic}</div>
<div>{dynamic}</div>
</div>
如果構建一個管理系統(tǒng),或者一個包含大量靜態(tài)內容的網(wǎng)站,塊虛擬 DOM 可能非常適合。但是,如果構建一個網(wǎng)站,其中比較數(shù)據(jù)所需的計算量明顯大于比較虛擬 DOM 所需的計算量,那么可能看不出太大差異。
例如,這個組件不適合塊虛擬 DOM,因為要比較的數(shù)據(jù)值多于虛擬 DOM 節(jié)點:
// 5個要比較的數(shù)據(jù)值
function Component({ a, b, c, d, e }) {
// 1個要比較的虛擬DOM節(jié)點
return <div>{a + b + c + d + e}</div>;
}
“穩(wěn)定”的 UI 樹
塊狀虛擬 DOM 也適用于“穩(wěn)定”的 UI 樹,或者變化不大的 UI 樹。這是因為 Edit Map 只創(chuàng)建一次,不需要在每次渲染時都重新創(chuàng)建。
例如,以下組件是塊虛擬 DOM 的一個很好的使用場景:
function Component() {
return <div>{dynamic}</div>;
}
但是這個組件可能比常規(guī)的虛擬 DOM 慢:
function Component() {
return Math.random() > 0.5 ? <div>{dynamic}</div> : <p>sad</p>;
}
注意,“穩(wěn)定”返回意味著不允許具有非列表類動態(tài)的組件(如同一組件中的條件返回)。
細粒度使用
初學者犯的最大錯誤之一是到處使用塊虛擬 DOM。這是個壞主意,因為塊虛擬 DOM 并不總是比常規(guī)虛擬 DOM 快。
相反,應該識別塊虛擬 DOM 更快的某些模式,并僅在這些情況下使用它。例如,可能對大表使用塊虛擬 DOM,但對具有少量靜態(tài)內容的小表單使用常規(guī)虛擬 DOM。
總結
塊虛擬 DOM 為虛擬 DOM 概念提供了一個全新的視角,提供了一種管理更新和最小化開銷的替代方法。盡管它具有潛力,但它并不是一種放之四海而皆準的解決方案,開發(fā)人員在決定是否采用這種方法之前應該評估應用的具體需求和性能要求。
對于很多應用來說,傳統(tǒng)的虛擬 DOM 可能就足夠了,不需要切換到塊虛擬 DOM 或其他以性能為中心的框架。如果應用在大多數(shù)設備上運行流暢且沒有性能問題,那么可能不值得花時間和精力過渡到不同的框架。在對技術堆棧進行任何重大更改之前,必須仔細權衡取舍并評估應用的要求。