React 渲染的未來

大家好,我是 CUGGZ。

在過去的幾年中,React 的流行度一直在增加,而且還在加速。React 每週的 npm 下載量超過 1400 萬次 ,React Devtools Chrome 擴展有超過 300 萬 的周活躍用戶。

然而,在 React 18 之前,React 中的渲染模式幾乎是相同的。在本文中,我們將研究 React 當前的渲染模式、它們存在的問題,以及 React 18 引入的新模式如何是解決這些問題的。

相關術語

在深入研究渲染模式之前,讓我們來看一下將在這篇文章中使用的一些重要術語:

當前的渲染模式

目前,我們在 React 中使用的最常見的模式就是客戶端渲染和服務端渲染,以及由 Next.js 等框架提供的一些高級形式的服務端渲染,例如靜態站點生成(SSG)、增量靜態生成(ISR)。我們將研究其中這其中的每一個,並深入研究 React 18 引入的新模式。

客戶端渲染(CSR)

在 Next.js 和 Remix 等元框架出現之前,客戶端渲染(主要使用 create-react-app 或其他類似的腳手架)是構建 React 應用程序的默認方式。

使用客戶端渲染,服務端只需要爲包含必要<script><link>標籤的頁面提供基本 HTML。一旦相關的 JavaScript 下載到瀏覽器。 React 渲染樹並生成所有 DOM 節點。 路由和數據獲取的所有邏輯也由客戶端 JavaScript 處理。

爲了看看 CSR 是如何工作的,我們來渲染以下應用:

<Layout>
  <Navbar />
  <Sidebar />
  <RightPane>
      <Post />
      <Comments />
  </RightPane>
</Layout>

渲染週期如下:

CSR 渲染週期. gif

這是客戶端渲染的 Network 圖:

因此,CSR 應用接收到應答數據第一個字節很快(TTFB),因爲它們主要依賴於靜態資源。 但是,在下載相關 JavaScript 之前,用戶必須盯着空白屏幕。 在那之後,由於大多數應用都需要從 API 獲取數據並向用戶顯示相關數據,這導致加載頁面主要內容所需的時間(LCP)很長。

CSR 的優點

CSR 的缺點

服務端渲染(SSR)

目前,在 React 中服務端渲染的工作方式如下:

  1. 通過 renderToString 獲取相關數據並在服務端爲頁面運行客戶端 JavaScript,這爲我們提供了顯示頁面所需的所有 HTML。

  2. 將此 HTML 提供給客戶端,從而實現快速的 First Contentful Paint。

  3. 這時還沒有完成, 我們仍然需要下載並執行客戶端 JavaScript 以將 JavaScript 邏輯連接到服務端生成的 HTML 以使頁面具有交互性(這個過程就是 “注水”)。

爲了更好地理解它是如何工作的,讓我們來看一下上面例子中使用 SSR 時的生命週期:

<Layout>
  <Navbar />
  <Sidebar />
  <RightPane>
      <Post />
      <Comments />
  </RightPane>
</Layout>

渲染週期如下:

SSR 渲染週期. gif

這是服務端渲染的 Network 圖:

因此,使用 SSR,我們可以獲得良好的 FCP 和 LCP,但 TTFB 會受到影響,因爲我們必須在服務端獲取數據,然後將其轉換爲 HTML 字符串。

現在,你可能會問這和 Next.js 的 SSG/ISR 有啥區別呢?它們也必須經歷上面的過程。 唯一的區別是,它們不會受到 TTFB 時間較長的影響。因爲 HTML 要麼是在構建時生成的,要麼是在請求傳入時以增量方式生成和緩存的。

但是,SSG/ISR 更適合公共頁面。對於根據用戶登錄狀態或瀏覽器上存儲的其他 cookie 更改的頁面,必須使用 SSR。

SSR 的優點

SSR 的缺點

新的渲染模式

上面,我們介紹了 React 中當前的渲染模式是什麼以及它們存在什麼問題。 總結一下:

React 團隊正在研究一些旨在解決這些問題的新模式。

流式 SSR

瀏覽器可以通過 HTTP 流接收 HTML。流式傳輸允許 Web 服務端通過單個 HTTP 連接將數據發送到客戶端,該連接可以無限期保持打開狀態。因此,我們可以通過網絡以多個塊的形式在瀏覽器上加載數據,這些數據在渲染時按順序加載。

(1)React 18 之前的流式渲染

流式渲染並不是 React 18 中全新的東西。事實上,它從 React 16 開始就存在了。React 16 有一個名爲 renderToNodeStream 的方法,與 renderToString 不同,它將前端渲染爲瀏覽器的 HTTP 流。

這允許在渲染它的同時以塊的形式發送 HTML,從而爲用戶提供更快的 TTFB 和 LCP,因爲初始 HTML 更快地到達瀏覽器。

(2)React 18 中的流式 SSR

React 18 棄用了 renderToNodeStream API,取而代之的是一個名爲 renderToPipeableStream 的新 API,它通過 Suspense 解鎖了一些新功能,允許將應用分解爲更小的獨立單元,這些單元可以獨立完成我們在 SSR 中看到的步驟。這是因爲 Suspense 添加了兩個主要功能:

① 服務端流式渲染

如上所述,React 18 之前的 SSR 是一種全有或全無的方法。 首先,需要獲取頁面所需的數據,並生成 HTML,然後將其發送到客戶端。 由於 HTTP 流,情況不再如此。

在 React 18 中想要使用這種方式,可以包裝可能需要較長時間才能加載且在 Suspense 中不需要立即顯示在屏幕上的組件。

爲了瞭解它的工作原理,假設 Comments API 很慢,所以我們將 Comments 組件包裝在 Suspense 中:

<Layout>
  <NavBar />
  <Sidebar />
  <RightPane>
    <Post />
    <Suspense fallback={<Spinner />}>
      <Comments />
    </Suspense>
  </RightPane>
</Layout>

這樣,初始 HTML 中就不存在 Comments,返回的只有佔位的 Spinner:

<main>
  <nav>
    <!--NavBar -->
    <a href="/">Home</a>
   </nav>
  <aside>
    <!-- Sidebar -->
    <a href="/profile">Profile</a>
  </aside>
  <article>
    <!-- Post -->
    <p>Hello world</p>
  </article>
  <section id="comments-spinner">
    <!-- Spinner -->
    <img width=400 src="spinner.gif" alt="Loading..." />
  </section>
</main>

最後,當數據準備好用於服務端的 Comments 時,React 將發送最少的 HTML 到帶有內聯<script>標籤的同一流中,以將 HTML 放在正確的位置:

<div hidden id="comments">
  <!-- Comments -->
  <p>First comment</p>
  <p>Second comment</p>
</div>
<script>
  // 簡化了實現
  document.getElementById('sections-spinner').replaceChildren(
    document.getElementById('comments')
  );
</script>

因此,這解決了第一個問題,因爲現在不需要等待服務端獲取所有數據,瀏覽器可以開始渲染應用的其餘部分,即使某些部分尚未準備好。

② 客戶端選擇性注水

即使 HTML 被流式傳輸,頁面也不會可交互的,除非頁面的整個 JavaScript 被下載完。這就是選擇性注水的用武之地。

在客戶端渲染期間避免頁面上出現大型包的一種方法就是通過 React.lazy 進行代碼拆分。 它指定了應用的某個特定部分不需要同步加載,並且打包工具會將其拆分爲單獨的<script>標籤。

React.lazy 的限制是它不適用於服務端渲染。但在 React 18 中,<Suspense> 除了允許流式傳輸 HTML 之外,它還可以爲應用的其餘部分注水。

所以,現在 React.lazy 在服務端開箱即用。 當你將 lazy 組件包裹在 <Suspense> 中時,不僅告訴 React 你希望它被流式傳輸,而且即使包裹在 <Suspense> 中的組件仍在被流式傳輸,也允許其餘部分注水。這也解決了我們在傳統服務端渲染中看到的第二個問題。在開始注水之前,不再需要等待所有 JavaScript 下載完畢。

下面,我們把 Comments 包含在 Suspense 中,並使用新的 Suspense 架構,來看看應用的生命週期:

<Layout>
  <NavBar />
  <Sidebar />
  <RightPane>
    <Post />
    <Suspense fallback={<Spinner />}>
      <Comments />
    </Suspense>
  </RightPane>
</Layout>

渲染週期如下:

流式 SSR 渲染週期. gif

這就產生了像下面這樣的 Network 圖:

這個例子想說明的是,對於 Suspense,很多連續發生的事情現在可以並行發生。

這不僅有助於我們在 HTML 被流式傳輸後更快地 TTFB,而且用戶不必等待所有 JavaScript 被下載才能開始與應用交互。 除此之外,它還有助於在頁面開始流式傳輸時立即加載其他資源(CSS、JavaScript、字體等),有助於並行更多請求。

另外,如果有多個組件包裹在 Suspense 中並且還沒有在客戶端上注水,但是用戶開始與其中一個交互,React 將優先考慮給該組件注水。

Server components (Alpha)

上面,我們介紹瞭如何通過將應用分解爲更小的單元並分別對它們進行流式處理和選擇性注水來提高服務端渲染性能。 但是,如果有一種方法可以完全不需要對應用的某些部分進行注水呢?

這就是全新的 Server Components RFC 的用武之地。它旨在補充服務端渲染,允許擁有僅在服務端渲染且沒有交互性的組件。

它們的工作方式就是可以使用 .server.js/jsx/ts/tsx 擴展創建非交互式服務端組件,然後它們可以無縫集成並將 props 傳遞給客戶端組件(使用 .client.js/jsx/ts/tsx 擴展),它可以處理頁面的交互部分。以下是它提供的功能的:

(1)不影響客戶端包

服務端組件僅在服務端渲染,不需要注水。它允許我們在服務端渲染靜態內容,同時對客戶端包大小沒有影響。 如果使用的是繁重的庫並且沒有交互性,這可能特別有用,並且它可以完全渲染在服務端,而不會影響客戶端包。 RFC 中的 Notes 預覽就是一個很好的例子:

// NoteWithMarkdown.js
// 在 Server Components 之前

import marked from 'marked'; // 35.9K (11.2K gzipped)
import sanitizeHtml from 'sanitize-html'; // 206K (63.3K gzipped)

function NoteWithMarkdown({text}) {
  const html = sanitizeHtml(marked(text));
  return (/* render */);
}
// NoteWithMarkdown.server.js - Server Component === 包大小爲0

import marked from 'marked'; // 包大小爲0
import sanitizeHtml from 'sanitize-html'; // 包大小爲0

function NoteWithMarkdown({text}) {
  const html = sanitizeHtml(marked(text));
  return (/* render */);
}

(2)服務端組件不具有交互性,但可以與客戶端組件組合

由於它們只在服務端渲染,它們只是接收 props 並渲染視圖的 React 組件。 因此,它們不能像常規客戶端組件中那樣擁有狀態、effects 和事件處理程序之類的東西。

儘管它們可以導入具有交互性的客戶端組件,並且在客戶端上渲染時注水,正如我們在普通 SSR 中看到的那樣。 客戶端組件與服務端組件類似,使用 .client.jsx.client.tsx 後綴定義。

這種可組合性使開發人員在頁面上節省大量的包大小,例如具有大部分靜態內容和很少交互元素的詳情頁。 例如:

// Post.server.js

import { parseISO, format } from 'date-fns';
import marked from 'marked';
import sanitizeHtml from 'sanitize-html';

import Comments from '../Comments.server.jsx'
// 導入客戶端組件
import AddComment from '../AddComment.client.jsx';

function Post({ content, created_at, title, slug }) {
  const html = sanitizeHtml(marked(content));
  const formattedDate = format(parseISO(created_at)'dd/MM/yyyy')

  return (
    <main>
        <h1>{title}</h1>
        <span>Posted on {formattedDate}</span>
        {content}
        <AddComment slug={slug} />
        <Comments slug={slug} />
    </main>
  )
}
// AddComment.client.js

function AddComment({ hasUpvoted, postSlug }) {

  const [comment, setComment] = useState('');
  
  function handleCommentChange(event) {
    setComment(event.target.value);
  } 

  function handleSubmit() {
    // ...
  } 

  return (
    <form onSubmit={handleSubmit}>
      <textarea  onChange={handleCommentChange} value={comment}/>
      <button type="submit">
        Comment
      </button>
    </form>
  )
}

上面的代碼是服務端組件如何與客戶端組件組合的示例。 讓我們來分解一下:

這裏,我們在服務端組件中導入的所有日期和 markdown 解析庫都不會在客戶端下載。我們在客戶端下載的唯一 JavaScript 就是 AddComment 組件。

(3)服務端組件可以直接訪問後端

由於它們僅在服務端渲染,因此可以使用它們直接從組件訪問數據庫和其他僅限後端的數據源,如下所示:

// Post.server.js

import { parseISO, format } from 'date-fns';
import marked from 'marked';
import sanitizeHtml from 'sanitize-html';

import db from 'db.server';

// 導入客戶端組件
import Upvote from '../Upvote.client.js';

function Post({ slug }) {
  // 直接從數據庫中讀取數據
  const { content, created_at, title } = db.posts.get(slug);
  const html = sanitizeHtml(marked(content));
  const formattedDate = format(parseISO(created_at)'dd/MM/yyyy');

  return (
    <main>
      <h1>{title}</h1>
      <span>Posted on {formattedDate}</span>
      {content}
      <AddComment slug={slug} />
      <Comments slug={slug} />
    </main>
  );
}

現在你可能會說,在傳統的服務端渲染中也可以實現這一點。 例如,Next.js 可以直接在 getServerSidePropsgetStaticProps 中訪問服務端數據。 沒錯,但區別在於,傳統的 SSR 是一種全有或全無的方法,只能在頂級頁面上完成,但服務端組件可以在每個組件的基礎上執行此操作。

(4)自動代碼拆分

代碼拆分是一個概念,它允許將應用分成更小的塊,向客戶端發送更少的代碼。對應用進行代碼拆分的最常見方式就是按路由進行拆分。這也是 Next.js 等框架默認拆分包的方式。

除了自動代碼拆分之外,React 還允許使用 React.lazy API 在運行時延遲加載不同的模塊。 這又是一個來自 RFC 的很好的例子,說明這可能特別有用:

// PhotoRenderer.js
// 在 Server Components 之前

import React from 'react';
const OldPhotoRenderer = React.lazy(() => import('./OldPhotoRenderer.js'));
const NewPhotoRenderer = React.lazy(() => import('./NewPhotoRenderer.js'));

function Photo(props) {
  if (FeatureFlags.useNewPhotoRenderer) {
    return <NewPhotoRenderer {...props} />; 
  } else {
    return <OldPhotoRenderer {...props} />;
  }
}

這種技術通過在運行時只動態導入需要的組件來提高性能,但它確實有一些問題。 例如,這種方法會延遲應用開始加載代碼的時間,從而抵消了加載更少代碼的好處。

正如我們之前在客戶端組件如何與服務器組件組合中看到的那樣,它們通過將所有客戶端組件導入視爲潛在的代碼拆分點,並允許開發人員選擇要在服務端更早渲染的內容,從而使客戶端能夠更早下載。 下面是 RFC 中使用服務端組件的相同 PhotoRenderer 示例:

// PhotoRenderer.server.js - Server Component

import React from 'react';
import OldPhotoRenderer from './OldPhotoRenderer.client.js';
import NewPhotoRenderer from './NewPhotoRenderer.client.js';

function Photo(props) {
  if (FeatureFlags.useNewPhotoRenderer) {
    return <NewPhotoRenderer {...props} />;
  } else {
    return <OldPhotoRenderer {...props} />;
  }
}

服務端組件可以在保留客戶端狀態的同時重新加載:我們可以隨時從客戶端重新獲取服務端樹,以從服務端獲取更新的狀態,而不會破壞本地客戶端狀態、焦點甚至正在進行的動畫。

這是可能的,因爲接收到的 UI 描述是數據而不是純 HTML,這允許 React 將數據合併到現有組件中,從而使客戶端狀態不會被破壞。

(5)服務端組件與 Suspense 集成

服務器組件可以通過 <Suspense> 逐步流式傳輸,正如在上面中看到的那樣,這允許我們在等待頁面剩餘部分加載時創建加載狀態並快速顯示重要內容。

接下來看看上面的例子在使用 React 服務端組件時是什麼樣的。 這次 Sidebar 和 Post 是服務端組件,而 Navbar 和 Comments 是客戶端組件。 我們也將 Post 包裹在 Suspense 中。

<Layout>
  <NavBar />
  <SidebarServerComponent />
  <RightPane>
    <Suspense fallback={<Spinner />}>
      <PostServerComponent />
    </Suspense>
    <Suspense fallback={<Spinner />}>
      <Comments />
    </Suspense>
  </RightPane>
</Layout>

渲染週期如下:

Server components  渲染週期. gif

它的 Network 圖與使用 Suspense 的流式渲染非常相似,但 JavaScript 更少。因此,服務端組件甚至是解決我們一開始的問題的進一步措施,它不僅可以下載更少的 JavaScript,而且還顯着改善了開發者體驗。

React 團隊還在 RFC 常見問題解答中提到,他們在 Facebook 的單個頁面上對少數用戶進行了實驗,產品代碼大小減少了約 30%。

什麼時候可以開始使用這些功能?

目前,服務端組件仍處於 alpha 階段,而具有新 Suspense 架構的流式 SSR 所需的用於數據獲取的 Suspense 還沒有正式發佈,將在 React 18 的小更新中發佈。

相關演示

在這裏查看 React 團隊和 Next.js 團隊的演示:

**參考文章:**https://prateeksurana.me/blog/future-of-rendering-in-react/

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/8j1-ZT_dfzHf9NA4KNuaNQ