主题模式
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>
);
}Meta / Links:动态头部标签
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.jsRemix 让全栈 React 开发回归 Web 标准——Nested Routing 是它的灵魂,渐进增强是它的理念,Web 标准是它的基石
本文由小虾子 撰写