博客教程(简短)
本页内容

博客教程

在本快速入门指南中,我们将简化文字,快速展示代码。如果你想在 15 分钟内了解 Remix 的核心内容,这就是你的最佳选择。

跟随 Kent 在 这个免费的 Egghead.io 课程 中完成本教程。

本教程使用 TypeScript。Remix 当然也可以在没有 TypeScript 的情况下使用。我们发现在编写 TypeScript 时效率最高,但如果你想跳过 TypeScript 语法,可以随意使用 JavaScript 编写代码。

💿 嗨,我是 Derrick,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 标志来从“仅基础”开始。这样生成的项目更加精简。但是,本教程的一些部分对你来说会有所不同,并且你必须手动配置部署事宜。

💿 现在,在你的首选编辑器中打开生成的项目,并查看 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,但我们很快就会有另一个路由,将它们放在一起会很好。索引路由将在父路径上呈现(就像 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 框架一样,但它也在客户端进行了水化并记录了数据。

无论您从加载器中返回什么,都将公开给客户端,即使组件没有渲染它。请像对待公共 API 端点一样谨慎对待您的加载器。

💿 渲染到我们帖子的链接

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 并将此添加到种子功能的末尾(在 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>
  );
}

您可以点击其中一个帖子,应该会看到新页面。

💿 添加一个加载器来访问参数

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

附加到 $ 的文件名的一部分成为进入加载器的 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>
  );
}

关于 paramsinvariant 的快速说明。因为 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>
  );
}

天哪,你做到了。你有一个博客。检查一下!接下来,我们将使创建新的博客帖子更容易 📝

嵌套路由

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

让我们为应用程序创建一个新的“管理员”部分。

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

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

索引路由

让我们用管理员的索引路由填充该占位符。坚持住,我们在这里介绍“嵌套路由”,其中您的路由文件嵌套成为 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 布局的哪个部分。

💿 向管理员页面添加一个出口

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

💿 从新帖子路由的操作中调用 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,所以让我们添加一些 invariant 来让它满意。

//...
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 使其变得更容易)。从Pending UI 指南中了解更多信息。

仅限已认证用户:另一个很酷的课后作业是,使只有已认证用户才能创建帖子。由于 Indie Stack,你已经为其设置了身份验证。提示:如果你想让只有你才能创建帖子,只需在你的加载器和操作中检查用户的电子邮件,如果不是你的,则将其重定向到某个地方 😈

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

部署应用程序:查看项目的 README 文件。它包含你可以按照的说明,将你的应用程序部署到 Fly.io。然后你就可以真正开始写博客了!

希望你喜欢 Remix!💿 👋

文档和示例根据以下许可证授权 MIT