Chapter 02

文件系统路由

掌握 Remix 的扁平文件路由约定,理解点分隔嵌套、布局路由与 Outlet 渲染机制

2.1 路由约定总览

Remix v2 使用扁平文件路由(Flat Routes)约定:所有路由文件都放在 app/routes/ 目录下,用点号(.)分隔来表达路由层级关系,而不是用子目录嵌套。这让路由结构一目了然,无需层层展开文件夹。

app/routes/
├── _index.tsx          # URL: /
├── about.tsx           # URL: /about
├── blog.tsx            # URL: /blog(布局路由,含 Outlet)
├── blog._index.tsx     # URL: /blog(索引路由)
├── blog.$slug.tsx      # URL: /blog/:slug(动态路由)
├── blog.new.tsx        # URL: /blog/new
├── _auth.tsx           # 无路径布局(下划线前缀)
├── _auth.login.tsx     # URL: /login(共享 _auth 布局)
├── _auth.register.tsx  # URL: /register(共享 _auth 布局)
└── $.tsx               # 通配符路由(404 页面)
STRUCTURE

2.2 索引路由 _index.tsx

文件名以 _index 结尾表示索引路由,当访问父路由路径(不带子路径)时渲染。它解决了"既要父路由有 Outlet 布局,又要父路径有内容"的问题。

// app/routes/_index.tsx — 首页 /
export default function IndexRoute() {
  return (
    <main>
      <h1>欢迎来到 Remix</h1>
    </main>
  );
}
TSX
// app/routes/blog._index.tsx — /blog 索引
// 当用户访问 /blog(不带文章)时显示文章列表
import { useLoaderData } from "@remix-run/react";
import { json } from "@remix-run/node";

export async function loader() {
  const posts = await getPosts();
  return json({ posts });
}

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

2.3 布局路由与 Outlet

当一个路由文件(如 blog.tsx)与同名子路由(如 blog.$slug.tsx)同时存在时,前者自动成为布局路由。布局路由需要渲染 <Outlet /> 来指定子路由的插入位置。

// app/routes/blog.tsx — /blog 布局路由
import { Outlet } from "@remix-run/react";

export default function BlogLayout() {
  return (
    <div className="blog-container">
      <nav className="blog-sidebar">
        <h2>博客</h2>
        {/* 侧边栏内容 */}
      </nav>
      <main className="blog-content">
        {/* 子路由(文章列表或文章详情)在这里渲染 */}
        <Outlet />
      </main>
    </div>
  );
}
TSX
ℹ️

路由层级示意
访问 /blog/hello-world 时,渲染树为:
root.tsx → blog.tsx (布局) → blog.$slug.tsx (内容)
每一层都有各自的 loader,数据并行加载,互不阻塞。

2.4 无路径布局路由(下划线前缀)

文件名以单下划线 _ 开头(如 _auth.tsx)表示无路径布局路由:它提供共享 UI 布局,但不向 URL 贡献路径段。子路由(如 _auth.login.tsx)的 URL 仍是 /login,而非 /auth/login

// app/routes/_auth.tsx — 认证页面共享布局
// URL 不包含 "_auth" 路径段
import { Outlet } from "@remix-run/react";

export default function AuthLayout() {
  return (
    <div className="auth-page">
      <div className="auth-box">
        <Outlet />  {/* /login 或 /register */}
      </div>
    </div>
  );
}
TSX

2.5 动态路径参数 $param

文件名中以 $ 开头的片段表示动态路径参数。例如 blog.$slug.tsx 匹配 /blog/任意值,参数名为 slug,可通过 params 对象在 loader/action 中访问。

// app/routes/blog.$slug.tsx
import { type LoaderFunctionArgs } from "@remix-run/node";
import { useLoaderData, useParams } from "@remix-run/react";
import { json } from "@remix-run/node";

export async function loader({ params }: LoaderFunctionArgs) {
  // params.slug 即 URL 中的动态参数
  const post = await getPost(params.slug!);
  if (!post) throw new Response("Not Found", { status: 404 });
  return json({ post });
}

export default function BlogPost() {
  const { post } = useLoaderData<typeof loader>();
  return (
    <article>
      <h1>{post.title}</h1>
      <div dangerouslySetInnerHTML={{ __html: post.content }} />
    </article>
  );
}
TSX

可选参数片段 ($optional)

将参数名用括号括起来表示可选参数,例如 ($lang).about.tsx 同时匹配 /about/zh/about

// app/routes/($lang).about.tsx
// 匹配 /about 和 /en/about、/zh/about 等
export async function loader({ params }: LoaderFunctionArgs) {
  const lang = params.lang ?? "zh"; // 未提供则默认中文
  return json({ lang });
}
TSX

2.6 路由元数据 meta export

每条路由可以导出 meta 函数,返回该页面的 <title><meta> 等标签,自动插入到 root.tsx<Meta /> 位置,无需 document.title 操作。

// app/routes/blog.$slug.tsx
import { type MetaFunction } from "@remix-run/node";

export const meta: MetaFunction<typeof loader> = ({ data }) => {
  if (!data) {
    return [{ title: "文章未找到" }];
  }
  return [
    { title: `${data.post.title} — 我的博客` },
    { name: "description", content: data.post.excerpt },
    { property: "og:title", content: data.post.title },
    { property: "og:image", content: data.post.coverImage },
  ];
};
TSX

2.7 links export — 路由级 CSS

每条路由可以导出 links 函数,声明该路由需要加载的 CSS/预加载资源。Remix 会在路由激活时注入这些链接,路由卸载时移除,实现路由级样式隔离。

import { type LinksFunction } from "@remix-run/node";
import blogStyles from "~/styles/blog.css?url";

export const links: LinksFunction = () => [
  { rel: "stylesheet", href: blogStyles },
  { rel: "preload", href: "/fonts/title.woff2", as: "font", type: "font/woff2", crossOrigin: "anonymous" },
];
TSX

2.8 路由约定速查表

文件名模式URL 示例作用
_index.tsx/根索引路由
about.tsx/about静态路由
blog.tsx/blog/*布局路由(含 Outlet)
blog._index.tsx/blog布局的索引
blog.$slug.tsx/blog/:slug动态参数
_layout.tsx无路径贡献无 URL 的共享布局
($lang).about.tsx/about 或 /zh/about可选参数
$.tsx/*(兜底)通配符/404
💡

路由可视化工具运行 npx remix routes 可以在终端输出当前项目的完整路由树,帮助理解嵌套关系。也可访问开发模式下的 /__remix/routes 查看路由详情。

ℹ️

本章小结Remix 的扁平路由约定通过点号分隔表达嵌套关系,核心规则:布局路由(同名文件)提供 Outlet 容器;索引路由(_index 后缀)处理父路径默认内容;无路径布局(下划线前缀)共享 UI 而不影响 URL;动态参数($ 前缀)匹配变化的路径段。每条路由还可通过 metalinks 导出控制页面标签和样式。