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 });
}

八、总结 #

本章我们学习了:

  1. 动态参数:使用 $ 定义和 useParams 获取
  2. 查询参数:使用 useSearchParams 处理
  3. 路由状态:通过 state 传递数据
  4. 分页实现:结合查询参数实现
  5. 面包屑导航:使用 handle 实现

核心要点:

  • 动态参数使用 $ 前缀
  • 查询参数使用 useSearchParams
  • 在 loader 中通过 paramsrequest 获取参数
  • 始终验证参数的有效性
最后更新:2026-03-28