React 18 如何提高應用性能?
React 18 引入了并發(fā)功能,從根本上改變了 React 應用的渲染方式。本文將探討 Transitions、Suspense 和 React Server Components 等并發(fā)功能如何影響和提高應用的性能。
主線程和長任務
當在瀏覽器中運行 JavaScript 時,JavaScript 引擎在單線程環(huán)境中執(zhí)行代碼,該環(huán)境通常稱為主線程。除了執(zhí)行 JavaScript 代碼之外,主線程還負責處理其他任務,包括管理用戶交互(如單擊)、處理網(wǎng)絡事件、計時器、更新動畫以及管理瀏覽器重排和重繪。
主線程負責將任務一一處理
當一個任務正在處理時,所有其他任務都必須等待。雖然瀏覽器可以順利執(zhí)行小型任務以提供無縫的用戶體驗,但較長的任務可能會出現(xiàn)問題,因為它們可能會阻止其他任務的處理。
任何運行時間超過 50 毫秒的任務都被視為“長任務”。
圖片
50 毫秒基準基于以下條件確認的:設備必須每 16 毫秒 (60 fps) 創(chuàng)建一個新幀才能保持流暢的視覺體驗。然而,設備還必須執(zhí)行其他任務,例如響應用戶輸入和執(zhí)行 JavaScript。50ms 基準測試允許設備將資源分配給渲染幀和執(zhí)行其他任務,并為設備提供約 33.33ms 的額外時間來執(zhí)行其他任務,同時保持流暢的視覺體驗。
性能指標
為了保持最佳性能,盡量減少長任務的數(shù)量非常重要。為了衡量網(wǎng)站的性能,有兩個指標可以衡量長時間任務對應用程序性能的影響:總阻塞時間和下次繪制的交互。
總阻塞時間 (TBT) 是衡量首次內(nèi)容繪制 (FCP) 和交互時間 (TTI) 之間時間的重要指標。TBT 是執(zhí)行時間超過 50 毫秒的任務的總和,這會對用戶體驗產(chǎn)生重大影響。
圖片
上圖的 TBT 為 45ms,因為有兩個任務在 TTI 之前花費了超過 50ms 的時間,分別超出了 50ms 閾值 30ms 和 15ms。總阻塞時間是這些值的累加:30ms + 15ms = 45ms。
下次繪制的交互(INP)是一個新的 Core Web Vitals 指標,用于衡量用戶首次與頁面進行交互(例如點擊按鈕)到該交互在屏幕上可見的時間,也就是下次繪制的時間。這個指標對于有很多用戶交互的頁面特別重要,比如電子商務網(wǎng)站或社交媒體平臺。它通過累積用戶當前訪問期間的所有 INP 測量值,并返回最差的得分來進行衡量。
圖片
到下次繪制的交互時間為 250 毫秒,因為這是測量到的最高視覺延遲。
要了解新的 React 更新如何針對這些測量進行優(yōu)化從而改善用戶體驗,首先要了解傳統(tǒng) React 的工作原理。
傳統(tǒng)的 React 渲染
React 中的視覺更新分為兩個階段:渲染階段和提交階段。React 中的渲染階段是一個純粹的計算階段,其中 React 元素與現(xiàn)有 DOM 進行協(xié)調(diào)(即比較)。此階段涉及創(chuàng)建新的 React 元素樹,也稱為“虛擬 DOM”,它本質(zhì)上是實際 DOM 的輕量級內(nèi)存中表示。
在渲染階段,React 計算當前 DOM 和新 React 組件樹之間的差異并準備必要的更新。
圖片
渲染階段之后是提交階段。在此階段,React 將渲染階段計算出的更新應用于實際 DOM。這涉及創(chuàng)建、更新和刪除 DOM 節(jié)點以鏡像新的 React 組件樹。
在傳統(tǒng)的同步渲染中,React 會給組件樹中的所有元素賦予相同的優(yōu)先級。當渲染組件樹時,無論是在初始渲染還是在狀態(tài)更新時,React 都會繼續(xù)并在單個不間斷任務中渲染該樹,然后將其提交給 DOM 以直觀地更新屏幕上的組件。
圖片
同步渲染是一種“全有或全無”的操作,它保證開始渲染的組件總是會完成。根據(jù)組件的復雜性,渲染階段可能需要一段時間才能完成。主線程在此期間被阻塞,這意味著嘗試與應用交互的用戶會遇到無響應的 UI,直到 React 完成渲染并將結果提交到 DOM。
下面來看一個例子,有一個文本輸入字段和一個很長的城市列表,根據(jù)文本輸入的當前值進行過濾。在同步渲染中,React 將在每次按鍵時重新渲染CitiesList組件。這是一個相當昂貴的計算,因為該列表由數(shù)萬個城市組成,所以在按鍵和在文本輸入中看到反映的之間存在明顯的視覺反饋延遲。
// APP.js
import React, { useState } from "react";
import CityList from "./CityList";
export default function SearchCities() {
const [text, setText] = useState("Am");
return (
<main>
<h1>Traditional Rendering</h1>
<input type="text" onChange={(e) => setText(e.target.value) } />
<CityList searchQuery={text} />
</main>
);
};
// CityList.js
import cities from "cities-list";
import React, { useEffect, useState } from "react";
const citiesList = Object.keys(cities);
const CityList = React.memo(({ searchQuery }) => {
const [filteredCities, setCities] = useState([]);
useEffect(() => {
if (!searchQuery) return;
setCities(() =>
citiesList.filter((x) =>
x.toLowerCase().startsWith(searchQuery.toLowerCase())
)
);
}, [searchQuery]);
return (
<ul>
{filteredCities.map((city) => (
<li key={city}>
{city}
</li>
))}
</ul>
)
});
export default CityList;
// index.js
import { StrictMode } from "react";
import ReactDOM from "react-dom";
import App from "./App";
import "./styles.css";
const rootElement = document.getElementById("root");
ReactDOM.render(<StrictMode><App /></StrictMode>, rootElement);
效果如下:
圖片
在線體驗:https://codesandbox.io/s/w4zcct
注意:如果使用的是高端設備,比如 Macbook,可以將 CPU 限制為慢 4 倍的速度,以模擬低端設備。可以在 Devtools > Performance > ?? > CPU中找到此設置,如圖所示:
圖片
當查看 Performance 選項卡時,可以看到每次點擊都會發(fā)生很長的任務:
圖片
標有紅角的任務被視為“長任務”,注意總阻塞時間為 4425.40ms。
在這種情況下,React 開發(fā)者通常會使用像"debounce"這樣的方法來延遲渲染,但沒有內(nèi)置的解決方案。
React 18 引入了一個在幕后運行的新的并發(fā)渲染器。該渲染器提供了一些將某些渲染標記為非緊急的方法。
當渲染低優(yōu)先級組件(粉色)時,React 返回主線程以檢查更重要的任務
在這種情況下,React 將每 5 毫秒返回主線程,看看是否有更重要的任務需要處理,例如用戶輸入,甚至渲染另一個對當時的用戶體驗更重要的 React 組件。通過不斷返回主線程,React 能夠使此類渲染成為非阻塞并優(yōu)先處理更重要的任務。
并發(fā)渲染器不是為每個渲染執(zhí)行一個不可中斷的任務,而是在低優(yōu)先級組件的(重新)渲染期間以 5 毫秒的間隔將控制權交還給主線程。
此外,并發(fā)渲染器能夠在后臺“同時”渲染組件樹的多個版本,而無需立即提交結果。
同步渲染是一種全有或全無的計算,而并發(fā)渲染器允許 React 暫停和恢復一個或多個組件樹的渲染,以實現(xiàn)最佳的用戶體驗。
React 根據(jù)用戶交互暫停當前渲染,迫使其優(yōu)先渲染另一個更新
使用并發(fā)功能,React 可以根據(jù)外部事件(例如用戶交互)暫停和恢復組件的渲染。當用戶開始與 ComponentTwo 交互時,React 暫停當前的渲染,優(yōu)先渲染ComponentTwo,然后恢復渲染 ComponentOne。
Transitions
可以使用useTransition鉤子提供的startTransition函數(shù)將更新標記為非緊急。這是一個強大的新功能,允許將某些狀態(tài)更新標記為“過渡”,表示它們可能導致視覺變化,如果同步渲染可能會對用戶體驗造成干擾。
通過將狀態(tài)更新包裝在startTransition中,就告訴 React 可以延遲或中斷渲染,以優(yōu)先處理更重要的任務,以保持當前的用戶界面具有交互性。
import { useTransition } from "react";
function Button() {
const [isPending, startTransition] = useTransition();
return (
<button
onClick={() => {
urgentUpdate();
startTransition(() => {
nonUrgentUpdate()
})
}}
>...</button>
)
}
當過渡開始時,并發(fā)渲染器在后臺準備新樹。一旦完成渲染,它會將結果保留在內(nèi)存中,直到 React 調(diào)度程序可以高效地更新 DOM 以反映新狀態(tài)。這一刻可能是當瀏覽器空閑并且更高優(yōu)先級的任務(例如用戶交互)沒有待處理時。
對于上面的 CitiesList 例子來說,使用過渡效果將是完美的。可以將每個按鍵上直接調(diào)用setCities 的操作改為在startTransition中進行包裝。這告訴 React 狀態(tài)更新可能會導致對用戶造成干擾的視覺變化,因此 React 應該嘗試在后臺準備新的狀態(tài)時保持當前界面的交互性,而不立即提交更新。
// CitiesList.js
import cities from "cities-list";
import React, { useEffect, useState, useTransition } from "react";
const citiesList = Object.keys(cities);
const CityList = React.memo(({ searchQuery }) => {
const [filteredCities, setCities] = useState([]);
const [isPending, startTransition] = useTransition();
useEffect(() => {
if (!searchQuery) return;
startTransition(() => {
setCities(() =>
citiesList.filter((x) =>
x.toLowerCase().startsWith(searchQuery.toLowerCase())
)
);
});
}, [searchQuery]);
return (
<ul>
{filteredCities.map((city) => (
<li key={city} style={isPending ? { opacity: 0.2 } : null}>
{city}
</li>
))}
</ul>
)
});
export default CityList;
在線體驗:https://codesandbox.io/s/rgqrky
現(xiàn)在在輸入框中輸入時,用戶輸入保持平滑,沒有按鍵之間的視覺延遲。這是因為文本狀態(tài)仍然同步更新,輸入框使用它作為其值。然而,CitiesList組件將其狀態(tài)更新包裝在startTransition中。
在后臺,React 開始在每次擊鍵時渲染新樹。但這并不是一個全有或全無的同步任務,React 開始在內(nèi)存中準備組件樹的新版本,同時當前 UI(顯示“舊”狀態(tài))仍然響應進一步的用戶輸入。
查看 Performance 選項卡,相較于未使用過渡的實現(xiàn)的性能圖表,將狀態(tài)更新包裝在startTransition中顯著降低了長任務的數(shù)量和總阻塞時間。
過渡是React渲染模型中的一個重要變革,使React能夠同時渲染多個版本的UI,并管理不同任務之間的優(yōu)先級。這可以提供更流暢、響應更靈敏的用戶體驗,特別是在處理高頻更新或 CPU 密集型渲染任務時。
React Server Components
React Server Components 是 React 18 中的一項實驗性功能,但已準備好供框架采用。
傳統(tǒng)上,React 提供了幾種主要的方式來渲染應用。可以完全在客戶端上渲染所有內(nèi)容(客戶端渲染),也可以在服務端將組件樹渲染為 HTML,并將此靜態(tài) HTML 與 JavaScript 包一起發(fā)送到客戶端,然后在客戶端進行組件的水合(服務端渲染)。
這兩種方法都依賴于一個事實:同步 React 渲染器需要使用附帶的 JavaScript 包在客戶端重建組件樹,即使該組件樹已經(jīng)在服務端可用。
React Server Components 允許 React 將實際的序列化組件樹發(fā)送到客戶端。客戶端 React 渲染器理解這種格式,并使用它來高效地重建 React 組件樹,而無需發(fā)送 HTML 文件或 JavaScript 包。
可以通過結合react-dom/server的renderToPipeableStream方法和react-dom/client的createRoot方法來使用這種新的渲染模式。
// server/index.js
import App from '../src/App.js'
app.get('/rsc', async function(req, res) {
const {pipe} = renderToPipeableStream(React.createElement(App));
return pipe(res);
});
// src/index.js
import { createRoot } from 'react-dom/client';
import { createFromFetch } from 'react-server-dom-webpack/client';
export function Index() {
...
return createFromFetch(fetch('/rsc'));
}
const root = createRoot(document.getElementById('root'));
root.render(<Index />);
在線體驗:https://codesandbox.io/p/sandbox/cocky-minsky-m7sgfx
默認情況下,React 不會對 React Server Components 進行水合。這些組件不應該使用任何客戶端交互,例如訪問window對象或使用像useState或useEffect這樣的hook。
要將組件及其導入添加到 JavaScript 包中,并發(fā)送到客戶端使其具備交互功能,可以在文件頂部使用 use client 指令。這告訴打包工具將該組件及其導入添加到客戶端包,并告知 React 在客戶端進行水合以添加交互性,這種組件稱為客戶端組件。
圖片
注意:框架實現(xiàn)可能有所不同。例如,Next.js 將在服務端將客戶端組件預渲染為 HTML,類似于傳統(tǒng)的 SSR 方法。然而,默認情況下,客戶端組件的渲染方式與 CSR 方法類似。
在使用客戶端組件時,開發(fā)人員需要優(yōu)化構建包的大小。可以使用以下方式:
- 確保只有交互組件的最末端節(jié)點定義了 use client指令,這可能需要對組件進行解耦。
- 將組件樹作為 props 傳遞,而不是直接導入它們。這允許 React 將 children 渲染為 React 服務端組件,而無需將它們添加到客戶端包中。
Suspense
另一個重要的新并發(fā)功能就是Suspense。盡管這并不完全是新的,因為Suspense是在React 16 中發(fā)布的,用于與React.lazy進行代碼拆分,但 React 18 引入的新功能將Suspense 擴展到了數(shù)據(jù)獲取。
使用Suspense 可以延遲組件的渲染,直到滿足某些條件,例如從遠程源加載數(shù)據(jù)。同時,我們可以渲染一個回退組件,指示該組件仍在加載。
通過聲明性地定義加載狀態(tài),減少了任何條件渲染邏輯的需求。將Suspense與React Server Components 結合使用,可以直接訪問服務端的數(shù)據(jù)源,無需額外的API端點,如數(shù)據(jù)庫或文件系統(tǒng)。
async function BlogPosts() {
const posts = await db.posts.findAll();
return '...';
}
export default function Page() {
return (
<Suspense fallback={<Skeleton />}>
<BlogPosts />
</Suspense>
)
}
使用 React Server Components 與 Suspense 無縫協(xié)作,這允許在組件仍在加載時定義加載狀態(tài)。
Suspense 的真正力量來自于它與 React 的并發(fā)功能的深度集成。當組件被掛起時,例如因為它仍在等待數(shù)據(jù)加載,React 不會只是閑置直到組件收到數(shù)據(jù)。相反,它會暫停暫停組件的渲染并將其焦點轉(zhuǎn)移到其他任務。
圖片
在此期間,會告訴 React 渲染一個后備 UI 以指示該組件仍在加載。一旦等待的數(shù)據(jù)可用,React 就可以以可中斷的方式無縫地恢復先前掛起的組件的渲染,就像上面看到的過渡一樣。
React 還可以根據(jù)用戶交互重新調(diào)整組件的優(yōu)先級。例如,當用戶與當前未渲染的掛起組件進行交互時,React 會掛起正在進行的渲染并優(yōu)先考慮用戶正在與之交互的組件。
圖片
一旦準備就緒,React 會將其提交到 DOM,并恢復之前的渲染。這確保了用戶交互的優(yōu)先級,并且 UI 保持響應并根據(jù)用戶輸入保持最新狀態(tài)。
Suspense與React Server Component的可流化格式相結合,允許高優(yōu)先級更新在準備好后立即發(fā)送到客戶端,而無需等待低優(yōu)先級渲染任務完成。這使客戶端能夠更快地開始處理數(shù)據(jù),并通過在內(nèi)容以非阻塞方式到達時逐漸顯示內(nèi)容來提供更流暢的用戶體驗。
這種可中斷的渲染機制與Suspense處理異步操作的能力相結合,提供了更流暢、更以用戶為中心的體驗,特別是在具有大量數(shù)據(jù)獲取需求的復雜應用中。
數(shù)據(jù)獲取
除了渲染更新之外,React 18 還引入了一個新的 API 來有效地獲取數(shù)據(jù)并記住結果。
React 18 有一個 cache 函數(shù),可以記住包裝函數(shù)調(diào)用的結果。如果在同一個渲染過程中使用相同的參數(shù)調(diào)用相同的函數(shù),它將使用記憶的值,而無需再次執(zhí)行該函數(shù)。
import { cache } from 'react'
export const getUser = cache(async (id) => {
const user = await db.user.findUnique({ id })
return user;
})
getUser(1)
getUser(1) // 在同一渲染過程中調(diào)用:返回已存儲的結果。
在fetch調(diào)用中,React 18 現(xiàn)在默認包含類似的緩存機制,而無需使用cache。這有助于減少單個渲染過程中的網(wǎng)絡請求數(shù)量,從而提高應用性能并降低 API 成本。
export const fetchPost = (id) => {
const res = await fetch(`https://.../posts/${id}`);
const data = await res.json();
return { post: data.post }
}
fetchPost(1)
fetchPost(1) // 在同一渲染過程中調(diào)用:返回已存儲的結果。
這些功能在使用 React 服務端組件時非常有用,因為它們無法訪問 Context API。緩存和 fetch 的自動緩存行為允許從全局模塊導出單個函數(shù)并在整個應用中重用它。
圖片
async function fetchBlogPost(id) {
const res = await fetch(`/api/posts/${id}`);
return res.json();
}
async function BlogPostLayout() {
const post = await fetchBlogPost('123');
return '...'
}
async function BlogPostContent() {
const post = await fetchBlogPost('123'); // 返回緩存值
return '...'
}
export default function Page() {
return (
<BlogPostLayout>
<BlogPostContent />
</BlogPostLayout>
)
}
總結
React 18 中的新功能在很多方面提高了應用的性能:
- 并發(fā)模式:渲染過程可以暫停并稍后恢復,甚至放棄。這意味著即使正在進行大型渲染任務,UI 也可以立即響應用戶輸入。
- Transitions API: 允許在數(shù)據(jù)獲取或屏幕更改期間實現(xiàn)更平滑的過渡,而不會阻止用戶輸入。
- React Server Components: 支持構建可在服務器和客戶端上運行的組件,將客戶端應用的交互性與傳統(tǒng)服務端渲染的性能相結合,而無需水合成本。
- 擴展 Suspense 功能:允許應用的某些部分先于其他可能需要更長時間獲取數(shù)據(jù)的部分進行渲染,從而提高了加載性能。
參考:https://vercel.com/blog/how-react-18-improves-application-performance