在本快速入门中,我们将尽量简洁,快速编写代码。如果你想在 15 分钟内了解 Remix 的全部内容,那么这就是了。
本教程使用 TypeScript。Remix 绝对可以在不使用 TypeScript 的情况下使用。我们觉得在使用 TypeScript 时效率最高,但如果你更喜欢跳过 TypeScript 语法,请随意使用 JavaScript 编写你的代码。
单击此按钮创建一个 Gitpod 工作区,该工作区已设置好项目并准备好在 VS Code 或 JetBrains 中直接在浏览器或桌面上运行。
如果你想在自己的计算机上本地遵循本教程,请务必安装以下内容
💿 初始化一个新的 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>
你可以把它放在任何你喜欢的地方。我把它放在了堆栈中使用的所有技术的图标之上
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 框架一样,但它也在客户端进行了水合,并也在那里进行了记录。
💿 渲染指向我们帖子的链接
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 命令)。
如果你以前从未使用过 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();
}
~/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
中通过相同的名称可用。哦,别忘了,request
和 formData
对象都直接来自 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.tsx
和 app/routes/posts.admin.new.tsx
中。你只需要将它们组合在一起即可。
乐观 UI: 你知道当你收藏一条推文时,心形会立即变成红色,如果该推文被删除,它会恢复为空吗?这就是乐观 UI:假设请求会成功,并呈现用户在成功时将看到的内容。因此,你的作业是使其在点击 “创建” 时,在左侧导航栏中呈现文章,并呈现 “创建新文章” 链接(或者如果你添加了更新/删除,也对它们执行此操作)。你会发现这最终比你想象的要容易,即使你花一点时间才能达到这个目的(如果你过去已经实现了这种模式,你会发现 Remix 使这更容易)。从待处理 UI 指南中了解更多信息。
仅限已认证用户:你可以做的另一个很酷的作业是使只有已认证的用户才能创建文章。由于 Indie Stack,你已经为你设置好了身份验证。提示:如果你想使其只有你可以创建文章,只需在你的加载器和操作中检查用户的电子邮件,如果不是你的电子邮件,则将其重定向到某个地方😈
自定义应用程序:如果你对 Tailwind CSS 感到满意,请保留它,否则,请查看样式指南以了解其他选项。删除 Notes
模型和路由等。无论你想要什么,都可以让它成为你自己的东西。
部署应用程序:请查看你的项目的 README。它有你可以遵循的说明,以将你的应用程序部署到 Fly.io。然后你就可以真正开始写博客了!
我们希望你喜欢 Remix!💿 👋