教程
第一部分。理论上
本教程将检查 Real World App,也称为 Conduit。Conduit 是一个基本的 Medium 克隆 — 它让您阅读和编写文章,以及对他人的文章进行评论。
这是一个相当小的应用程序,所以我们将保持简单并避免过度分解。整个应用程序很可能只需要三个 layers:App、Pages 和 Shared。如果不是,我们将在过程中引入额外的 layers。准备好了吗?
从列出页面开始
如果我们查看上面的截图,我们可以至少假设以下页面:
- 主页(文章流)
- 登录和注册
- 文章阅读器
- 文章编辑器
- 用户资料查看器
- 用户资料编辑器(用户设置)
这些页面中的每一个都将成为 Pages layer 上的自己的 slice。回忆一下概览中的内容,slices 简单来说就是 layers 内部的文件夹,而 layers 简单来说就是具有预定义名称的文件夹,如 pages
。
因此,我们的 Pages 文件夹将如下所示:
📂 pages/
📁 feed/
📁 sign-in/
📁 article-read/
📁 article-edit/
📁 profile/
📁 settings/
Feature-Sliced Design 与无规则代码结构的关键区别是页面不能相互引用。也就是说,一个页面不能从另一个页面导入代码。这是由于 layers 上的导入规则:
slice 中的模块(文件)只能在其他 slices 位于严格低于当前的 layers 时才能导入它们。
在这种情况下,页面是一个 slice,所以这个页面内部的模块(文件)只能引用下层 layers 的代码,而不能引用同一 layer Pages 的代码。
仔细查看 feed
Anonymous user’s perspective
Authenticated user’s perspective
feed 页面上有三个动态区域:
- 带有登录状态指示的登录链接
- 触发 feed 中过滤的标签列表
- 一个/两个文章 feeds,每篇文章都有一个点赞按钮
登录链接是所有页面通用的头部的一部分,我们将单独重新访问它。
标签列表
要构建标签列表,我们需要获取可用的标签,将每个标签渲染为芯片,并将选中的标签存储在客户端存储中。这些操作分别属于“API 交互”、“用户界面”和“存储”类别。在 Feature-Sliced Design 中,代码使用 segments 按目的分离。Segments 是 slices 中的文件夹,它们可以有描述目的的任意名称,但某些目的非常常见,以至于某些 segment 名称有约定:
- 📂
api/
用于后端交互 - 📂
ui/
用于处理渲染和外观的代码 - 📂
model/
用于存储和业务逻辑 - 📂
config/
用于 feature flags、环境变量和其他形式的配置
我们将获取标签的代码放入 api
,标签组件放入 ui
,存储交互放入 model
。
文章
使用相同的分组原则,我们可以将文章 feed 分解为相同的三个 segments:
- 📂
api/
: 获取带有点赞数的分页文章;点赞文章 - 📂
ui/
:- 可以在选中标签时渲染额外选项卡的选项卡列表
- 单个文章
- 功能分页
- 📂
model/
: 当前加载的文章和当前页面的客户端存储(如果需要)
重用通用代码
大多数页面在意图上非常不同,但某些东西在整个应用程序中保持不变 — 例如,符合设计语言的 UI 套件,或后端上使用相同认证方法的 REST API 来完成所有事情的约定。由于 slices 旨在被隔离,代码重用由更低的 layer Shared 促进。
Shared 与其他 layers 不同,它包含 segments 而不是 slices。这样,Shared layer 可以被认为是 layer 和 slice 之间的混合体。
通常,Shared 中的代码不是提前计划的,而是在开发过程中提取的,因为只有在开发过程中才能明确哪些代码部分实际上是共享的。然而,记住哪种代码自然属于 Shared 仍然是有帮助的:
- 📂
ui/
— the UI kit, pure appearance, no business logic. For example, buttons, modal dialogs, form inputs. - 📂
api/
— convenience wrappers around request making primitives (likefetch()
on the Web) and, optionally, functions for triggering particular requests according to the backend specification. - 📂
config/
— parsing environment variables - 📂
i18n/
— configuration of language support - 📂
router/
— routing primitives and route constants
这些只是 Shared 中 segment 名称的几个示例,但您可以省略其中任何一个或创建自己的。创建新 segments 时要记住的唯一重要事情是,segment 名称应该描述目的(为什么),而不是本质(是什么)。像 "components"、"hooks"、"modals" 这样的名称不应该使用,因为它们描述了这些文件是什么,但不能帮助在内部导航代码。这要求团队中的人在这样的文件夹中挖掘每个文件,并且也保持不相关的代码接近,这导致了重构影响的代码区域广泛,从而使代码审查和测试更加困难。
定义严格的 public API
在 Feature-Sliced Design 的上下文中,术语 public API 指的是 slice 或 segment 声明项目中的其他模块可以从它导入什么。例如,在 JavaScript 中,这可以是一个 index.js
文件,从 slice 中的其他文件重新导出对象。这使得在 slice 内部重构代码的自由度成为可能,只要与外部世界的契约(即 public API)保持不变。
对于没有 slices 的 Shared layer,通常为每个 segment 定义单独的 public API 比定义 Shared 中所有内容的一个单一索引更方便。这使得从 Shared 的导入按意图自然地组织。对于具有 slices 的其他 layers,情况相反 — 通常每个 slice 定义一个索引并让 slice 决定外部世界未知的自己的 segments 集合更实用,因为其他 layers 通常有更少的导出。
我们的 slices/segments 将以以下方式相互出现:
📂 pages/
📂 feed/
📄 index
📂 sign-in/
📄 index
📂 article-read/
📄 index
📁 …
📂 shared/
📂 ui/
📄 index
📂 api/
📄 index
📁 …
像 pages/feed
或 shared/ui
这样的文件夹内部的任何内容只有这些文件夹知道,其他文件不应该依赖这些文件夹的内部结构。
UI 中的大型重用块
早些时候我们记录了要重新访问出现在每个页面上的头部。在每个页面上从头开始重建它是不切实际的,所以想要重用它是很自然的。我们已经有 Shared 来促进代码重用,然而,在 Shared 中放置大型 UI 块有一个警告 — Shared layer 不应该了解上面的任何 layers。
在 Shared 和 Pages 之间有三个其他 layers:Entities、Features 和 Widgets。某些项目可能在这些 layers 中有他们在大型可重用块中需要的东西,这意味着我们不能将该可重用块放在 Shared 中,否则它将从上层 layers 导入,这是被禁止的。这就是 Widgets layer 的用武之地。它位于 Shared、Entities 和 Features 之上,所以它可以使用它们所有。
在我们的情况下,头部非常简单 — 它是一个静态 logo 和顶级导航。导航需要向 API 发出请求以确定用户当前是否已登录,但这可以通过从 api
segment 的简单导入来处理。因此,我们将把我们的头部保留在 Shared 中。
仔细查看带有表单的页面
让我们也检查一个用于编辑而不是阅读的页面。例如,文章编写器:
它看起来微不足道,但包含了我们尚未探索的应用程序开发的几个方面 — 表单验证、错误状态和数据持久化。
如果我们要构建这个页面,我们会从 Shared 中获取一些输入和按钮,并在此页面的 ui
segment 中组合一个表单。然后,在 api
segment 中,我们将定义一个变更请求以在后端创建文章。
为了在发送之前验证请求,我们需要一个验证模式,一个好地方是 model
segment,因为它是数据模型。在那里我们将产生错误消息并使用 ui
segment 中的另一个组件显示它们。
为了改善用户体验,我们还可以持久化输入以防止意外数据丢失。这也是 model
segment 的工作。
总结
我们已经检查了几个页面并为我们的应用程序概述了初步结构:
- Shared layer
ui
将包含我们可重用的 UI 套件api
将包含我们与后端的原始交互- 其余将根据需要安排
- Pages layer — 每个页面都是一个单独的 slice
ui
将包含页面本身及其所有部分api
将包含更专门的数据获取,使用shared/api
model
可能包含我们将显示的数据的客户端存储
让我们开始构建吧!
第二部分。在代码中
现在我们有了计划,让我们付诸实践。我们将使用 React 和 Remix。
有一个为此项目准备的模板,从 GitHub 克隆它以获得先机:https://github.com/feature-sliced/tutorial-conduit/tree/clean。
使用 npm install
安装依赖项并使用 npm run dev
启动开发服务器。打开 http://localhost:3000,您应该看到一个空白应用程序。
布局页面
让我们首先为所有页面创建空白组件。在您的项目中运行以下命令:
npx fsd pages feed sign-in article-read article-edit profile settings --segments ui
这将为每个页面创建像 pages/feed/ui/
这样的文件夹和一个索引文件 pages/feed/index.ts
。
连接 feed 页面
让我们将应用程序的根路由连接到 feed 页面。在 pages/feed/ui
中创建一个组件 FeedPage.tsx
并将以下内容放入其中:
export function FeedPage() {
return (
<div className="home-page">
<div className="banner">
<div className="container">
<h1 className="logo-font">conduit</h1>
<p>A place to share your knowledge.</p>
</div>
</div>
</div>
);
}
然后在 feed 页面的 public API,pages/feed/index.ts
文件中重新导出此组件:
export { FeedPage } from "./ui/FeedPage";
现在将它连接到根路由。在 Remix 中,路由是基于文件的,路由文件位于 app/routes
文件夹中,这与 Feature-Sliced Design 很好地契合。
在 app/routes/_index.tsx
中使用 FeedPage
组件:
import type { MetaFunction } from "@remix-run/node";
import { FeedPage } from "pages/feed";
export const meta: MetaFunction = () => {
return [{ title: "Conduit" }];
};
export default FeedPage;
然后,如果您运行开发服务器并打开应用程序,您应该会看到 Conduit 横幅!
API 客户端
为了与 RealWorld 后端通信,让我们在 Shared 中创建一个方便的 API 客户端。创建两个 segments,api
用于客户端,config
用于像后端基础 URL 这样的变量:
npx fsd shared --segments api config
然后创建 shared/config/backend.ts
:
export { mockBackendUrl as backendBaseUrl } from "mocks/handlers";
export { backendBaseUrl } from "./backend";
由于 RealWorld 项目方便地提供了 OpenAPI 规范,我们可以利用为我们的客户端自动生成的类型。我们将使用 the openapi-fetch
package,它附带一个额外的类型生成器。
运行以下命令生成最新的 API 类型:
npm run generate-api-types
这将创建一个文件 shared/api/v1.d.ts
。我们将使用此文件在 shared/api/client.ts
中创建一个类型化的 API 客户端:
import createClient from "openapi-fetch";
import { backendBaseUrl } from "shared/config";
import type { paths } from "./v1";
export const { GET, POST, PUT, DELETE } = createClient<paths>({ baseUrl: backendBaseUrl });
export { GET, POST, PUT, DELETE } from "./client";
feed 中的真实数据
我们现在可以继续向 feed 添加从后端获取的文章。让我们首先实现一个文章预览组件。
使用以下内容创建 pages/feed/ui/ArticlePreview.tsx
:
export function ArticlePreview({ article }) { /* TODO */ }
由于我们用 TypeScript 编写,有一个类型化的 article 对象会很好。如果我们探索生成的 v1.d.ts
,我们可以看到 article 对象可以通过 components["schemas"]["Article"]
获得。所以让我们在 Shared 中创建一个包含我们数据模型的文件并导出模型:
import type { components } from "./v1";
export type Article = components["schemas"]["Article"];
export { GET, POST, PUT, DELETE } from "./client";
export type { Article } from "./models";
现在我们可以回到文章预览组件并用数据填充标记。使用以下内容更新组件:
import { Link } from "@remix-run/react";
import type { Article } from "shared/api";
interface ArticlePreviewProps {
article: Article;
}
export function ArticlePreview({ article }: ArticlePreviewProps) {
return (
<div className="article-preview">
<div className="article-meta">
<Link to={`/profile/${article.author.username}`} prefetch="intent">
<img src={article.author.image} alt="" />
</Link>
<div className="info">
<Link
to={`/profile/${article.author.username}`}
className="author"
prefetch="intent"
>
{article.author.username}
</Link>
<span className="date" suppressHydrationWarning>
{new Date(article.createdAt).toLocaleDateString(undefined, {
dateStyle: "long",
})}
</span>
</div>
<button className="btn btn-outline-primary btn-sm pull-xs-right">
<i className="ion-heart"></i> {article.favoritesCount}
</button>
</div>
<Link
to={`/article/${article.slug}`}
className="preview-link"
prefetch="intent"
>
<h1>{article.title}</h1>
<p>{article.description}</p>
<span>Read more...</span>
<ul className="tag-list">
{article.tagList.map((tag) => (
<li key={tag} className="tag-default tag-pill tag-outline">
{tag}
</li>
))}
</ul>
</Link>
</div>
);
}
点赞按钮目前不做任何事情,我们将在到达文章阅读器页面并实现点赞功能时修复它。
现在我们可以获取文章并渲染出一堆这些卡片。在 Remix 中获取数据是通过 loaders 完成的 — 服务器端函数,获取页面所需的确切内容。Loaders 代表页面与 API 交互,所以我们将它们放在页面的 api
segment 中:
import { json } from "@remix-run/node";
import { GET } from "shared/api";
export const loader = async () => {
const { data: articles, error, response } = await GET("/articles");
if (error !== undefined) {
throw json(error, { status: response.status });
}
return json({ articles });
};
要将它连接到页面,我们需要从路由文件中以名称 loader
导出它:
export { FeedPage } from "./ui/FeedPage";
export { loader } from "./api/loader";
import type { MetaFunction } from "@remix-run/node";
import { FeedPage } from "pages/feed";
export { loader } from "pages/feed";
export const meta: MetaFunction = () => {
return [{ title: "Conduit" }];
};
export default FeedPage;
最后一步是在 feed 中渲染这些卡片。使用以下代码更新您的 FeedPage
:
import { useLoaderData } from "@remix-run/react";
import type { loader } from "../api/loader";
import { ArticlePreview } from "./ArticlePreview";
export function FeedPage() {
const { articles } = useLoaderData<typeof loader>();
return (
<div className="home-page">
<div className="banner">
<div className="container">
<h1 className="logo-font">conduit</h1>
<p>A place to share your knowledge.</p>
</div>
</div>
<div className="container page">
<div className="row">
<div className="col-md-9">
{articles.articles.map((article) => (
<ArticlePreview key={article.slug} article={article} />
))}
</div>
</div>
</div>
</div>
);
}
按标签过滤
关于标签,我们的工作是从后端获取它们并存储当前选中的标签。我们已经知道如何进行获取 — 这是来自 loader 的另一个请求。我们将使用来自已安装的 remix-utils
包的便利函数 promiseHash
。
使用以下代码更新 loader 文件 pages/feed/api/loader.ts
:
import { json } from "@remix-run/node";
import type { FetchResponse } from "openapi-fetch";
import { promiseHash } from "remix-utils/promise";
import { GET } from "shared/api";
async function throwAnyErrors<T, O, Media extends `${string}/${string}`>(
responsePromise: Promise<FetchResponse<T, O, Media>>,
) {
const { data, error, response } = await responsePromise;
if (error !== undefined) {
throw json(error, { status: response.status });
}
return data as NonNullable<typeof data>;
}
export const loader = async () => {
return json(
await promiseHash({
articles: throwAnyErrors(GET("/articles")),
tags: throwAnyErrors(GET("/tags")),
}),
);
};
您可能会注意到我们将错误处理提取到一个通用函数 throwAnyErrors
中。它看起来非常有用,所以我们可能希望稍后重用它,但现在让我们先留意一下。
现在,到标签列表。它需要是交互式的 — 点击标签应该使该标签被选中。按照 Remix 约定,我们将使用 URL 搜索参数作为我们选中标签的存储。让浏览器处理存储,而我们专注于更重要的事情。
使用以下代码更新 pages/feed/ui/FeedPage.tsx
:
import { Form, useLoaderData } from "@remix-run/react";
import { ExistingSearchParams } from "remix-utils/existing-search-params";
import type { loader } from "../api/loader";
import { ArticlePreview } from "./ArticlePreview";
export function FeedPage() {
const { articles, tags } = useLoaderData<typeof loader>();
return (
<div className="home-page">
<div className="banner">
<div className="container">
<h1 className="logo-font">conduit</h1>
<p>A place to share your knowledge.</p>
</div>
</div>
<div className="container page">
<div className="row">
<div className="col-md-9">
{articles.articles.map((article) => (
<ArticlePreview key={article.slug} article={article} />
))}
</div>
<div className="col-md-3">
<div className="sidebar">
<p>Popular Tags</p>
<Form>
<ExistingSearchParams exclude={["tag"]} />
<div className="tag-list">
{tags.tags.map((tag) => (
<button
key={tag}
name="tag"
value={tag}
className="tag-pill tag-default"
>
{tag}
</button>
))}
</div>
</Form>
</div>
</div>
</div>
</div>
</div>
);
}
然后我们需要在我们的 loader 中使用 tag
搜索参数。将 pages/feed/api/loader.ts
中的 loader
函数更改为以下内容:
import { json, type LoaderFunctionArgs } from "@remix-run/node";
import type { FetchResponse } from "openapi-fetch";
import { promiseHash } from "remix-utils/promise";
import { GET } from "shared/api";
async function throwAnyErrors<T, O, Media extends `${string}/${string}`>(
responsePromise: Promise<FetchResponse<T, O, Media>>,
) {
const { data, error, response } = await responsePromise;
if (error !== undefined) {
throw json(error, { status: response.status });
}
return data as NonNullable<typeof data>;
}
export const loader = async ({ request }: LoaderFunctionArgs) => {
const url = new URL(request.url);
const selectedTag = url.searchParams.get("tag") ?? undefined;
return json(
await promiseHash({
articles: throwAnyErrors(
GET("/articles", { params: { query: { tag: selectedTag } } }),
),
tags: throwAnyErrors(GET("/tags")),
}),
);
};
就是这样,不需要 model
segment。Remix 非常整洁。
分页
以类似的方式,我们可以实现分页。随意自己尝试一下或直接复制下面的代码。反正没有人会判断您。
import { json, type LoaderFunctionArgs } from "@remix-run/node";
import type { FetchResponse } from "openapi-fetch";
import { promiseHash } from "remix-utils/promise";
import { GET } from "shared/api";
async function throwAnyErrors<T, O, Media extends `${string}/${string}`>(
responsePromise: Promise<FetchResponse<T, O, Media>>,
) {
const { data, error, response } = await responsePromise;
if (error !== undefined) {
throw json(error, { status: response.status });
}
return data as NonNullable<typeof data>;
}
/** Amount of articles on one page. */
export const LIMIT = 20;
export const loader = async ({ request }: LoaderFunctionArgs) => {
const url = new URL(request.url);
const selectedTag = url.searchParams.get("tag") ?? undefined;
const page = parseInt(url.searchParams.get("page") ?? "", 10);
return json(
await promiseHash({
articles: throwAnyErrors(
GET("/articles", {
params: {
query: {
tag: selectedTag,
limit: LIMIT,
offset: !Number.isNaN(page) ? page * LIMIT : undefined,
},
},
}),
),
tags: throwAnyErrors(GET("/tags")),
}),
);
};
import { Form, useLoaderData, useSearchParams } from "@remix-run/react";
import { ExistingSearchParams } from "remix-utils/existing-search-params";
import { LIMIT, type loader } from "../api/loader";
import { ArticlePreview } from "./ArticlePreview";
export function FeedPage() {
const [searchParams] = useSearchParams();
const { articles, tags } = useLoaderData<typeof loader>();
const pageAmount = Math.ceil(articles.articlesCount / LIMIT);
const currentPage = parseInt(searchParams.get("page") ?? "1", 10);
return (
<div className="home-page">
<div className="banner">
<div className="container">
<h1 className="logo-font">conduit</h1>
<p>A place to share your knowledge.</p>
</div>
</div>
<div className="container page">
<div className="row">
<div className="col-md-9">
{articles.articles.map((article) => (
<ArticlePreview key={article.slug} article={article} />
))}
<Form>
<ExistingSearchParams exclude={["page"]} />
<ul className="pagination">
{Array(pageAmount)
.fill(null)
.map((_, index) =>
index + 1 === currentPage ? (
<li key={index} className="page-item active">
<span className="page-link">{index + 1}</span>
</li>
) : (
<li key={index} className="page-item">
<button
className="page-link"
name="page"
value={index + 1}
>
{index + 1}
</button>
</li>
),
)}
</ul>
</Form>
</div>
<div className="col-md-3">
<div className="sidebar">
<p>Popular Tags</p>
<Form>
<ExistingSearchParams exclude={["tag", "page"]} />
<div className="tag-list">
{tags.tags.map((tag) => (
<button
key={tag}
name="tag"
value={tag}
className="tag-pill tag-default"
>
{tag}
</button>
))}
</div>
</Form>
</div>
</div>
</div>
</div>
</div>
);
}
这样也完成了。还有选项卡列表可以类似地实现,但让我们等到实现身份验证时再处理。说到这个!
身份验证
身份验证涉及两个页面 — 一个用于登录,另一个用于注册。它们大部分相同,所以将它们保持在同一个 slice sign-in
中是有意义的,这样它们可以在需要时重用代码。
在 pages/sign-in
的 ui
segment 中创建 RegisterPage.tsx
,内容如下:
import { Form, Link, useActionData } from "@remix-run/react";
import type { register } from "../api/register";
export function RegisterPage() {
const registerData = useActionData<typeof register>();
return (
<div className="auth-page">
<div className="container page">
<div className="row">
<div className="col-md-6 offset-md-3 col-xs-12">
<h1 className="text-xs-center">Sign up</h1>
<p className="text-xs-center">
<Link to="/login">Have an account?</Link>
</p>
{registerData?.error && (
<ul className="error-messages">
{registerData.error.errors.body.map((error) => (
<li key={error}>{error}</li>
))}
</ul>
)}
<Form method="post">
<fieldset className="form-group">
<input
className="form-control form-control-lg"
type="text"
name="username"
placeholder="Username"
/>
</fieldset>
<fieldset className="form-group">
<input
className="form-control form-control-lg"
type="text"
name="email"
placeholder="Email"
/>
</fieldset>
<fieldset className="form-group">
<input
className="form-control form-control-lg"
type="password"
name="password"
placeholder="Password"
/>
</fieldset>
<button className="btn btn-lg btn-primary pull-xs-right">
Sign up
</button>
</Form>
</div>
</div>
</div>
</div>
);
}
我们现在有一个损坏的导入要修复。它涉及一个新的 segment,所以创建它:
npx fsd pages sign-in -s api
然而,在我们可以实现注册的后端部分之前,我们需要一些供 Remix 处理会话的基础设施代码。这放在 Shared 中,以防其他页面需要它。
将以下代码放入 shared/api/auth.server.ts
。这高度特定于 Remix,所以不要太担心,只需复制粘贴:
import { createCookieSessionStorage, redirect } from "@remix-run/node";
import invariant from "tiny-invariant";
import type { User } from "./models";
invariant(
process.env.SESSION_SECRET,
"SESSION_SECRET must be set for authentication to work",
);
const sessionStorage = createCookieSessionStorage<{
user: User;
}>({
cookie: {
name: "__session",
httpOnly: true,
path: "/",
sameSite: "lax",
secrets: [process.env.SESSION_SECRET],
secure: process.env.NODE_ENV === "production",
},
});
export async function createUserSession({
request,
user,
redirectTo,
}: {
request: Request;
user: User;
redirectTo: string;
}) {
const cookie = request.headers.get("Cookie");
const session = await sessionStorage.getSession(cookie);
session.set("user", user);
return redirect(redirectTo, {
headers: {
"Set-Cookie": await sessionStorage.commitSession(session, {
maxAge: 60 * 60 * 24 * 7, // 7 days
}),
},
});
}
export async function getUserFromSession(request: Request) {
const cookie = request.headers.get("Cookie");
const session = await sessionStorage.getSession(cookie);
return session.get("user") ?? null;
}
export async function requireUser(request: Request) {
const user = await getUserFromSession(request);
if (user === null) {
throw redirect("/login");
}
return user;
}
同时从旁边的 models.ts
文件中导出 User
模型:
import type { components } from "./v1";
export type Article = components["schemas"]["Article"];
export type User = components["schemas"]["User"];
在此代码能够工作之前,需要设置 SESSION_SECRET
环境变量。在项目根目录中创建一个名为 .env
的文件,写入 SESSION_SECRET=
,然后在键盘上随意敲击一些键来创建一个长的随机字符串。您应该得到类似这样的东西:
SESSION_SECRET=dontyoudarecopypastethis
最后,向 public API 添加一些导出以使用此代码:
export { GET, POST, PUT, DELETE } from "./client";
export type { Article } from "./models";
export { createUserSession, getUserFromSession, requireUser } from "./auth.server";
现在我们可以编写与 RealWorld 后端通信以实际进行注册的代码。我们将其保存在 pages/sign-in/api
中。创建一个名为 register.ts
的文件,并将以下代码放入其中:
import { json, type ActionFunctionArgs } from "@remix-run/node";
import { POST, createUserSession } from "shared/api";
export const register = async ({ request }: ActionFunctionArgs) => {
const formData = await request.formData();
const username = formData.get("username")?.toString() ?? "";
const email = formData.get("email")?.toString() ?? "";
const password = formData.get("password")?.toString() ?? "";
const { data, error } = await POST("/users", {
body: { user: { email, password, username } },
});
if (error) {
return json({ error }, { status: 400 });
} else {
return createUserSession({
request: request,
user: data.user,
redirectTo: "/",
});
}
};
export { RegisterPage } from './ui/RegisterPage';
export { register } from './api/register';
几乎完成了!只需要将页面和操作连接到 /register
路由。在 app/routes
中创建 register.tsx
:
import { RegisterPage, register } from "pages/sign-in";
export { register as action };
export default RegisterPage;
现在如果您转到 http://localhost:3000/register,您应该能够创建用户!应用程序的其余部分还不会对此做出反应,我们将立即解决这个问题。
以非常类似的方式,我们可以实现登录页面。尝试一下或直接获取代码并继续:
import { json, type ActionFunctionArgs } from "@remix-run/node";
import { POST, createUserSession } from "shared/api";
export const signIn = async ({ request }: ActionFunctionArgs) => {
const formData = await request.formData();
const email = formData.get("email")?.toString() ?? "";
const password = formData.get("password")?.toString() ?? "";
const { data, error } = await POST("/users/login", {
body: { user: { email, password } },
});
if (error) {
return json({ error }, { status: 400 });
} else {
return createUserSession({
request: request,
user: data.user,
redirectTo: "/",
});
}
};
import { Form, Link, useActionData } from "@remix-run/react";
import type { signIn } from "../api/sign-in";
export function SignInPage() {
const signInData = useActionData<typeof signIn>();
return (
<div className="auth-page">
<div className="container page">
<div className="row">
<div className="col-md-6 offset-md-3 col-xs-12">
<h1 className="text-xs-center">Sign in</h1>
<p className="text-xs-center">
<Link to="/register">Need an account?</Link>
</p>
{signInData?.error && (
<ul className="error-messages">
{signInData.error.errors.body.map((error) => (
<li key={error}>{error}</li>
))}
</ul>
)}
<Form method="post">
<fieldset className="form-group">
<input
className="form-control form-control-lg"
name="email"
type="text"
placeholder="Email"
/>
</fieldset>
<fieldset className="form-group">
<input
className="form-control form-control-lg"
name="password"
type="password"
placeholder="Password"
/>
</fieldset>
<button className="btn btn-lg btn-primary pull-xs-right">
Sign in
</button>
</Form>
</div>
</div>
</div>
</div>
);
}
export { RegisterPage } from './ui/RegisterPage';
export { register } from './api/register';
export { SignInPage } from './ui/SignInPage';
export { signIn } from './api/sign-in';
import { SignInPage, signIn } from "pages/sign-in";
export { signIn as action };
export default SignInPage;
现在让我们给用户一种实际达到这些页面的方法。
头部
正如我们在第一部分中讨论的,应用程序头部通常放在 Widgets 或 Shared 中。我们将其放在 Shared 中,因为它非常简单,所有业务逻辑都可以保持在它之外。让我们为它创建一个地方:
npx fsd shared ui
现在创建 shared/ui/Header.tsx
,内容如下:
import { useContext } from "react";
import { Link, useLocation } from "@remix-run/react";
import { CurrentUser } from "../api/currentUser";
export function Header() {
const currentUser = useContext(CurrentUser);
const { pathname } = useLocation();
return (
<nav className="navbar navbar-light">
<div className="container">
<Link className="navbar-brand" to="/" prefetch="intent">
conduit
</Link>
<ul className="nav navbar-nav pull-xs-right">
<li className="nav-item">
<Link
prefetch="intent"
className={`nav-link ${pathname == "/" ? "active" : ""}`}
to="/"
>
Home
</Link>
</li>
{currentUser == null ? (
<>
<li className="nav-item">
<Link
prefetch="intent"
className={`nav-link ${pathname == "/login" ? "active" : ""}`}
to="/login"
>
Sign in
</Link>
</li>
<li className="nav-item">
<Link
prefetch="intent"
className={`nav-link ${pathname == "/register" ? "active" : ""}`}
to="/register"
>
Sign up
</Link>
</li>
</>
) : (
<>
<li className="nav-item">
<Link
prefetch="intent"
className={`nav-link ${pathname == "/editor" ? "active" : ""}`}
to="/editor"
>
<i className="ion-compose"></i> New Article{" "}
</Link>
</li>
<li className="nav-item">
<Link
prefetch="intent"
className={`nav-link ${pathname == "/settings" ? "active" : ""}`}
to="/settings"
>
{" "}
<i className="ion-gear-a"></i> Settings{" "}
</Link>
</li>
<li className="nav-item">
<Link
prefetch="intent"
className={`nav-link ${pathname.includes("/profile") ? "active" : ""}`}
to={`/profile/${currentUser.username}`}
>
{currentUser.image && (
<img
width={25}
height={25}
src={currentUser.image}
className="user-pic"
alt=""
/>
)}
{currentUser.username}
</Link>
</li>
</>
)}
</ul>
</div>
</nav>
);
}
从 shared/ui
导出此组件:
export { Header } from "./Header";
在头部中,我们依赖保存在 shared/api
中的上下文。也创建它:
import { createContext } from "react";
import type { User } from "./models";
export const CurrentUser = createContext<User | null>(null);
export { GET, POST, PUT, DELETE } from "./client";
export type { Article } from "./models";
export { createUserSession, getUserFromSession, requireUser } from "./auth.server";
export { CurrentUser } from "./currentUser";
现在让我们将头部添加到页面。我们希望它出现在每一个页面上,所以简单地将其添加到根路由并用 CurrentUser
上下文提供者包装 outlet(页面将被渲染的地方)是有意义的。这样我们的整个应用程序以及头部都可以访问当前用户对象。我们还将添加一个 loader 来实际从 cookies 中获取当前用户对象。将以下内容放入 app/root.tsx
:
import { cssBundleHref } from "@remix-run/css-bundle";
import type { LinksFunction, LoaderFunctionArgs } from "@remix-run/node";
import {
Links,
LiveReload,
Meta,
Outlet,
Scripts,
ScrollRestoration,
useLoaderData,
} from "@remix-run/react";
import { Header } from "shared/ui";
import { getUserFromSession, CurrentUser } from "shared/api";
export const links: LinksFunction = () => [
...(cssBundleHref ? [{ rel: "stylesheet", href: cssBundleHref }] : []),
];
export const loader = ({ request }: LoaderFunctionArgs) =>
getUserFromSession(request);
export default function App() {
const user = useLoaderData<typeof loader>();
return (
<html lang="en">
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<Meta />
<Links />
<link
href="//code.ionicframework.com/ionicons/2.0.1/css/ionicons.min.css"
rel="stylesheet"
type="text/css"
/>
<link
href="//fonts.googleapis.com/css?family=Titillium+Web:700|Source+Serif+Pro:400,700|Merriweather+Sans:400,700|Source+Sans+Pro:400,300,600,700,300italic,400italic,600italic,700italic"
rel="stylesheet"
type="text/css"
/>
<link rel="stylesheet" href="//demo.productionready.io/main.css" />
<style>{`
button {
border: 0;
}
`}</style>
</head>
<body>
<CurrentUser.Provider value={user}>
<Header />
<Outlet />
</CurrentUser.Provider>
<ScrollRestoration />
<Scripts />
<LiveReload />
</body>
</html>
);
}
在这一点,您应该在主页上得到以下结果:
选项卡
现在我们可以检测身份验证状态,让我们也快速实现选项卡和帖子点赞来完成 feed 页面。我们需要另一个表单,但这个页面文件正在变得有点大,所以让我们将这些表单移动到相邻的文件中。我们将创建 Tabs.tsx
、PopularTags.tsx
和 Pagination.tsx
,内容如下:
import { useContext } from "react";
import { Form, useSearchParams } from "@remix-run/react";
import { CurrentUser } from "shared/api";
export function Tabs() {
const [searchParams] = useSearchParams();
const currentUser = useContext(CurrentUser);
return (
<Form>
<div className="feed-toggle">
<ul className="nav nav-pills outline-active">
{currentUser !== null && (
<li className="nav-item">
<button
name="source"
value="my-feed"
className={`nav-link ${searchParams.get("source") === "my-feed" ? "active" : ""}`}
>
Your Feed
</button>
</li>
)}
<li className="nav-item">
<button
className={`nav-link ${searchParams.has("tag") || searchParams.has("source") ? "" : "active"}`}
>
Global Feed
</button>
</li>
{searchParams.has("tag") && (
<li className="nav-item">
<span className="nav-link active">
<i className="ion-pound"></i> {searchParams.get("tag")}
</span>
</li>
)}
</ul>
</div>
</Form>
);
}
import { Form, useLoaderData } from "@remix-run/react";
import { ExistingSearchParams } from "remix-utils/existing-search-params";
import type { loader } from "../api/loader";
export function PopularTags() {
const { tags } = useLoaderData<typeof loader>();
return (
<div className="sidebar">
<p>Popular Tags</p>
<Form>
<ExistingSearchParams exclude={["tag", "page", "source"]} />
<div className="tag-list">
{tags.tags.map((tag) => (
<button
key={tag}
name="tag"
value={tag}
className="tag-pill tag-default"
>
{tag}
</button>
))}
</div>
</Form>
</div>
);
}
import { Form, useLoaderData, useSearchParams } from "@remix-run/react";
import { ExistingSearchParams } from "remix-utils/existing-search-params";
import { LIMIT, type loader } from "../api/loader";
export function Pagination() {
const [searchParams] = useSearchParams();
const { articles } = useLoaderData<typeof loader>();
const pageAmount = Math.ceil(articles.articlesCount / LIMIT);
const currentPage = parseInt(searchParams.get("page") ?? "1", 10);
return (
<Form>
<ExistingSearchParams exclude={["page"]} />
<ul className="pagination">
{Array(pageAmount)
.fill(null)
.map((_, index) =>
index + 1 === currentPage ? (
<li key={index} className="page-item active">
<span className="page-link">{index + 1}</span>
</li>
) : (
<li key={index} className="page-item">
<button className="page-link" name="page" value={index + 1}>
{index + 1}
</button>
</li>
),
)}
</ul>
</Form>
);
}
现在我们可以显著简化 feed 页面本身:
import { useLoaderData } from "@remix-run/react";
import type { loader } from "../api/loader";
import { ArticlePreview } from "./ArticlePreview";
import { Tabs } from "./Tabs";
import { PopularTags } from "./PopularTags";
import { Pagination } from "./Pagination";
export function FeedPage() {
const { articles } = useLoaderData<typeof loader>();
return (
<div className="home-page">
<div className="banner">
<div className="container">
<h1 className="logo-font">conduit</h1>
<p>A place to share your knowledge.</p>
</div>
</div>
<div className="container page">
<div className="row">
<div className="col-md-9">
<Tabs />
{articles.articles.map((article) => (
<ArticlePreview key={article.slug} article={article} />
))}
<Pagination />
</div>
<div className="col-md-3">
<PopularTags />
</div>
</div>
</div>
</div>
);
}
我们还需要在 loader 函数中考虑新选项卡:
import { json, type LoaderFunctionArgs } from "@remix-run/node";
import type { FetchResponse } from "openapi-fetch";
import { promiseHash } from "remix-utils/promise";
import { GET, requireUser } from "shared/api";
async function throwAnyErrors<T, O, Media extends `${string}/${string}`>(
responsePromise: Promise<FetchResponse<T, O, Media>>,
) {
/* unchanged */
}
/** Amount of articles on one page. */
export const LIMIT = 20;
export const loader = async ({ request }: LoaderFunctionArgs) => {
const url = new URL(request.url);
const selectedTag = url.searchParams.get("tag") ?? undefined;
const page = parseInt(url.searchParams.get("page") ?? "", 10);
if (url.searchParams.get("source") === "my-feed") {
const userSession = await requireUser(request);
return json(
await promiseHash({
articles: throwAnyErrors(
GET("/articles/feed", {
params: {
query: {
limit: LIMIT,
offset: !Number.isNaN(page) ? page * LIMIT : undefined,
},
},
headers: { Authorization: `Token ${userSession.token}` },
}),
),
tags: throwAnyErrors(GET("/tags")),
}),
);
}
return json(
await promiseHash({
articles: throwAnyErrors(
GET("/articles", {
params: {
query: {
tag: selectedTag,
limit: LIMIT,
offset: !Number.isNaN(page) ? page * LIMIT : undefined,
},
},
}),
),
tags: throwAnyErrors(GET("/tags")),
}),
);
};
在我们离开 feed 页面之前,让我们添加一些处理帖子点赞的代码。将您的 ArticlePreview.tsx
更改为以下内容:
import { Form, Link } from "@remix-run/react";
import type { Article } from "shared/api";
interface ArticlePreviewProps {
article: Article;
}
export function ArticlePreview({ article }: ArticlePreviewProps) {
return (
<div className="article-preview">
<div className="article-meta">
<Link to={`/profile/${article.author.username}`} prefetch="intent">
<img src={article.author.image} alt="" />
</Link>
<div className="info">
<Link
to={`/profile/${article.author.username}`}
className="author"
prefetch="intent"
>
{article.author.username}
</Link>
<span className="date" suppressHydrationWarning>
{new Date(article.createdAt).toLocaleDateString(undefined, {
dateStyle: "long",
})}
</span>
</div>
<Form
method="post"
action={`/article/${article.slug}`}
preventScrollReset
>
<button
name="_action"
value={article.favorited ? "unfavorite" : "favorite"}
className={`btn ${article.favorited ? "btn-primary" : "btn-outline-primary"} btn-sm pull-xs-right`}
>
<i className="ion-heart"></i> {article.favoritesCount}
</button>
</Form>
</div>
<Link
to={`/article/${article.slug}`}
className="preview-link"
prefetch="intent"
>
<h1>{article.title}</h1>
<p>{article.description}</p>
<span>Read more...</span>
<ul className="tag-list">
{article.tagList.map((tag) => (
<li key={tag} className="tag-default tag-pill tag-outline">
{tag}
</li>
))}
</ul>
</Link>
</div>
);
}
此代码将向 /article/:slug
发送带有 _action=favorite
的 POST 请求以将文章标记为收藏。它还不会工作,但当我们开始处理文章阅读器时,我们也会实现这个功能。
这样我们就正式完成了 feed!太好了!
文章阅读器
首先,我们需要数据。让我们创建一个 loader:
npx fsd pages article-read -s api
import { json, type LoaderFunctionArgs } from "@remix-run/node";
import invariant from "tiny-invariant";
import type { FetchResponse } from "openapi-fetch";
import { promiseHash } from "remix-utils/promise";
import { GET, getUserFromSession } from "shared/api";
async function throwAnyErrors<T, O, Media extends `${string}/${string}`>(
responsePromise: Promise<FetchResponse<T, O, Media>>,
) {
const { data, error, response } = await responsePromise;
if (error !== undefined) {
throw json(error, { status: response.status });
}
return data as NonNullable<typeof data>;
}
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
invariant(params.slug, "Expected a slug parameter");
const currentUser = await getUserFromSession(request);
const authorization = currentUser
? { Authorization: `Token ${currentUser.token}` }
: undefined;
return json(
await promiseHash({
article: throwAnyErrors(
GET("/articles/{slug}", {
params: {
path: { slug: params.slug },
},
headers: authorization,
}),
),
comments: throwAnyErrors(
GET("/articles/{slug}/comments", {
params: {
path: { slug: params.slug },
},
headers: authorization,
}),
),
}),
);
};
export { loader } from "./api/loader";
现在我们可以通过创建一个名为 article.$slug.tsx
的路由文件将其连接到路由 /article/:slug
:
export { loader } from "pages/article-read";
页面本身由三个主要块组成 — 带有操作的文章头部(重复两次)、文章主体和评论部分。这是页面的标记,它并不特别有趣:
import { useLoaderData } from "@remix-run/react";
import type { loader } from "../api/loader";
import { ArticleMeta } from "./ArticleMeta";
import { Comments } from "./Comments";
export function ArticleReadPage() {
const { article } = useLoaderData<typeof loader>();
return (
<div className="article-page">
<div className="banner">
<div className="container">
<h1>{article.article.title}</h1>
<ArticleMeta />
</div>
</div>
<div className="container page">
<div className="row article-content">
<div className="col-md-12">
<p>{article.article.body}</p>
<ul className="tag-list">
{article.article.tagList.map((tag) => (
<li className="tag-default tag-pill tag-outline" key={tag}>
{tag}
</li>
))}
</ul>
</div>
</div>
<hr />
<div className="article-actions">
<ArticleMeta />
</div>
<div className="row">
<Comments />
</div>
</div>
</div>
);
}
更有趣的是 ArticleMeta
和 Comments
。它们包含写操作,如点赞文章、留下评论等。要让它们工作,我们首先需要实现后端部分。在页面的 api
segment 中创建 action.ts
:
import { redirect, type ActionFunctionArgs } from "@remix-run/node";
import { namedAction } from "remix-utils/named-action";
import { redirectBack } from "remix-utils/redirect-back";
import invariant from "tiny-invariant";
import { DELETE, POST, requireUser } from "shared/api";
export const action = async ({ request, params }: ActionFunctionArgs) => {
const currentUser = await requireUser(request);
const authorization = { Authorization: `Token ${currentUser.token}` };
const formData = await request.formData();
return namedAction(formData, {
async delete() {
invariant(params.slug, "Expected a slug parameter");
await DELETE("/articles/{slug}", {
params: { path: { slug: params.slug } },
headers: authorization,
});
return redirect("/");
},
async favorite() {
invariant(params.slug, "Expected a slug parameter");
await POST("/articles/{slug}/favorite", {
params: { path: { slug: params.slug } },
headers: authorization,
});
return redirectBack(request, { fallback: "/" });
},
async unfavorite() {
invariant(params.slug, "Expected a slug parameter");
await DELETE("/articles/{slug}/favorite", {
params: { path: { slug: params.slug } },
headers: authorization,
});
return redirectBack(request, { fallback: "/" });
},
async createComment() {
invariant(params.slug, "Expected a slug parameter");
const comment = formData.get("comment");
invariant(typeof comment === "string", "Expected a comment parameter");
await POST("/articles/{slug}/comments", {
params: { path: { slug: params.slug } },
headers: { ...authorization, "Content-Type": "application/json" },
body: { comment: { body: comment } },
});
return redirectBack(request, { fallback: "/" });
},
async deleteComment() {
invariant(params.slug, "Expected a slug parameter");
const commentId = formData.get("id");
invariant(typeof commentId === "string", "Expected an id parameter");
const commentIdNumeric = parseInt(commentId, 10);
invariant(
!Number.isNaN(commentIdNumeric),
"Expected a numeric id parameter",
);
await DELETE("/articles/{slug}/comments/{id}", {
params: { path: { slug: params.slug, id: commentIdNumeric } },
headers: authorization,
});
return redirectBack(request, { fallback: "/" });
},
async followAuthor() {
const authorUsername = formData.get("username");
invariant(
typeof authorUsername === "string",
"Expected a username parameter",
);
await POST("/profiles/{username}/follow", {
params: { path: { username: authorUsername } },
headers: authorization,
});
return redirectBack(request, { fallback: "/" });
},
async unfollowAuthor() {
const authorUsername = formData.get("username");
invariant(
typeof authorUsername === "string",
"Expected a username parameter",
);
await DELETE("/profiles/{username}/follow", {
params: { path: { username: authorUsername } },
headers: authorization,
});
return redirectBack(request, { fallback: "/" });
},
});
};
从 slice 中导出它,然后从路由中导出。趁着这个机会,让我们也连接页面本身:
export { ArticleReadPage } from "./ui/ArticleReadPage";
export { loader } from "./api/loader";
export { action } from "./api/action";
import { ArticleReadPage } from "pages/article-read";
export { loader, action } from "pages/article-read";
export default ArticleReadPage;
现在,尽管我们还没有在阅读器页面上实现点赞按钮,但 feed 中的点赞按钮将开始工作!这是因为它一直在向这个路由发送"点赞"请求。试试看吧。
ArticleMeta
和 Comments
又是一堆表单。我们之前已经做过这个,让我们获取它们的代码并继续:
import { Form, Link, useLoaderData } from "@remix-run/react";
import { useContext } from "react";
import { CurrentUser } from "shared/api";
import type { loader } from "../api/loader";
export function ArticleMeta() {
const currentUser = useContext(CurrentUser);
const { article } = useLoaderData<typeof loader>();
return (
<Form method="post">
<div className="article-meta">
<Link
prefetch="intent"
to={`/profile/${article.article.author.username}`}
>
<img src={article.article.author.image} alt="" />
</Link>
<div className="info">
<Link
prefetch="intent"
to={`/profile/${article.article.author.username}`}
className="author"
>
{article.article.author.username}
</Link>
<span className="date">{article.article.createdAt}</span>
</div>
{article.article.author.username == currentUser?.username ? (
<>
<Link
prefetch="intent"
to={`/editor/${article.article.slug}`}
className="btn btn-sm btn-outline-secondary"
>
<i className="ion-edit"></i> Edit Article
</Link>
<button
name="_action"
value="delete"
className="btn btn-sm btn-outline-danger"
>
<i className="ion-trash-a"></i> Delete Article
</button>
</>
) : (
<>
<input
name="username"
value={article.article.author.username}
type="hidden"
/>
<button
name="_action"
value={
article.article.author.following
? "unfollowAuthor"
: "followAuthor"
}
className={`btn btn-sm ${article.article.author.following ? "btn-secondary" : "btn-outline-secondary"}`}
>
<i className="ion-plus-round"></i>
{" "}
{article.article.author.following
? "Unfollow"
: "Follow"}{" "}
{article.article.author.username}
</button>
<button
name="_action"
value={article.article.favorited ? "unfavorite" : "favorite"}
className={`btn btn-sm ${article.article.favorited ? "btn-primary" : "btn-outline-primary"}`}
>
<i className="ion-heart"></i>
{article.article.favorited
? "Unfavorite"
: "Favorite"}{" "}
Post{" "}
<span className="counter">
({article.article.favoritesCount})
</span>
</button>
</>
)}
</div>
</Form>
);
}
import { useContext } from "react";
import { Form, Link, useLoaderData } from "@remix-run/react";
import { CurrentUser } from "shared/api";
import type { loader } from "../api/loader";
export function Comments() {
const { comments } = useLoaderData<typeof loader>();
const currentUser = useContext(CurrentUser);
return (
<div className="col-xs-12 col-md-8 offset-md-2">
{currentUser !== null ? (
<Form
preventScrollReset={true}
method="post"
className="card comment-form"
>
<div className="card-block">
<textarea
required
className="form-control"
name="comment"
placeholder="Write a comment..."
rows={3}
></textarea>
</div>
<div className="card-footer">
<img
src={currentUser.image}
className="comment-author-img"
alt=""
/>
<button
className="btn btn-sm btn-primary"
name="_action"
value="createComment"
>
Post Comment
</button>
</div>
</Form>
) : (
<div className="row">
<div className="col-xs-12 col-md-8 offset-md-2">
<p>
<Link to="/login">Sign in</Link>
or
<Link to="/register">Sign up</Link>
to add comments on this article.
</p>
</div>
</div>
)}
{comments.comments.map((comment) => (
<div className="card" key={comment.id}>
<div className="card-block">
<p className="card-text">{comment.body}</p>
</div>
<div className="card-footer">
<Link
to={`/profile/${comment.author.username}`}
className="comment-author"
>
<img
src={comment.author.image}
className="comment-author-img"
alt=""
/>
</Link>
<Link
to={`/profile/${comment.author.username}`}
className="comment-author"
>
{comment.author.username}
</Link>
<span className="date-posted">{comment.createdAt}</span>
{comment.author.username === currentUser?.username && (
<span className="mod-options">
<Form method="post" preventScrollReset={true}>
<input type="hidden" name="id" value={comment.id} />
<button
name="_action"
value="deleteComment"
style={{
border: "none",
outline: "none",
backgroundColor: "transparent",
}}
>
<i className="ion-trash-a"></i>
</button>
</Form>
</span>
)}
</div>
</div>
))}
</div>
);
}
这样我们的文章阅读器也完成了!关注作者、点赞帖子和留下评论的按钮现在应该能按预期工作。
文章编辑器
这是我们将在本教程中涵盖的最后一个页面,这里最有趣的部分是我们将如何验证表单数据。
页面本身,article-edit/ui/ArticleEditPage.tsx
,将非常简单,额外的复杂性被存储到其他两个组件中:
import { Form, useLoaderData } from "@remix-run/react";
import type { loader } from "../api/loader";
import { TagsInput } from "./TagsInput";
import { FormErrors } from "./FormErrors";
export function ArticleEditPage() {
const article = useLoaderData<typeof loader>();
return (
<div className="editor-page">
<div className="container page">
<div className="row">
<div className="col-md-10 offset-md-1 col-xs-12">
<FormErrors />
<Form method="post">
<fieldset>
<fieldset className="form-group">
<input
type="text"
className="form-control form-control-lg"
name="title"
placeholder="Article Title"
defaultValue={article.article?.title}
/>
</fieldset>
<fieldset className="form-group">
<input
type="text"
className="form-control"
name="description"
placeholder="What's this article about?"
defaultValue={article.article?.description}
/>
</fieldset>
<fieldset className="form-group">
<textarea
className="form-control"
name="body"
rows={8}
placeholder="Write your article (in markdown)"
defaultValue={article.article?.body}
></textarea>
</fieldset>
<fieldset className="form-group">
<TagsInput
name="tags"
defaultValue={article.article?.tagList ?? []}
/>
</fieldset>
<button className="btn btn-lg pull-xs-right btn-primary">
Publish Article
</button>
</fieldset>
</Form>
</div>
</div>
</div>
</div>
);
}
此页面获取当前文章(除非我们从头开始编写)并填写相应的表单字段。我们之前见过这个。有趣的部分是 FormErrors
,因为它将接收验证结果并向用户显示。让我们看一下:
import { useActionData } from "@remix-run/react";
import type { action } from "../api/action";
export function FormErrors() {
const actionData = useActionData<typeof action>();
return actionData?.errors != null ? (
<ul className="error-messages">
{actionData.errors.map((error) => (
<li key={error}>{error}</li>
))}
</ul>
) : null;
}
这里我们假设我们的 action 将返回 errors
字段,一个人类可读的错误消息数组。我们很快就会讲到 action。
另一个组件是标签输入。它只是一个普通的输入字段,附带所选标签的额外预览。这里没什么可看的:
import { useEffect, useRef, useState } from "react";
export function TagsInput({
name,
defaultValue,
}: {
name: string;
defaultValue?: Array<string>;
}) {
const [tagListState, setTagListState] = useState(defaultValue ?? []);
function removeTag(tag: string): void {
const newTagList = tagListState.filter((t) => t !== tag);
setTagListState(newTagList);
}
const tagsInput = useRef<HTMLInputElement>(null);
useEffect(() => {
tagsInput.current && (tagsInput.current.value = tagListState.join(","));
}, [tagListState]);
return (
<>
<input
type="text"
className="form-control"
id="tags"
name={name}
placeholder="Enter tags"
defaultValue={tagListState.join(",")}
onChange={(e) =>
setTagListState(e.target.value.split(",").filter(Boolean))
}
/>
<div className="tag-list">
{tagListState.map((tag) => (
<span className="tag-default tag-pill" key={tag}>
<i
className="ion-close-round"
role="button"
tabIndex={0}
onKeyDown={(e) =>
[" ", "Enter"].includes(e.key) && removeTag(tag)
}
onClick={() => removeTag(tag)}
></i>{" "}
{tag}
</span>
))}
</div>
</>
);
}
现在,API 部分。loader 应该查看 URL,如果它包含文章 slug,那意味着我们正在编辑现有文章,应该加载其数据。否则,返回空。让我们创建该 loader:
import { json, type LoaderFunctionArgs } from "@remix-run/node";
import type { FetchResponse } from "openapi-fetch";
import { GET, requireUser } from "shared/api";
async function throwAnyErrors<T, O, Media extends `${string}/${string}`>(
responsePromise: Promise<FetchResponse<T, O, Media>>,
) {
const { data, error, response } = await responsePromise;
if (error !== undefined) {
throw json(error, { status: response.status });
}
return data as NonNullable<typeof data>;
}
export const loader = async ({ params, request }: LoaderFunctionArgs) => {
const currentUser = await requireUser(request);
if (!params.slug) {
return { article: null };
}
return throwAnyErrors(
GET("/articles/{slug}", {
params: { path: { slug: params.slug } },
headers: { Authorization: `Token ${currentUser.token}` },
}),
);
};
action 将获取新的字段值,通过我们的数据模式运行它们,如果一切都正确,就将这些更改提交到后端,通过更新现有文章或创建新文章:
import { json, redirect, type ActionFunctionArgs } from "@remix-run/node";
import { POST, PUT, requireUser } from "shared/api";
import { parseAsArticle } from "../model/parseAsArticle";
export const action = async ({ request, params }: ActionFunctionArgs) => {
try {
const { body, description, title, tags } = parseAsArticle(
await request.formData(),
);
const tagList = tags?.split(",") ?? [];
const currentUser = await requireUser(request);
const payload = {
body: {
article: {
title,
description,
body,
tagList,
},
},
headers: { Authorization: `Token ${currentUser.token}` },
};
const { data, error } = await (params.slug
? PUT("/articles/{slug}", {
params: { path: { slug: params.slug } },
...payload,
})
: POST("/articles", payload));
if (error) {
return json({ errors: error }, { status: 422 });
}
return redirect(`/article/${data.article.slug ?? ""}`);
} catch (errors) {
return json({ errors }, { status: 400 });
}
};
模式同时作为 FormData
的解析函数,这使我们可以方便地获取干净的字段或只是抛出错误在末尾处理。这里是该解析函数的样子:
export function parseAsArticle(data: FormData) {
const errors = [];
const title = data.get("title");
if (typeof title !== "string" || title === "") {
errors.push("Give this article a title");
}
const description = data.get("description");
if (typeof description !== "string" || description === "") {
errors.push("Describe what this article is about");
}
const body = data.get("body");
if (typeof body !== "string" || body === "") {
errors.push("Write the article itself");
}
const tags = data.get("tags");
if (typeof tags !== "string") {
errors.push("The tags must be a string");
}
if (errors.length > 0) {
throw errors;
}
return { title, description, body, tags: data.get("tags") ?? "" } as {
title: string;
description: string;
body: string;
tags: string;
};
}
可以说,它有点凗长和重复,但这是我们为人类可读错误付出的代价。这也可以是一个 Zod 模式,例如,但然后我们必须在前端渲染错误消息,这个表单不值得复杂化。
最后一步 — 将页面、loader 和 action 连接到路由。由于我们巧妙地支持创建和编辑,我们可以从 editor._index.tsx
和 editor.$slug.tsx
两者导出相同的东西:
export { ArticleEditPage } from "./ui/ArticleEditPage";
export { loader } from "./api/loader";
export { action } from "./api/action";
import { ArticleEditPage } from "pages/article-edit";
export { loader, action } from "pages/article-edit";
export default ArticleEditPage;
我们现在完成了!登录并尝试创建一篇新文章。或者“忘记”编写文章并看到验证生效。
资料和设置页面与文章阅读器和编辑器非常相似,它们留作读者的练习,这就是您 :)