iT邦幫忙

2023 iThome 鐵人賽

DAY 15
0
Modern Web

深入淺出,完整認識 Next.js 13 !系列 第 15

Day 15 - 提升 Server-Side Rendering 的使用者體驗:Streaming、Suspense 與 loading.tsx

  • 分享至 

  • xImage
  •  

解決了 local storage 呼叫環境以及 server render 內容和 client 內容不一致的問題後,我們來看另個問題:

假如今天網頁是 Pre-Rendering, 其中一個 Server Component 要 fetch 一包很大的資料,那使用者進入頁面後,不就要等很久才能看到畫面嗎?

對,的確會這樣,為什麼呢?我們來看一下 Server-Side Rendering 大致的流程:

  1. Server fetch 頁面需要的所有資料
  2. 得到所有資料後,server 接著渲染頁面 HTML
  3. 渲染完後,server 將 HTML、CSS、JavaScript 傳回給 client
  4. 瀏覽器利用 HTML 和 CSS 先產生一個靜態頁面
  5. React 執行 hydration 賦予網頁互動效果

以上這些流程是有順序性的,只要前一步還沒完成,下一步就不會執行。 所以當 data fetching 還沒完成,server 就不會進行 render;client 還沒載完全部 components 就不會進行 hydration。
server-side rendering process
( 圖片來源:https://nextjs.org/docs/app/building-your-application/routing/loading-ui-and-streaming )

Streaming & Suspense

為了改善這個問題,React 18 推出了新的 SSR 渲染機制 - Streaming,讓網頁可以分段渲染後傳回給 client,讓 client 可以先顯示渲染好的部分,並針對渲染好的部分先進行 hydration。

比方說我們以 components 當作分段單位,就可以達到當某個 component 在 fetch data 時,其他不用 fetch data,或是 data 比較小的 components 可以先渲染完傳回 client,讓 client 可以先顯示渲染好的 UI 並執行 hydration。用戶就不用盯著白頁老半天,不知道發生什麼事。
streaming ssr process
( 圖片來源:https://nextjs.org/docs/app/building-your-application/routing/loading-ui-and-streaming )

單純分段還不夠,假如還在 data fetching 的部分,沒有任何 UI 也蠻奇怪的對吧?因此 React 18 推出了一個特殊 component - <Suspense>讓開發者可以自訂 components 載入完成前的 UI。

其實 Suspense 的概念早在 React 16 時就被提出,只是當時必須搭配 React.lazy 使用。

React.lazy
什麼是 React.lazy 呢?當我們的專案 components 很多,打包後的 bundle.js 檔案很大時,有可能導致使用者首次拜訪網站時,瀏覽器需花較久的時間下載 JS 檔, 造成 loading 速度很慢。因此 React 16 時官方推出了 React.lazy 來讓開發者可以較簡單地做到 code splitting,而不用去改 bundler 設定。

簡單來說,當使用 React.lazy 來 import 一個 component 時,瀏覽器會等到第一次渲染這個 component 才會下載它的 JS 檔,從而減少網站首次載入時需下載的 JS 程式碼量,加快網站首次載入的速度。

import { lazy } from "react";


const MarkdownPreview = lazy(() => import("./MarkdownPreview"));

但也因為 JS 檔要重新下載,勢必在切換 route 的時候會產生等待時間,這時考量使用者體驗,我們可能希望等待 JS 下載時頁面能有 loading...等等的提示,而不是直接白頁。於是官方提供了一個特殊 component - <Suspense>,讓開發者能自定義等待 JS 載入時顯示的 components。

以下方官方範例為例,在 <Biography><Panel><Albums> 所需要的資料與程式碼都載入完成前,渲染會暫停,並讓畫面顯示 fallback component - <Loading>,當三個 components 的資料與程式碼都載入完成後,React 會再隱藏 <Loading /> 並接著渲染三個 components。

<Suspense fallback={<Loading />}>
  <Biography artistId={artist.id} />
  <Panel>
  <Albums artistId={artist.id} />
  </Panel>
</Suspense>

suspense demo video

<Suspense> 只有在載入 lazy-loading component 的資料與程式碼,以及 Suspense data fetching ( React 16 時還不支援 ) 時才會觸發,假如使用 useEffect 或 event handler fetch data 並不會觸發 <Suspense>

React 16 時還沒辦法做到 Suspense data fetching,所以當時 <Suspense> 必須與 React.lazy 一起使用。直到 React 18 時,官方才開始支援在 Relay, Next.js, Hydorgen, Remix 上使用 Suspense data fetching。

以 Next.js 13 為例,預設會以 route 當作 streaming 的分界。 所以當 route 的 UI 還在渲染時,渲染好的 layout 會先顯示。

除此之外,還記得 Day06 時我們有提到,App Router 有一些特殊檔案嗎 ( ex: page.tsx, layout.tsx 等等 )?其中一個特殊檔案 loading.tsx 就是用來當作 Suspense fallback component,可以讓開發者自定義 route segment 還在 loading 時的 UI。

所以藉由 loading.stx,當頁面還在 fetch data 時,可以讓渲染好的 layout 會先顯示,並讓頁面內容顯示 loading.tsx 定義的 UI。
loading ui

舉例來說,我希望 /products 下的 route segments 都有一個相同的 header 和 footer,於是我在 app/products/layout.tsx 中 import 這兩個 components:

/* app/products/layout.tsx */
import React from 'react';
import Footer from './components/Footer';
import Header from './components/Header';

export default function Layout({ children }: { children: React.ReactNode }) {
  return (
    <>
      <Header />
      {children}
      <Footer />
    </>
  );
}

接著我希望 /products 的內容,包含一個固定的標題字「強檔商品特賣中!」,和一個需要打 API 取得資料的 Server Component - <ProductList>

/* app/products/page.tsx */
import ProductList from './components/ProductList';

export default function Page() {
  return (
    <div className='mt-[100px]'>
      <div className='w-full text-center font-bold text-[30px] mb-[50px]'>
        強檔商品特賣中!
      </div>
      <ProductList />
    </div>
  );
}

UX 考量,我希望等待 <ProductList> fetch data 時,頁面能顯示 "loading...",這時就可以在 app/products 中建一個 loading.tsx:

/* page/products/loading.tsx */
export default function Loading() {
  return (
    <div className='absolute top-1/2 left-1/2 translate-x-[-50%] font-bold text-[30px]'>
      Loading...
    </div>
  );
}


完成後,當我們進入頁面時,header 和 footer 會先渲染完成,而頁面中間尚在等待 data fetching 的地方會顯示 <Loading>,等頁面內容 render 完後會再轉為 page.tsx 中定義的 UI。
loading.tsx demo

但因為 loading.tsx 是以 route 當作 streaming 的分界,所以「強檔商品特賣中」也必須等到<ProductList> 渲染完成後才會在頁面顯示。假如我們希望將分界改到 <ProductList>,讓靜態的標題也能先渲染和顯示,可以直接使用 <Suspense>

/* app/products/page.tsx */
import { Suspense } from 'react';
import Loading from './components/Loading';
import ProductList from './components/ProductList';

export default function Page() {
  return (
    <div className='mt-[100px]'>
      <div className='w-full text-center font-bold text-[30px] mb-[50px]'>
        強檔商品特賣中!
      </div>
      <Suspense fallback={<Loading />}>
        <ProductList />
      </Suspense>
    </div>
  );
}

suspense demo


所以回到最開頭的問題,假如頁面某個 Server Component 要花較久渲染時間,就可以使用 loading.tsx<Suspense> 來讓其他部分先渲染和顯示。

但學會 streaming 後,我心中產生了另個疑問:既然都把頁面分段了,除了渲染以外,也可以做 error handling 嗎?

這部分就留到明天和大家分享囉!

謝謝大家的耐心閱讀,我們明天見!


上一篇
Day 14 - local storage is not defined 與 Text content does not match server-rendered HTML 錯誤
下一篇
Day 16 - 如何防止整頁白頁:Error Boundaries & error.tsx
系列文
深入淺出,完整認識 Next.js 13 !30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

1 則留言

0
alincode
iT邦新手 1 級 ‧ 2023-12-27 10:25:02

所以「強檔特賣中」也必須等 ---> 所以「強檔商品特賣中」也必須等

S.C iT邦新手 5 級 ‧ 2023-12-27 10:30:14 檢舉

您好,謝謝您的提醒,已修正

alincode iT邦新手 1 級 ‧ 2023-12-27 10:39:39 檢舉

/images/emoticon/emoticon34.gif

我要留言

立即登入留言