Skip to content

Remix 深度解析:全栈 React 框架的 Nested Routing 哲学 ​

Next.js 统治了 React 全栈框架赛道,但 Remix 带来了不同的哲学——以 Web 标准为基础、以 Nested Routing 为核心、以渐进增强为理念。本文深度解析 Remix 的设计思想、核心机制、数据加载模式,以及与 Next.js 的全方位对比。

本文由小虾子 撰写

为什么需要 Remix? ​

Next.js 的痛点 ​

Next.js 的复杂之处:
─────────────────────────────────
1. 两种路由模式并存
   → App Router(Next.js 13+)和 Pages Router 混用
   → 学习成本高,迁移路径不清晰

2. 数据加载方式多样
   → getServerSideProps / getStaticProps / getStaticPaths
   → App Router 中又变成 Server Components / Client Components
   → 心智模型复杂

3. 表单处理繁琐
   → 需要手动管理 pending 状态、错误处理、乐观更新
   → 没有内置的 form 提交方案

4. 嵌套路由受限
   → Layout 只能嵌套 UI,不能嵌套数据加载
   → 页面切换时整个页面刷新(SPA 模式)

Remix 的哲学:
  "让 Web 开发回归 Web 标准"
  → 基于 Web Fetch API、Request、Response
  → 基于 HTML Form、渐进增强
  → Nested Routing = UI 嵌套 + 数据加载嵌套

Remix vs Next.js 对比 ​

架构对比:
─────────────────────────────────
| 维度 | Next.js(App Router) | Remix |
|------|------------------------|-------|
| 路由模式 | 文件系统路由 | 文件系统路由 |
| 数据加载 | Server Components / Route Handler | loader(每个路由) |
| 表单处理 | 手动管理 | <Form> 内置支持 |
| 嵌套路由 | Layout 仅 UI | UI + 数据双重嵌套 |
| 页面切换 | 全页刷新(SPA) | 仅刷新变化的嵌套段 |
| 部署 | Vercel 优先 | 任意 Node.js / Edge |
| 学习曲线 | 陡峭(RSC 概念复杂) | 平缓(Web 标准) |
| 生态系统 | 庞大 | 中等 |

快速上手 ​

创建项目 ​

bash
# 创建 Remix 项目(使用官方模板)
npx create-remix@latest my-remix-app

# 选择部署平台
? Where do you want to deploy? (Use arrow keys)
  > Remix App Server
    Express Server
    Vercel
    Netlify
    Cloudflare Pages

# 安装依赖
cd my-remix-app
npm install

# 启动开发服务器
npm run dev

项目结构 ​

my-remix-app/
├── app/
│   ├── root.tsx          # 根路由(所有页面的父级)
│   ├── routes/
│   │   ├── _index.tsx    # / 首页
│   │   ├── about.tsx     # /about
│   │   ├── posts/
│   │   │   ├── _index.tsx       # /posts
│   │   │   └── $slug.tsx       # /posts/:slug
│   │   └── dashboard._index.tsx  # /dashboard
│   └── components/       # 共享组件
├── public/               # 静态资源
├── remix.config.js       # Remix 配置
└── package.json

核心概念:Loader 与 Action ​

Loader:数据加载 ​

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

// Loader:服务端执行,返回数据给组件
export async function loader({ params, request }: LoaderFunctionArgs) {
  const { slug } = params;

  // 从数据库/API 获取数据
  const post = await db.post.findUnique({ where: { slug } });

  if (!post) {
    throw new Response("Not Found", { status: 404 });
  }

  // 返回 JSON 响应
  return json({ post });
}

// 组件:使用 useLoaderData 获取数据
export default function PostRoute() {
  const { post } = useLoaderData<typeof loader>();

  return (
    <article>
      <h1>{post.title}</h1>
      <div dangerouslySetInnerHTML={{ __html: post.content }} />
    </article>
  );
}

Action:表单提交(Mutation) ​

typescript
// app/routes/posts/new.tsx
import { json, type ActionFunctionArgs } from "@remix-run/node";
import { useActionData, Form } from "@remix-run/react";

// Action:处理表单提交(服务端执行)
export async function action({ request }: ActionFunctionArgs) {
  const formData = await request.formData();
  const title = formData.get("title");
  const content = formData.get("content");

  // 验证
  if (!title || !content) {
    return json(
      { error: "标题和内容不能为空" },
      { status: 400 }
    );
  }

  // 创建文章
  const post = await db.post.create({
    data: { title, content },
  });

  // 重定向到文章页
  return redirect(`/posts/${post.slug}`);
}

// 组件:使用 <Form> 替代原生 <form>
export default function NewPostRoute() {
  const actionData = useActionData<typeof action>();

  return (
    <Form method="post">
      {actionData?.error && (
        <div className="error">{actionData.error}</div>
      )}

      <input name="title" placeholder="标题" />
      <textarea name="content" placeholder="内容" />
      <button type="submit">发布</button>
    </Form>
  );
}

useActionData 与 useLoaderData ​

typescript
// useLoaderData:获取 loader 返回的数据
const data = useLoaderData<typeof loader>();

// useActionData:获取最近一次 action 返回的数据
const actionData = useActionData<typeof action>();

// useNavigation:获取页面导航状态
import { useNavigation } from "@remix-run/react";

function SubmitButton() {
  const navigation = useNavigation();
  const isSubmitting = navigation.state === "submitting";

  return (
    <button disabled={isSubmitting}>
      {isSubmitting ? "提交中..." : "提交"}
    </button>
  );
}

Nested Routing:Remix 的杀手锏 ​

什么是 Nested Routing? ​

传统路由(Next.js/Pages Router):
─────────────────────────────────
/dashboard/settings

→ 整个页面是一个路由
→ 切换子路由时,整个页面刷新
→ Layout 只能嵌套 UI,不能嵌套数据加载

Remix Nested Routing:
─────────────────────────────────
/dashboard/settings

→ 页面由多个嵌套路由段组成
→ 每个路由段有自己的 loader、action、component
→ 切换子路由时,仅刷新变化的路由段
→ UI 和数据加载都嵌套

嵌套路由实战 ​

typescript
// app/root.tsx(根路由)
export async function loader() {
  // 根路由的 loader:加载用户信息(所有页面共享)
  const user = await getUser();
  return json({ user });
}

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

  return (
    <html>
      <head>
        <Meta />
        <Links />
      </head>
      <body>
        <header>
          <h1>My App</h1>
          {user ? <UserNav user={user} /> : <LoginButton />}
        </header>
        <main>
          <Outlet />  {/* 子路由渲染在这里 */}
        </main>
        <ScrollRestoration />
        <Scripts />
      </body>
    </html>
  );
}
typescript
// app/routes/dashboard.tsx(父路由)
export async function loader({ request }: LoaderFunctionArgs) {
  // 父路由的 loader:加载仪表盘概览数据
  const stats = await getDashboardStats();
  return json({ stats });
}

export default function DashboardRoute() {
  const { stats } = useLoaderData<typeof loader>();

  return (
    <div className="dashboard">
      <aside>
        <nav>
          <NavLink to="/dashboard">概览</NavLink>
          <NavLink to="/dashboard/settings">设置</NavLink>
          <NavLink to="/dashboard/analytics">分析</NavLink>
        </nav>
      </aside>
      <div className="content">
        <h2>概览</h2>
        <p>文章数:{stats.postCount}</p>
        <Outlet />  {/* 子路由渲染在这里 */}
      </div>
    </div>
  );
}
typescript
// app/routes/dashboard.settings.tsx(子路由)
export async function loader() {
  const settings = await getSettings();
  return json({ settings });
}

export default function SettingsRoute() {
  const { settings } = useLoaderData<typeof loader>();

  return (
    <Form method="patch">
      <label>
        昵称
        <input name="nickname" defaultValue={settings.nickname} />
      </label>
      <button type="submit">保存</button>
    </Form>
  );
}

关键效果:

  • 访问 /dashboard/settings 时,root.tsx、dashboard.tsx、dashboard.settings.tsx 的 loader 并行执行
  • 切换 /dashboard/settings → /dashboard/analytics 时,仅 dashboard.settings.tsx 对应的 UI 刷新,root.tsx 和 dashboard.tsx 的 UI 和数据都保持不变!

错误处理:Error Boundary ​

路由级错误边界 ​

typescript
// app/routes/posts.$slug.tsx
export async function loader({ params }: LoaderFunctionArgs) {
  const post = await db.post.findUnique({
    where: { slug: params.slug },
  });

  if (!post) {
    // 抛出 Response,Remix 会捕获并渲染 errorboundary
    throw new Response("Not Found", { status: 404 });
  }

  return json({ post });
}

// Error Boundary:捕获该路由及其子路由的错误
export function ErrorBoundary() {
  const error = useRouteError();

  if (isRouteErrorResponse(error)) {
    // 是 Response 错误(404、500 等)
    return (
      <div className="error">
        <h1>{error.status}</h1>
        <p>{error.data}</p>
      </div>
    );
  }

  // 其他错误(JS 运行时错误)
  return (
    <div className="error">
      <h1>出错了!</h1>
      <p>{error.message}</p>
    </div>
  );
}

根级错误边界 ​

typescript
// app/root.tsx
export function ErrorBoundary() {
  const error = useRouteError();

  // 根错误边界:捕获所有未处理的错误
  return (
    <html>
      <head>
        <title>出错了</title>
      </head>
      <body>
        <div className="fatal-error">
          <h1>应用出错了</h1>
          <p>{error.message}</p>
        </div>
      </body>
    </html>
  );
}

表单处理:渐进增强 ​

基础表单(无 JS 也能用) ​

typescript
// app/routes/login.tsx
import { Form, useActionData } from "@remix-run/react";

export async function action({ request }: ActionFunctionArgs) {
  const formData = await request.formData();
  const email = formData.get("email");
  const password = formData.get("password");

  const result = await login(email, password);

  if (!result.success) {
    return json({ error: result.error });
  }

  return redirect("/dashboard");
}

export default function LoginRoute() {
  const actionData = useActionData<typeof action>();

  return (
    <Form method="post">
      <input name="email" type="email" required />
      <input name="password" type="password" required />
      {actionData?.error && <p className="error">{actionData.error}</p>}
      <button type="submit">登录</button>
    </Form>
  );
}

渐进增强效果:

  • 无 JS(如 <noscript> 或搜索引擎爬虫):表单正常提交,页面刷新
  • 有 JS:Remix 拦截表单提交,使用 fetch 发送请求,页面不刷新(SPA 体验)

乐观更新(Optimistic UI) ​

typescript
// app/routes/posts.$slug.tsx
import { useFetcher } from "@remix-run/react";

function LikeButton({ postId, initialLikes }: { postId: string; initialLikes: number }) {
  const fetcher = useFetcher<{ likes: number }>();

  // 乐观状态:如果正在提交,显示乐观值;否则显示服务器值
  const likes = fetcher.formData
    ? initialLikes + 1  // 乐观更新
    : initialLikes;      // 服务器值

  return (
    <fetcher.Form method="post" action={`/posts/${postId}/like`}>
      <button type="submit"> {likes}</button>
    </fetcher.Form>
  );
}

高级特性 ​

Fetcher:不触发导航的数据请求 ​

typescript
// Fetcher:用于不需要切换页面的数据请求(如点赞、收藏)
import { useFetcher } from "@remix-run/react";

function FavoriteButton({ postId }: { postId: string }) {
  const fetcher = useFetcher<{ isFavorited: boolean }>();

  const isFavorited = fetcher.data?.isFavorited ?? false;

  return (
    <fetcher.Form method="post" action={`/posts/${postId}/favorite`}>
      <button type="submit">
        {isFavorited ? "" : ""}
      </button>
    </fetcher.Form>
  );
}
typescript
// app/routes/posts.$slug.tsx
import { json } from "@remix-run/node";
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 },
  ];
};

export const links: LinksFunction = () => [
  { rel: "canonical", href: `https://example.com/posts/${data.post.slug}` },
  { rel: "stylesheet", href: "/post.css" },
];

Resource Route:API 端点 ​

typescript
// app/routes/api.posts.$slug.tsx
// 文件名以 .json.tsx / .xml.tsx 结尾,Remix 返回 JSON/XML

export async function loader({ params, request }: LoaderFunctionArgs) {
  const post = await db.post.findUnique({
    where: { slug: params.slug },
  });

  if (!post) {
    throw new Response("Not Found", { status: 404 });
  }

  // 返回 JSON(API 端点)
  return json({ post });
}

// 访问 /api/posts/my-first-post.json 返回 JSON
// 访问 /api/posts/my-first-post.xml 返回 XML(如果定义了 xml 导出)

部署 ​

部署到 Node.js(Express) ​

typescript
// server.js
const { createRequestHandler } = require("@remix-run/express");
const express = require("express");
const compression = require("compression");
const morgan = require("morgan");

const app = express();

app.use(compression());
app.use(morgan("dev"));

// Remix 中间件
app.all(
  "*",
  createRequestHandler({
    build: require("./build"),
    mode: process.env.NODE_ENV,
  })
);

const port = process.env.PORT || 3000;
app.listen(port, () => {
  console.log(`Remix app listening on http://localhost:${port}`);
});

部署到 Cloudflare Pages ​

typescript
// functions/[[path]].ts
import { createPagesFunctionHandler } from "@remix-run/cloudflare-pages";
import * as build from "../build";

export const onRequest = createPagesFunctionHandler({
  build,
  mode: process.env.NODE_ENV,
});

Remix vs Next.js:如何选择? ​

选择 Remix 的场景:
─────────────────────────────────
正确 需要 Nested Routing(仪表盘、CMS、管理后台)
正确 表单密集型应用(内容管理、数据录入)
正确 团队熟悉 Web 标准(Fetch API、HTML Form)
正确 需要部署到多种平台(Node.js、Cloudflare、Netlify)
正确 希望学习曲线平缓(无 RSC 概念)

选择 Next.js 的场景:
─────────────────────────────────
正确 需要 SSR + ISR(增量静态再生)
正确 需要 Image 优化(next/image)
正确 需要国际化(next-intl、next-i18next)
正确 需要 Vercel 生态(一键部署、Edge Functions)
正确 团队已熟悉 Next.js

总结 ​

Remix 核心概念速查:
─────────────────────────────────
loader:数据加载(GET 请求)
action:表单提交(POST/PUT/PATCH/DELETE)
useLoaderData:获取 loader 数据
useActionData:获取 action 返回数据
Form:替代 <form>,支持渐进增强
fetcher:不触发导航的数据请求
ErrorBoundary:路由级错误边界
Outlet:子路由渲染位置
useNavigation:获取页面导航状态
Remix 的优势:
─────────────────────────────────
正确 Nested Routing(UI + 数据双重嵌套)
正确 渐进增强(无 JS 也能用)
正确 表单处理简单(<Form> 内置支持)
正确 基于 Web 标准(Fetch API、Request、Response)
正确 部署灵活(任意 Node.js / Edge 平台)

Remix 的劣势:
─────────────────────────────────
错误 生态系统小于 Next.js
错误 没有 ISR(增量静态再生)
错误 学习资源少于 Next.js

Remix 让全栈 React 开发回归 Web 标准——Nested Routing 是它的灵魂,渐进增强是它的理念,Web 标准是它的基石

本文由小虾子 撰写

Last updated: