CAFECA-IO / KnowledgeManagement

Creating, Sharing, Using and Managing the knowledge and information of CAFECA
https://mermer.com.tw/knowledge-management
MIT License
0 stars 1 forks source link

[KM] App Router vs Pages Router in Next.js (撰寫 part 5) #248

Closed godmmt closed 2 months ago

godmmt commented 3 months ago

文章位置: https://github.com/CAFECA-IO/KnowledgeManagement/blob/master/NextJs/app-router-vs-pages-router-in-next-js.md

已完成:


未完成:(留至 #249 處理)


總共:

  1. 定義路由(Defining Routes)
  2. 佈局和模板(Layouts and Templates)
  3. 連結和導航(Linking and Navigating)
  4. 錯誤處理(Error Handling)
  5. 載入 UI 和串流(Loading UI and Streaming)
  6. 重新導向(Redirecting)
  7. 路由分組(Route Groups)
  8. 專案組織(Project Organization)
  9. 動態路由(Dynamic Routes)
  10. 平行路由(Parallel Routes)
  11. 攔截路由(Intercepting Routes)
  12. 路由處理程式(Route Handlers)
  13. 中介軟體(Middleware)
  14. 國際化(Internationalization)
godmmt commented 2 months ago

更新: 佈局和模板(Layouts and Templates)

重構內容,新增官方文件的最新描述,更新的內容如下:


2. 佈局和模板(Layouts and Templates)

說明:

App Router 可以透過特殊檔案  layout.tsx  更簡單地實現 persistent layout。

什麼是 persistent layout?
簡單來說就是路由切換時,沒有變動的部分不會 re-render,讓 state 和頁面狀態(ex: 滾輪位置)可以維持一樣。

以下的範例是參考這篇文章,內文有仔細講解同樣的需求在 Page Router 可以實現的方式,這裡只截取部分說明以及 App Router 的實作方式。

假設我們有個需求,想要做一個設定頁面 /settings,sub routes 包含像是 /profile/account/notifications 等不同設定。

每個 sub routes 都有一個共用的 scroll bar,這個 scroll bar 的寬度固定,超出寬度的部分則用 overflow-auto 來讓使用者左右滾動查看。點擊 scrollbar 上的選項時,則會轉到對應的頁面 (ex: 點 Profile 文字按鈕 → 轉至頁面 settings/profile)。最重要的是,我們希望在切換頁面時,scroll bar 不會重新 render,而是保持在原本的位置。

原先在 Page Router 的實作方式大概有三種:

  1. 寫一個 ScrollBar 的共用元件,並在 /pages/settings 的頁面檔案導入: 每個要用的頁面都要 import Scrollbar,而且每個頁面切換時,scroll bar 都會重新 render。而作為 SPA 重點之一,persistent layout 即是希望當 URL 切換時,不需改動的 UI 元素可以不 re-render,保留某些狀態 (ex: 當前 scroll bar 位置),增強使用者體驗。
  2. 使用 getLayout() 創建一個含有 scroll bar 的 layout: 這個做法達到了 persistent layout,但每個要使用的頁面都要 import 這個 layout。
  3. _app.tsx 中加入 scroll bar,並用 URL 判斷是否顯示: 這個做法也可以達到 persistent layout,也不用每個要使用的檔案都 import layout,但假如今天 layout 很多,可能會讓 _app.tsx 很難維護。

為了讓開發者能更輕鬆地實現 persistent layout,App Router 版本推出了一個新的 file convention : layout.js/jsx/tsx

Layout 是一個可以在路徑底下的子路徑中,共用的 UI。它不會影響 routing 而且當使用者在子路由之間切換時,也不會 re-render。

以剛剛的例子來說,我們可以在 /app/settings中建一個 layout.tsx,此檔案中 default export 的 component 就會被當作 /settings 路由下的 layout:

/* app/settings/layout.tsx */
import ScrollBar from "@/components/ScrollBar";
import React from "react";

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

/settings 底下的頁面元件就會以 children props 的方式傳入 Layout 中,達到元件共用且切換路由時 scroll bar 會維持原本位置的效果!

這裡針對 Layouts 的部分再進一步說明,而 Templates 就先不談。

版面配置 (Layouts)

版面配置是一種在多個路由之間共用的 UI。導航時,版面配置會保留狀態,保持互動性且不會重新渲染(re-render)。而且,版面配置還可以巢狀(nested)。

我們可以透過在 layout.js 檔案中預設匯出一個 React 元件來定義版面配置。而該元件接受一個 children 屬性,且該屬性在渲染時會填入子版面配置(如果存在的話)頁面

例如,此版面配置將與 /dashboard/dashboard/settings 頁面共用:

image

app/dashboard/layout.tsx

export default function DashboardLayout({
  children, // will be a page or nested layout
}: {
  children: React.ReactNode;
}) {
  return (
    <section>
      {/* Include shared UI here e.g. a header or sidebar */}
      <nav></nav>

      {children}
    </section>
  );
}

在 Layout 中有個最特殊的 layout,就是 Root Layout,它的有些規則與一般的 Layouts 不同。

根版面配置 (Root Layout) - 必要

根版面配置定義在 app 目錄的頂層,並適用於所有路由。此版面配置是必要的,且必須包含 htmlbody 標籤,允許我們修改伺服器回傳的初始 HTML。

app/layout.tsx

/* app/layout.tsx */
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import Header from "./Header";
import "./globals.css";

const inter = Inter({ subsets: ["latin"] });

export const metadata: Metadata = {
  title: "Create Next App",
  description: "Generated by create next app",
};

export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang='en'>
      <body className={inter.className}>
        <Header />
        {children}
      </body>
    </html>
  );
}

注意:

巢狀版面配置 (Nesting Layouts)

預設情況下,資料夾層級(folder hierarchy)中的 layouts 是巢狀的,意思就是,layouts 會通過其 children 屬性來包裹子版面配置(child layouts)。

在特定的路由片段(也就是資料夾)中添加 layout.js ,會自動巢狀版面配置。

例如,在 /dashboard 路由建立一個版面配置,也就是在 dashboard 目錄中新增一個 layout.js 檔案:

image

app/dashboard/layout.tsx

export default function DashboardLayout({ children }: { children: React.ReactNode }) {
  return <section>{children}</section>;
}

將上面兩個版面配置結合起來,根版面配置(app/layout.js)會包裹 dashboard 的版面配置(app/dashboard/layout.js),而 dashboard 的版面配置則包裹 app/dashboard/* 內的路由片段。

簡單來說就是,巢狀會讓一般的 layout 被以 children props 的形式傳到 root layout。因此,app/dashboard 的子路由(像是 /dashboard/about 或者 /dashboard/settings)會同時吃到 root layout 和 dashboard layout。

這兩個版面配置會呈現巢狀,如下圖:

image

或者也可以參考這張圖: image

關於 Layouts 需要了解的事:

godmmt commented 2 months ago

我好像越寫越多,可是好難取捨......

godmmt commented 2 months ago

錯誤處理

godmmt commented 2 months ago

4. 錯誤處理(Error Handling)

說明:

錯誤可以分為兩類:預期錯誤(expected errors)未捕獲的例外情況(uncaught exceptions)

處理預期錯誤(Expected Errors)

預期錯誤是指應用程式正常操作期間可能發生的錯誤,例如:來自 伺服器端表單驗證 或失敗的請求。這些錯誤應該被明確處理並回傳給客戶端。

處理伺服器操作中的預期錯誤

使用 useFormState hook 來管理伺服器操作的狀態,包括錯誤處理。

這種方法避免使用 try/catch 區塊來處理預期錯誤,這些錯誤應被建模為回傳值,而非拋出異常。

app/actions.ts

"use server";

import { redirect } from "next/navigation";

export async function createUser(prevState: any, formData: FormData) {
  const res = await fetch("https://...");
  const json = await res.json();

  if (!res.ok) {
    return { message: "Please enter a valid email" };
  }

  redirect("/dashboard");
}

接著,我們可以將 action 傳遞給 useFormState hook,並使用回傳的 state 來顯示錯誤訊息。

app/ui/signup.tsx

"use client";

import { useFormState } from "react";
import { createUser } from "@/app/actions";

const initialState = {
  message: "",
};

export function Signup() {
  const [state, formAction] = useFormState(createUser, initialState);

  return (
    <form action={formAction}>
      <label htmlFor='email'>Email</label>
      <input type='text' id='email' name='email' required />
      {/* ... */}
      <p aria-live='polite'>{state?.message}</p>
      <button>Sign up</button>
    </form>
  );
}

注意:這些範例使用了 Next.js App Router 內建的 React useFormState hook。如果使用的是 React 19,則要改用 useActionState。(可參考 React 文件

我們還可以使用回傳的狀態來顯示來自客戶端元件的 toast 提示訊息。

處理伺服器元件中的預期錯誤

在伺服器元件內部抓取資料時,我們可以使用 response 來有條件地渲染錯誤訊息或 redirect

app/page.tsx

export default async function Page() {
  const res = await fetch(`https://...`);
  const data = await res.json();

  if (!res.ok) {
    return "There was an error.";
  }

  return "...";
}

未捕獲的例外情況(Uncaught Exceptions)

未捕獲的例外情況,這裡的例外情況,意思就是意外的異常、未預期的錯誤,這些錯誤表示應用程式正常流程中不應該發生的問題或 bug。這些應該通過拋出錯誤來處理,然後由錯誤邊界捕獲。

使用錯誤邊界(Error Boundaries)

Next.js 使用錯誤邊界(Error Boundaries)來處理未捕獲的錯誤。錯誤邊界會捕獲其子元件中的錯誤並顯示回退 UI(fallback UI),而不是崩潰的元件樹。

通過在路由段中新增一個 error.tsx 檔案並導出一個 React 元件來建立錯誤邊界:

app/dashboard/error.tsx

"use client"; // Error boundaries must be Client Components

import { useEffect } from "react";

export default function Error({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) {
  useEffect(() => {
    // Log the error to an error reporting service
    console.error(error);
  }, [error]);

  return (
    <div>
      <h2>Something went wrong!</h2>
      <button
        onClick={
          // Attempt to recover by trying to re-render the segment
          () => reset()
        }
      >
        Try again
      </button>
    </div>
  );
}

如果我們希望錯誤向上冒泡到父級錯誤邊界(parent error boundary),可以在渲染 error 元件時 throw

處理巢狀路由中的錯誤

錯誤會冒泡到最近的父級錯誤邊界。這讓我們可以透過在路由層次結構(route hierarchy)的不同層級放置 error.tsx 檔案,來進行更精細的錯誤處理。

image

處理全域錯誤

雖然較少見,但我們可以在根佈局中使用 app/global-error.js 處理錯誤,這個檔案位於根 app 目錄中,即使在使用國際化(internationalization)時也是如此。

全域錯誤 UI 必須定義自己的 <html><body> 標籤,因為當它啟用時,會取代根佈局(root layout)或範本(template)。

app/global-error.tsx

"use client"; // Error boundaries must be Client Components

export default function GlobalError({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) {
  return (
    // global-error must include html and body tags
    <html>
      <body>
        <h2>Something went wrong!</h2>
        <button onClick={() => reset()}>Try again</button>
      </body>
    </html>
  );
}
godmmt commented 2 months ago

5. 載入 UI 和串流(Loading UI and Streaming)

說明:

特殊檔案 loading.js 幫助我們使用 React Suspense 建立有意義的載入 UI。透過這個約定,我們可以在路由片段的內容載入時,從伺服器顯示 即時載入狀態。一旦渲染完成,新內容會自動替換。

image

即時載入狀態

即時載入狀態(Instant Loading States)是導航時立即顯示的後備 UI(fallback UI)。我們可以預渲染載入指示器,例如:骨架畫面(skeletons)或轉圈圈(spinners),或是未來畫面的某個小但有意義的部分,如封面照片、標題等。這有助於使用者理解應用程式正在回應,並提供更好的使用者體驗。

在資料夾中新增一個 loading.js 檔案來建立一個載入狀態。

image

app/dashboard/loading.tsx

export default function Loading() {
  // You can add any UI inside Loading, including a Skeleton.
  return <LoadingSkeleton />;
}

在同一資料夾中,loading.js 會被置入於 layout.js 裡面。它會自動將 page.js 檔案和其之下的所有子元件都包裹在 <Suspense> 邊界內。

image

值得注意:

  • 即使使用 以伺服器為中心的路由,導航(Navigation)也是即時的。
  • 導航是可中斷的,這意味著更改路由不需要等待路由內容完全載入後才導航到另一個路由。
  • 共享佈局在新路由片段載入時仍然保持互動性。

建議:盡量對於路由片段(佈局和頁面)使用 loading.js 約定,因為 Next.js 優化了此功能。

godmmt commented 2 months ago

5. 載入 UI 和串流(Loading UI and Streaming)

說明:

特殊檔案 loading.js 幫助我們使用 React Suspense 建立有意義的載入 UI。透過這個約定,我們可以在路由片段的內容載入時,從伺服器顯示 即時載入狀態。一旦渲染完成,新內容會自動替換。

image

即時載入狀態

即時載入狀態(Instant Loading States)是導航時立即顯示的後備 UI(fallback UI)。我們可以預渲染載入指示器,例如:骨架畫面(skeletons)或轉圈圈(spinners),或是未來畫面的某個小但有意義的部分,如封面照片、標題等。這有助於使用者理解應用程式正在回應,並提供更好的使用者體驗。

在資料夾中新增一個 loading.js 檔案來建立一個載入狀態。

image

app/dashboard/loading.tsx

export default function Loading() {
  // You can add any UI inside Loading, including a Skeleton.
  return <LoadingSkeleton />;
}

在同一資料夾中,loading.js 會被置入於 layout.js 裡面。它會自動將 page.js 檔案和其之下的所有子元件都包裹在 <Suspense> 邊界內。

image

值得注意:

  • 即使使用 以伺服器為中心的路由,導航(Navigation)也是即時的。
  • 導航是可中斷的,這意味著更改路由不需要等待路由內容完全載入後才導航到另一個路由。
  • 共享佈局在新路由片段載入時仍然保持互動性。

建議:盡量對於路由片段(佈局和頁面)使用 loading.js 約定,因為 Next.js 優化了此功能。

使用 Suspense 進行串流

除了 loading.js,我們還可以手動為自己的 UI 元件創建 Suspense 邊界。App Router 支援在 Node.js 和 Edge 執行環境中使用 Suspense 進行串流。

值得注意:

  • 某些瀏覽器會緩衝串流響應。我們可能在響應超過 1024 位元組之前看不到串流響應。這通常只影響「Hello World」應用程式,而不影響實際應用程式。

什麼是串流?

要了解 React 和 Next.js 中的串流如何運作,首先要了解 伺服器端渲染(SSR) 及其局限性。

使用 SSR,需要完成一系列步驟才能讓使用者看到並與頁面互動:

  1. 首先,在伺服器上抓取給定頁面的所有資料。
  2. 然後,伺服器渲染該頁面的 HTML。
  3. 將該頁面的 HTML、CSS 和 JavaScript 發送到客戶端。
  4. 使用生成的 HTML 和 CSS 顯示非互動的使用者介面。
  5. 最後,React hydrate 使用者介面,使其變得互動。

image

這些步驟是按特定順序的(sequential)且阻塞(blocking)的,這意味著伺服器只能在抓取完所有資料後渲染頁面的 HTML。而且,在客戶端,React 只能在下載完頁面中所有元件的程式碼後,才可以進行 hydrate。

結合 React 和 Next.js 的 SSR 透過盡快向使用者顯示非互動式頁面,來幫助改善感知載效能。

補充:具體來說,當我們使用 SSR 時,伺服器會在將頁面傳送給使用者之前,先將頁面的 HTML 在伺服器端生成。這樣使用者可以盡快看到頁面的內容,即使這時候頁面還不能進行互動(例如按鈕還不能點擊、表單還不能提交),但至少頁面的視覺內容已經顯示出來了,讓使用者不會覺得網站很慢或者沒有反應。

這種方式能讓使用者感覺網站載入得更快,因為他們在資料尚未完全載入或 JavaScript 還沒完全執行之前,就已經能夠看到頁面的主要內容了。

image

然而,由於伺服器上需要完成所有資料獲取,才能將頁面顯示給使用者,它仍然可能較慢。

串流(Streaming) 允許我們將頁面的 HTML 分解為較小的塊,並逐步將這些塊從伺服器發送到客戶端。

image

這使得頁面的部分內容可以更快顯示,而不必等待所有資料載入完成後才能渲染任何 UI。

串流與 React 的元件模型配合良好,因為每個元件都可以被視為一個塊。優先級較高的元件(例如產品資訊)或不依賴資料的元件可以優先發送(例如佈局),React 可以更早開始 hydrate。優先級較低的元件(例如評論、相關產品)可以在其資料獲取後在同一伺服器請求中發送。

image

當我們想避免長時間的資料請求阻塞頁面渲染時,串流特別有用,因為它可以減少 首字節時間 (TTFB)首次內容渲染 (FCP)。它還有助於提高 互動時間 (TTI),尤其是在較慢的設備上。

godmmt commented 2 months ago

加班 2.5 hours

Total time: 9.5 hours

done.

完成打勾部分,剩餘移到 #249 繼續處理。