文章目录
- 请求搜索参数重定向
- 进行服务器端分页
- 从 URL 读取查询参数
- 不要覆盖其他查询参数
- 使用提交按钮而不是链接
- 向按钮添加标签
- Loaders & Actions 中中止异步调用
- 全局类型及类型安全
请求搜索参数重定向
假设您要确保始终设置特定的搜索参数。为此,您可以首先检查是否设置了搜索参数,如果没有,则重定向到设置了搜索参数的同一路由。
export async function loader({ request }: LoaderFunctionArgs) {
let url = new URL(request.url);
if (!url.searchParams.has("key")) {
url.searchParams.set("key", "value");
throw redirect(url.toString());
}
// the rest of your code
}
首先,我们创建一个 URL 实例,这使我们能够更轻松地使用 URL。
然后我们访问 URLSearchParams 实例 url.searchParams 并检查它是否没有我们的密钥。如果没有,我们将其设置为 url.searchParams.set(“key”, “value”) .
最后,我们使用新 URL 抛出重定向。这会将用户重定向到相同的路由,但设置了搜索参数。
如果请求 URL 已设置搜索参数,则加载程序将继续执行代码的其余部分。
进行服务器端分页
开发人员通常将客户端分页作为折衷措施实现,以避免服务器端分页的复杂性。
在服务器上处理分页时,需要将特定的页面和页面大小传达给服务器,并且需要返回项总数,以便客户端可以呈现分页控件。
随着页面的更改,您需要更新 URL 并创建新的历史记录条目,以便用户可以使用后退按钮转到上一页,并与其他用户共享特定页面。
然后是数据获取问题:每次页面更改时,您都需要从服务器获取新数据并将其呈现在页面上。
但Remix就是为这种事情而构建的。
为每个 URL 显示正确的数据是 Remix 的目的,因此使用 Remix 实现服务器端分页实际上比在客户端上实现分页更容易。
如果您将页码和大小存储在 URL 中,Remix 将获取新数据并在 URL 更改时立即显示在页面上,并且由于数据库通常有办法一次返回一页数据,因此您也可以免费获得。
从 URL 读取查询参数
OData 规范规定,应使用 和 查询参数分别指定页面大小 $top
和 $skip
页码。
因此,如果要显示 10 个项目的第一页,请使用 ?$top=10
,而要显示第二页,请使用 ?$top=10&$skip=10
。
从加载程序中的请求 URL 读取查询参数,并使用它们从数据库中获取正确的数据页。
根据您的数据库适配器,这部分看起来会有所不同,但通常有一种一流的方法可以做到这一点,例如 Prisma 的选项 { take, skip } 或 SQL 和 LIMIT OFFSET 子句。
export async function loader({ request }: LoaderArgs) {
const url = new URL(request.url)
const $top = Number(url.searchParams.get("$top")) || 10
const $skip = Number(url.searchParams.get("$skip")) || 0
// Slice the current page of issues from the database
const issues = db.issues.slice($skip, $skip + $top)
return json({
total: db.issues.length,
issues,
})
}
让我们创建一个滚动分页组件,显示当前页面以及它之前和之后的几个页面,就像 Google 搜索一样。
关于此实现,有几点需要注意
- 选择第一页时,上一页按钮被禁用,并在当前页面右侧显示 6 页
- 选择最后一页时,下一页按钮将被禁用,并在当前页面左侧显示 6 页
- 当当前页面位于中间时,它向左显示 3 页,向右显示 3 页
由于组件可以独立访问 URL 参数,因此我们不需要任何回调或状态管理即可完成此操作。每个按钮都只是一个表单中的提交按钮,用于将 $skip 参数设置为正确的值。
我们需要传递给组件的一个值是项目总数,因此它可以计算总页数。
export function PaginationBar({
total,
}: {
total: number
}) {
const [searchParams] = useSearchParams()
const $skip = Number(searchParams.get("$skip")) || 0
const $top = Number(searchParams.get("$top")) || 10
const totalPages = Math.ceil(total / $top)
const currentPage = Math.floor($skip / $top) + 1
const maxPages = 7
const halfMaxPages = Math.floor(maxPages / 2)
const canPageBackwards = $skip > 0
const canPageForwards = $skip + $top < total
const pageNumbers = [] as Array<number>
if (totalPages <= maxPages) {
for (let i = 1; i <= totalPages; i++) {
pageNumbers.push(i)
}
} else {
let startPage = currentPage - halfMaxPages
let endPage = currentPage + halfMaxPages
if (startPage < 1) {
endPage += Math.abs(startPage) + 1
startPage = 1
}
if (endPage > totalPages) {
startPage -= endPage - totalPages
endPage = totalPages
}
for (let i = startPage; i <= endPage; i++) {
pageNumbers.push(i)
}
}
const existingParams = Array.from(
searchParams.entries(),
).filter(([key]) => {
return key !== "$skip" && key !== "$top"
})
return (
<Form
method="GET"
className="flex items-center gap-1"
preventScrollReset
>
<>
{[["$top", String($top)], ...existingParams].map(
([key, value]) => {
return (
<input
key={key}
type="hidden"
name={key}
value={value}
/>
)
},
)}
</>
<button
type="submit"
name="$skip"
className="text-neutral-600"
value="0"
disabled={!canPageBackwards}
aria-label="First page"
>
<Icon name="double-arrow-left" />
</button>
<button
variant="outline"
size="xs"
type="submit"
name="$skip"
className="text-neutral-600"
value={Math.max($skip - $top, 0)}
disabled={!canPageBackwards}
aria-label="Previous page"
>
<Icon name="arrow-left" />
</button>
{pageNumbers.map((pageNumber) => {
const pageSkip = (pageNumber - 1) * $top
const isCurrentPage = pageNumber === currentPage
const isValidPage =
pageSkip >= 0 && pageSkip < total
if (isCurrentPage) {
return (
<button
type="submit"
name="$skip"
className="min-w-[2rem] bg-neutral-200 text-black"
key={`${pageNumber}-active`}
value={pageSkip}
aria-label={`Page ${pageNumber}`}
disabled={!isValidPage}
>
{pageNumber}
</button>
)
} else {
return (
<button
type="submit"
className="min-w-[2rem] font-normal text-neutral-600"
name="$skip"
key={pageNumber}
value={pageSkip}
aria-label={`Page ${pageNumber}`}
disabled={!isValidPage}
>
{pageNumber}
</button>
)
}
})}
<button
type="submit"
name="$skip"
className="text-neutral-600"
value={Math.min($skip + $top, total - $top + 1)}
disabled={!canPageForwards}
aria-label="Next page"
>
<Icon name="arrow-right" />
</button>
<button
type="submit"
name="$skip"
className="text-neutral-600"
value={(totalPages - 1) * $top}
disabled={!canPageForwards}
aria-label="Last page"
>
<Icon name="double-arrow-right" />
</button>
</Form>
)
}
不要覆盖其他查询参数
URL是一个非常方便的全局状态,但它带有与所有全局状态解决方案相同的警告:它们是全局的。
应用的其他部分可能依赖于自己的查询参数,当我们创建设置参数的分页组件时,我们不希望覆盖其他查询 $skip 参数。
为了解决这个问题,我们可以使用 useSearchParams 钩子来获取现有的查询参数,并为每个参数创建隐藏的输入。这样,当提交表单时,现有查询参数将包含在请求中。
如果没有 $top 参数,我也想将其添加到表单中,以便始终发送。只要加载器代码给出合理的默认值,从技术上讲,您就不需要这样做,但是您需要记住在两个地方更新默认页面大小。
const [searchParams] = useSearchParams()
const existingParams = Array.from(
searchParams.entries(),
).filter(([key]) => {
return key !== "$skip" && key !== "$top"
})
return (
<Form method="GET" className="flex items-center gap-1">
<>
{[["$top", String($top)], ...existingParams].map(
([key, value]) => {
return (
<input
key={key}
type="hidden"
name={key}
value={value}
/>
)
},
)}
</>
{/* ... */}
</Form>
)
使用提交按钮而不是链接
链接在这里似乎很自然,使用链接不会错,但是我更喜欢为此使用表单有几个原因。
首先,没有办法在HTML中实际禁用链接。当用户位于分页的任一端时,我们希望禁用“上一个”和“下一个”按钮,因此你只能用非交互式元素替换它们以指示它们已禁用。
每个链接都需要一个构造的 URL,其中包含页面上的所有现有查询参数以及适当的 $skip 值。当屏幕阅读器用户访问页面时,这些链接中的每一个都会被列为导航点,这将非常嘈杂。
搜索爬虫也可能跟踪链接并索引它们,这可能会用一堆重复的页面或分裂的页面排名污染您的 SEO 个人资料。
使用表单意味着您可以为任何现有查询参数添加一次隐藏输入,无论单击哪个提交按钮,它都将包含在内。
向按钮添加标签
这里有一些带有箭头而不是文本的图标按钮,因此请确保为它们添加辅助标签,以便屏幕阅读器用户知道他们的操作。
简单的 aria-label
属性可以处理大多数情况,尽管一些专家建议改用视觉隐藏的文本,以便更好地跨设备兼容。
Loaders & Actions 中中止异步调用
假设您正在编写一个加载器,该加载器需要执行 fetch 调用来获取一些数据,仅举个例子就很简单:
import { json } from "@remix-run/node";
export async function loader() {
let response = await fetch("https://jsonplaceholder.typicode.com/todos");
return json(await response.json());
}
现在让我们假设用户点击 到这条路线,所以 Remix 在执行导航之前获取加载器数据。但是如果用户在我们的加载器发送响应之前点击 到另一个路由,它会发送一个信号来中止请求,这基本上会忽略响应。
但是,由于我们的加载器的工作方式,由于我们已经收到了请求,我们仍将完全执行加载器并生成响应。
相反,我们可以做得更好:我们可以知道浏览器何时中止请求并停止执行我们的请求;我们甚至可以中止自己的获取调用!
export async function loader({ request }: LoaderFunctionArgs) {
let response = await fetch("https://jsonplaceholder.typicode.com/todos", {
signal: request.signal
});
return json(await response.json());
}
request.signal
如果浏览器中止它,它将中止,因此我们可以将相同的信号传递给我们的获取调用以获得相同的结果。
如果我们执行多个 fetch 调用,我们也可以重用它,因此每个调用都将中止。
import {
json,
type LoaderFunctionArgs
} from "@remix-run/node";
export async function loader({ request }: LoaderFunctionArgs) {
let [res1, res2] = await Promise.all([
fetch(url1, { signal: request.signal }),
fetch(url2, { signal: request.signal }),
]);
// more code
}
要考虑的是 中止的获取会引发错误 AbortError: The operation was aborted. .
这意味着我们的代码在获取之后或之后 Promise.all 将不会运行,因为抛出此错误。但这也意味着,如果我们使用 try/catch 处理加载器中的错误,我们将需要考虑它。
export async function loader({ request }: LoaderFunctionArgs) {
try {
let response = await fetch("https://jsonplaceholder.typicode.com/todos", {
signal: request.signal
});
return json(await response.json());
} catch (error) {
if (error instanceof Error && error.name === "AbortError") {
// for aborted errors send a 204 No Content response
return new Response(null, { status: 204 });
}
throw error;
}
}
如果我们只做获取调用,这一切都很棒,但是如果我们做任何其他异步代码怎么办?
好吧,这在很大程度上取决于异步代码是否支持 AbortSignal,但如果不支持,我们始终可以手动检查中止状态。
if (request.signal.aborted) console.log("aborted!");
通过使用 request.signal.aborted ,我们将知道何时发生这种情况。因此,假设您正在从文件系统读取文件;根据内容,您读取第二个文件。
let pkg = await readFile(resolve("./package.json"), "utf-8");
if (request.signal.aborted) {
let error = new Error("Aborted");
// this is required to simulate an AbortError, but we can
// also throw a normal Error or a custom Error subclass and
// then handle it in our try/catch
error.name = "AbortError";
throw error;
}
let tsConfig = await readFile(resolve("./tsconfig.json"), "utf-8");
如果我们正在使用数据库 ORM,我们还可以使用数据库事务让我们在请求中止时中止对数据库所做的一项或多项更改。
export async function action({ request }: ActionFunctionArgs) {
let result = await db.transaction(async trx => {
// perform and await first query
if (request.signal.aborted) throw new Error("Aborted");
// perform and await second query
if (request.signal.aborted) throw new Error("Aborted");
});
return json(result);
}
通过在数据库查询之间进行检查 request.signal.aborted ,我们可以随时停止,事务将确保我们不会进行半途而废的更改。
如果ORM支持AbortSignal,我们可能会简化它。
let result = await db.transaction(async trx => {
// perform and await first query
// perform and await second query
}, { signal: request.signal });
但是我现在不知道有任何支持AbortSignal的ORM。
需要考虑的事项:如果我们中止对另一个 API 的 POST 请求,并且该 API 没有中止数据库更改,我们可能仍然会遇到一半突变发生的问题,因为一半已经运行但另一半已中止。
如果我们不确定,最好不要将其用于操作,而将其限制为仅加载程序。这样,我们的加载器仍然可以更早地停止运行,并且我们的突变可以安全地执行。
全局类型及类型安全
TypeScript 有两种主要类型的文件。 .ts 文件是包含类型和可执行代码的实现文件。这些是生成 .js 输出的文件,是您通常编写代码的位置。
.d.ts
文件是仅包含类型信息的声明文件。这些文件不生成 .js
输出;它们仅用于类型检查。
让我们利用这个事实来创建我们自己的声明文件,并使用它来键入我们的 Remix 应用程序。
首先,在 Remix 项目的根目录上创建一个 index.d.ts
文件。然后,您可以将以下代码粘贴到其中:
// As long as Remix is using React this part shouldn't change
import type { useActionData, useLoaderData, ShouldRevalidateFunction as RemixShouldRevalidateFunction } from "@remix-run/react";
// These imports will depend on the runtime you are using, if it's node you
// don't need to change anything, if it's vercel for example, change to
// from "@remix-run/vercel";
import type {
V2_MetaFunction,
LinksFunction as RemixLinksFunction,
LoaderArgs as RemixLoaderArgs,
ActionArgs as RemixActionArgs,
DataFunctionArgs as RemixDataFunctionArgs,
HeadersFunction as RemixHeadersFunction
// Change based on runtime
} from "@remix-run/node";
// Declares global types so we don't have to import them anywhere we just consume them
export declare global {
declare type MetaFunction = V2_MetaFunction;
declare type LinksFunction = RemixLinksFunction;
declare type LoaderArgs = RemixLoaderArgs;
declare type ActionArgs = RemixActionArgs;
declare type DataFunctionArgs = RemixDataFunctionArgs;
declare type HeadersFunction = RemixHeadersFunction;
declare type ShouldRevalidateFunction = RemixShouldRevalidateFunction;
declare type LoaderData<Loader> = ReturnType<typeof useLoaderData<Loader>>;
declare type ActionData<Action> = ReturnType<typeof useActionData<Action>>;
}
好吧,那么这可能想知道这样做有什么好处?好了,现在您可以在路由内执行以下操作:
export const loader = ({ request }: LoaderArgs) => {
// fully typed request object
}
export const action = ({ request }: ActionArgs) => {
// fully typed request object
}
// Fully typed
export const meta: V2_MetaFunction = () => {
return [
{ title: "Very cool app | Remix" },
];
};
// you get the point, fully typed
export const shouldRevalidate: ShouldRevalidateFunction = ({
currentParams,
currentUrl,
...props
}) => {
return true;
};