React Router v7 已发布。 查看文档
博客教程(简短)
本页内容

博客教程

在本快速入门中,我们将尽量简洁,快速编写代码。如果你想在 15 分钟内了解 Remix 的全部内容,那么这就是了。

通过 这个免费的 Egghead.io 课程与 Kent 一起学习本教程

本教程使用 TypeScript。Remix 绝对可以在不使用 TypeScript 的情况下使用。我们觉得在使用 TypeScript 时效率最高,但如果你更喜欢跳过 TypeScript 语法,请随意使用 JavaScript 编写你的代码。

💿 嘿,我是 Remix 光盘德里克 👋 当你需要 *做* 某事时,你会看到我

先决条件

单击此按钮创建一个 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 中添加一个指向帖子的链接

请直接复制/粘贴这段代码

<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,但我们稍后会创建另一个路由,并且将它们放在一起会更好。索引路由将在父路径下渲染(就像 Web 服务器上的 index.html 一样)。

现在,如果你导航到 /posts 路由,你将收到一个错误,表明没有办法处理请求。那是因为我们还没有在该路由中做任何事情!让我们添加一个组件并将其导出为默认组件

💿 创建 posts 组件

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

你可能需要刷新浏览器才能看到我们新的、简陋的 posts 路由。

加载数据

数据加载已内置于 Remix 中。

如果你的 Web 开发背景主要来自最近几年,你可能习惯于在此处创建两件事:一个提供数据的 API 路由和一个使用它的前端组件。在 Remix 中,你的前端组件也是它自己的 API 路由,并且它已经知道如何从浏览器在服务器上与自身通信。也就是说,你无需获取它。

如果你的背景比这更早,使用过像 Rails 这样的 MVC Web 框架,那么你可以将 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 文档,就像传统的 Web 框架一样,但它也在客户端进行了水合,并也在那里进行了记录。

你从 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,因为即使它目前没有执行任何异步操作,它很快就会!

💿 更新 posts 路由以使用我们新的 posts 模块

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 定义以匹配架构更改。我们将迁移命名为“create post model”。

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

💿 让我们用一些帖子来填充数据库。打开 prisma/seed.ts 并将此添加到种子功能的末尾(在 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,因此你可以一遍又一遍地运行种子脚本,而不会每次都添加同一帖子的多个版本。

太好了,让我们使用种子脚本将这些帖子放入数据库中

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 获取帖子内容。

💿 向我们的 post 模块添加一个 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>
  );
}

我的天哪,你成功了。你有一个博客。去看看吧!接下来,我们将使其更容易创建新的博客文章 📝

嵌套路由

现在,我们的博客文章只是来自填充数据库。这不是一个真正的解决方案,因此我们需要一种在数据库中创建新博客文章的方法。我们将为此使用操作。

让我们创建应用程序的新 “admin” 部分。

💿 首先,让我们在帖子索引路由上添加一个指向 admin 部分的链接

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

将其放置在组件中的任何位置。我将其放在 <h1> 的正下方。

你是否注意到 to prop 只是 “admin”,并且它链接到 /posts/admin?使用 Remix,你将获得相对链接。

💿 在 app/routes/posts.admin.tsx 创建一个 admin 路由

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>
  );
}

你应该会识别出我们在那里所做的一些事情,这些事情与我们到目前为止所做的事情相同。有了它,你应该有一个外观不错的页面,左侧是帖子,右侧是占位符。现在,如果你单击“Admin”链接,它将把你带到 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 布局的哪个部分。

💿 向 admin 页面添加一个 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” 路由中构建一个表单来创建新文章。

💿 向 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 });
}

💿 从 new 文章路由的操作中调用 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 后的操作。

💿 将验证消息添加到 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,因此让我们添加一些不变性来使其满意。

//...
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”。

💿 通过虚假延迟来减慢我们的操作速度

// ...
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,你已经为你设置好了身份验证。提示:如果你想使其只有你可以创建文章,只需在你的加载器和操作中检查用户的电子邮件,如果不是你的电子邮件,则将其重定向到某个地方😈

自定义应用程序:如果你对 Tailwind CSS 感到满意,请保留它,否则,请查看样式指南以了解其他选项。删除 Notes 模型和路由等。无论你想要什么,都可以让它成为你自己的东西。

部署应用程序:请查看你的项目的 README。它有你可以遵循的说明,以将你的应用程序部署到 Fly.io。然后你就可以真正开始写博客了!

我们希望你喜欢 Remix!💿 👋

文档和示例在以下许可下授权 MIT