文章目录
类型安全的 Fetcher 钩子
RPC 是一种远程过程调用,这是一种奇特的说法,表示“在服务器上运行的函数”。
他们现在正在经历一个鼎盛时期,gRPC、tRPC 和 Next.js Server Actions 等工具越来越受欢迎,并重新激发了对该模式的兴趣。
但我不建议将它们与 Remix 一起使用。
Remix 的工作方式与典型的 Web 框架略有不同。它的设计重点是渐进式增强和利用浏览器的强大功能。
通过使用 RPC 库,您将远离这些好处。
例如,不能使用 tRPC 路由器生成与基本 HTML 表单兼容的 Endpoints。
在 Next.js 服务器操作宣布之前,Next 框架从未真正承认数据突变是一回事。由于没有内置的支持,tRPC 非常适合该利基市场,两者成为开发的绝佳组合。
通过在编写 Remix 应用程序的方式中采用一些新习惯,您可以在不牺牲 Remix 优势的情况下获得 RPC 的好处。
一切从资源路由开始
Remix 源于 React Router,路由是它所说的语言。Remix 应用是通过创建路由来获取数据、处理突变、提供文件、呈现页面等来构建的。
在单个文件中,任何页面都可以通过指定操作函数成为 POST 端点。
export async function action({ request }: ActionArgs) {
const body = await request.formData()
const title = body.get("title")
if (!title) {
throw new Response("Title is required", { status: 400 })
}
const description = body.get("description")
const item = db.create({
title: title.toString(),
description: description?.toString(),
})
return json(item, { status: 201 })
}
或者,它可以通过指定加载程序函数成为 GET 终结点。
export async function loader({ params }: LoaderArgs) {
const item = db.get(params.id)
if (item) {
return json(item, { status: 200 })
}
throw new Response("Not found", { status: 404 })
}
这些函数的终结点 URL 是根据文件路径自动生成的。要调用这些函数,任何组件都需要知道它要调用的资源路由的 URL,然后它可以向该 URL 发出请求。
下面是一些以编程方式调用上一个 POST 终结点的客户端代码。
const body = new FormData()
body.append("title", title)
body.append("description", description)
const response = await fetch("/items", {
method: "POST",
body,
})
由于几个原因,这并不完全理想
- URL 是硬编码的,因此如果 URL 发生更改,您必须在使用它的所有位置更新它
- 您无法知道端点需要哪些参数
- 你无法知道响应会是什么样子
这就是 RPC 模式的用武之地
RPC 只是使用内置的 URL 获取
Web 应用程序通过在客户端和服务器之间发送 HTTP 请求来工作。
大多数(如果不是全部)专用 RPC 库的运行方式相同。它们只是抽象出HTTP请求和响应的细节,并为您提供一个不错的API。
我们可以自己做!以前面的请求为例,并将其包装在一个函数中。
我们可以使用 Typescript 来定义一个 Item 类型,该类型与我们传入的参数以及我们期望的响应相匹配。
type Item = {
id: string
title: string
description?: string
}
export async function createItem(
item: Omit<Item, "id">,
): Item {
const body = new FormData()
body.append("title", item.title)
body.append("description", item.description)
const response = await fetch("/items", {
method: "POST",
body,
})
if (!response.ok) {
throw new Error("Failed to create item")
}
const createdItem = await response.json()
if (!createdItem.id || !createdItem.title) {
throw new Error("Invalid response")
}
return createdItem
}
如果从资源路由导出该函数,则可以在应用中的任何位置使用它,并获得完整的端到端类型安全性和自动完成功能。
import { createItem } from "~/routes/items.server"
使用 Zod 验证您的 RPC
手动验证可能会很痛苦,尤其是当类型变得更加复杂时。幸运的是,有一个库!
您可以使用 Zod 和 zod-form-data 在 RPC 和操作函数中验证表单数据。
import { z } from "zod"
import { zfd } from "zod-form-data"
const itemSchema = zfd.formData({
title: z.string().min(1),
description: z.string().optional(),
})
export async function action({ request }: ActionArgs) {
const body = itemSchema.parse(await request.formData())
const item = db.create({
title: body.title,
description: body.description,
})
return json(item, { status: 201 })
}
export async function createItem(
item: z.infer<itemSchema>,
) {
const body = new FormData()
body.append("title", item.title)
body.append("description", item.description)
const response = await fetch("/items", {
method: "POST",
body,
})
if (!response.ok) {
throw new Error("Failed to create item")
}
const createdItem = await response.json()
return itemSchema.parse(createdItem)
}
现在,您可以在客户端和服务器中使用相同的验证,并且可以确信要发送和接收的数据是有效的。
下一步是自定义提取器钩子
如果您尝试调用的终端节点影响加载程序使用的数据,您可能不希望只对其进行常规提取调用。
Remix 的 useFetcher 钩子有很多你想要利用的生活质量功能,例如
- 自动重新获取装载机
- 重复请求取消
- 避免具有多个请求的争用条件
- 如果服务器返回重定向响应,则重定向客户端
因此,为了在这里正确使用它,我们可以在模式中采用创建一个自定义的类型安全获取器钩子,我们可以在应用程序中的任何位置使用它。
export async function useSubmitItem() {
const fetcher = useFetcher()
const submit = useCallback(
(item: z.infer<itemSchema>) => {
const body = new FormData()
body.append("title", item.title)
body.append("description", item.description)
fetcher.submit(body, {
method: "POST",
action: "/items",
})
},
[fetcher],
)
return submit
}
这是使我们与 tRPC 等解决方案具有平价功能缺失的部分。
它感觉不像一个 RPC,更像是一个自定义钩子,但用法是相同的:
- 每个资源路由导出客户端可以调用以与服务器交互的函数
- 客户端与服务器交互的主要方式是通过这些功能
- 当服务器上的类型更新时,客户端将收到类型错误,直到它更新其函数的使用
此外,您还可以获得 RPC 库无法提供的好处,例如
- 对本机表单和表单组件的开箱即用支持
- 服务器代码与客户端代码的共置,因此您不需要定义所有 RPC 函数的中央路由器文件
黑暗模式主题切换
今天,多亏了像Tailwind这样的工具,我们可以轻松地在我们的应用程序中实现暗模式。现在,通过此功能(暗模式)寻求最佳用户体验是另一回事。这就是 Remix 的亮点,让您完全控制从后端到前端的用户体验。
“最佳用户体验”是什么意思?
为了获得更好的暗模式体验,我认为(这是个人意见)的要求是:
- 用户首次访问页面时,服务器必须以深色或浅色模式发送页面,具体取决于用户当时的计算机设置。
否则,用户将在应用程序中遇到闪光,这是因为服务器最初发送具有一个主题的页面,但随后应用程序在用户的计算机上检测到不同的主题并进行切换。如下图所示:
- 如果用户未选择任何模式,则当用户更改其计算机的模式时,页面将切换到深色或浅色模式。
- 如果用户选择某种模式,页面将切换到该模式,但如果他们更改其计算机上的模式,则不会影响页面。
- 如果用户选择模式,则模式将更改为用户计算机上当前设置的 System 模式。如果用户更改其计算机上的模式,则会影响页面。
第一个要求
为了满足第一个要求,我们需要在从服务器提供页面之前以某种方式确定用户在其计算机上选择的模式。据我了解,这是无法实现的,因为服务器不知道用户在其计算机上的选择。
那么,我们如何解决这个问题呢?
我学到的解决此问题的技巧是在组件中呈现一个
function ThemeMonitor() {
return (
<script dangerouslySetInnerHTML={{ __html: `
console.log('Theme script is running');
const allCookies = (document.cookie || "").split(";");
const themeCookie = allCookies.find((cookie) => cookie.trim().startsWith("theme="));
if (!themeCookie && navigator.cookieEnabled) {
const themeDetected = window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
document.cookie = 'theme=' + JSON.stringify({ detected: themeDetected, selected: "" }) + ';path=/';
window.location.reload();
}
`,
}}
/>
);
}
然后我们可以在我们的 root.tsx 中添加:
<html>
<head>
<ThemeMonitor />
<!--more tags here...-->
</head>
<!--more tags here...-->
</html>
这个技巧使我们能够在用户看到呈现的页面😎之前检测用户的模式。
存储在 Cookie 中的值是一个对象,我将在后面进一步研究,但它具有以下结构:
const theme = {detected: 'dark', selected: ''}
第二个要求
发现如何满足这一要求是一个惊喜。说实话,我不知道当用户在计算机上切换模式时,可以在浏览器中检测到。
我利用首选配色方案来解决这个问题。这一创新功能允许网站无缝适应用户在其操作系统或浏览器上的首选颜色模式。通过检测用户是否选择了浅色或深色模式,网站可以相应地定制其视觉外观,从而提高可读性和整体浏览体验。例:
@media (prefers-color-scheme: dark) {
/* Styles for Dark Mode */
body {
background-color: #1a1a1a;
color: #ffffff;
}
}
让我们深入研究一下我如何在 ThemeMonitor 组件中实现此功能。
function ThemeMonitor() {
const { revalidate } = useRevalidator();
useEffect(() => {
const themeQuery = window.matchMedia("(prefers-color-scheme: dark)");
function handleThemeChange() {
const currentTheme = getTheme(document.cookie);
document.cookie = commitTheme({
...currentTheme,
detected: themeQuery.matches ? "dark" : "light",
});
revalidate();
}
themeQuery.addEventListener("change", handleThemeChange);
return () => {
themeQuery.removeEventListener("change", handleThemeChange);
};
}, [revalidate]);
return <script dangerouslySetInnerHTML={{ __html: `***previous code here***`}} />
}
这里要提到的一些相关要点是:
- 由于页面从服务器接收需要处理的主题,因此在使用此钩子重新验证页面时,我们有可用的更新数据:
export async function loader({ request }: LoaderArgs) {
const theme = getTheme(request.headers.get("Cookie"));
return json({ theme }); // {detected: 'dark', selected: 'light'}
}
- MatchMedia: matchMedia 是一个JavaScript API,通过允许您向浏览器查询特定CSS媒体查询的当前状态来实现响应式设计。它提供了一种以编程方式检测设备特征(如屏幕宽度、方向和配色方案)的方法。
通过为首选配色方案(“深色”)创建媒体查询,我使用更改事件监视对此首选项的更改。每当发生更改时,我都会更新 Cookie 中检测到的主题并触发重新验证。
- Cookie:当您使用以下格式将 Cookie 分配给文档时:
document.cookie = newCookie;
它不会删除现有的 Cookie。相反,它会设置或更新您分配的特定 Cookie。这并不直观,但这就是我们拥有😩的 API .
第三个要求
为了满足第三个要求,我采用了一种策略,该策略涉及结构化数据,使我能够根据需要确定检测到的主题和用户选择的主题:
const theme = {detected: 'dark', selected: 'ligth'}
这种方法使我们能够确定要在页面上应用的主题将是:
const data = useLoaderData<typeof loader>();
const theme = data.theme.selected || data.theme.detected;
如果用户选择了模式,则 data.theme.selected || data.theme.detected 评估结果将是所选主题。😎
第四个要求
如果用户选择了以下 System 选项:
该 selected 属性将保持为空。因此,将应用检测到的主题。
好吧,就是这样。😀