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