Chapter 05

嵌套路由与布局

理解 Remix 嵌套路由的 UI 与数据双重嵌套机制,构建生产级多层布局应用

5.1 嵌套路由的本质

Remix 的嵌套路由远不只是"嵌套布局",它实现了 UI 嵌套与数据嵌套的统一。每层路由不仅提供布局组件,还有独立的 loader,这些 loader 在同一次页面请求中并行执行,而不是等父路由数据加载完再加载子路由数据。

ℹ️

并行 vs 串行访问 /admin/users/123 时,Remix 会同时发起 admin.tsx loaderadmin.users.tsx loaderadmin.users.$id.tsx loader 三个请求,总耗时是最慢那个,而非三者之和。这是 Remix 嵌套路由最大的性能优势。

路由树示意

访问 /admin/users/123 的渲染树:

root.tsx                    # HTML 骨架 + 全局导航
└── _admin.tsx              # 后台布局(侧边栏 + 顶部栏)
    └── admin.users.tsx     # 用户列表布局(含列表侧边)
        └── admin.users.$id.tsx  # 用户详情内容区

并行执行的 loader:
root loader      → 当前用户信息
admin loader     → 后台菜单权限
users loader     → 用户列表
users.$id loader → 单个用户详情
TEXT

5.2 Outlet context — 父子路由传值

父路由可以通过 <Outlet context={value} /> 向子路由传递数据,子路由通过 useOutletContext() 获取。这避免了通过 URL 参数或全局状态传递内部数据。

// app/routes/admin.users.tsx — 父路由传递 context
import { Outlet, useLoaderData } from "@remix-run/react";

type UserListContext = {
  refetchTrigger: number;
  onUserUpdated: () => void;
};

export default function UsersLayout() {
  const { users } = useLoaderData<typeof loader>();

  return (
    <div className="users-layout">
      <aside>
        {users.map(u => <UserListItem key={u.id} user={u} />)}
      </aside>
      <main>
        {/* 将父路由数据传给子路由 */}
        <Outlet context={{ users } satisfies UserListContext} />
      </main>
    </div>
  );
}
TSX
// app/routes/admin.users.$id.tsx — 子路由接收 context
import { useOutletContext } from "@remix-run/react";

export default function UserDetail() {
  const { users } = useOutletContext<UserListContext>();
  // 可以访问父路由传来的 users 数据
  return <div>...</div>;
}
TSX

5.3 路由级错误边界 ErrorBoundary

每条路由都可以导出 ErrorBoundary 组件。当该路由的 loaderaction 或组件渲染抛出错误时,Remix 会渲染该路由的 ErrorBoundary,而不影响父路由的正常显示。这实现了精确的错误隔离。

// app/routes/blog.$slug.tsx
import { useRouteError, isRouteErrorResponse } from "@remix-run/react";

export function ErrorBoundary() {
  const error = useRouteError();

  // 处理从 loader/action 抛出的 Response 对象
  if (isRouteErrorResponse(error)) {
    return (
      <div>
        <h1>{error.status} — {error.statusText}</h1>
        <p>{error.data}</p>
      </div>
    );
  }

  // 处理意外的 JS 错误
  if (error instanceof Error) {
    return (
      <div>
        <h1>出错了</h1>
        <p>{error.message}</p>
      </div>
    );
  }

  return <h1>未知错误</h1>;
}
TSX
💡

错误隔离效果如果 /blog/not-found 的 loader 抛出 404,只有 blog.$slug.tsx 的 ErrorBoundary 被渲染,导航栏、页脚、博客列表侧边栏等父路由 UI 仍然正常显示,用户不会看到整个页面崩溃。

5.4 实战:后台管理三级嵌套布局

构建一个典型的后台管理系统,演示三层嵌套路由的完整实现。

app/routes/
├── _admin.tsx              # 无 URL 的后台主布局(侧边栏+顶栏)
├── _admin._index.tsx       # /(后台首页仪表盘)
├── _admin.users.tsx        # /users(用户管理布局)
├── _admin.users._index.tsx # /users(用户列表)
├── _admin.users.$id.tsx    # /users/:id(用户详情)
├── _admin.users.new.tsx    # /users/new(新建用户)
└── _admin.settings.tsx     # /settings(系统设置)
STRUCTURE
// app/routes/_admin.tsx — 后台主布局
import { Outlet, NavLink } from "@remix-run/react";
import { requireUser } from "~/session.server";

export async function loader({ request }: LoaderFunctionArgs) {
  // 路由守卫:未登录跳转到 /login
  const user = await requireUser(request);
  return json({ user });
}

export default function AdminLayout() {
  const { user } = useLoaderData<typeof loader>();

  return (
    <div className="admin-shell">
      <aside className="sidebar">
        <p>{user.name}</p>
        <NavLink to="/">仪表盘</NavLink>
        <NavLink to="/users">用户管理</NavLink>
        <NavLink to="/settings">系统设置</NavLink>
      </aside>
      <main className="admin-content">
        <Outlet />
      </main>
    </div>
  );
}
TSX
// app/routes/_admin.users.tsx — 用户管理布局
import { Outlet, useLoaderData, NavLink } from "@remix-run/react";

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

export default function UsersLayout() {
  const { users } = useLoaderData<typeof loader>();

  return (
    <div className="users-layout">
      <nav className="user-list">
        <NavLink to="new">+ 新建用户</NavLink>
        {users.map(u => (
          <NavLink key={u.id} to={`${u.id}`}>{u.name}</NavLink>
        ))}
      </nav>
      <section>
        {/* 用户详情/新建表单在这里渲染 */}
        <Outlet />
      </section>
    </div>
  );
}
TSX

5.5 NavLink 激活状态

Remix 的 <NavLink> 会根据当前 URL 自动添加 active class(或通过 className 函数自定义),无需手动管理激活状态。

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

// 方式一:自动添加 .active class
<NavLink to="/users">用户管理</NavLink>

// 方式二:函数式 className
<NavLink
  to="/users"
  className={({ isActive, isPending }) =>
    isActive ? "nav-link active" : isPending ? "nav-link pending" : "nav-link"
  }
>
  用户管理
</NavLink>
TSX
ℹ️

本章小结Remix 嵌套路由的精髓:UI 树与数据加载树完全对应;各层 loader 并行执行,无串行等待;Outlet context 让父路由向子路由传值;每条路由独立的 ErrorBoundary 实现精准错误隔离,父路由 UI 不受影响。这套机制是 Remix 在性能和架构上优于其他框架的核心所在。