譯:Next-js 13 與 Remix:深入案例研究

原文:Next.js 13 vs Remix: An In-depth case study。

編者注:這篇文章很長很深入,我在閱讀和調整格式上花了近兩個小時,收穫頗豐,推薦大家閱讀。

說到構建 Web 應用程序,React 已經走在了前列,而且其採用率還在持續增長。在使用 React 構建 Web 應用程序的最常見方法中,Next.js 是最受歡迎的選擇之一。

去年,Next.js 發佈了該框架有史以來最大的更新 —— App Router,自此便備受矚目。它引入了一種使用嵌套佈局的全新路由架構,並與 React Server Components 和 Suspense 緊密集成。

不過,Next.js 並不是第一個實現這種基於佈局路由的 React 框架。在 Next.js 公開發布應用路由器的近一年前,另一個名爲 Remix 的框架也在其公開版本 v1 中發佈了應用路由器。Remix 是由 React 應用程序中最受歡迎的客戶端路由器 React Router 的幕後人員構建的。

Remix 背後的理念很簡單,它是一個邊緣優先的全棧框架,鼓勵使用標準的 Web API(如 RequestResponseFormData 等)構建網站,其功能包括創建嵌套佈局,並行加載數據,爲您處理競賽條件,以及讓您的網站在 JavaScript 開始加載之前就能正常運行。他們的教學方法是,當你熟練掌握 Remix 時,你就能更好地掌握網絡基礎知識。

我非常欣賞 Remix 背後的理念,也對 Next.js 與 React Server Components 的發展方向感到非常興奮。因此,我想有什麼比構建一個完整的全棧應用程序更好的方式來學習這兩種技術呢?因此,我創建了我最喜歡的網站之一 X(Twitter 的前身),其中集成了這兩個框架的大部分核心功能。這篇博文的重點是我學到的經驗教訓、一個框架應從另一個框架中採用的方面,以及我在使用這兩個框架開發應用程序時的個人經驗和觀點。

TLDR; Next.js 和 Remix 應用程序都部署在 Vercel 上,您可以分別在以下網址進行測試:https://twitter-rsc.vercel.app/ 和 https://twitter-remix-run.vercel.app/。

除了框架之外,我在這兩個應用程序中使用的技術棧是: Tailwind CSS 用於樣式 Turborepo 用於管理 monorepo Prisma ORM 用於處理數據庫

您還可以在 GitHub 上的這個 monorepo 中找到這兩個應用的代碼。

我們將在不同的部分對它們進行比較,其中包括佈局、數據獲取、流、數據突變、無限加載和其他一些功能。

佈局

說到佈局,我很喜歡這兩個框架的相同之處,它們都允許創建共享嵌套佈局,並在導航之間保持不變。

如今,我們構建的大多數網絡應用都會以這樣或那樣的方式使用由多個 URL 組合在一起的共享佈局。無論是文檔中的側邊欄還是分析儀表板中的標籤,共享佈局無處不在,Twitter Clone 也不例外。事實上,有幾個頁面出現了一種佈局嵌套在另一種佈局中的情況。用戶配置文件頁面的側邊欄幾乎貫穿了所有路徑,而且還有標籤頁,每個標籤頁都有自己的 URL。

如果你用 Next.js 12 或更早版本構建過這樣的佈局,你就會知道它們有多複雜和凌亂,你必須在組件上創建函數,並將它們封裝在 _app.tsx 中的函數中。如果佈局需要在服務器上獲取一些數據,情況就會變得更加複雜。您必須在共享這些佈局的所有頁面的 getServerSideProps 中複製佈局所需的數據獲取邏輯。

但現在,有了 Remix 和 Next.js 13,你就可以依靠框架基於文件系統的路由器爲你創建佈局。

Remix

在 Remix 的新 v2 版本中,您可以使用點分隔符在 URL 中創建斜線 (/)。例如,名爲 app/routes/invoice.new.tsx 的文件將與路由 /invoice/new 匹配,而名爲 app/routes/invoice/$id.tsx 的路由將與路由 /invoice/{id} 匹配,其中 id 代表發票 ID。

如果你的發票 URL 有共同的佈局,你可以創建一個包含佈局的 invoice.tsx 文件。在該文件中,你可以在共享佈局的頁面上添加 <Outlet /> 組件,這樣 /invoices/new 和 /invoices/{id} 頁面就會共享該佈局。

在某些情況下,你可能需要一個共同的佈局,但卻沒有一個共享的 URL 結構。Remix 也有相應的解決方案,只要你創建一個前綴爲 _ 的路由,該路由就不會包含在 URL 中。這些路由稱爲無路徑路由(Pathless Routes)。

所有這些功能結合在一起,可以讓你組成並創建功能強大的嵌套佈局,例如 Twitter Clone 應用程序中的用戶配置文件佈局。

除了幾乎所有頁面共享的側邊欄外,用戶配置文件頁面還需要單獨的側邊欄。用戶配置文件頁面還需要一個單獨的佈局,因爲它包含了推文、回覆和點讚的標籤,而這些本來都是單獨的頁面,因此需要獨立的 URL。這就是 Remix 應用程序的文件結構:

app/

  routes/

    _base.tsx

    _base._index.tsx -->   /

    _base.$username.tsx

    _base.$username._index.tsx -->   /{username}

    _base.$username.replies.tsx -->   /{username}/replies

    _base.$username.likes.tsx -->   /{username}/likes

    _base.status.$id.tsx -->   /status/{id}

    _auth.tsx

    _auth.signin.tsx -->   /signin

    _auth.signup.tsx -->   /signup

這裏的 _base.tsx 是主佈局,包含大多數頁面共享的側邊欄。然後是 _base.$username.tsx 佈局,它是基礎佈局中的嵌套佈局,包含個人資料頁眉以及推文、回覆和點讚的標籤。._index.tsx 表示給定佈局的 / URL。

下面是這些路由在應用程序中的用戶配置文件頁面的工作情況:

您還可以在 GitHub 上查看路由的代碼,並在 Remix 文檔和這個超棒的可視化文檔中瞭解更多有關路由文件命名規則的信息。

Next.js

在 Next.js 13 的應用目錄中,佈局系統也非常相似;主要區別在於你使用目錄來表示 URL 和目錄內的文件,比如 layout.tsx 用於佈局,page.tsx 用於公開訪問路由,並在佈局中使用 React 的 children prop 來填充子佈局或頁面。

事實上,Next.js 13 甚至更進一步,允許您爲每個路由段創建單獨的文件,用 loading.tsx 定義加載狀態,用 error.tsx 定義錯誤狀態。我們將在接下來的章節中詳細討論這些內容。

創建不共用 URL 的佈局也與 Remix 非常相似,唯一不同的是,我們創建的不是以 _ 開頭的文件,而是在括號中加上文件夾名的目錄,如 (folderName)。這些目錄在 Next.js 中稱爲路由組。通過用方括號封裝文件夾名稱,創建 URL 的動態段,如 [id] 或 [username]

這就是 Next.js 13 Twitter 克隆路由的文件結構:

app/

  (base)/

    [username]/

      likes/

        page.tsx -->   /{username}/likes

      replies/

        page.tsx -->   /{username}/replies

      layout.tsx

      page.tsx -->   /{username}

    status/[id]/

      page.tsx -->   /status/{id}

    layout.tsx

    page.tsx  -->   /

  (auth)

    signin/

      page.tsx -->   /signin

    signup/

      page.tsx -->   /signup

下圖是這些路由如何呈現用戶配置文件頁面:

你也可以在 GitHub 上查看代碼,並在 Next.js 文檔中閱讀更多關於佈局和頁面的內容。

總結

現在,如果我們比較一下這兩個框架的路由機制,我非常喜歡 Remix 的路由機制,因爲它非常直觀,你只需看一眼就能知道文件 / 佈局代表了哪條路由。而使用 Next.js,你最終會看到一個由 page.tsx 和 layout.tsx 組成的意大利麪,而且你必須查看目錄結構,才能知道某個頁面將在哪個 URL 上呈現。

不過話雖如此,我也理解 Next.js 爲什麼要這麼做,因爲這些目錄中不僅有頁面和佈局,還有其他東西,比如 notFound.tsx 、loading.tsx 、error.tsx 等,它們可以幫助你定義每個路由段的加載 / 出錯狀態。還有一個好處是,你可以將組件與路由放在一起。

無論如何,我都喜歡這兩個框架在基於文件系統的路由方面選擇了幾乎相同的方向,而且感覺這是正確的做法。

數據獲取

數據獲取是現代網絡應用程序的重要組成部分。起初,大多數 React 應用程序都是在客戶端渲染的,服務器只是發送一個空的 index.html 文件,並在 <script /> 標記中包含相關的 JavaScript 包。這導致在瀏覽器下載和執行 JavaScript、React 初始化並開始獲取數據以呈現組件的過程中,最初的頁面是空白的。這會嚴重影響低功耗設備或網絡連接不佳設備的性能。

Next.js 和 Gatsby 簡化了在服務器上和 / 或在 React 應用程序構建時獲取數據的過程,允許預渲染初始 HTML,從而主要改變了這一情況。因此,現在用戶在網站首次加載時就已經準備好了初始用戶界面。儘管用戶仍需等待 JavaScript 下載完畢和 React 水合之後才能開始交互。

現在,Next.js 13 和 Remix 都在此基礎上更進一步。Next.js 配備了 React Server 組件,而 Remix 則配備了加載器和並行數據獲取功能。

Remix

在 Remix 中,獲取數據的方式是通過加載器,每個路由都可以定義一個加載器函數,在呈現時爲路由提供相關數據。加載器只在服務器上運行。

下面是 Remix Twitter 克隆版中加載器的一個示例,該加載器用於 _base.tsx 佈局:

export const loader = async ({ request }: LoaderFunctionArgs) => {

  const currentLoggedInUser = await getCurrentLoggedInUser(request);

  return json({ user: currentLoggedInUser }, { status: 200 });

};



export default export default function RootLayout() {

  const { user } = useLoaderData<typeof loader>();

  ....

}

加載器會獲取 Fetch Request 對象作爲參數,這樣它們就能讀取 "headers"、"cookies" 等內容,而加載器的返回類型始終是 Fetch Response。Remix 在 Response 對象之上提供了一些封裝器,如 jsonredirect 等,讓你可以返回帶有相關狀態代碼的特定類型的響應。然後,您可以使用 useLoaderData 鉤子在組件中使用加載器數據。

在 Remix 中,由於您可以在路由段的每個部分(包括佈局)中定義加載器,因此它可以並行加載所有路由段的數據,而不是在瀏覽器上獲取數據時以瀑布流的方式獲取數據。其 Landing 頁面上的這幅圖最能說明這一點:

這也是 Remix Twitter 克隆版的網絡圖:

Remix Parallel Loaders Network Graph for the User Profile page on the Twitter Clone app

其次,加載器不僅用於在服務器上渲染頁面。由於加載器的響應只是 HTTP 抓取響應,因此 Remix 還可以通過瀏覽器中的 fetch 調用加載器,進行導航或重新驗證。

Next.js

隨着 Next.js 13 中應用目錄的引入,Next.js 已經從僅在頁面文件中定義 getServerSideProps / getStaticProps 的服務器數據獲取邏輯轉向了 React 服務器組件 (RSC)。

RSC 是一個寬泛的話題,除了在服務器上獲取數據外,它們還能解決很多其他問題,值得單獨寫一篇博客(實際上,我在 React 渲染的未來博客中對此進行了更詳細的介紹)。

簡而言之,服務器組件是 React 中的一種新範式。它們是隻在服務器上渲染的組件,與 React 中傳統的服務器端渲染不同,它們從不在客戶端水合。服務器組件有很多優點,包括

以及更多。

對於需要交互的部分,您需要創建客戶端組件。與它們的名字不同,客戶端組件也是在服務器上渲染的,但它們遵循通常的服務器端渲染管道,必須在客戶端下載並執行相關的 JavaScript 來爲它們加水。

服務器組件並非靈丹妙藥,它有一些侷限性,其中包括

在 app 目錄中,默認情況下所有組件都是服務器組件,如果要添加交互性,則需要在組件樹中添加客戶端組件。客戶端組件是通過在文件頂部添加 'use client' 指令創建的。此外,服務器組件和客戶端組件絕不能出現在同一個文件中。

下面再以 Remix 中的基本佈局爲例,說明如何將其作爲 Next.js 中的服務器組件:

export default async function BaseLayout({

  children,

}: {

  children: React.ReactNode;

}) {

  const user = await getCurrentLoggedInUser();

  const isLoggedIn = !!user;



  return (

    ...

  );

};

由於服務器組件是在服務器上渲染的,因此它們可以返回 Promises。這樣,您就可以在組件中等待數據,然後在呈現時使用這些數據。

Next.js 還提供了 headerscookiesredirectrevalidatePath 等輔助方法,允許你在服務器端訪問請求數據並執行服務器操作。這裏的 getCurrentLoggedInUser 方法實際上是使用 cookie 從數據庫中獲取當前登錄用戶的詳細信息。

這是一個真正改變遊戲規則的功能,其可能性是無限的,因爲現在您不僅可以直接在 React 組件中以聲明的方式從數據庫中讀取數據,還可以在組件樹的任何層級中讀取數據,而不僅僅是在路由段中讀取數據,只要您是在服務器組件中讀取數據即可。

在 Next.js 13 中編寫應用程序的推薦方式是將客戶端組件保留在組件樹的葉子上,只在需要交互性、狀態或僅瀏覽器 API 的地方使用。下面是 Next.js Twitter 克隆版中用戶配置文件頁面的組件分佈情況:

Next.js Server and Client Components Composition for the User Profile page

Next.js 渲染文檔中還有一個表格,可以幫助你決定何時使用客戶端組件或服務器組件。

Next.js Server and Client Components Composition Comparison table

總結

雖然我很欣賞 Remix 使用加載器構建強大 API 的方式,它允許並行獲取子路由的數據並輕鬆進行重驗證,我也很喜歡加載器總是返回 Fetch Response 的事實,但 React Server Components 仍然是正確的選擇。

除了其他優勢(如確定的捆綁包大小),React 服務器組件還提供了良好的開發者體驗(DX)。它們允許您以準確獲取數據的方式組成組件樹,而不是隻在路由段中獲取數據。

Remix 也承認 RSC 帶來的好處,他們也計劃在未來集成 React Server Components。

加載器的另一個注意事項是,要在與組件相同的文件中定義加載器。雖然編譯器能很好地隔離客戶端和服務器端的捆綁包,但仍有可能導致意外暴露服務器端專用的祕密,或將服務器端專用的捆綁包發送到客戶端,Remix 也有一份完整的文檔說明了在 Route 片段中導入模塊時需要注意的問題。是的,app 目錄之前的 Next.js 的 getServerSideProps 也存在相同的問題。

最後,服務器組件自身也存在一些問題。默認情況下,如果只是在服務器組件中獲取數據,數據將沿着服務器組件樹以瀑布流的方式順序獲取。雖然有辦法將其並行化,但解決方案遠非完美。

有了 React 18,您可以使用 Streaming 和 Suspense,它允許您逐步渲染和增量流式地將 UI 的渲染單元傳輸到客戶端。

通過流,您可以爲具有阻塞數據要求的佈局部分和路由段顯示加載狀態。服務器可以先返回依賴部分的加載狀態,然後在從服務器獲取實際數據後將其替換爲加載狀態,而不是延遲頁面加載,直到服務器上的所有數據都準備就緒。Next.js 流文檔中的這幅插圖很好地解釋了這一點:

Remix 和 Next.js 13 都很好地支持了 Suspense 流。

Remix

使用 Remix,您可以簡單地使用 defer 包裝器,併爲您想從加載器中流式傳輸的項目返回 Promise,而不是解析值。然後,在組件中,您可以使用 Await 組件來處理延遲加載器的 Promise,並將其封裝在 Suspense 邊界中,以顯示加載指示器,直到 Promise resolved。

下面是我在 Twitter 克隆應用中使用該組件的簡化版本,在該應用中,我從服務器流式傳輸了第一頁的無限推文:

export const loader = async ({ request, params }: LoaderFunctionArgs) => {

  const username = params.username as string;



  return defer({

    tweets: getTweetsByUsername(request, username),

    currentLoggedInUser: await getCurrentLoggedInUser(request),

  });

};



export default function UserTweets = (

  props: SuspendedInfiniteTweetsProps

) => {

  const data = useLoaderData<typeof loader>();

  console.log(currentLoggedInUser.name)



  return (

    <Suspense fallback={<Spinner />}>

      <Await

        resolve={props.tweets}

        errorElement={<p>Something went wrong!</p>}

      >

        {(initialTweets) => (

          {/* Render the Tweets */}

        )}

      </Await>

    </Suspense>

  );

}

在上面的示例中,請注意 getCurrentLoggedInUser 是被 await 的,因此它不會被流式處理,你可以像使用普通加載器響應一樣直接使用它。

如果你想知道流式響應在網絡選項卡中的實際效果,請觀看人爲放慢流式響應的視頻:

正如你所看到的,頁面一加載,你就會看到一個 Spinner,用於顯示延遲的用戶初始推文,但它們一加載,就會被服務器本身的實際推文所取代。 在請求時序分解中,我們可以看到第一個字節的時間(綠線表示)發生在一秒鐘內,用戶會看到頁面加載了第一頁推文的旋轉器。內容下載完成所需的時間(藍線)約爲 2 秒,這是流數據加載到服務器並更新到最終 HTML 輸出中的時間。 如果沒有流媒體,請求將需要 3 秒鐘才能完成,在這段時間內用戶將看到一個空白頁。

Next.js

有了 Next.js 13,流式傳輸就更簡單了。正如我們之前在佈局部分所討論的,你可以直接在路由段目錄中創建 loading.tsx,以獲得該目錄下路由段的即時加載狀態。

實際上,Next.js 會將路由段中的頁面包裹在 Suspense 邊界內,並使用你在 loading.tsx 中指定的 fallback 組件。下圖是 Next.js 文檔中的最佳示例。

除此之外,如果你想 Suspend 某個非路由段的內容,你仍然可以通過將 Suspense 封裝在一個有數據獲取需求的異步組件上來實現。

例如,在 Twitter Clone 應用程序中,它的一個很好的用途是在主頁上,我想暫停初始推文,但又不想讓用戶等待標題中的 "創建推文" CTA 因此而被阻止。

export default async function Home() {

  const user = await getCurrentLoggedInUser();



  return (

    <>

      {/** Header stuff */}

      {user && (

        <div class>

          <Image

            src={user.profileImage ?? DEFAULT_PROFILE_IMAGE}

            className="rounded-full object-contain max-h-[48px]"

            width={48}

            height={48}

            alt={`${user.username}'s avatar`}

          />

          <div class>

            <CreateTweetHomePage />

          </div>

        </div>

      )}

      <Suspense fallback={<Spinner />}>

        <HomeTweets />

      </Suspense>

    </>

  );

}



async function HomeTweets() {

  const initialTweets = await getHomeTweets();



  return (

    /** Render initial infinite Tweets */

  );

}

總結

Suspense 流是 React 中的一項出色功能,它通過大幅縮短第一個字節的時間和顯示即時加載狀態,爲用戶提供了出色的用戶體驗,同時還能保持在服務器上獲取所有數據。

此外,我非常喜歡 React 服務器組件的直觀性,您只需將有阻塞數據獲取需求的組件封裝在 Suspense 邊界中即可。

數據突變

說到突變,我們可能都習慣於自己處理,向後端服務器發出 API 請求,然後更新本地狀態以反映變化,或者甚至使用 React Query 這樣的庫來幫你處理大部分事情。這兩個框架都希望通過將操作作爲其核心功能的一部分來改變這種情況。

在 Remix 中,這些都由 action 來處理,而在撰寫本博客時,Next.js 也在 13.4 中添加了服務器 action,但它們仍處於 alpha 階段。

Remix

在 Remix 中,突變由 action 處理,它們是 Remix 的核心功能之一。Action 是通過導出一個名爲 action 的函數在路由文件中定義的。與 loader 類似,action 也是一個僅用於服務器的函數,你可以從中返回 Fetch Response,但與 loader 不同的是,它可以處理路由的非 GET 請求(POSTPUTPATCHDELETE)。

在 remix 中,與 action 交互的主要方式是通過 HTML 表單。還記得我之前提到過,隨着你對網絡的掌握越來越好,你對 Remix 的掌握也會越來越好?這一點在 Remix 中體現得淋漓盡致。Remix 鼓勵你將應用程序中用戶進行操作的每個部分都變成 HTML 表單。沒錯,就連 "喜歡" 按鈕也是一個表單。

每當用戶觸發表單提交時,它都會調用上下文中最接近的路由上的 action(您可以通過表單的 action 屬性指定要發佈表單的 URL 來修改)。action 執行後,Remix 會通過瀏覽器獲取請求重新獲取該路由的所有 loader,並刷新用戶界面,從而確保用戶界面始終與數據庫保持同步。這就是 Remix 的 "全棧數據流"。

讓我們通過 Twitter Clone 的一些示例來看看它是如何工作的。登錄頁面的代碼如下所示

export const action = async ({ request }: ActionFunctionArgs) => {

  const form = await request.formData();

  const usernameOrEmail = form.get("usernameOrEmail")?.toString() ?? "";

  const password = form.get("password")?.toString() ?? "";

  

  const isUsername = !isEmail(usernameOrEmail);

  // Find an account

  const user = await prisma.user.findFirst({

    where: {

      [isUsername ? "username" : "email"]: usernameOrEmail,

    },

  });



  const fields = {

    usernameOrEmail,

    password,

  };



  if(!user) {

    return json({

      fields,

      fieldErrors: {

        usernameOrEmail: `No account found with the given ${

          isUsername ? "username" : "email"

        }`,

        password: null,

      },

    }, {

      status: 400

    })

  }



  const isPasswordCorrect = await comparePassword(password, user.passwordHash);



  if(!isPasswordCorrect) {

    return json({

      fields,

      fieldErrors: {

        usernameOrEmail: null,

        password: "Incorrect password",

      },

    }, {

      status: 400

    })

  }



  return createUserSession(user.id, "/");



}



export default function Signin() {

  const actionData = useActionData<typeof action>();

  const navigation = useNavigation();

  return (

    <>

      <h1 class>Sign in to Twitter</h1>

      <Form method="post">

        <div class>

          <FloatingInput

            autoFocus

            label="Username or Email"

            id="usernameOrEmail"

            

            placeholder="john@doe.com"

            defaultValue={actionData?.fields?.usernameOrEmail ?? ""}

            error={actionData?.fieldErrors?.usernameOrEmail ?? undefined}

            aria-invalid={Boolean(actionData?.fieldErrors?.usernameOrEmail)}

            aria-errormessage={actionData?.fieldErrors?.usernameOrEmail ?? undefined}

          />

          <FloatingInput

            required

            label="Password"

            id="password"

            

            placeholder="********"

            type="password"

            defaultValue={actionData?.fields?.password ?? ""}

            error={actionData?.fieldErrors?.password ?? undefined}

            aria-invalid={Boolean(actionData?.fieldErrors?.password)}

            aria-errormessage={actionData?.fieldErrors?.password ?? undefined}

          />

        </div>

        <ButtonOrLink type="submit" size="large" stretch disabled={navigation.state === "submitting"}>

          Sign In

        </ButtonOrLink>

      </Form>

    </>

  );

}

對於需要更改 URL 的表單提交,Remix 提供了一個 Form 組件,它是對原生 HTML form 元素的增強封裝。然後,您可以使用 useNavigation 鉤子爲您提供有關待處理頁面導航的信息,以便向用戶反饋加載狀態。在登錄頁面中,我們使用它在提交表單時禁用按鈕。

與我們在 loader 中看到的 useLoaderData 類似,Remix 也提供了 useActionData,它充當了服務器和客戶端之間的橋樑,爲通知用戶任何提交錯誤提供反饋。

此外,請注意我們沒有使用任何狀態來管理輸入。相反,我們依靠瀏覽器的默認行爲來序列化正文中的所有表單字段,並在提交表單時將其 "POST" 到服務器。在 action 中,我們可以通過 Fetch Request 的 formData 方法讀取表單數據。

但我們不想每次提交表單時都進行跳轉。因此,Remix 還提供了另一種無需導航即可與表單交互的工具,名爲 fetcher。Twitter 克隆版中的其餘表單幾乎都是 fetcher 表單。讓我們以在狀態頁面上點贊一條推文爲例:

export default function TweetStatus() {

  const { tweet, user, replies } = useLoaderData<typeof loader>();

  const fetcher = useFetcher();

  const isLoading = fetcher.state !== "idle";



	return (

		{/** Rest of UI on page ,omitted for brevity **/}

		<fetcher.Form method="post">

      <input type="hidden"  value={originalTweetId} />

      <input

        type="hidden"

        

        value={(!tweet.hasLiked).toString()}

      />

      <TweetAction

        size="normal"

        type="like"

        active={tweet.hasLiked}

        disabled={isLoading}

        submit

        

        value="toggle_tweet_like"

      />

    </fetcher.Form>

   {/** Rest of UI on page ,omitted for brevity **/}

	);

}



export const action = async ({ request }: ActionFunctionArgs) => {

  const formData = await request.formData();

  const action = formData.get("_action");

  const userId = await getUserSession(request);



  if (!userId) {

    return redirect("/signin", 302);

  }



  switch (action) {

    case "toggle_tweet_like":

      {

        const tweetId = formData.get("tweetId") as string;

        const hasLiked = formData.get("hasLiked") === "true";

        await toggleTweetLike({

          request,

          tweetId,

          hasLiked,

        });

      }

      break;

    case "toggle_tweet_retweet":

      {

        /** Handle tweet retweet **/      

			}

      break;

    case "reply_to_tweet":

      {

        /** Handle tweet reply **/

      }

      break;

  }



  return json({ success: true });

};

請注意我們是如何在表單中使用 hidden type 的 input 將 tweetId 和 hasLiked 等相關數據傳遞給服務器的。我們還將按鈕的名稱設置爲 _action,將值設置爲 toggle_tweet_like,這樣我們就能在服務器上識別觸發的操作類型,這在頁面上有多個表單時非常有用。

正如我們在全棧數據流中看到的,Remix 會通過瀏覽器 fetch 自動運行頁面上的所有 loader,更新從相關 loader 讀取數據的頁面上的用戶界面。因此,推文點贊數和按鈕狀態會自動更新。觀看視頻,瞭解其工作原理:

我最喜歡的部分是,Remix 會強制你在所有地方使用 HTML 表單,而瀏覽器默認會將表單輸入序列化,並自動將數據發送到服務器,因此用戶可以在 JavaScript 加載之前就開始與頁面交互。您可以禁用 JavaScript,然後在應用中的幾乎任何頁面上執行操作來驗證這一點。

例如,下面是登錄頁面的一個示例,即使沒有 JavaScript,也會顯示錶單錯誤:

還有一個從用戶配置文件頁面跟蹤用戶的示例:

Next.js

在 Next.js 13.4 之前,在服務器上創建和執行操作的唯一方法是創建 API 路由。在 pages/api 下創建的任何文件都會被視爲 API 端點,而不是普通頁面。

對於需要在服務器上進行一些處理的一次性 API 路由來說,這是一個很好的解決方案,但對於在客戶端調用 API 並進行任何重新驗證來說,這並不是一個完整的解決方案。

這也是 trpc 等解決方案大受歡迎的原因之一,它們利用 Next.js 的 API 路由系統和 React Query 來處理客戶端的 API 請求和突變。

Next.js 13.4 引入了服務器 action,在撰寫本文時,它們仍處於試驗階段。

有了服務器 action,你就不需要創建 API 端點。相反,您只需創建異步服務器函數即可直接從組件中調用,並可訪問所有 Next.js 服務器專用實用程序,如 cookiesrevalidateredirect等。

Lee Robinson 的這條推文很好地總結了使用服務器 action 可以減少多少代碼的編寫:

如果你使用的是服務器組件,你可以在組件內定義一個服務器 action,方法是在第一行寫上 'use server',然後直接將其傳遞給 form 的 action prop,或者將其傳遞給客戶端組件。(在服務器組件中使用 action prop 時,表單無需 JavaScript 即可運行)

export default async function Page() {

	async function createTodo(formData: FormData) {

    'use server'

    // This will be executed on the server

  }

 

  return <form action={createTodo}>...</form>

  // or

  return <ClientComponent createTodo={createTodo} />

}

You can also create a separate file with the 'use server' directive at at top of the file, and all the functions exported from that file can be used as server actions and can be directly imported into client components.

您也可以創建一個單獨的文件,並在文件頂部添加 'use server' 指令,這樣從該文件導出的所有函數都可用作服務器操作,並可直接導入到客戶端組件中。

'use server'



export async function doStuff() {

  // This will be executed on the server

}
'use client'

import { doStuff } from './actions';



export default function Button() {

  return (

    <form action={doStuff}>

      <button type="submit">Do stuff</button>

    </form>

  )

}

在客戶端組件上使用 action 屬性時,操作將被置於隊列中,直到表單水合完成。選擇性水合(Selective Hydration) 會優先處理 <form>,因此會盡快執行。

讓我們看看 Next.js Twitter 克隆版中的一些示例。下面是登錄頁面的代碼:

export default function Signin({

  searchParams,

}: {

  searchParams: Record<string, string>;

}) {

  const signin = async (formData: FormData) => {

    "use server";

    const auth = {

      usernameOrEmail: formData.get("usernameOrEmail")?.toString() ?? "",

      password: formData.get("password")?.toString() ?? "",

    };

    const isUsername = !isEmail(auth.usernameOrEmail);

    // Find an account

    const user = await prisma.user.findFirst({

      where: {

        [isUsername ? "username" : "email"]: auth.usernameOrEmail,

      },

    });



    if (!user) {

      const error = encodeValueAndErrors({

        fieldErrors: {

          usernameOrEmail: `No account found with the given ${

            isUsername ? "username" : "email"

          }`,

        },

        fieldValues: auth,

      });

      return redirect(`/signin?${error}`);

    }



    // Compare password

    const isPasswordCorrect = await comparePassword(

      auth.password,

      user.passwordHash

    );



    if (!isPasswordCorrect) {

      const error = encodeValueAndErrors({

        fieldErrors: {

          password: "Incorrect password",

        },

        fieldValues: auth,

      });

      return redirect(`/signin?${error}`);

    }



    // Set auth cookie

    setAuthCookie({

      userId: user.id,

    });

    return redirect("/");

  };



  const { fieldErrors, fieldValues } = decodeValueAndErrors({

    fieldErrors: searchParams.fieldErrors,

    fieldValues: searchParams.fieldValues,

  });



  return (

    <>

      <h1 class>Sign in to Twitter</h1>

      <form action={signin}>

        <div class>

          <FloatingInput

            autoFocus

            label="Username or Email"

            id="usernameOrEmail"

            

            placeholder="john@doe.com"

            defaultValue={fieldValues?.usernameOrEmail}

            error={fieldErrors?.usernameOrEmail}

            aria-invalid={Boolean(fieldErrors?.usernameOrEmail)}

            aria-errormessage={fieldErrors?.usernameOrEmail ?? undefined}

          />

          <FloatingInput

            required

            label="Password"

            id="password"

            

            placeholder="********"

            type="password"

            defaultValue={fieldValues?.password}

            error={fieldErrors?.password}

            aria-invalid={Boolean(fieldErrors?.password)}

            aria-errormessage={fieldErrors?.password ?? undefined}    

          />

        </div>

        <SubmitButton>Sign In</SubmitButton>

      </form>

    </>

  );

}

雖然到目前爲止,還沒有像 Remix 的 useActionData 那樣的聲明式方法來讀取服務器動作的響應,但對於登錄頁面,我希望有一種無需 JavaScript 就能讓用戶顯示錯誤的方法,因此我使用了搜索參數來對字段值和錯誤進行編碼和解碼。

這裏的 SubmitButton 是一個客戶端組件,它使用了一個名爲 useFormStatus 的實驗性鉤子,以便在提交表單時顯示禁用狀態。

"use client";

import { experimental_useFormStatus as useFormStatus } from "react-dom";

import { ButtonOrLink } from "components/ButtonOrLink";



export const SubmitButton = ({ children }: { children?: React.ReactNode; }) => {

  const { pending } = useFormStatus();



  return (

    <ButtonOrLink

      type="submit"

      size="large"

      disabled={pending}

    >

      {children ?? "Submit"}

    </ButtonOrLink>

  );

};

在客戶端,您還可以使用 startTransition API 來執行服務器 action,這些動作會進行服務器突變(調用 revalidatePathredirect 或 revalidateTag),並在點擊按鈕時直接執行服務器動作,例如,請查看 Follow 按鈕是如何實現的:

const [isPending, startTransition] = React.useTransition();



<ButtonOrLink

  disabled={isPending}

  onClick={() => {

    startTransition(async () => {      

      await toggleFollowUser({ userId: profileUserId, isFollowing: true });

    });

  }}

  variant="secondary"

>

  Follow

</ButtonOrLink>

與 Remix 類似,您可以直接從服務器操作中重新驗證路徑,這將導致服務器組件失效,用戶界面也會自動反映更新。與 Remix 不同的是,您必須手動調用 revalidatePath 來刷新特定路徑的數據。

export const toggleFollowUser = async ({

  userId,

  isFollowing,

}: {

  userId: string;

  isFollowing: boolean;

}) => {

  /* Updating the value in DB, omitted for brevity */

  revalidatePath("/[username]");

};

Here is a demo of how the following state is automatically updated on the profile page with the revalidatePath when the user clicks on the follow button:

下面演示了當用戶點擊關注按鈕時,如何使用 revalidatePath 在個人資料頁面上自動更新關注狀態:

總結

平心而論,我非常喜歡 Remix 的操作方法,它通過自動重新獲取加載器和更新用戶界面來完成全棧數據流,甚至在 JavaScript 加載之前就能讓應用程序正常運行,不僅大大改善了用戶體驗,也大大改善了開發人員的體驗。

不過,action 也有一個注意事項,與我們在加載器中看到的一樣,即只能在路由段中定義。如果要在多個地方重複使用一個動作,就必須在表單的 action 屬性中指定動作的 URL。這可能會隨着應用程序的增長而變得混亂,因爲您必須根據 action prop 中提供的值找到執行 action 的文件。舉個例子,你可以看看我是如何使用它來創建 tweet 操作的,它被用在兩個地方,一個是主頁,另一個是 tweet 模態。

Next.js 的服務器 action 解決了上述問題,它允許你創建只需導入就能在應用程序內任意位置調用的函數。不過,目前它們還缺乏 Remix 所擁有的良好表單支持和自動重新驗證功能,而且感覺很不穩定,文檔也不夠完善。我不得不在 Next.js 中進行了幾次討論,才弄明白 API 是如何工作的。

Next.js 最近發佈了 v13.5 版,對服務器 action 進行了重大更新。因此,上面使用的一些 API(如使用 startTransition 進行服務器突變)似乎不再有文檔記錄。此外,他們還爲表單添加了更好的支持。我很快就會在應用程序和博客中更新最新的 API。

作爲參考,你可以在 Next.js 文檔的 Web Archive 中找到我在構建 Twitter 克隆版時使用的舊版服務器 action API。

無限加載

無限滾動的無限加載是一個有趣的問題,因爲這兩個框架都沒有一流的支持,但它卻是 Twitter Clone 應用程序非常重要的一部分,因爲它幾乎出現在每個頁面上。

由於需要在客戶端處理無限加載,我不得不在客戶端通過 useReducer 來管理它們的狀態。我將其添加到這兩個應用中的經歷非常有趣,因此我認爲這值得單獨寫一節。

Remix

無限滾動的實現在很大程度上受到了 Kent C Dodds 的《Full Stack Components》一文的啓發。正是在這篇文章中,我瞭解到了資源路由以及它們在 Remix 中的強大功能。

資源路由的概念是,你可以創建一個與普通路由模塊類似的路由,但如果你沒有從該路由中導出默認組件,你仍然可以通過 GET 和 POST 請求使用該路由中定義的加載器和操作。它們幾乎就像是 Next.js 版本的 API 路由。

因此,我爲 Remix 創建了一個名爲 routes/resource-infinite-tweets.tsx 的新路由,其中有一個名爲 InfiniteTweets 的導出。由於這不是默認導出,Remix 不會爲該路由渲染任何用戶界面。這個具名導出被用於所有具有無限加載推文的組件中。

關於組件的工作原理我就不多說了,你可以在 GitHub 上查看相關代碼。簡而言之,我使用 IntersectionObserver API 來檢測頁面的結束,並觸發請求以獲取下一頁的推文,然後將其添加到 reducer 中。所有其他狀態,包括喜歡 / 轉發 / 回覆計數,也都存儲在 reducer 中。

讓我們以使用該組件的其中一個頁面爲例:用戶推文頁面。正如我們在 "流" 一節中所看到的,推文的第一頁在服務器上加載,並以流的形式傳輸到客戶端。但對於下一頁,我們使用 resource-infinite-tweets.tsx 中定義的 loader,它看起來像這樣:

export const loader = async ({ request }: LoaderFunctionArgs) => {

  const cursor = getSearchParam(request.url, "cursor") ?? undefined;

  const type = getSearchParam(request.url, "type") as InfiniteTweetType;

  const username = getSearchParam(request.url, "username");

  const tweetId = getSearchParam(request.url, "tweetId");



  let tweets: Array<TweetWithMeta> = [];

  switch (type) {

    case "user_tweets":

      tweets = await getTweetsByUsername(request, username as string, cursor);

      break;

    case "home_timeline":

      tweets = await getHomeTweets(request, cursor);

      break;

    case "tweet_replies":

      tweets = await getTweetReplies(request, tweetId as string, cursor);

      break;

    case "user_replies":

      tweets = await getUserReplies(request, username as string, cursor);

      break;

    case "user_likes":

      tweets = await getUserLikes(request, username as string, cursor);

      break;

  }



  return json(

    {

      tweets,

    },

    200

  );

};

現在,爲了觸發 loader,我們使用了在數據突變部分看到的 fetcher。它還有一個名爲 submit 的方法,允許我們以編程方式觸發對加載器的 GET 請求,從而獲取下一批推文。

React.useEffect(() => {

    if (isLoading || isLastPage || !isVisible || !shouldFetch) {

      return;

    }

    fetcher.submit(

      {

        type,

        cursor: lastTweetId,

        ...rest,

      },

      {

        method: "GET",

        action: "/resource/infinite-tweets",

      }

    );

    setShouldFetch(false);



  }, [

    isVisible,

    lastTweetId,

    isLoading,

    isLastPage,

    type,

    shouldFetch,

		rest,

    fetcher

  ]);

當滿足特定條件,表明需要獲取頁面時,就會觸發該效果。然後,數據會在 fetcher.data 中提供,並在另一個 effect 中添加到 reducer 中。

React.useEffect(() => {

  if (fetcher.data && Array.isArray(fetcher.data.tweets)) {

    dispatch({

      type: "add_tweets",

      newTweets: mapToTweet(fetcher.data.tweets, isLoggedIn),

    });

    setShouldFetch(true);

  }

}, [fetcher.data, isLoggedIn]);

該路由模塊也有一個 action,用於處理推文的所有點贊 / 轉發 / 回覆,與我們在數據突變部分看到的 fetcher.Form 代碼相同。

Next.js

Next.js 中的實現也與 Remix 非常相似,主要區別在於 InfiniteTweets 是一個客戶端組件,而我們使用服務器 action 來加載下一組頁面。

與 Remix 類似,推文的第一頁也是從服務器流式傳輸的。我們在流部分看到的 loading.tsx 文件在這裏派上了大用場。我們只需在個人資料頁面的所有標籤中添加該文件,Next.js 就會處理 Suspense 邊界中的頁面。

下面是用戶推文頁面的代碼:

export default async function Profile({

  params: { username },

}: {

  params: { username: string };

}) {

  const [tweets, currentLoggedInUser] = await Promise.all([

    getTweetsByUsername(username),

    getCurrentLoggedInUser(),

  ]);



  const fetchNextUserTweetsPage = async (cursor: string) => {

    "use server";

    const tweets = await getTweetsByUsername(username, cursor);

    return tweets;

  };



  return (

    <>

      {/** Tweets */}

      <div>

        <InfiniteTweets

          initialTweets={tweets}

          currentLoggedInUser={

            currentLoggedInUser

              ? {

                  id: currentLoggedInUser.id,

                  username: currentLoggedInUser.username,

                  name: currentLoggedInUser.name ?? undefined,

                  profileImage: currentLoggedInUser.profileImage,

                }

              : undefined

          }

          fetchNextPage={fetchNextUserTweetsPage}

          isUserProfile

        />

      </div>

    </>

  );

}

請注意我們是如何創建一個名爲 fetchNextUserTweetsPage 的服務器 action,並將其傳遞給 InfiniteTweets 組件的。然後,該組件通過調用通過該 prop 傳遞的 action 來獲取下一頁推文。

React.useEffect(() => {

  const updateTweets = async () => {

    if (isLoading || isLastPage) {

      return;

    }

    setIsLoading(true);

    const nextTweets = await fetchNextPage(lastTweetId);

    setIsLoading(false);

    dispatch({

      type: "add_tweets",

      newTweetsRemixToTweet(nextTweets, isLoggedIn),

    });

  };

  if (isVisible) {

    updateTweets();

  }

}, [isVisible, lastTweetId, isLoading, isLastPage, fetchNextPage, isLoggedIn]);

然後,與 Remix 類似,我們將數據添加到 reducer,reducer 將下一組推文呈現在頁面上。

總結

無限加載是這兩個框架中唯一需要我在客戶端管理推文狀態的部分。

在 Next.js 13 中,服務器 action 的可組合性在這部分大放異彩。我只需在服務器組件中獲取將被流式傳輸的第一個頁面,然後在組件中創建一個服務器 action 來獲取下一個頁面,並直接傳遞給客戶端組件。

在 Remix 中,雖然獲取器和資源路由確實讓獲取數據變得更容易,但我們還必須爲每個路由創建一個單獨的加載器,以便爲無限推文流式傳輸第一頁。

無論如何,這兩個解決方案都不完美,總的來說,我更傾向於使用 React Query 等庫提供的 useInfiniteQuery 鉤子,該鉤子可以幫助您在客戶端很好地管理無效和樂觀更新,從而實現類似的無限查詢。

其他功能

這兩個框架都有很多其他有用的功能,包括:

路由

Remix 和 Next.js 都有一個非常強大的客戶端路由器,它們不會重載整個頁面並往返服務器獲取完整文檔,而只是更新 UI,只重新渲染髮生變化的路由段。

Next.js 會在後臺檢測視口中可見的所有 <Link/> 標記中需要 prefetch 的路由。對於動態路由,共享佈局會一直向下,直到第一個 loading.tsx 文件被 prefetch 並緩存 30 秒。這樣,一旦用戶點擊路由,就能立即顯示加載狀態。

Remix 在 prefetch 屬性上更進一步,允許你根據使用情況指定不同的值。我最喜歡的是 intent,它不僅能獲取所有 JavaScript 捆綁程序,還能在 hover 在鏈接上時通過 <link rel="prefetch"> 標記獲取下一個路由所需的所有數據。這樣,您幾乎可以立即呈現下一個頁面。

錯誤處理

這兩個框架都支持在全局和每個路由段內處理預期錯誤和意外錯誤。

在 Remix 中,與 loader 和 action 類似,您可以導出 ErrorBoundary,它將爲路由段呈現錯誤狀態。它既能處理服務器或瀏覽器中可能出現的意外錯誤,也能處理 404 等預期錯誤。要捕捉預期錯誤,可以從 loader 中 throw 一個 Response。例如,請查看用戶配置文件頁面的 404 狀態。

同樣,在 Next.js 中,每個路由段都有單獨的文件,用於呈現該路由的錯誤狀態。error.tsx 用於專門處理路由段中出現的任何瀏覽器或服務器錯誤。它將 ErrorBoundary 包在路由段上,類似於我們在 loading.tsx 中看到的懸掛邊界。同樣,Next.js 文檔中的這張圖片也很好地說明了這一點:

Next.js Error Boundary

爲了處理 404,Next.js 爲每個路由段準備了一個名爲 not-found.tsx 的特定文件。這些都是通過在服務器組件中返回 notFound util 函數觸發的。你可以再次查看 Next.js Twitter 克隆版用戶配置文件頁面的 not-found.tsx 文件。

緩存

對於 Twitter 克隆應用,緩存和靜態渲染的使用並不多,因爲所有路由段都需要使用用戶的 cookie 來獲取與用戶相關的數據。

Next.js 顯著改進了緩存支持,他們擁有不同層次的緩存,不僅可以緩存渲染的路由,還可以緩存邊緣上獲取請求的響應。您可以在 Next.js 的緩存文檔中瞭解更多詳情。

對於 Twitter Clone,我們確實使用了請求備忘錄化(request memoization),它將在服務器 React 組件樹的多個位置請求相同數據的函數進行備忘錄化,同時只執行一次。

Remix 對緩存沒有任何想法,而且由於它只使用 HTTP,所以你只需使用 Cache-Control 標頭在邊緣和瀏覽器中緩存響應,或者使用 Redis 等其他服務器端緩存解決方案。

結論

如果你已經讀到這裏,那麼我希望你喜歡閱讀這篇博客,你對這兩個框架中的任何一個都有了一些有趣的啓發,這有助於你在構建下一個全棧應用程序時做出更好的決定。

總之,有了這兩個框架,使用 React 構建複雜的全棧 Web 應用程序變得前所未有的快速和簡單。就我而言,我非常喜歡 Remix 構建的框架,它利用了基本的 Web API,爲您提供了一種簡單而強大的方式來構建現代 Web 應用程序。同時,Next.js 中的 app 目錄讓我大開眼界,React Server Components 和 Server Actions 如何讓您組成和創建全棧組件,同時向瀏覽器發送確定的捆綁包大小。對於這兩種框架的未來,我感到非常興奮。

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