hello 大家好,我是 superZidan,這篇文章想跟大家聊聊 React 18 如何提升應用性能
這個話題
React 18 引入了并發功能,從根本上改變了 React 應用程序的渲染方式。 我們將探討這些最新功能如何影響和提高應用程序的性能
首先,讓我們退一步來了解長任務的基礎知識和相應的性能測量
主線程和長任務
當我們在瀏覽器運行 JavaScript 時,JavaScript 引擎會在一個單進程環境中運行代碼,而這個進程一般被稱之為主進程。主線程除了負責運行代碼還要處理其他的任務,比如處理用戶操作(鼠標點擊和鍵盤輸入),處理網絡事件,定時器管理,更新動畫,管理瀏覽器的回流和重繪
當一個任務正在處理的時候,其他任務就必須等待。所以如果遇到短任務,瀏覽器可以平滑地處理并且提供絲滑的用戶體驗;如果遇到長任務,瀏覽器會在執行的過程中卡住其他的任務,導致用戶體驗不佳。
任何運行時間超過 50 毫秒的任務會被任務是「長任務」
「50 毫秒基準」是由于:終端設備必須每 16 毫秒 (60 fps) 創建一個新幀才能保持流暢的視覺體驗。然而,設備還必須執行其他任務,例如響應用戶輸入和執行 JavaScript。
「50毫秒 基準」測試允許設備將資源分配給渲染幀和執行其他任務,并為設備提供約 33.33毫秒 的額外時間來執行其他任務,同時保持流暢的視覺體驗。你可以閱讀這篇文章來了解更多關于「50毫秒基準」的相關內容
為了保證用戶體驗,就必須減少長任務的數量。為了衡量網站的性能,有兩個指標可以衡量長任務對應用程序性能的影響:總阻塞時間(TBT) 和 下次渲染所需等待時間 INP(Interaction to Next Paint)
TBT 是一個重要的指標來衡量 FCP 和 TTI 之間的時間。TBT 是執行時間超過 50 毫秒的任務耗時的總和,這會對用戶體驗產生重大影響
INP 測量網頁響應用戶交互所花費的時間,從用戶開始交互(比如點擊了頁面的按鈕)到在屏幕上繪制下一幀的那一刻。這個指標對于具有很多用戶交互的站點來說十分重要,比如電商網站和社交媒體平臺。它是通過累積用戶當前訪問期間的所有 INP 測量值并返回最差分數來衡量的。
要了解 React18 如何針對這些測量指標進行優化從而改善用戶體驗,要先了解一下傳統 React 的工作原理
傳統的 React 渲染
一個視覺的更新在 react 中會被分成兩個階段:渲染階段(Render Phase和 提交階段(Commit Phase)。React 渲染階段是一個純粹的計算過程,React 元素會跟已有的 DOM 進行協調(對比)。在這個階段中會涉及到創建一個新的 React 組件樹,就是我們經常聽到的 “虛擬 DOM”,它是一個輕量級的內存對象,用來表示真實的 DOM。
在渲染階段,React 計算當前的 DOM 和新的 React 組件樹的差異,并且準備必要的更新。
在渲染階段之后就是提交階段。在這個階段中,React 會把渲染階段計算出來的更新應用到真實的 DOM 中。這個階段包含了創建,更新和刪除 DOM 節點,以此來跟新的 React 組件樹保持鏡像同步。
在傳統的同步更新中,React 會賦予組件樹中所有的元素一個相同的優先級。當組件樹被渲染,不管是初始化渲染還是狀態更新,React 都會一股腦的運行,在一個不能被打斷的任務中渲染這棵樹,直到 commit 階段完成,組件樹的修改都被更新到可視的 DOM 樹上為止。
同步渲染是一種 “全有或全無” 的操作,它保證開始渲染的組件總是會完成。根據組件的復雜性,渲染階段可能需要一段時間才能完成。主線程在這段時間內會被阻塞,這意味著如果用戶在這段時間跟應用程序進行交互,那么用戶需要等到 React 完成整個渲染階段和提交階段,真實 DOM 更新完成,否則就得不到響應。
你可以在下面的例子中看到這種情況的發生。 我們有一個文本輸入框和一個很大的城市列表,它們根據文本輸入的當前值進行過濾。在同步渲染中,React 將在每次輸入時重新渲染 CitiesList
組件。這是一個相當耗費性能的計算,因為該列表包含數以萬計的城市,因此在用戶輸入和展示過濾列表之間存在明顯的視覺反饋延遲,也就是卡頓現象。
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);
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;
style.css
* { font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;}
:root { --foreground-rgb: 0, 0, 0; --background-rgb: 244, 244, 245; --border-rgb: 228, 228, 231;}
@media (prefers-color-scheme: dark) { :root { --foreground-rgb: 255, 255, 255; --background-rgb: 0, 0, 0; --border-rgb: 39, 39, 42; --input-background-rgb: 28, 28, 28; }}
body { color: rgb(var(--foreground-rgb)); background: rgb(var(--background-rgb));}
h1 { margin-bottom: 2em; font-size: 1.5em;}
input { border: 1px solid rgb(var(--border-rgb)); border-radius: 3px; padding: 1em 2em; font-size: 1.1em; background-color: rgb(var(--input-background-rgb)); color: rgb(var(--foreground-rgb)); outline: none; min-width: 70vw;}
code { font-family: Menlo; font-size: 90%; background: rgb(var(--border-rgb)); padding: 0.3em 0.5em; border-radius: 3px;}
main { padding: 1em 3em; display: flex; flex-direction: column; align-items: center;}
ul { overflow: scroll; padding: 0; min-width: 70vw;}
li { list-style-type: none; padding: 1em; border-bottom: 1px solid rgb(var(--border-rgb));}
如果你使用的是類似 Macbook 這樣的高性能設備,可能需要限制 CPU 4x 來模擬低端設備。可以在
Devtools > Performance > ?? > CPU
中看到此設置。
當我們查看性能選項卡時,可以看到每次輸入都會發生很長的任務,這是不太好的
標有紅角的任務被視為“長任務”。 請注意總阻塞時間( TBT ) 為 4425.40ms
在這種情況下,React 開發人員經常會使用 debounce
等第三方庫來延遲渲染,但沒有內置的解決方案
React 18 引入了一個在幕后運行的新并發渲染器。 該渲染器為我們提供了一些將某些渲染標記為非緊急的方法。
在這個例子中, React 會每隔 5 毫秒就回到主線程檢查一下是否有更重要的任務需要優先執行。比如用戶的輸入或者渲染在這一時刻對于用戶體驗來說更重要的 React 組件。通過不斷的回到主進程,React 做到了可以「非阻塞」渲染,優先執行更加重要的任務。
此外,并發渲染器能夠在后臺“同時”渲染組件樹的多個版本,而無需立即提交結果。
同步渲染是一種 “全有或全無” 的計算,而并發渲染器允許 React 暫停和恢復一個或多個組件樹的渲染,以實現最佳的用戶體驗。
使用并發功能,React 可以根據外部事件比如用戶交互,來暫停或者恢復組件的渲染。當用戶在與 componentTwo
進行交互的時候,React 可以暫停當前的渲染,提升componentTwo
的優先級并渲染 componentTwo
,渲染結束后再恢復渲染 componentOne
,我們還會在 Suspense 這一章節再討論這個特性。
Transitions
我們可以通過 useTransition
這個 hook 來獲得 startTransition
這個函數,將某些更新標記為「不緊急」。
這是一個強大的新功能,允許我們將某些狀態更新標記為 “transitions”,表明它們可能會導致視覺變化,如果同步渲染,可能會影響用戶體驗。
通過把一個 state 的更新包裹在 startTransition
函數里面,我們可以告訴 React 我們可以推遲或中斷渲染,以優先處理更重要的任務,以保持當前用戶界面的可交互性。
import { useTransition } from "react";
function Button() {
const [isPending, startTransition] = useTransition();
return (
<button
onClick={() => {
urgentUpdate();
// 這里
startTransition(() => {
nonUrgentUpdate()
})
}}
>...</button>
)
}
當這個 transition 開始執行,并發渲染器會開始在后臺準備一顆新的樹。一旦完成渲染,它將把結果保存在內存中,直到 React 調度器可以高效地更新 DOM 以顯示新狀態。這個時間點可能是當瀏覽器空閑并且沒有待處理的更高優先級的任務(例如用戶交互)。
在 CitiesList
這個例子中,使用 transition 是更好的選擇。而不是在每次擊鍵時直接調用 setCities - 這反過來會導致每次擊鍵時同步渲染調用 - 我們可以將狀態更新包裹在 startTransition 中。這告訴 React 狀態更新可能會導致視覺變化,從而對用戶造成干擾,因此 React 應嘗試保持當前 UI 交互,同時在后臺準備新狀態,而不立即提交更新。
index.js
import { StrictMode } from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
import "./styles.css";
const rootElement = document.getElementById("root");
const root = ReactDOM.createRoot(rootElement);
root.render(<StrictMode><App /></StrictMode>);
App.js
import React, { useState } from "react";
import CityList from "./CityList";
export default function SearchCities() {
const [text, setText] = useState("Am");
return (
<main>
<h1><code>startTransition</code></h1>
<input type="text" onChange={(e) => setText(e.target.value) } />
<CityList searchQuery={text} />
</main>
);
};
CityList
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;
style.css
* { font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale;}
:root { --foreground-rgb: 0, 0, 0; --background-rgb: 244, 244, 245; --border-rgb: 228, 228, 231;}
@media (prefers-color-scheme: dark) { :root { --foreground-rgb: 255, 255, 255; --background-rgb: 0, 0, 0; --border-rgb: 39, 39, 42; --input-background-rgb: 28, 28, 28; }}
body { color: rgb(var(--foreground-rgb)); background: rgb(var(--background-rgb));}
h1 { margin-bottom: 2em; font-size: 1.5em;}
input { border: 1px solid rgb(var(--border-rgb)); border-radius: 3px; padding: 1em 2em; font-size: 1.1em; background-color: rgb(var(--input-background-rgb)); color: rgb(var(--foreground-rgb)); outline: none; min-width: 70vw;}
code { font-family: Menlo; font-size: 90%; background: rgb(var(--border-rgb)); padding: 0.3em 0.5em; border-radius: 3px;}
main { padding: 1em 3em; display: flex; flex-direction: column; align-items: center;}
ul { overflow: scroll; padding: 0; min-width: 70vw;}
li { list-style-type: none; padding: 1em; border-bottom: 1px solid rgb(var(--border-rgb));}
現在,當我們在輸入字段中輸入內容時,用戶輸入保持流暢,輸入之間沒有任何視覺延遲。發生這種情況是因為 text
狀態仍然同步更新,輸入框將這個 text 作為它的 value
。
在后臺,在每次用戶輸入的時候 React 都會渲染新的樹。但這并不是一個“全有或全無”的同步任務,React 開始在內存中準備新版本的組件樹,同時當前 UI(顯示“舊”的狀態)仍然能夠響應用戶的后續輸入。
查看性能選項卡,與不使用 transitions 的實現的性能圖進行相比,將狀態變更包裹在 startTransition
中明顯減少了長任務的數量和總阻塞時間(TBT)
Transitions 是 React 渲染模型根本性轉變的一部分,它允許 React 并發渲染多個版本的 UI ,同時在不同的任務中管理不同的優先級。它使得應用在處理高頻率更新或者 CPU 密集型渲染時能過保持用戶體驗更順滑且界面更快響應
React Server Components
React Server Components 是 React 18 中的一項實驗性功能,但已準備好提供給到框架使用。在我們深入研究 Next.js 之前了解這一點很重要
傳統上,React 提供了幾種方式來渲染我們的應用程序。要么我們把所有的渲染工作都放在客戶端(CSR),要么我們將整個組件樹在服務端渲染成靜態 HTML 并包含一個 JavaScript 包一起返回給客戶端,在客戶端對組件進行注水(SSR)
這兩種方法都需要「同步 React 渲染器」使用附帶的 JavaScript 包在客戶端重建組件樹,即使該組件樹已經在服務端上是可用的了。
我們可以結合 react-server-dom-webpack/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 />);
點擊這里查看完整例子,在下一章中我們將覆蓋更復雜的場景
默認情況下,React 不會給 React Server Components 進行注水操作(hydrate)。 這些組件不應該與客戶端有任何交互(例如訪問 window 對象)或使用 useState
或 useEffect
等 hook。
要將組件及其依賴添加至發送到客戶端的 JavaScript 包中,從而使組件具有交互性,那么你可以使用文件頂部的 “use client” 構建器指令。它告訴構建器在打客戶端的包的時候添加這個組件及其依賴 并且告訴 React 在客戶端渲染的時候給這個組件注水,以讓這個組件具備可交互能力。此類組件稱為「客戶端組件」
在使用「客戶端組件」時,開發人員需要優化構建包的大小。 開發人員可以通過以下方式做到這一點:
- 確保只有交互組件的最末端葉子節點(leaf-most node)定義了“use client”指令。 這可能需要一些組件解耦。
- 使用 props 的方式傳入組件而不是直接 import 組件。這允許 React 將
children
渲染為 React Server Component,而無需將它們添加到客戶端的包中。
suspense
另一個重要的并非特性是 suspense。雖然這個特性不是很新,它在 React 16 的 React.lazy
中就已經被應用于代碼分割功能了。React 18 通過擴展了suspense
的新能力,用于數據獲取方面。
使用 suspense
,我們可以延遲組件的渲染,直到滿足某些特定條件,例如從遠程數據源完成數據的加載。比如在加載數據的期間,我們可以渲染一個兜底的組件,以顯示該組件仍在加載。通過聲明性地定義加載狀態,我們減少了條件渲染邏輯。將 Suspense
與 React Server Components 結合使用,讓我們可以直接訪問服務器端數據源,而不需要單獨的 API 接口(例如,讓接口讀取數據庫或文件系統的數據并返回給客戶端)
async function BlogPosts() {
const posts = await db.posts.findAll();
return '...';
}
export default function Page() {
return (
<Suspense fallback={<Skeleton />}>
<BlogPosts />
</Suspense>
)
}
Suspense
的真正強大的地方來自于它與 React 「并發特性」的深度集成。 當組件被掛起時,例如因為它仍在等待數據加載,React 不會只是阻塞或閑置直到組件收到數據。 相反,React 會暫停組件的渲染并將其焦點轉移到其他任務上。
在此期間,我們可以告訴 React 渲染一個兜底的 UI 以顯示該組件仍在加載。一旦等待的數據可用(加載完成),React 就無縫地恢復先前掛起的組件的渲染,而且這個渲染也是可中斷的,就像我們之前看到的 transitions 一樣。
React 還可以根據用戶的交互重新調整組件的優先級。 例如,當用戶與當前未渲染的被掛起組件進行交互時,React 會掛起正在進行的渲染,并且優先考慮正在與用戶交互的組件。
一旦準備就緒,React 會將其 commit 到 DOM,并恢復之前的渲染。 這確保了用戶交互的優先級,并且保持 UI 可響應,同時也能根據用戶輸入保持最新狀態。
Suspense
與 React Server Component 的流式結合,允許 React 的高優先級更新在準備好后立即發送到客戶端,而無需等待低優先級渲染任務完成。 這使客戶端能夠更快地開始處理數據,并通過漸進且非阻塞的方式顯示內容,來提供更流暢的用戶體驗。
這種可中斷的渲染機制與 Suspense
處理異步操作的能力相結合,提供了更流暢、更以用戶為中心的體驗,特別是在具有大量數據獲取需求的復雜應用程序中,這種效果會更加明顯。
數據獲取
除了渲染更新之外,React 18 還引入了一個新的 API 來有效地獲取數據并緩存住對應的結果。
React 18 項目的 rfcs 有提到一個 緩存函數,可以記住包裹函數調用的結果。 如果在同一次渲染中使用相同的參數調用相同的函數,它將使用緩存的值,而無需再次執行該函數。( 類似于 useMemo )
import { cache } from 'react'
export const getUser = cache(async (id) => {
const user = await db.user.findUnique({ id })
return user;
})
getUser(1)
getUser(1) // 返回緩存的值
在 fetch 調用中,React 18 現在默認包含類似的緩存機制,而無需使用緩存函數。 這有助于減少單個渲染過程中的網絡請求數量,從而提高應用程序性能并降低 API 成本。
特別聲明:截至 2023 年 7 月,本人沒有找到 React 18 對 fetch 的緩存功能,但我們可以借鑒這種思路,使用第三方類似 react-query 這樣的庫對請求的內容進行緩存,效果是一樣的。
export const fetchPost = (id) => {
const res = await fetch(`https://.../posts/${id}`);
const data = await res.json();
return { post: data.post }
}
fetchPost(1)
fetchPost(1) // 返回緩存的值
這些功能在使用 React Server Component 時非常有用。因為它們無法訪問 Context API,所以自動緩存行為允許開發者從全局模塊導出一個用于請求的函數并在整個應用程序中復用它。
// 導出一個用于請求的函數 fetchBlogPost
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 的最新功能在很多方面提升了性能。
- 使用 Concurrent React,渲染過程可以暫停并在稍后恢復,甚至放棄。 這意味著即使正在進行大型渲染任務,UI 也可以立即響應用戶的輸入
- Transitions API 允許在數據獲取或界面更新期間實現更平滑的轉換,而不會阻止用戶輸入
- React Server Components 允許開發人員構建可在服務器和客戶端上運行的組件,將客戶端應用程序的交互性與傳統服務器渲染的性能相結合,而減少了注水(hydration)的成本。
- 擴展的 Suspense 功能允許應用程序的某些部分優先于其他可能需要更長時間獲取數據的部分進行渲染,從而提高了加載性能
本文是翻譯文,原文地址