banner
Koresamuel

Koresamuel

Sometimes ever, sometimes never.
github
twitter
email

React18是如何提升應用性能的

React 18 引入了並發特性,從根本上改變了渲染 React 應用程序的方式。我們將探討這些最新特性如何影響並提升應用性能。

首先,讓我們稍微了解一下長任務以及相應的性能測量基礎知識。

主線程與長任務#

當我們在瀏覽器中運行 JavaScript 時,JavaScript 引擎在單線程環境中執行代碼,該環境通常稱為主線程。除了執行 JavaScript 代碼外,主線程還負責處理其他任務,包括管理用戶交互之類的諸如點擊和鍵盤事件,處理網絡事件,計時器,更新動畫以及管理瀏覽器的重繪和重排。

主線程負責一一處理任務

當一個任務正在處理時,所有其他任務都必須等待。雖然瀏覽器可以順利地執行小任務以提供無縫的用戶體驗,但長時間的任務可能會造成問題,因為它們可能會阻塞其他任務的處理。

任何運行時間超過 50 毫秒的任務都被視為 “長任務”。

image

這個 50 毫秒的基準是基於這樣一個事實:設備必須每 16 毫秒(60fps)創建一個新的幀,以保持平滑的視覺體驗。然而,設備還必須執行其他任務,比如響應用戶輸入和執行 JavaScript 代碼。

這個 50 毫秒的基準允許設備同時為渲染幀和執行其他任務分配資源,並為設備提供額外的大約 33.33 毫秒來執行其他任務,同時保持平滑的視覺體驗。您可以在這篇涵蓋 RAIL 模型的博文中詳細了解 50 毫秒的基準。

為了保持最佳性能,重要的是最大程度地減少長任務的數量。為了衡量網站的性能,有兩個指標衡量長任務對應用程序性能的影響:TBT (Total Blocking Time) 和 INP (Interaction to Next Paint)。

TBT(Total Blocking Time)#

總阻塞時間(TBT)是一個重要的度量標準,用於衡量首次內容渲染(FCP)可交互時間(Time to Interactive,TTI)之間的時間。TBT 是超過 50 毫秒的任務執行所花費的時間的總和,這可能會對用戶體驗產生重大影響。

TBT 為 45 毫秒,因為我們有兩個任務在 TTI 之前花費的時間超過了 50 毫秒的閾值,分別超過了 50 毫秒的閾值 30 毫秒和 15 毫秒。總阻塞時間是這些值的累積:30 毫秒 + 15 毫秒 = 45 毫秒。

INP(Interaction to Next Paint)#

可交互到下一次繪製(INP)是一個新的核心網絡性能指標,它衡量了用戶首次與頁面進行交互(例如點擊按鈕)到該交互在屏幕上可見的時間;即下一次繪製。這個指標對於具有許多用戶交互的頁面,比如電子商務網站或社交媒體平台,尤其重要。它通過累積用戶在當前訪問期間的所有 INP 測量值,並返回最差得分來進行衡量。

可交互到下一次繪製為 250 毫秒,這是測量到的最高可見延遲。

為了理解 React18 如何針對這些測量值進行優化,從而改善了用戶體驗,重要的是要了解傳統 React 的工作原理。

過去的 React 渲染機制#

在 React 中,視覺更新分為兩個階段:渲染 (render) 階段和 提交 (commit) 階段。React 中的渲染階段是一個純計算階段,在這個階段,React 元素與現有的 DOM 進行對比。這個階段涉及創建一個新的 React 樹,也被稱為 “虛擬 DOM”,它實質上是實際 DOM 的輕量內存表示。

在渲染階段,React 計算當前 DOM 和新的 React 組件樹之間的差異,並準備必要的更新。

image

緊隨渲染階段之後是提交階段。在這個階段,React 將在渲染階段計算得出的更新應用到實際的 DOM 上。這涉及創建、更新和刪除 DOM 節點,以映射新的 React 組件樹。

在傳統的同步渲染中,React 會給組件樹中的所有元素相同的優先級。當組件樹被渲染時,無論是在初始渲染還是在狀態更新時,React 都會繼續渲染整個樹形結構,形成一個單一的不可中斷的任務,之後將其提交到 DOM 中,以在屏幕上視覺更新組件。

image

同步渲染是一個 “全有或全無” 的操作,可以保證開始渲染的組件會一直完成渲染過程。根據組件的複雜性,渲染階段可能需要一些時間才能完成。在此期間,主線程被阻塞,意味著用戶在嘗試與應用程序交互時會遇到無響應的界面,直到 React 完成渲染並將結果提交到 DOM 中。

可以在以下演示中看到這一現象。我們有一個文本輸入字段和一個大型城市列表,根據文本輸入字段的當前值進行過濾。在同步渲染中,React 會在每次按鍵時重新渲染CitiesList組件。由於列表包含成千上萬的城市,這是一項相當昂貴的計算,因此在按鍵和在文本輸入字段中看到存在明顯的視覺反饋延遲。

如果使用的是像 Macbook 這樣的高端設備,可能需要將 CPU 性能降低 4 倍,以模擬低端設備的情況。可以在開發者工具中找到這個設置,路徑是 Performance > ⚙️ > CPU。

當我們查看 Performance 選項卡時,你會發現每次按鍵都會出現長時間的任務,這是不太理想的情況。

標記為紅色角的任務被認為是 “長任務”。4425.40ms 的總阻塞時間。

在這種情況下,React 開發人員通常會使用第三方庫(例如Debounce)推遲渲染,但是沒有內置的解決方案。

React 18 引入了一種新的並發渲染器,它在幕後運行。這個渲染器提供了一些方法,讓我們可以將某些渲染標記為非緊急的。

在渲染低優先級組件(粉色的)時,React 會讓出主線程,以檢查是否有更重要的任務。

在這種情況下,React 將每 5 毫秒讓出主線程,以查看是否有更重要的任務需要處理,比如用戶輸入,甚至是渲染另一個 React 組件狀態更新,這些在當前情況下對用戶體驗更重要。通過不斷地讓出主線程,React 能夠使這些渲染變得非阻塞,並優先處理更重要的任務。

與每次渲染都執行單一的不可中斷任務不同,這個並發渲染器在渲染低優先級組件時會以 5 毫秒的間隔將控制權讓回主線程。

此外,這個並發渲染器能夠在背景 “並發” 地渲染多個版本的組件樹,而不立即提交結果。

而同步渲染是一個全有或全無的計算過程,這個並發渲染器允許 React 暫停和恢復一個或多個組件樹的渲染,以實現最優化的用戶體驗。

React 根據用戶交互來暫停當前的渲染,這迫使它優先處理另一個更新的渲染。

通過使用並發特性,React 可以根據用戶交互等外部事件暫停和恢復組件的渲染。當用戶開始與ComponentTwo進行交互時,React 會暫停當前的渲染,優先渲染ComponentTwo,然後再恢復渲染ComponentOne

Transitions#

我們可以使用由useTransition鉤子提供的startTransition函數,將更新標記為非緊急狀態。這是一個強大的新功能,允許我們將某些狀態更新標記為 “transitions”,表示它們可能導致視覺變化,如果它們以同步方式渲染可能會干擾用戶體驗。

通過將狀態更新包裝在startTransition中,我們可以告訴 React,我們可以推遲或中斷渲染,以優先處理更重要的任務,保持當前用戶界面的互動性。

import { useTransition } from "react";

function Button() {
  const [isPending, startTransition] = useTransition();

  return (
    <button
      onClick={() => {
        urgentUpdate();
        startTransition(() => {
          nonUrgentUpdate()
        })
      }}
    >...</button>
  )
}

當 transition 開始時,並發渲染器會在背景準備新的樹形結構。一旦渲染完成,它會將結果保留在內存中,直到 React 調度程序能夠高效地更新 DOM 以反映新的狀態。這個時機可能是在瀏覽器處於空閒狀態時,沒有更高優先級的任務(比如用戶交互)正在等待的時候。

image

CitiesList演示中使用 transition 是非常理想的。與其在每次按鍵時直接更新傳遞給searchQuery參數的值(從而在每次按鍵時觸發同步渲染調用),我們可以將狀態拆分為兩個值,並將searchQuery的狀態更新包裝在startTransition中。

這會告訴 React,狀態更新可能會導致對用戶有干擾的視覺變化,因此 React 應該嘗試在背景準備新狀態的同時保持當前的互動式 UI,而不立即提交更新。

現在,當我們在輸入字段中鍵入時,用戶輸入保持流暢,沒有按鍵之間的視覺延遲。這是因為text狀態仍然是同步更新的,輸入字段將其用作value

在背景,React 在每次按鍵時都開始渲染新的樹形結構。但與其成為一個全有或全無的同步任務不同,React 開始在內存中準備新版本的組件樹,同時當前的 UI(顯示 “舊” 狀態)保持對進一步用戶輸入的響應。

在 Performance 選項卡中,將狀態更新包裝在startTransition中顯著減少了長時間任務的數量和總阻塞時間,與沒有使用轉換的性能圖表相比。

Performance 選項卡顯示,長任務的數量和總阻塞時間大大減少了。

Transitions是 React 渲染模型中的一個基本變革,使 React 能夠同時渲染多個版本的 UI,並在不同任務之間管理優先級。這可以實現更平滑、更響應的用戶體驗,尤其是在處理高頻更新或 CPU 密集型渲染任務時。

React 伺服器組件 (RSC)#

React伺服器組件(RSC)是 React 18 中的一個 實驗性 特性,但已經準備好為框架採用。在我們深入研究 Next.js 之前,了解這一點很重要。

傳統上,React 提供了幾種主要的渲染應用程序的方式。我們可以在客戶端完全渲染所有內容(客戶端渲染 CSR),或者我們可以在伺服器上將組件樹渲染為 HTML,並將這個靜態 HTML 與 JavaScript bundle 一起發送到客戶端,以便在客戶端進行組件的整合(伺服器端渲染 SSR)。

image

這兩種方法都依賴於同步的 React 渲染器需要通過使用提供的 JavaScript bundle 在客戶端重新構建組件樹,就算這個組件樹已經在伺服器上可用。

RSC 允許 React 將實際已序列化的組件樹發送到客戶端。客戶端的 React 渲染器理解這個格式,並使用它來高效地重構 React 組件樹,而無需發送 HTML 文件或 JavaScript bundle。

image

我們可以通過將react-server-dom-webpack/serverrenderToPipeableStream方法與react-dom/clientcreateRoot方法相結合,來使用這種新的渲染模式。

// 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 不會對RSC進行補水(hydration)。這些組件不應該使用任何客戶端交互,比如訪問window對象或使用像useStateuseEffect這樣的鉤子。

要將一個組件及其導入添加到 JavaScript Bundle 中,從而使其具有交互性,可以在文件的頂部使用“use client”指令。這會告訴Bundler將這個 組件及其導入 添加到客戶端 Bundle 中,並告訴 React 在客戶端進行補水,以添加交互性。這種組件被稱為Client Components(客戶端組件)。

注意:框架的實現可能會有所不同。例如,Next.js 將在伺服器上將 Client Components 預渲染為 HTML,類似於傳統的 SSR 方法。然而,默認情況下,Client Components 的渲染類似於 CSR 方法。

在使用Client Components時,優化 Bundle 大小取決於開發者。開發者可以通過以下方式實現:

  • 確保只有交互式組件的最底層節點定義了“use client”指令。這可能需要對一些組件進行解耦。
  • 將組件樹作為 props 傳遞,而不是直接導入。這允許 React 將子組件作為 RSC 進行渲染,而無需將它們添加到客戶端 Bundle 中。

Suspense#

另一個重要的新的並發特性是Suspense。雖然它在 React 16 中已經發布,用於React.lazy的代碼拆分,但是 React 18 引入的新功能將Suspense擴展到了數據獲取。

使用Suspense,我們可以推遲組件的渲染,直到滿足某些條件,比如從遠程源加載數據。與此同時,我們可以渲染一個回退組件,表示這個組件仍在加載中。

通過聲明性地定義加載狀態,我們減少了對條件渲染邏輯的需求。使用Suspense結合RSC,我們可以直接訪問伺服器端的數據源,而不需要單獨的 API 端點,比如數據庫或文件系統。

async function BlogPosts() {
  const posts = await db.posts.findAll();
  return '...';
}

export default function Page() {
  return (
    <Suspense fallback={<Skeleton />}>
      <BlogPosts />
    </Suspense>
  )
}

Suspense的真正力量來自它與 React 的並發特性的深度集成。例如,當一個組件 suspended,仍在等待數據加載時,React 不會等著組件收到數據閒置不動。相反,它會暫停 suspended 的組件的渲染,並將注意力轉移到其他任務上。

image

在這段時間裡,我們可以告訴 React 渲染一個回退 UI,以指示這個組件仍在加載中。一旦等待的數據可用,React 可以通過中斷的方式無縫地恢復先前被暫停的組件的渲染,就像我們之前在 transitions 中看到的一樣。

React 還可以根據用戶交互重新安排組件的優先級。例如,當用戶與一個當前沒有在渲染的 suspended 組件進行交互時,React 會暫停正在進行的渲染,並優先考慮用戶正在交互的組件。

image

一旦準備就緒,React 將其提交到 DOM,並恢復之前的渲染。這確保了用戶交互的優先級,並且 UI 保持響應性,與用戶輸入保持最新。

SuspenseRSC的可流式格式的結合,允許高優先級的更新在就緒後立即發送到客戶端,無需等待低優先級的渲染任務完成。這使得客戶端可以更早地開始處理數據,並逐漸以非阻塞的方式展示內容,為用戶提供更流暢的體驗。

這種可中斷的渲染機制與Suspense處理異步操作的能力相結合,為複雜應用程序,尤其是具有大量數據獲取需求的應用程序,提供了更流暢、更以用戶為中心的體驗。

數據獲取#

除了渲染更新,React 18 還引入了一種新的 API 來高效地獲取數據並進行記憶化處理。

React 18 現在具有一個緩存函數,它會記住包裝函數調用的結果。如果在同一次渲染過程中使用 相同的參數 再次調用同一個函數,它將使用記憶化的值,而無需再次執行函數。

import { cache } from 'react'

export const getUser = cache(async (id) => {
  const user = await db.user.findUnique({ id })
  return user;
})

getUser(1)
getUser(1) // Called within same render pass: returns memoized result.

在 fetch 調用中,React 18 現在默認包含了類似的緩存機制,無需使用 cache。這有助於減少單個渲染過程中的網絡請求次數,從而提高應用程序性能並降低 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) // Called within same render pass: returns memoized result.

當使用 React Server 組件時,這些功能非常有用,因為它們無法訪問 Context API。cache 和 fetch 的自動緩存行為允許從全局模塊中導出一個單一的函數,並在整個應用程序中重複使用它。

image

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'); // Returns memoized value
  return '...'
}

export default function Page() {
  return (
    <BlogPostLayout>
      <BlogPostContent />
    </BlogPostLayout>
  )
}

結論#

總之,React 18 的最新功能在許多方面都提高了性能。

  • 通過 並發 React,渲染過程可以暫停並稍後繼續,甚至可以放棄。這意味著即使一個大的渲染任務正在進行中,UI 也可以立即對用戶輸入做出響應。
  • Transitions API 允許在數據獲取或屏幕切換期間實現更平滑的過渡,而不會阻塞用戶輸入。
  • React Server Component 使開發人員能夠構建在伺服器和客戶端上都能工作的組件,將客戶端應用的交互性與傳統伺服器渲染的性能相結合,而不需要進行補水。
  • 擴展的Suspense功能通過允許應用程序的部分部分在其他部分之前渲染,從而提高了加載性能,這些部分可能需要更長時間來獲取數據。

使用Next.js 的 App Router的開發人員現在可以開始利用在文章中提到的緩存和 Server Components 等框架可用的功能

參考#

載入中......
此文章數據所有權由區塊鏈加密技術和智能合約保障僅歸創作者所有。