Remix动态路由 #
一、动态参数 #
1.1 定义动态路由 #
使用 $ 前缀定义动态参数:
text
app/routes/
├── posts.$id.tsx # /posts/:id
├── users.$userId.tsx # /users/:userId
└── blog.$category.$slug.tsx # /blog/:category/:slug
1.2 获取动态参数 #
使用 useParams Hook:
tsx
import { useParams } from "@remix-run/react";
export default function PostDetail() {
const { id } = useParams();
return <div>文章ID: {id}</div>;
}
1.3 在loader中获取参数 #
tsx
import type { LoaderFunctionArgs } from "@remix-run/node";
import { json } from "@remix-run/node";
export async function loader({ params }: LoaderFunctionArgs) {
const { id } = params;
const post = await getPost(id);
if (!post) {
throw new Response("未找到", { status: 404 });
}
return json({ post });
}
export default function PostDetail() {
const { post } = useLoaderData<typeof loader>();
return <div>{post.title}</div>;
}
1.4 多个动态参数 #
tsx
// 文件: app/routes/blog.$category.$slug.tsx
export async function loader({ params }: LoaderFunctionArgs) {
const { category, slug } = params;
const post = await getPost(category, slug);
return json({ post });
}
export default function BlogPost() {
const { category, slug } = useParams();
const { post } = useLoaderData<typeof loader>();
return (
<div>
<p>分类: {category}</p>
<p>Slug: {slug}</p>
<h1>{post.title}</h1>
</div>
);
}
二、查询参数 #
2.1 获取查询参数 #
使用 useSearchParams Hook:
tsx
import { useSearchParams } from "@remix-run/react";
export default function SearchPage() {
const [searchParams, setSearchParams] = useSearchParams();
const query = searchParams.get("q") || "";
const page = searchParams.get("page") || "1";
const category = searchParams.getAll("category");
return (
<div>
<p>搜索: {query}</p>
<p>页码: {page}</p>
<p>分类: {category.join(", ")}</p>
</div>
);
}
2.2 修改查询参数 #
tsx
import { useSearchParams } from "@remix-run/react";
export default function SearchPage() {
const [searchParams, setSearchParams] = useSearchParams();
const handleSearch = (newQuery: string) => {
setSearchParams({ q: newQuery, page: "1" });
};
const handlePageChange = (newPage: number) => {
setSearchParams((prev) => {
prev.set("page", String(newPage));
return prev;
});
};
const handleClear = () => {
setSearchParams({});
};
return (
<div>
<input
value={searchParams.get("q") || ""}
onChange={(e) => handleSearch(e.target.value)}
/>
<button onClick={() => handlePageChange(2)}>下一页</button>
<button onClick={handleClear}>清除</button>
</div>
);
}
2.3 在loader中获取查询参数 #
tsx
import type { LoaderFunctionArgs } from "@remix-run/node";
export async function loader({ request }: LoaderFunctionArgs) {
const url = new URL(request.url);
const query = url.searchParams.get("q");
const page = parseInt(url.searchParams.get("page") || "1");
const results = await searchPosts(query, page);
return json({ results, query, page });
}
三、路径匹配 #
3.1 Splat路由 #
使用 $ 捕获剩余路径:
text
app/routes/
├── docs.$.tsx # /docs/*
└── files.$.tsx # /files/*
tsx
// app/routes/docs.$.tsx
export async function loader({ params }: LoaderFunctionArgs) {
const splat = params["*"];
const doc = await getDoc(splat);
return json({ doc });
}
export default function DocPage() {
const splat = useParams()["*"];
const { doc } = useLoaderData<typeof loader>();
return (
<div>
<p>路径: {splat}</p>
<div>{doc.content}</div>
</div>
);
}
3.2 可选参数 #
使用括号创建可选参数:
text
app/routes/
├── posts.$lang($category).tsx
这会匹配:
/posts/en/posts/en/tech
四、路由状态 #
4.1 传递状态 #
使用 state 属性:
tsx
import { Link, useLocation } from "@remix-run/react";
function PostList() {
const posts = [
{ id: 1, title: "文章1" },
{ id: 2, title: "文章2" },
];
return (
<ul>
{posts.map((post) => (
<li key={post.id}>
<Link
to={`/posts/${post.id}`}
state={{ from: "list", referrer: post }}
>
{post.title}
</Link>
</li>
))}
</ul>
);
}
function PostDetail() {
const location = useLocation();
const state = location.state as { from?: string; referrer?: any };
return (
<div>
<p>来源: {state?.from}</p>
<Link to="..">返回列表</Link>
</div>
);
}
4.2 使用useNavigation #
获取导航状态:
tsx
import { useNavigation } from "@remix-run/react";
export default function Page() {
const navigation = useNavigation();
const isLoading = navigation.state === "loading";
const isSubmitting = navigation.state === "submitting";
return (
<div>
{isLoading && <div>加载中...</div>}
{isSubmitting && <div>提交中...</div>}
<p>当前路径: {navigation.location?.pathname}</p>
</div>
);
}
五、分页实现 #
5.1 分页组件 #
tsx
import { Link, useSearchParams, useLoaderData } from "@remix-run/react";
import type { LoaderFunctionArgs } from "@remix-run/node";
import { json } from "@remix-run/node";
export async function loader({ request }: LoaderFunctionArgs) {
const url = new URL(request.url);
const page = parseInt(url.searchParams.get("page") || "1");
const perPage = 10;
const { posts, total } = await getPosts(page, perPage);
const totalPages = Math.ceil(total / perPage);
return json({ posts, page, totalPages });
}
export default function PostList() {
const { posts, page, totalPages } = useLoaderData<typeof loader>();
const [searchParams] = useSearchParams();
const buildPageUrl = (pageNum: number) => {
const params = new URLSearchParams(searchParams);
params.set("page", String(pageNum));
return `?${params.toString()}`;
};
return (
<div>
<ul>
{posts.map((post) => (
<li key={post.id}>{post.title}</li>
))}
</ul>
<nav className="flex gap-2">
{page > 1 && (
<Link to={buildPageUrl(page - 1)}>上一页</Link>
)}
{Array.from({ length: totalPages }, (_, i) => i + 1).map((p) => (
<Link
key={p}
to={buildPageUrl(p)}
className={p === page ? "font-bold" : ""}
>
{p}
</Link>
))}
{page < totalPages && (
<Link to={buildPageUrl(page + 1)}>下一页</Link>
)}
</nav>
</div>
);
}
六、面包屑导航 #
6.1 使用handle实现 #
tsx
// app/routes/posts.$id.tsx
export const handle = {
breadcrumb: (matches) => {
const post = matches.data.post;
return post.title;
},
};
// app/routes/posts._index.tsx
export const handle = {
breadcrumb: () => "文章列表",
};
// app/root.tsx
import { useMatches } from "@remix-run/react";
export default function App() {
const matches = useMatches();
return (
<html>
<body>
<nav>
{matches
.filter((match) => match.handle?.breadcrumb)
.map((match, index, array) => (
<span key={match.id}>
{match.handle.breadcrumb(match)}
{index < array.length - 1 && " > "}
</span>
))}
</nav>
<Outlet />
</body>
</html>
);
}
七、最佳实践 #
7.1 参数验证 #
tsx
import { z } from "zod";
const paramsSchema = z.object({
id: z.string().uuid(),
});
export async function loader({ params }: LoaderFunctionArgs) {
const result = paramsSchema.safeParse(params);
if (!result.success) {
throw new Response("无效的ID", { status: 400 });
}
const { id } = result.data;
const post = await getPost(id);
return json({ post });
}
7.2 类型安全 #
tsx
// 使用loader的类型推断
export default function PostDetail() {
const { post } = useLoaderData<typeof loader>();
// post 有完整的类型推断
}
7.3 404处理 #
tsx
export async function loader({ params }: LoaderFunctionArgs) {
const post = await getPost(params.id);
if (!post) {
throw new Response("文章不存在", { status: 404 });
}
return json({ post });
}
八、总结 #
本章我们学习了:
- 动态参数:使用
$定义和useParams获取 - 查询参数:使用
useSearchParams处理 - 路由状态:通过
state传递数据 - 分页实现:结合查询参数实现
- 面包屑导航:使用
handle实现
核心要点:
- 动态参数使用
$前缀 - 查询参数使用
useSearchParams - 在 loader 中通过
params和request获取参数 - 始终验证参数的有效性
最后更新:2026-03-28