Remix嵌套路由 #

一、嵌套路由概述 #

Remix 的核心特性之一是嵌套路由。每个路由可以有自己的布局和数据加载,父路由和子路由可以并行加载数据。

二、创建嵌套路由 #

2.1 文件结构 #

text
app/routes/
├── _index.tsx              # /
├── admin.tsx               # admin布局
├── admin._index.tsx        # /admin
├── admin.users.tsx         # /admin/users
├── admin.settings.tsx      # /admin/settings
└── admin.posts._index.tsx  # /admin/posts

2.2 父布局组件 #

admin.tsx 作为布局组件:

tsx
import { Outlet, Link, useLoaderData } from "@remix-run/react";
import type { LoaderFunctionArgs } from "@remix-run/node";
import { json } from "@remix-run/node";

export async function loader({ request }: LoaderFunctionArgs) {
  return json({
    user: await getCurrentUser(request),
  });
}

export default function AdminLayout() {
  const { user } = useLoaderData<typeof loader>();
  
  return (
    <div className="flex min-h-screen">
      <aside className="w-64 bg-gray-100 p-4">
        <div className="mb-4">
          <p>欢迎, {user.name}</p>
        </div>
        <nav className="space-y-2">
          <Link to="/admin" className="block p-2 hover:bg-gray-200">
            仪表板
          </Link>
          <Link to="/admin/users" className="block p-2 hover:bg-gray-200">
            用户管理
          </Link>
          <Link to="/admin/settings" className="block p-2 hover:bg-gray-200">
            系统设置
          </Link>
        </nav>
      </aside>
      <main className="flex-1 p-6">
        <Outlet />
      </main>
    </div>
  );
}

2.3 子路由页面 #

admin._index.tsx

tsx
export default function AdminIndex() {
  return (
    <div>
      <h1>仪表板</h1>
      <p>欢迎使用管理系统</p>
    </div>
  );
}

admin.users.tsx

tsx
import { useLoaderData } from "@remix-run/react";
import type { LoaderFunctionArgs } from "@remix-run/node";
import { json } from "@remix-run/node";

export async function loader({}: LoaderFunctionArgs) {
  const users = await getUsers();
  return json({ users });
}

export default function AdminUsers() {
  const { users } = useLoaderData<typeof loader>();
  
  return (
    <div>
      <h1>用户管理</h1>
      <ul>
        {users.map((user) => (
          <li key={user.id}>{user.name}</li>
        ))}
      </ul>
    </div>
  );
}

三、Outlet组件 #

3.1 Outlet的作用 #

Outlet 组件标记子路由渲染的位置:

tsx
export default function ParentLayout() {
  return (
    <div>
      <header>
        <nav>导航栏</nav>
      </header>
      <main>
        <Outlet />  {/* 子路由内容在这里渲染 */}
      </main>
      <footer>
        <p>页脚</p>
      </footer>
    </div>
  );
}

3.2 Outlet Context #

通过 context 属性向子路由传递数据:

tsx
// 父组件
export default function ParentLayout() {
  const [theme, setTheme] = useState("light");
  
  return (
    <div>
      <Outlet context={{ theme, setTheme }} />
    </div>
  );
}

// 子组件
import { useOutletContext } from "@remix-run/react";

export default function ChildPage() {
  const { theme, setTheme } = useOutletContext<{
    theme: string;
    setTheme: (theme: string) => void;
  }>();
  
  return (
    <div>
      <p>当前主题: {theme}</p>
      <button onClick={() => setTheme(theme === "light" ? "dark" : "light")}>
        切换主题
      </button>
    </div>
  );
}

四、并行数据加载 #

4.1 并行加载原理 #

当访问嵌套路由时,所有层级的 loader 会并行执行:

text
访问 /admin/users
├── admin.tsx loader      ─┐
├── admin.users.tsx loader ─┼─ 并行执行
└── 返回合并后的数据      ─┘

4.2 示例 #

tsx
// admin.tsx
export async function loader({ request }: LoaderFunctionArgs) {
  // 这个请求与子路由的请求并行
  const user = await getCurrentUser(request);
  const notifications = await getNotifications(user.id);
  return json({ user, notifications });
}

// admin.users.tsx
export async function loader({ request }: LoaderFunctionArgs) {
  // 这个请求与父路由的请求并行
  const users = await getUsers();
  return json({ users });
}

4.3 避免重复请求 #

如果子路由需要父路由的数据,可以通过 useRouteLoaderData 获取:

tsx
import { useRouteLoaderData } from "@remix-run/react";

export default function AdminUsers() {
  // 获取父路由的数据
  const adminData = useRouteLoaderData<typeof loader>("routes/admin");
  const { users } = useLoaderData<typeof loader>();
  
  return (
    <div>
      <p>当前用户: {adminData.user.name}</p>
      <ul>
        {users.map((user) => (
          <li key={user.id}>{user.name}</li>
        ))}
      </ul>
    </div>
  );
}

五、布局路由 #

5.1 无路径布局 #

使用 _ 前缀创建不生成URL的布局:

text
app/routes/
├── _index.tsx              # /
├── _layout.tsx             # 布局(不生成URL)
├── _layout.about.tsx       # /about
├── _layout.contact.tsx     # /contact
└── _layout.blog._index.tsx # /blog

_layout.tsx

tsx
import { Outlet } from "@remix-run/react";

export default function Layout() {
  return (
    <div>
      <header>
        <nav>全局导航</nav>
      </header>
      <main>
        <Outlet />
      </main>
      <footer>
        <p>全局页脚</p>
      </footer>
    </div>
  );
}

5.2 多层嵌套 #

text
app/routes/
├── _index.tsx
├── _layout.tsx                    # 第一层布局
├── _layout.about.tsx
├── _layout.admin.tsx              # 第二层布局
├── _layout.admin._index.tsx
├── _layout.admin.users.tsx
└── _layout.admin.users.$id.tsx

六、动态嵌套路由 #

6.1 动态父路由 #

text
app/routes/
├── users.$userId.tsx           # /users/:userId
├── users.$userId.posts.tsx     # /users/:userId/posts
└── users.$userId.posts.$id.tsx # /users/:userId/posts/:id
tsx
// users.$userId.tsx
export async function loader({ params }: LoaderFunctionArgs) {
  const user = await getUser(params.userId);
  return json({ user });
}

export default function UserLayout() {
  const { user } = useLoaderData<typeof loader>();
  
  return (
    <div>
      <h1>{user.name}</h1>
      <nav>
        <Link to="posts">文章</Link>
        <Link to="settings">设置</Link>
      </nav>
      <Outlet />
    </div>
  );
}

// users.$userId.posts.tsx
export async function loader({ params }: LoaderFunctionArgs) {
  const posts = await getUserPosts(params.userId);
  return json({ posts });
}

export default function UserPosts() {
  const { posts } = useLoaderData<typeof loader>();
  
  return (
    <ul>
      {posts.map((post) => (
        <li key={post.id}>{post.title}</li>
      ))}
    </ul>
  );
}

七、嵌套表单 #

7.1 在嵌套路由中处理表单 #

tsx
// admin.settings.tsx
import { Form, useActionData } from "@remix-run/react";
import type { ActionFunctionArgs } from "@remix-run/node";
import { json, redirect } from "@remix-run/node";

export async function action({ request }: ActionFunctionArgs) {
  const formData = await request.formData();
  const settings = Object.fromEntries(formData);
  
  const errors = validateSettings(settings);
  if (errors) {
    return json({ errors }, { status: 400 });
  }
  
  await saveSettings(settings);
  return redirect("/admin/settings");
}

export default function AdminSettings() {
  const actionData = useActionData<typeof action>();
  
  return (
    <div>
      <h1>系统设置</h1>
      <Form method="post">
        <div>
          <label>网站名称</label>
          <input name="siteName" />
          {actionData?.errors?.siteName && (
            <p className="error">{actionData.errors.siteName}</p>
          )}
        </div>
        <button type="submit">保存</button>
      </Form>
    </div>
  );
}

八、错误边界嵌套 #

8.1 层级错误处理 #

每个路由都可以有自己的错误边界:

tsx
// admin.tsx
export function ErrorBoundary() {
  const error = useRouteError();
  
  return (
    <div className="p-4 bg-red-100">
      <h2>管理后台错误</h2>
      <p>{error.message}</p>
      <Link to="/admin">返回仪表板</Link>
    </div>
  );
}

// admin.users.tsx
export function ErrorBoundary() {
  const error = useRouteError();
  
  return (
    <div className="p-4 bg-yellow-100">
      <h2>用户管理错误</h2>
      <p>{error.message}</p>
      <Link to="/admin/users">重试</Link>
    </div>
  );
}

九、最佳实践 #

9.1 合理划分层级 #

text
推荐:
app/routes/
├── _index.tsx
├── _layout.tsx
├── _layout.products._index.tsx
├── _layout.products.$id.tsx
├── _layout.admin.tsx
└── _layout.admin._index.tsx

不推荐(层级过深):
app/routes/
├── _index.tsx
├── a.b.c.d.e.tsx
└── a.b.c.d.f.tsx

9.2 共享布局数据 #

tsx
// 在根布局加载共享数据
export async function loader({ request }: LoaderFunctionArgs) {
  return json({
    user: await getUser(request),
    settings: await getSettings(),
  });
}

9.3 懒加载嵌套组件 #

tsx
import { lazy, Suspense } from "react";

const HeavyComponent = lazy(() => import("~/components/Heavy"));

export default function Page() {
  return (
    <Suspense fallback={<div>加载中...</div>}>
      <HeavyComponent />
    </Suspense>
  );
}

十、总结 #

本章我们学习了:

  1. 嵌套路由结构:文件命名和目录组织
  2. Outlet组件:子路由渲染位置
  3. 并行数据加载:优化性能
  4. 布局路由:无路径布局组件
  5. 错误边界嵌套:层级错误处理

核心要点:

  • 使用 Outlet 渲染子路由
  • 嵌套路由的 loader 并行执行
  • 使用 _ 前缀创建无路径布局
  • 每个路由可以有独立的错误边界
最后更新:2026-03-28