banner
AgedCoffee

AgedCoffee

理解Next13的App目錄架構

原文地址:Understanding App Directory Architecture In Next.js

理解 Next13 的 App 目錄架構#

概要:新的 App 目錄架構是最近 Next.js 發布的主要內容,它引發了很多討論。在本文中,Atila Fassina 探討了這種新策略的優點和缺陷,並思考了您現在是否應該在生產中使用它。

Next.js 13 發布以來,就有一些關於其公告中包含的新功能的穩定性的爭議。在“Next.js 13 有哪些新功能?”這篇文章中,我們介紹了發布的內容,並確定了 Next.js 13 雖然包含一些有趣的實驗,但絕對是穩定的。從那時起,對於新的 <Link> 和 <Image> 組件,甚至還有(仍處於 beta 階段的)@next/font,我們大多數人都看到了非常清晰的情況;這些都是可以直接使用的,具有即時收益。如公告中明確說明的那樣,Turbopack 仍處於 alpha 階段,僅針對開發構建,並且仍在積極開發中。您是否可以在日常工作中使用它取決於您的技術棧,因為仍有一些集成和優化正在進行中。本文的範圍僅限於公告的主角:新的應用目錄架構(簡稱 AppDir)。

由於應用目錄與 React 生態系統中的一個重要演進 - React 伺服器組件 - 以及邊緣運行時配合使用,因此它一直引起了問題。它顯然是我們 Next.js 應用程序未來的形態。雖然它是實驗性的,但其路線圖不是我們可以認為會在接下來的幾周內完成的。因此,您現在是否應該在生產中使用它?您可以從中獲得什麼優勢,以及您可能會遇到的陷阱是什麼?像往常一樣,軟件開發中的答案都是相同的:這取決於不同情況。

什麼是 App 目錄?#

這是在 Next.js 中處理路由和渲染視圖的新策略。它是由幾個不同的功能組合在一起實現的,並且它是為了最大程度地利用 React 並發特性而構建的(是的,我們正在談論 React Suspense)。它帶來了一個大的範式轉變,改變了你在 Next.js 應用程序中思考組件和頁面的方式。這種構建應用程序的新方式對架構有很多非常受歡迎的改進。以下是一個簡短的、非詳盡的列表:

  • 部分路由。
    • 路由分組。
    • 並行路由。
    • 攔截路由。
  • 伺服器組件 vs 客戶端組件。
  • Suspense 邊界。
  • 還有更多,請查看新文檔中的功能概述。

快速比較#

當談到當前路由和渲染架構(在 Pages 目錄中)時,開發者需要考慮每個路由的數據獲取方式。

  • getServerSideProps: 伺服器端渲染;
  • getStaticProps: 伺服器端預渲染和 / 或增量靜態再生;
  • getStaticPaths + getStaticProps: 伺服器端預渲染或靜態站點生成。

歷史上,還沒有辦法在每個頁面上選擇渲染策略。大多數應用程序要麼全面採用伺服器端渲染,要麼全面採用靜態站點生成。Next.js 創建了足夠的抽象層,使得在其架構內單獨考慮路由成為標準。

一旦應用程序到達瀏覽器,就會啟動 hydration 過程,並且通過將_app 組件包裝在 React Context Provider 中,可以使路由共享數據。這給了我們工具來將數據置於渲染樹的頂部並向下級傳遞數據。

import { type AppProps } from 'next/app';

export default function MyApp({ Component, pageProps }: AppProps) {
  return (
        <SomeProvider>
            <Component {...pageProps} />
        </SomeProvider>
}

能夠在每個路由中渲染和組織所需數據,使得這種方法成為必要時絕佳的工具,以便在整個應用中數據得以全局傳播。然而,將所有內容包裝在 Context Provider 中會將 hydration 捆綁到應用程序的根部,不再能夠在該樹上渲染任何分支(在該 Provider 上下文中的任何路由)。

這裡就是 Layout 模式的作用了。通過在頁面周圍創建包裝器,我們可以選擇針對每個路由的渲染策略,而不是一次性做出應用級決策。有關如何在 Pages Directory 中管理狀態,請參閱“Next.js 中的狀態管理”文章以及 Next.js 文檔

Layout 模式被證明是一個很好的解決方案。能夠精細定義渲染策略是一個非常受歡迎的功能。因此,App 目錄突出了 Layout 模式的重要性。作為 Next.js 架構的一等公民,它在性能、安全性和數據處理方面提供了巨大的改進。

通過 React concurrent 特性,現在可以將組件流式傳輸到瀏覽器,並讓每個組件處理其自己的數據。因此,渲染策略現在更加精細,不再是基於頁面而是基於組件。默認情況下,Layout 是嵌套的,這使得開發人員更加清楚地了解基於文件系統架構每個頁面的影響。最重要的是,在使用 Context 之前必須顯式地將組件轉換為客戶端組件(通過 "use client" 指令)。

App 目錄的構建模塊#

這種架構是圍繞著每個頁面一個佈局的架構建立的。現在沒有了_app 和_document 組件。它們都被根 layout.jsx 組件所取代。正如你所期望的那樣,這是一個特殊的佈局,將整個應用程序包裝起來。

export function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="en">
      <body>{children}</body>
    </html>
  )
}

根佈局是我們一次性操作返回給整個應用程序的 HTML 的方式。它是一個伺服器組件,在導航時不會重新渲染。這意味著佈局中的任何數據或狀態都將在應用程序的生命周期內持續存在。

雖然根佈局是我們整個應用程序的一個特殊組件,但我們還可以為其他構建塊定義根組件:

  • loading.jsx: 定義整個路由的 Suspense 邊界;
  • error.jsx: 定義整個路由的錯誤邊界;
  • template.jsx: 類似於佈局,但在每次導航時重新渲染。特別適用於處理路由之間的狀態,例如進入或退出過渡效果。

所有這些組件和約定默認都是嵌套的。這意味著 /about 將自動嵌套在 / 的包裝器中。

最後,我們還需要為每個路由定義一個 page.jsx,它將定義與該 URL 段(即你放置組件的位置)對應的主要組件。這些顯然不是默認嵌套的,並且只有在與它們相對應的 URL 段存在精確匹配時才會顯示在我們的 DOM 中。

當然,還有更多的架構(甚至還有更多即將到來!),但這應該足以在考慮將生產中的 Pages 目錄遷移到 App 目錄之前正確理解你的思維模型。務必查看官方升級指南

SERVER COMPONENTS 簡介#

React 伺服器組件允許應用程序利用基礎設施來提高性能和整體用戶體驗。例如,即時的改進在於捆綁大小方面,因為 RSC 不會將它們的依賴項帶入最終捆綁包中。因為它們在伺服器上呈現,任何類型的解析、格式化或組件庫都將保留在伺服器代碼上。其次,由於它們的異步性質,伺服器組件會流式傳輸到客戶端。這允許在瀏覽器上逐步增強呈現的 HTML。

因此,伺服器組件導致最終捆綁包的更可預測、可緩存和恆定大小,打破了應用程序大小和捆綁包大小之間的線性相關性。這立即將 RSC 作為最佳實踐與傳統的 React 組件(現在稱為客戶端組件以便於消歧義)進行比較。

在伺服器組件中,獲取數據也非常靈活,而且在我看來,更接近原生 JavaScript - 這總是使學習曲線更平滑。例如,了解 JavaScript 運行時使得定義數據獲取為並行或順序成為可能,因此可以更細粒度地控制資源加載瀑布。

  • 並行數據獲取,等待所有:
import TodoList from './todo-list'

async function getUser(userId) {
  const res = await fetch(`https://<some-api>/user/${userId}`)
  return res.json()
}

async function getTodos(userId) {
  const res = await fetch(`https://<some-api>/todos/${userId}/list`)
  return res.json()
}

export default async function Page({ params: { userId } }) {
  // Initiate both requests in parallel.
  const userResponse = getUser(userId)
  const todosResponse = getTodos(username)

  // Wait for the promises to resolve.
  const [user, todos] = await Promise.all([userResponse, todosResponse])

  return (
    <>
      <h1>{user.name}</h1>
      <TodoList list={todos}></TodoList>
    </>
  )
}
  • 並行,等待一個請求,流式傳輸另一個請求:
async function getUser(userId) {
  const res = await fetch(`https://<some-api>/user/${userId}`)
  return res.json()
}

async function getTodos(userId) {
  const res = await fetch(`https://<some-api>/todos/${userId}/list`)
  return res.json()
}

export default async function Page({ params: { userId } }) {
  // Initiate both requests in parallel.
  const userResponse = getUser(userId)
  const todosResponse = getTodos(userId)

  // Wait only for the user.
  const user = await userResponse

  return (
    <>
      <h1>{user.name}</h1>
      <Suspense fallback={<div>Fetching todos...</div>}>
        <TodoList listPromise={todosResponse}></TodoList>
      </Suspense>
    </>
  )
}

async function TodoList({ listPromise }) {
  // Wait for the album's promise to resolve.
  const todos = await listPromise

  return (
    <ul>
      {todos.map(({ id, name }) => (
        <li key={id}>{name}</li>
      ))}
    </ul>
  )
}

在這種情況下,<TodoList> 接收到一個正在進行中的 Promise,需要在渲染之前等待它。應用程序將呈現 suspense 回退組件,直到所有操作都完成。

  • 順序數據獲取一次只會觸發一個請求並等待每個請求:
async function getUser(username) {
  const res = await fetch(`https://<some-api>/user/${userId}`)
  return res.json()
}

async function getTodos(username) {
  const res = await fetch(`https://<some-api>/todos/${userId}/list`)
  return res.json()
}

export default async function Page({ params: { userId } }) {
  const user = await getUser(userId)

  return (
    <>
      <h1>{user.name}</h1>
      <Suspense fallback={<div>Fetching todos...</div>}>
        <TodoList userId={userId} />
      </Suspense>
    </>
  )
}

async function TodoList({ userId }) {
  const todos = await getTodos(userId)

  return (
    <ul>
      {todos.map(({ id, name }) => (
        <li key={id}>{name}</li>
      ))}
    </ul>
  )
}

現在,Page 將會在獲取 getUser 數據並等待它之後開始渲染。一旦到達 <TodoList>,它將獲取 getTodos 數據並等待它。這仍然比我們在 Pages 目錄中使用的方法更加細化。

需要注意的重要事項:

  • 在同一組件範圍內發出的請求將並行發出(更多關於此的信息請參見下面的擴展獲取 API)。

  • 在同一伺服器運行時發出的相同請求將被去重(只有一個實際發生,緩存過期時間最短的那個)。

  • 對於不使用 fetch 的請求(例如第三方庫,如 SDK、ORM 或數據庫客戶端),除非通過段緩存配置手動配置,否則路由緩存不會受到影響。

export const revalidate = 600; // revalidate every 10 minutes

export default function Contributors({
  params
}: {
  params: { projectId: string };
}) {
    const { projectId }  = params
    const { contributors } = await myORM.db.workspace.project({ id: projectId })

  return <ul>{*/ ... */}</ul>;
}

這說明開發人員可以更加精細地控制頁面的渲染。在 pages 目錄下,直到所有數據都準備好後,頁面渲染才會被解除阻塞。而使用 getServerSideProps 時,用戶仍然會看到加載動畫,直到整個路由的數據都準備好。為了在 App 目錄下模擬這種行為,需要在該路由的 layout.tsx 中發出 fetch 請求,因此應該避免這樣做。一種 "全有或全無" 的方法很少是你所需要的,相對於這種細粒度的策略,它會導致更差的感知性能。

擴展的 Fetch API#

語法仍然保持不變:fetch (route, options)。但是根據 Web Fetch Spec,options.cache 將決定該 API 如何與瀏覽器緩存進行交互。但在 Next.js 中,它將與框架的伺服器端 HTTP 緩存進行交互。

在談論 Next.js 的擴展 Fetch API 和其緩存策略時,有兩個值需要理解:

  • force-cache: 默認選項,查找最新匹配並返回它。
  • no-store 或 no-cache: 每次請求都從遠程伺服器獲取。
  • next.revalidate: 與 ISR 相同的語法,設置一個硬閾值來考慮資源是否新鮮。
fetch(`https://route`, { cache: 'force-cache', next: { revalidate: 60 } })

緩存策略允許我們對我們的請求進行分類:

  • 靜態數據:保留時間更長。例如,博客文章。
  • 動態數據:經常更改和 / 或是用戶交互的結果。例如,評論部分,購物車。

默認情況下,每個數據都被視為靜態數據。這是由於 force-cache 是默認的緩存策略。要完全排除動態數據的影響,可以定義 no-store 或 no-cache。

如果使用動態函數(例如設置 cookies 或 headers),默認值將從 force-cache 切換到 no-store!

最後,要實現與增量靜態再生更相似的內容,您需要使用 next.revalidate。好處是它只定義了它所在的組件,而不是整個路由。

遷移從 Pages 到 App#

將邏輯從 Pages 目錄遷移到 App 目錄可能看起來需要做很多工作,但是 Next.js 已經準備好讓這兩種架構共存,因此遷移可以逐步進行。此外,官方文檔中有一個非常好的遷移指南;在開始重構之前,我建議您完全閱讀它。

引導您完成遷移路徑超出了本文的範圍,並且會使其與文檔重複。相反,為了在官方文檔提供的基礎上增加價值,我將嘗試提供一些經驗,以幫助您避免摩擦點。

React 上下文的情況#

為了提供本文中提到的所有優點,RSC 不能是交互式的,這意味著它們沒有 hooks。因此,我們決定儘可能推遲將客戶端邏輯推向渲染樹的葉子節點;一旦添加交互性,該組件的子代將是客戶端端的。

在一些情況下,推遲推送某些組件將不可能(特別是如果某些關鍵功能依賴於 React 上下文,例如)。由於大多數庫都準備好保護其用戶免受 Prop Drilling 的影響,因此許多庫創建上下文提供程序,以跳過從根到遠處的後代組件。因此,完全放棄 React 上下文可能會導致一些外部庫無法正常工作。

作為臨時解決方案,有一種針對此問題的客戶端封裝程序:

// /providers.jsx
‘use client’

import { type ReactNode, createContext } from 'react';

const SomeContext = createContext();

export default function ThemeProvider({ children }: { children: ReactNode }) {
  return (
    <SomeContext.Provider value="data">
      {children}
    </SomeContext.Provider>
  );
}

因此,佈局組件不會抱怨跳過一個客戶端組件的渲染。

// app/.../layout.jsx
import { type ReactNode } from 'react';
import Providers from ‘./providers’;

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

重要的是要意識到,一旦這樣做,整個分支將變成客戶端渲染。該方法將使 <Providers> 組件中的所有內容不會在伺服器端呈現,因此僅在萬不得已的情況下使用。

Typescript 和異步 React 元素#

在 Layouts 和 Pages 之外使用 async/await 時,TypeScript 會基於其 JSX 定義所期望匹配的響應類型產生錯誤。儘管在運行時它仍然可用並且支持,但根據 Next.js 文檔,這需要在 TypeScript 上游進行修復。

目前的解決方案是在上面一行添加一個註釋{/* @ts-expect-error Server Component */}

客戶端獲取數據的工作#

歷史上,Next.js 沒有內置的數據變異(mutation)功能。從客戶端發出的請求是開發人員自己決定如何處理。使用 React 伺服器組件後,這種情況就有所改變了。React 團隊正在開發一個 use hook,該 hook 將接受一個 Promise,然後處理該 Promise 並直接返回其值。

在未來,這將替換大部分在使用中的 useEffect(更多相關內容請參閱“再見,UseEffect”這個出色的演講),並且可能成為處理客戶端 React 中的異步操作(包括獲取)的標準。

目前,仍然建議依賴於 React-Query 和 SWR 等庫來滿足您的客戶端獲取需求。尤其要注意獲取的行為!

那麼,它準備好了嗎?#

實驗是前進的本質,我們不能不打破蛋就做出美味的煎蛋卷。我希望本文可以幫助你回答自己特定用例的這個問題。

如果是新項目,我可能會嘗試使用 App 目錄,並將 Page 目錄作為後備或關鍵業務功能。如果是重構,那就取決於我有多少客戶端獲取數據。如果很少,就可以開始重構;如果很多,可能要等待完整的解決方案。

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