React Router v7 已發布。 查看文件
部落格教學 (簡短)
本頁內容

部落格教學

在這個快速入門中,我們將會盡量簡潔,並快速進入程式碼。如果您想在 15 分鐘內了解 Remix 的全部內容,那就是這裡了。

透過 這個免費的 Egghead.io 課程與 Kent 一起學習本教學課程

本教學課程使用 TypeScript。Remix 當然可以在不使用 TypeScript 的情況下使用。我們在編寫 TypeScript 時感覺效率最高,但如果您不想使用 TypeScript 語法,可以隨意使用 JavaScript 編寫程式碼。

💿 嗨,我是 Remix 光碟片 Derrick 👋 每當您應該執行某些操作時,您都會看到我

先決條件

點擊此按鈕以建立一個 Gitpod 工作區,該工作區已設定好並準備好在 VS Code 或 JetBrains 中直接在瀏覽器中或桌面上執行。

Gitpod Ready-to-Code

如果您想在自己的電腦上在本機上執行本教學課程,則必須安裝以下內容

  • Node.js 版本(>=18.0.0)
  • npm 7 或更高版本
  • 程式碼編輯器(VSCode 是個不錯的選擇)

建立專案

請確保您正在執行 Node v18 或更高版本

💿 初始化一個新的 Remix 專案。我們將其命名為「blog-tutorial」,但如果您願意,也可以將其命名為其他名稱。

npx create-remix@latest --template remix-run/indie-stack blog-tutorial
Install dependencies with npm?
Yes

您可以在堆疊文件中閱讀有關可用堆疊的更多資訊。

我們正在使用 Indie 堆疊,它是一個完整的應用程式,可以部署到 fly.io。這包括開發工具以及可投入生產環境的驗證和持久性。如果您不熟悉所使用的工具,請不要擔心,我們將逐步引導您完成操作。

請注意,您絕對可以透過執行 npx create-remix@latest 而不使用 --template 標誌來從「Just the basics」開始。這樣產生的專案會更精簡。但是,本教學課程的某些部分對您而言會有所不同,而且您必須手動設定部署。

💿 現在,在您偏好的編輯器中開啟產生的專案,並檢查 README.md 檔案中的指示。請隨意閱讀此內容。我們將在本教學課程稍後部分介紹部署部分。

💿 讓我們啟動開發伺服器

npm run dev

💿 開啟 https://127.0.0.1:3000,應用程式應該正在執行。

如果願意,花一點時間在 UI 中四處看看。請隨意建立一個帳戶,並建立/刪除一些筆記,以了解 UI 中開箱即用的功能。

你的第一個路由

我們將建立一個新路由,以在 "/posts" URL 上呈現。在我們執行此操作之前,讓我們先連結到它。

💿 在 app/routes/_index.tsx 中新增一個連結到 posts 的連結

繼續複製/貼上此內容

<div className="mx-auto mt-16 max-w-7xl text-center">
  <Link
    to="/posts"
    className="text-xl text-blue-600 underline"
  >
    Blog Posts
  </Link>
</div>

您可以將其放在您喜歡的任何位置。我把它放在堆疊中使用的所有技術圖示的正上方

Screenshot of the app showing the blog post link

您可能已經注意到我們正在使用 Tailwind CSS 類別。

Remix Indie 堆疊已預先設定 Tailwind CSS 支援。如果您不希望使用 Tailwind CSS,歡迎您移除它並使用其他東西。在樣式指南中了解有關 Remix 樣式選項的更多資訊。

回到瀏覽器中,繼續點擊連結。您應該會看到 404 頁面,因為我們尚未建立此路由。現在讓我們建立路由

💿 在 app/routes/posts._index.tsx 建立一個新檔案

touch app/routes/posts._index.tsx

當您看到建立檔案或資料夾的終端命令時,您當然可以透過您喜歡的任何方式執行此操作,但使用 touch 只是讓我們清楚地知道您應該建立哪些檔案。

我們本來可以將其命名為 posts.tsx,但我們很快就會有另一個路由,將它們放在彼此旁邊會很好。索引路由將在父系的 path 上呈現(就像網頁伺服器上的 index.html 一樣)。

現在,如果您導覽至 /posts 路由,您會收到一個錯誤,指出沒有方法可以處理請求。這是因為我們尚未在該路由中執行任何操作!讓我們新增一個元件並將其匯出為預設值

💿 建立 posts 元件

export default function Posts() {
  return (
    <main>
      <h1>Posts</h1>
    </main>
  );
}

您可能需要重新整理瀏覽器才能看到我們新的、簡陋的 posts 路由。

載入資料

資料載入是內建在 Remix 中的。

如果您的網頁開發背景主要是在過去幾年中,您可能習慣在這裡建立兩件事:一個用於提供資料的 API 路由,以及一個使用它的前端元件。在 Remix 中,您的前端元件也是其自己的 API 路由,並且它已經知道如何從瀏覽器與伺服器本身通訊。也就是說,您不必提取它。

如果您的背景更早一些,使用的是像 Rails 這樣的 MVC 網頁框架,那麼您可以將您的 Remix 路由視為使用 React 進行範本化的後端視圖,但接著它們知道如何在瀏覽器中無縫地重新整理,以新增一些風格,而不是編寫分離的 jQuery 程式碼來裝飾使用者互動。它是完全實現的漸進式增強。此外,您的路由是它們自己的控制器。

因此,讓我們開始並為我們的元件提供一些資料。

💿 建立 posts 路由 loader

import { json } from "@remix-run/node";
import { useLoaderData } from "@remix-run/react";

export const loader = async () => {
  return json({
    posts: [
      {
        slug: "my-first-post",
        title: "My First Post",
      },
      {
        slug: "90s-mixtape",
        title: "A Mixtape I Made Just For You",
      },
    ],
  });
};

export default function Posts() {
  const { posts } = useLoaderData<typeof loader>();
  return (
    <main>
      <h1>Posts</h1>
    </main>
  );
}

loader 函式是它們元件的後端「API」,並且它已經透過 useLoaderData 為您連接起來。在 Remix 路由中,用戶端和伺服器之間的界線有點模糊。如果您的伺服器和瀏覽器控制台都已開啟,您會注意到它們都記錄了我們的貼文資料。這是因為 Remix 在伺服器上呈現,以像傳統網頁框架一樣傳送完整的 HTML 文件,但它也在用戶端中重新整理並也在那裡記錄。

無論您從 loader 傳回什麼,都會向用戶端公開,即使元件不呈現它。像處理公共 API 端點一樣小心地處理您的 loader。

💿 呈現指向我們貼文的連結

import { json } from "@remix-run/node";
import { Link, useLoaderData } from "@remix-run/react";

// ...
export default function Posts() {
  const { posts } = useLoaderData<typeof loader>();
  return (
    <main>
      <h1>Posts</h1>
      <ul>
        {posts.map((post) => (
          <li key={post.slug}>
            <Link
              to={post.slug}
              className="text-blue-600 underline"
            >
              {post.title}
            </Link>
          </li>
        ))}
      </ul>
    </main>
  );
}

嘿,這很酷。我們即使透過網路請求也能獲得相當可靠的型別安全性,因為它都在同一個檔案中定義。除非 Remix 擷取資料時網路中斷,否則您在這個元件及其 API 中具有型別安全性(請記住,該元件已經是它自己的 API 路由)。

稍微重構

一個好的實踐方式是建立一個處理特定關注點的模組。在我們的例子中,它將會是關於讀取和寫入文章。現在讓我們設置它,並將 getPosts 導出添加到我們的模組中。

💿 建立 app/models/post.server.ts

touch app/models/post.server.ts

我們將主要從我們的路由複製/貼上內容

type Post = {
  slug: string;
  title: string;
};

export async function getPosts(): Promise<Array<Post>> {
  return [
    {
      slug: "my-first-post",
      title: "My First Post",
    },
    {
      slug: "90s-mixtape",
      title: "A Mixtape I Made Just For You",
    },
  ];
}

請注意,我們將 getPosts 函式設為 async,因為即使它目前沒有做任何異步操作,很快就會了!

💿 更新文章路由以使用我們新的文章模組

import { json } from "@remix-run/node";
import { Link, useLoaderData } from "@remix-run/react";

import { getPosts } from "~/models/post.server";

export const loader = async () => {
  return json({ posts: await getPosts() });
};

// ...

從資料來源提取

使用 Indie Stack,我們已經設定並配置好了一個 SQLite 資料庫,所以讓我們更新我們的資料庫架構以處理 SQLite。我們使用 Prisma 與資料庫互動,所以我們將更新該架構,Prisma 會負責更新我們的資料庫以匹配該架構(以及產生並執行遷移所需的 SQL 命令)。

使用 Remix 時,您不必使用 Prisma。Remix 可以很好地與您目前正在使用的任何現有資料庫或資料持久化服務一起工作。

如果您以前從未使用過 Prisma,請別擔心,我們會引導您完成。

💿 首先,我們需要更新我們的 Prisma 架構

// Stick this at the bottom of that file:

model Post {
  slug     String @id
  title    String
  markdown String

  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
}

💿 讓我們為我們的架構變更產生一個遷移檔案,如果您要部署您的應用程式而不是僅在本地開發模式下運行,則這是必需的。這也將更新我們的本機資料庫和 TypeScript 定義,以匹配架構變更。我們將遷移命名為「建立文章模型」。

npx prisma migrate dev --name "create post model"

💿 讓我們用一些文章來填充我們的資料庫。打開 prisma/seed.ts 並將此內容添加到 seed 功能的末尾(就在 console.log 之前)

const posts = [
  {
    slug: "my-first-post",
    title: "My First Post",
    markdown: `
# This is my first post

Isn't it great?
    `.trim(),
  },
  {
    slug: "90s-mixtape",
    title: "A Mixtape I Made Just For You",
    markdown: `
# 90s Mixtape

- I wish (Skee-Lo)
- This Is How We Do It (Montell Jordan)
- Everlong (Foo Fighters)
- Ms. Jackson (Outkast)
- Interstate Love Song (Stone Temple Pilots)
- Killing Me Softly With His Song (Fugees, Ms. Lauryn Hill)
- Just a Friend (Biz Markie)
- The Man Who Sold The World (Nirvana)
- Semi-Charmed Life (Third Eye Blind)
- ...Baby One More Time (Britney Spears)
- Better Man (Pearl Jam)
- It's All Coming Back to Me Now (Céline Dion)
- This Kiss (Faith Hill)
- Fly Away (Lenny Kravits)
- Scar Tissue (Red Hot Chili Peppers)
- Santa Monica (Everclear)
- C'mon N' Ride it (Quad City DJ's)
    `.trim(),
  },
];

for (const post of posts) {
  await prisma.post.upsert({
    where: { slug: post.slug },
    update: post,
    create: post,
  });
}

請注意,我們使用 upsert,因此您可以一次又一次地運行 seed 腳本,而不會每次都添加同一篇文章的多個版本。

太棒了,讓我們使用 seed 腳本將這些文章放入資料庫中

npx prisma db seed

💿 現在更新 app/models/post.server.ts 檔案以從 SQLite 資料庫讀取

import { prisma } from "~/db.server";

export async function getPosts() {
  return prisma.post.findMany();
}

請注意,我們能夠刪除返回類型,但所有內容仍然是完全類型化的。Prisma 的 TypeScript 功能是其最大的優勢之一。減少手動輸入,但仍然是類型安全的!

~/db.server 導入正在導入位於 app/db.server.ts 的檔案。~app 目錄的別名,因此您不必擔心在移動檔案時需要在導入中包含多少 ../../

您應該能夠前往 https://127.0.0.1:3000/posts,並且文章應該仍然在那裡,但現在它們來自 SQLite!

動態路由參數

現在讓我們建立一個路由來實際檢視文章。我們希望這些 URL 可以運作

/posts/my-first-post
/posts/90s-mixtape

我們可以使用 URL 中的「動態段」,而不是為我們的每一篇文章建立一個路由。Remix 將會解析並傳遞給我們,因此我們可以動態查找文章。

💿 在 app/routes/posts.$slug.tsx 建立一個動態路由

touch app/routes/posts.\$slug.tsx
export default function PostSlug() {
  return (
    <main className="mx-auto max-w-4xl">
      <h1 className="my-6 border-b-2 text-center text-3xl">
        Some Post
      </h1>
    </main>
  );
}

您可以點擊您的一篇文章,應該會看到新的頁面。

💿 新增一個 loader 以存取參數

import type { LoaderFunctionArgs } from "@remix-run/node";
import { json } from "@remix-run/node";
import { useLoaderData } from "@remix-run/react";

export const loader = async ({
  params,
}: LoaderFunctionArgs) => {
  return json({ slug: params.slug });
};

export default function PostSlug() {
  const { slug } = useLoaderData<typeof loader>();
  return (
    <main className="mx-auto max-w-4xl">
      <h1 className="my-6 border-b-2 text-center text-3xl">
        Some Post: {slug}
      </h1>
    </main>
  );
}

附加到 $ 的檔案名部分會成為進入您 loader 的 params 物件上的命名鍵。這就是我們查找部落格文章的方式。

現在,讓我們實際從資料庫中獲取文章內容(通過其 slug)。

💿 將 getPost 函式新增到我們的文章模組

import { prisma } from "~/db.server";

export async function getPosts() {
  return prisma.post.findMany();
}

export async function getPost(slug: string) {
  return prisma.post.findUnique({ where: { slug } });
}

💿 在路由中使用新的 getPost 函式

import type { LoaderFunctionArgs } from "@remix-run/node";
import { json } from "@remix-run/node";
import { useLoaderData } from "@remix-run/react";

import { getPost } from "~/models/post.server";

export const loader = async ({
  params,
}: LoaderFunctionArgs) => {
  const post = await getPost(params.slug);
  return json({ post });
};

export default function PostSlug() {
  const { post } = useLoaderData<typeof loader>();
  return (
    <main className="mx-auto max-w-4xl">
      <h1 className="my-6 border-b-2 text-center text-3xl">
        {post.title}
      </h1>
    </main>
  );
}

看看那個!我們現在正從資料來源提取我們的文章,而不是將所有內容都包含在瀏覽器中作為 JavaScript。

讓我們讓 TypeScript 對我們的程式碼感到滿意

import type { LoaderFunctionArgs } from "@remix-run/node";
import { json } from "@remix-run/node";
import { useLoaderData } from "@remix-run/react";
import invariant from "tiny-invariant";

import { getPost } from "~/models/post.server";

export const loader = async ({
  params,
}: LoaderFunctionArgs) => {
  invariant(params.slug, "params.slug is required");

  const post = await getPost(params.slug);
  invariant(post, `Post not found: ${params.slug}`);

  return json({ post });
};

export default function PostSlug() {
  const { post } = useLoaderData<typeof loader>();
  return (
    <main className="mx-auto max-w-4xl">
      <h1 className="my-6 border-b-2 text-center text-3xl">
        {post.title}
      </h1>
    </main>
  );
}

關於參數的 invariant 的快速說明。由於 params 來自 URL,因此我們無法完全確定 params.slug 是否已定義——也許您將檔案名稱更改為 posts.$postId.ts!使用 invariant 驗證這些內容是一個好習慣,它也讓 TypeScript 感到滿意。

我們也有一個用於文章的 invariant。稍後我們會更好地處理 404 案例。繼續前進!

現在讓我們將該 markdown 解析並呈現為頁面的 HTML。有很多 Markdown 解析器,我們將在本教學中使用 marked,因為它非常容易上手。

💿 將 markdown 解析為 HTML

npm add marked@^4.3.0
# additionally, if using typescript
npm add @types/marked@^4.3.1 -D

現在 marked 已安裝,我們需要重新啟動伺服器。因此,停止開發伺服器,然後使用 npm run dev 重新啟動。

import type { LoaderFunctionArgs } from "@remix-run/node";
import { json } from "@remix-run/node";
import { useLoaderData } from "@remix-run/react";
import { marked } from "marked";
import invariant from "tiny-invariant";

import { getPost } from "~/models/post.server";

export const loader = async ({
  params,
}: LoaderFunctionArgs) => {
  invariant(params.slug, "params.slug is required");

  const post = await getPost(params.slug);
  invariant(post, `Post not found: ${params.slug}`);

  const html = marked(post.markdown);
  return json({ html, post });
};

export default function PostSlug() {
  const { html, post } = useLoaderData<typeof loader>();
  return (
    <main className="mx-auto max-w-4xl">
      <h1 className="my-6 border-b-2 text-center text-3xl">
        {post.title}
      </h1>
      <div dangerouslySetInnerHTML={{ __html: html }} />
    </main>
  );
}

太棒了,您做到了。您有一個部落格。看看它!接下來,我們將使其更容易建立新的部落格文章 📝

巢狀路由

現在,我們的部落格文章僅來自填充資料庫。這不是真正的解決方案,所以我們需要一種在資料庫中建立新部落格文章的方法。我們將使用 actions 來實現。

讓我們建立應用程式的新的「管理」部分。

💿 首先,讓我們在文章索引路由上新增一個指向管理部分的連結

// ...
<Link to="admin" className="text-red-600 underline">
  Admin
</Link>
// ...

將其放在元件中的任何位置。我將其放在 <h1> 下面。

您是否注意到 to 屬性只是「admin」並且它連結到 /posts/admin?使用 Remix,您可以獲得相對連結。

💿 在 app/routes/posts.admin.tsx 建立一個管理路由

touch app/routes/posts.admin.tsx
import { json } from "@remix-run/node";
import { Link, useLoaderData } from "@remix-run/react";

import { getPosts } from "~/models/post.server";

export const loader = async () => {
  return json({ posts: await getPosts() });
};

export default function PostAdmin() {
  const { posts } = useLoaderData<typeof loader>();
  return (
    <div className="mx-auto max-w-4xl">
      <h1 className="my-6 mb-2 border-b-2 text-center text-3xl">
        Blog Admin
      </h1>
      <div className="grid grid-cols-4 gap-6">
        <nav className="col-span-4 md:col-span-1">
          <ul>
            {posts.map((post) => (
              <li key={post.slug}>
                <Link
                  to={post.slug}
                  className="text-blue-600 underline"
                >
                  {post.title}
                </Link>
              </li>
            ))}
          </ul>
        </nav>
        <main className="col-span-4 md:col-span-3">
          ...
        </main>
      </div>
    </div>
  );
}

您應該從我們到目前為止所做的事情中識別出我們在其中所做的一些事情。這樣,您應該有一個看起來還不錯的頁面,左側是文章,右側是佔位符。現在,如果您點擊「管理」連結,它會將您帶到 https://127.0.0.1:3000/posts/admin

索引路由

讓我們用 admin 的索引路由填寫該佔位符。請跟我們一起,我們在這裡引入「巢狀路由」,其中您的路由檔案巢狀結構將成為 UI 元件巢狀結構。

💿 為 posts.admin.tsx 的子路由建立一個索引路由

touch app/routes/posts.admin._index.tsx
import { Link } from "@remix-run/react";

export default function AdminIndex() {
  return (
    <p>
      <Link to="new" className="text-blue-600 underline">
        Create a New Post
      </Link>
    </p>
  );
}

如果您刷新,您還不會看到它。現在,每個以 app/routes/posts.admin. 開頭的路由都可以在它們的 URL 匹配時渲染到 app/routes/posts.admin.tsx內部。您可以控制子路由渲染到 posts.admin.tsx 版面的哪個部分。

💿 將一個 outlet 新增到管理頁面

import { json } from "@remix-run/node";
import {
  Link,
  Outlet,
  useLoaderData,
} from "@remix-run/react";

import { getPosts } from "~/models/post.server";

export const loader = async () => {
  return json({ posts: await getPosts() });
};

export default function PostAdmin() {
  const { posts } = useLoaderData<typeof loader>();
  return (
    <div className="mx-auto max-w-4xl">
      <h1 className="my-6 mb-2 border-b-2 text-center text-3xl">
        Blog Admin
      </h1>
      <div className="grid grid-cols-4 gap-6">
        <nav className="col-span-4 md:col-span-1">
          <ul>
            {posts.map((post) => (
              <li key={post.slug}>
                <Link
                  to={post.slug}
                  className="text-blue-600 underline"
                >
                  {post.title}
                </Link>
              </li>
            ))}
          </ul>
        </nav>
        <main className="col-span-4 md:col-span-3">
          <Outlet />
        </main>
      </div>
    </div>
  );
}

請稍等一下,索引路由一開始可能會讓人感到困惑。只要知道當 URL 與父路由的路徑匹配時,索引將在 Outlet 內部渲染即可。

也許這會有幫助,讓我們新增 /posts/admin/new 路由,看看當我們點擊連結時會發生什麼。

💿 建立 app/routes/posts.admin.new.tsx 檔案

touch app/routes/posts.admin.new.tsx
export default function NewPost() {
  return <h2>New Post</h2>;
}

現在點擊索引路由中的連結,觀看 <Outlet/> 自動將索引路由換成「new」路由!

動作

我們現在要認真起來了。讓我們在新的「new」路由中建立一個表單來建立新的文章。

💿 將表單新增到新的路由

import { Form } from "@remix-run/react";

const inputClassName =
  "w-full rounded border border-gray-500 px-2 py-1 text-lg";

export default function NewPost() {
  return (
    <Form method="post">
      <p>
        <label>
          Post Title:{" "}
          <input
            type="text"
            name="title"
            className={inputClassName}
          />
        </label>
      </p>
      <p>
        <label>
          Post Slug:{" "}
          <input
            type="text"
            name="slug"
            className={inputClassName}
          />
        </label>
      </p>
      <p>
        <label htmlFor="markdown">Markdown: </label>
        <br />
        <textarea
          id="markdown"
          rows={20}
          name="markdown"
          className={`${inputClassName} font-mono`}
        />
      </p>
      <p className="text-right">
        <button
          type="submit"
          className="rounded bg-blue-500 py-2 px-4 text-white hover:bg-blue-600 focus:bg-blue-400 disabled:bg-blue-300"
        >
          Create Post
        </button>
      </p>
    </Form>
  );
}

如果您像我們一樣熱愛 HTML,您應該會感到非常興奮。如果您一直在做很多 <form onSubmit><button onClick>,您將被 HTML 震撼到。

像這樣的功能,您真正需要的只是一個從使用者獲取資料的表單和一個處理它的後端動作。在 Remix 中,這也是您必須做的全部。

讓我們在我們的 post.ts 模組中首先建立知道如何儲存文章的必要程式碼。

💿 在 app/models/post.server.ts 的任何位置新增 createPost

// ...
export async function createPost(post) {
  return prisma.post.create({ data: post });
}

💿 從新的文章路由的 action 呼叫 createPost

import type { ActionFunctionArgs } from "@remix-run/node";
import { redirect } from "@remix-run/node";
import { Form } from "@remix-run/react";

import { createPost } from "~/models/post.server";

export const action = async ({
  request,
}: ActionFunctionArgs) => {
  const formData = await request.formData();

  const title = formData.get("title");
  const slug = formData.get("slug");
  const markdown = formData.get("markdown");

  await createPost({ title, slug, markdown });

  return redirect("/posts/admin");
};

// ...

就這樣。Remix(和瀏覽器)將負責其餘部分。點擊提交按鈕,並觀看列出我們文章的側邊欄自動更新。

在 HTML 中,輸入的 name 屬性透過網路傳送,並在請求的 formData 中以相同的名稱提供。哦,別忘了,requestformData 物件都直接來自 Web 規格。因此,如果您想了解更多關於它們的任何資訊,請前往 MDN!

TypeScript 又生氣了,讓我們新增一些類型。

💿 將類型新增到 app/models/post.server.ts

// ...
import type { Post } from "@prisma/client";

// ...

export async function createPost(
  post: Pick<Post, "slug" | "title" | "markdown">
) {
  return prisma.post.create({ data: post });
}

無論您是否使用 TypeScript,當使用者未在某些欄位上提供值時,我們都會遇到問題(並且 TS 仍然對 createPost 的呼叫感到惱火)。

讓我們在建立文章之前新增一些驗證。

💿 驗證表單資料是否包含我們需要的內容,如果沒有則返回錯誤

import type { ActionFunctionArgs } from "@remix-run/node";
import { json, redirect } from "@remix-run/node";
import { Form } from "@remix-run/react";

import { createPost } from "~/models/post.server";

export const action = async ({
  request,
}: ActionFunctionArgs) => {
  const formData = await request.formData();

  const title = formData.get("title");
  const slug = formData.get("slug");
  const markdown = formData.get("markdown");

  const errors = {
    title: title ? null : "Title is required",
    slug: slug ? null : "Slug is required",
    markdown: markdown ? null : "Markdown is required",
  };
  const hasErrors = Object.values(errors).some(
    (errorMessage) => errorMessage
  );
  if (hasErrors) {
    return json(errors);
  }

  await createPost({ title, slug, markdown });

  return redirect("/posts/admin");
};

// ...

請注意,這次我們不返回重定向,我們實際上返回錯誤。這些錯誤可以通過 useActionData 提供給元件。它就像 useLoaderData 一樣,但資料來自表單 POST 後的 action。

💿 將驗證訊息新增到 UI

import type { ActionFunctionArgs } from "@remix-run/node";
import { redirect, json } from "@remix-run/node";
import { Form, useActionData } from "@remix-run/react";

// ...

const inputClassName =
  "w-full rounded border border-gray-500 px-2 py-1 text-lg";

export default function NewPost() {
  const errors = useActionData<typeof action>();

  return (
    <Form method="post">
      <p>
        <label>
          Post Title:{" "}
          {errors?.title ? (
            <em className="text-red-600">{errors.title}</em>
          ) : null}
          <input type="text" name="title" className={inputClassName} />
        </label>
      </p>
      <p>
        <label>
          Post Slug:{" "}
          {errors?.slug ? (
            <em className="text-red-600">{errors.slug}</em>
          ) : null}
          <input type="text" name="slug" className={inputClassName} />
        </label>
      </p>
      <p>
        <label htmlFor="markdown">
          Markdown:{" "}
          {errors?.markdown ? (
            <em className="text-red-600">
              {errors.markdown}
            </em>
          ) : null}
        </label>
        <br />
        <textarea
          id="markdown"
          rows={20}
          name="markdown"
          className={`${inputClassName} font-mono`}
        />
      </p>
      <p className="text-right">
        <button
          type="submit"
          className="rounded bg-blue-500 py-2 px-4 text-white hover:bg-blue-600 focus:bg-blue-400 disabled:bg-blue-300"
        >
          Create Post
        </button>
      </p>
    </Form>
  );
}

TypeScript 仍然生氣,因為有人可能會使用非字串值呼叫我們的 API,所以讓我們新增一些 invariants 來讓它高興。

//...
import invariant from "tiny-invariant";
// ..

export const action = async ({
  request,
}: ActionFunctionArgs) => {
  // ...
  invariant(
    typeof title === "string",
    "title must be a string"
  );
  invariant(
    typeof slug === "string",
    "slug must be a string"
  );
  invariant(
    typeof markdown === "string",
    "markdown must be a string"
  );

  await createPost({ title, slug, markdown });

  return redirect("/posts/admin");
};

漸進式增強

為了真正有趣,請在您的開發人員工具中停用 JavaScript 並嘗試一下。由於 Remix 是基於 HTTP 和 HTML 的基礎而建立的,因此整個過程在瀏覽器中無需 JavaScript 即可運作🤯。但這不是重點。它的酷之處在於這意味著我們的 UI 可以抵禦網路問題。但我們真的喜歡在瀏覽器中使用 JavaScript,而且當我們擁有它時,我們可以做很多很酷的事情,所以請確保在繼續之前重新啟用 JavaScript,因為我們將需要它來漸進式增強用戶體驗。

讓我們放慢速度,並向我們的表單新增一些「擱置 UI」。

💿 使用虛假的延遲來減慢我們的 action

// ...
export const action = async ({
  request,
}: ActionFunctionArgs) => {
  // TODO: remove me
  await new Promise((res) => setTimeout(res, 1000));

  // ...
};
//...

💿 使用 useNavigation 新增一些擱置 UI

import type { ActionFunctionArgs } from "@remix-run/node";
import { json, redirect } from "@remix-run/node";
import {
  Form,
  useActionData,
  useNavigation,
} from "@remix-run/react";

// ..

export default function NewPost() {
  const errors = useActionData<typeof action>();

  const navigation = useNavigation();
  const isCreating = Boolean(
    navigation.state === "submitting"
  );

  return (
    <Form method="post">
      {/* ... */}
      <p className="text-right">
        <button
          type="submit"
          className="rounded bg-blue-500 py-2 px-4 text-white hover:bg-blue-600 focus:bg-blue-400 disabled:bg-blue-300"
          disabled={isCreating}
        >
          {isCreating ? "Creating..." : "Create Post"}
        </button>
      </p>
    </Form>
  );
}

鏘鏘!您剛剛實作了啟用 JavaScript 的漸進式增強!🥳 透過我們所做的,體驗比瀏覽器本身所能提供的更好。許多應用程式使用 JavaScript 來啟用體驗(只有少數真正需要 JavaScript 才能運作),但我們已經有了一個可運作的體驗作為基礎,而只是使用 JavaScript 來增強它。

作業

今天就到這裡!如果您想深入了解,這裡有一些作業可以實作:

更新/刪除文章:為您的文章建立一個 posts.admin.$slug.tsx 頁面。這應該會開啟一個文章編輯頁面,讓您可以更新文章甚至刪除它。側邊欄中已經有連結,但它們會返回 404 錯誤!建立一個新的路由來讀取文章,並將它們放入欄位中。您需要的所有程式碼都已經在 app/routes/posts.$slug.tsxapp/routes/posts.admin.new.tsx 中。您只需要把它們組合在一起。

樂觀 UI:您知道當您將推文加入最愛時,愛心會立即變成紅色,如果推文被刪除,它會恢復為空嗎?這就是樂觀 UI:假設請求會成功,並呈現使用者如果請求成功會看到的內容。所以您的作業是讓您在點擊「建立」時,在左側導覽中呈現文章,並呈現「建立新文章」連結(或者如果您新增了更新/刪除,也為它們執行此操作)。您會發現這最終比您想像的更容易,即使您需要一點時間才能達到那一步(如果您過去實作過這種模式,您會發現 Remix 使其變得容易得多)。從待定 UI 指南了解更多資訊。

僅限驗證使用者:另一個您可以做的很酷的作業是讓只有通過驗證的使用者才能建立文章。由於 Indie Stack,您已經為您設定了所有驗證。提示:如果您想讓只有您才能建立文章,只需在您的 loaders 和 actions 中檢查使用者的電子郵件,如果不是您的電子郵件,請將他們重定向到某個地方😈

自訂應用程式:如果您對 Tailwind CSS 感到滿意,請保留它,否則,請查看樣式指南以了解其他選項。移除 Notes 模型和路由等等。無論您想做什麼,都請讓它成為您自己的東西。

部署應用程式:查看您專案的 README 檔案。它有您可以遵循的說明,以將您的應用程式部署到 Fly.io。然後您就可以真正開始寫部落格了!

我們希望您喜歡 Remix! 💿 👋

文件和範例以 MIT 授權