Remix 完全指南
NOTE
本文档最后更新于 2026年4月,涵盖 Remix 核心概念、嵌套路由、Web 标准优先哲学、与 Next.js/SvelteKit 的对比及在 AI 应用中的定位。
目录
- Remix 概述与核心哲学
- Remix vs Next.js vs SvelteKit 对比
- Loader vs Server Action
- 核心概念详解
- 嵌套路由与错误边界
- 表单处理与渐进增强
- Streaming SSR
- 部署与托管
- Remix 的局限性
- AI 应用实战场景
- 选型建议
Remix 概述与核心哲学
什么是 Remix
Remix 是一个全栈 Web 框架,以 Web 标准为核心,主张”利用浏览器原生能力,减少客户端 JavaScript 依赖”的开发理念。Remix 起源于 React Router 团队的经验总结,其核心理念是将服务端能力(数据加载、表单处理、错误处理)与客户端能力(交互反馈、离线支持)有机结合。
Remix 核心特点:
| 特性 | 说明 |
|---|---|
| Web 标准优先 | 充分利用 HTTP 语义,不重复造轮子 |
| 嵌套路由 | 基于文件系统的嵌套布局结构 |
| 渐进增强 | 基础功能纯 HTML,增强依赖 JavaScript |
| SSR 优先 | 服务端渲染,支持流式响应 |
| 错误边界 | 嵌套路由级别的错误处理 |
| 数据加载 | Loader/Action 模式,服务端获取/提交数据 |
Remix 发展历程
| 版本 | 发布年份 | 核心特性 |
|---|---|---|
| Remix 0.x | 2020 | 开源框架发布,核心架构确立 |
| Remix 1.0 | 2021 | 稳定版发布 |
| Remix 1.5-1.8 | 2022-2023 | 性能优化,Vite 集成 |
| Remix v2 | 2024 | 新的路由约定,稳定 API |
| Remix v3 | 2026 | React 19 支持,性能大幅提升 |
核心理念
Remix 的设计哲学:
- Web 标准优先:Remix 拥抱 HTTP 协议本身,利用
<form>、HTTP 方法、缓存头部等原生能力,而非发明新的抽象 - 渐进增强:网站在无 JavaScript 时应能正常工作,JavaScript 只是增强层
- 嵌套路由:URL 结构映射到 UI 结构,每个路由片段独立管理数据
- 错误处理:将错误视为 UI 问题而非代码问题,提供优雅的错误展示
IMPORTANT
Remix 不追求”零 JavaScript”,而是追求”正确使用 JavaScript”。每个客户端决策都应该有明确的理由。
Remix vs Next.js vs SvelteKit 对比
框架定位对比
| 维度 | Remix | Next.js | SvelteKit |
|---|---|---|---|
| 核心哲学 | Web 标准优先 | 功能全面 | 编译时优化 |
| 路由约定 | 基于文件 + 嵌套 | 基于文件 + App Router | 基于文件 + 嵌套 |
| 数据获取 | Loader/Action | Server Components / Route Handlers | +page.server.js / +server.js |
| 渲染模式 | SSR + Streaming | SSR/SSG/ISR + Server Components | SSR/SSG + Adapter |
| 状态管理 | React 状态 + Cookie/Session | React Context/Server State | Svelte Store |
| 学习曲线 | 中等 | 较陡 | 低 |
功能对比表
| 特性 | Remix | Next.js | SvelteKit |
|---|---|---|---|
| 嵌套布局 | ✅ 原生支持 | ⚠️ App Router 支持 | ✅ 原生支持 |
| 错误边界 | ✅ 按路由层级 | ⚠️ 需手动处理 | ✅ Error.svelte |
| Loading 状态 | ✅ useNavigation | ✅ useRouterEvents | ⚠️ 需手动处理 |
| 表单处理 | ✅ 原生 Form + Action | ⚠️ Server Actions | ✅ Form + actions |
| 重定向 | ✅ redirect() | ✅ redirect() | ✅ redirect() |
| 缓存控制 | ✅ Cache-Control | ⚠️ 复杂 | ✅ headers() |
| 边缘部署 | ✅ Cloudflare Workers | ✅ Vercel Edge | ✅ 多个 Adapter |
| TypeScript | ✅ 原生支持 | ✅ 原生支持 | ✅ 原生支持 |
渲染模式对比
| 渲染模式 | Remix | Next.js | SvelteKit |
|---|---|---|---|
| SSR | ✅ | ✅ | ✅ |
| SSG | ⚠️ 需配置 | ✅ | ✅ |
| ISR | ❌ | ✅ | ⚠️ 需插件 |
| Streaming | ✅ | ✅ | ✅ |
| 边缘函数 | ✅ | ✅ | ✅ |
代码风格对比
Remix 模式(Loader/Action):
// routes/users.tsx
import { json, redirect } from '@remix-run/node';
import { useLoaderData, Form } from '@remix-run/react';
import { db } from '~/utils/db';
// Loader: 服务端数据加载
export async function loader() {
const users = await db.users.findMany();
return json({ users });
}
// Action: 表单提交处理
export async function action({ request }) {
const formData = await request.formData();
const name = formData.get('name');
await db.users.create({ name });
return redirect('/users');
}
export default function UsersPage() {
const { users } = useLoaderData<typeof loader>();
return (
<div>
<h1>Users</h1>
<Form method="post">
<input name="name" />
<button type="submit">Add User</button>
</Form>
<ul>
{users.map(user => (
<li key={user.id}>{user.name}</li>
))}
</ul>
</div>
);
}Next.js App Router 模式:
// app/users/page.tsx
import { db } from '~/utils/db';
import { redirect } from 'next/navigation';
import UserForm from './UserForm';
// Server Component
export default async function UsersPage() {
const users = await db.users.findMany();
return (
<div>
<h1>Users</h1>
<UserForm />
<ul>
{users.map(user => (
<li key={user.id}>{user.name}</li>
))}
</ul>
</div>
);
}
// Server Action
export async function createUser(formData: FormData) {
'use server';
const name = formData.get('name');
await db.users.create({ name });
redirect('/users');
}SvelteKit 模式:
// routes/users/+page.server.ts
import { db } from '~/utils/db';
import { redirect } from '@sveltejs/kit';
export async function load() {
const users = await db.users.findMany();
return { users };
}
export const actions = {
default: async ({ request }) => {
const formData = await request.formData();
const name = formData.get('name');
await db.users.create({ name });
throw redirect(303, '/users');
}
};Loader vs Server Action
Loader - 数据加载
Loader 是 Remix 中用于服务端数据加载的专用函数,每个路由都可以定义一个 Loader。
基础 Loader:
// routes/dashboard.tsx
import { json } from '@remix-run/node';
import { useLoaderData } from '@remix-run/react';
// 定义 Loader 类型(TypeScript)
export async function loader() {
const user = await getCurrentUser();
const stats = await getDashboardStats();
const recentActivity = await getRecentActivity();
// 返回 JSON 数据
return json({
user,
stats,
recentActivity
});
}
export default function Dashboard() {
// 类型安全的 data 获取
const { user, stats, recentActivity } = useLoaderData<typeof loader>();
return (
<div>
<h1>Welcome, {user.name}</h1>
<StatsGrid stats={stats} />
<ActivityFeed activities={recentActivity} />
</div>
);
}带参数的 Loader:
// routes/users/$userId.tsx
import { json, LoaderFunctionArgs } from '@remix-run/node';
import { useLoaderData } from '@remix-run/react';
export async function loader({ params }: LoaderFunctionArgs) {
const { userId } = params;
if (!userId) {
throw new Response('User ID required', { status: 400 });
}
const user = await db.users.findUnique({
where: { id: userId },
include: { posts: true, followers: true }
});
if (!user) {
throw new Response('User not found', { status: 404 });
}
return json({ user });
}使用 cookies/sessions:
import { json, LoaderFunctionArgs } from '@remix-run/node';
import { getSession, commitSession, destroySession } from '~/sessions';
export async function loader({ request }: LoaderFunctionArgs) {
const session = await getSession(request.headers.get('Cookie'));
// 读取 session 数据
const userId = session.get('userId');
const user = await db.users.findUnique({ where: { id: userId } });
return json({ user });
}
export async function action({ request }: LoaderFunctionArgs) {
const session = await getSession(request.headers.get('Cookie'));
// 设置 session 数据
session.set('userId', 'new-user-id');
// 提交 session,返回 Set-Cookie 头
return json(
{ success: true },
{
headers: {
'Set-Cookie': await commitSession(session)
}
}
);
}Action - 数据提交
Action 处理 POST、PUT、PATCH、DELETE 请求,通常与 <Form> 组件配合使用。
基础 Action:
import { json, redirect, ActionFunctionArgs } from '@remix-run/node';
import { Form, useActionData, useNavigation } from '@remix-run/react';
import { z } from 'zod';
const schema = z.object({
title: z.string().min(1).max(200),
content: z.string().min(1)
});
export async function action({ request }: ActionFunctionArgs) {
const formData = await request.formData();
const data = Object.fromEntries(formData);
// 验证数据
const result = schema.safeParse(data);
if (!result.success) {
return json(
{ errors: result.error.flatten() },
{ status: 400 }
);
}
// 创建帖子
const post = await db.posts.create(result.data);
// 重定向到新帖子
return redirect(`/posts/${post.id}`);
}
export default function NewPost() {
const actionData = useActionData<typeof action>();
const navigation = useNavigation();
const isSubmitting = navigation.state === 'submitting';
return (
<Form method="post">
<div>
<label>
Title
<input name="title" type="text" />
</label>
{actionData?.errors?.fieldErrors?.title && (
<p>{actionData.errors.fieldErrors.title[0]}</p>
)}
</div>
<div>
<label>
Content
<textarea name="content" rows={10} />
</label>
{actionData?.errors?.fieldErrors?.content && (
<p>{actionData.errors.fieldErrors.content[0]}</p>
)}
</div>
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Publishing...' : 'Publish'}
</button>
</Form>
);
}多 Action 路由:
import { json, ActionFunctionArgs } from '@remix-run/node';
export async function action({ request }: ActionFunctionArgs) {
const formData = await request.formData();
const intent = formData.get('intent');
switch (intent) {
case 'publish': {
const postId = formData.get('postId');
await db.posts.update({
where: { id: postId },
data: { published: true }
});
return json({ success: true, action: 'published' });
}
case 'archive': {
const postId = formData.get('postId');
await db.posts.update({
where: { id: postId },
data: { archived: true }
});
return json({ success: true, action: 'archived' });
}
case 'delete': {
const postId = formData.get('postId');
await db.posts.delete({ where: { id: postId } });
return json({ success: true, action: 'deleted' });
}
default:
return json({ error: 'Unknown intent' }, { status: 400 });
}
}
// 使用 intent 区分不同的操作
export default function PostManager() {
return (
<div>
<Form method="post">
<input type="hidden" name="intent" value="publish" />
<input type="hidden" name="postId" value="123" />
<button type="submit">Publish</button>
</Form>
<Form method="post">
<input type="hidden" name="intent" value="delete" />
<input type="hidden" name="postId" value="123" />
<button type="submit">Delete</button>
</Form>
</div>
);
}Loader vs Server Action 对比
| 维度 | Loader | Action |
|---|---|---|
| HTTP 方法 | GET | POST/PUT/PATCH/DELETE |
| 触发时机 | 路由加载时 | 表单提交时 |
| 返回数据 | 页面数据 | 操作结果 |
| 副作用 | 无(应 idempotent) | 有(创建/更新/删除) |
| 用途 | 获取列表、详情、配置 | 创建、更新、删除数据 |
| 浏览器行为 | 可直接访问 URL | 必须通过表单提交 |
| 缓存 | 可被浏览器缓存 | 不可缓存 |
核心概念详解
路由约定
Remix 使用文件系统路由,文件结构决定 URL 结构:
app/
├── routes/
│ ├── _index.tsx → /
│ ├── about.tsx → /about
│ ├── contact.tsx → /contact
│ ├── users.tsx → /users
│ ├── users._index.tsx → /users (索引)
│ ├── users.$userId.tsx → /users/:userId
│ ├── users.$userId.edit.tsx → /users/:userId/edit
│ └── api.users.tsx → /api/users (无布局)
嵌套路由与布局
布局文件(以 _ 开头):
// routes/_layout.tsx
import { Outlet, Link, useLocation } from '@remix-run/react';
export default function Layout() {
const location = useLocation();
return (
<div className="app">
<nav>
<Link to="/">Home</Link>
<Link to="/about">About</Link>
<Link to="/users">Users</Link>
</nav>
<main>
{/* 子路由内容渲染位置 */}
<Outlet />
</main>
<footer>
Current path: {location.pathname}
</footer>
</div>
);
}带路径参数的布局:
// routes/users.$userId.tsx
import { Outlet, useParams } from '@remix-run/react';
export default function UserLayout() {
const { userId } = useParams();
return (
<div className="user-layout">
<UserSidebar userId={userId} />
<main>
<Outlet />
</main>
</div>
);
}错误边界
Remix 提供路由级别的错误处理能力:
根布局错误边界:
// root.tsx
import { Links, Meta, Outlet, Scripts, ScrollRestoration, isRouteErrorResponse, useRouteError } from '@remix-run/react';
export default function App() {
return (
<html>
<head>
<Meta />
<Links />
</head>
<body>
<Outlet />
<ScrollRestoration />
<Scripts />
</body>
</html>
);
}
// 全局错误边界
export function ErrorBoundary() {
const error = useRouteError();
if (isRouteErrorResponse(error)) {
return (
<div>
<h1>{error.status} - {error.statusText}</h1>
<p>{error.data}</p>
</div>
);
}
return (
<div>
<h1>Unexpected Error</h1>
<p>{error instanceof Error ? error.message : 'Unknown error'}</p>
</div>
);
}路由级错误边界:
// routes/users.$userId.tsx
import { useRouteError, isRouteErrorResponse } from '@remix-run/react';
import { json, LoaderFunctionArgs } from '@remix-run/node';
export async function loader({ params }: LoaderFunctionArgs) {
const user = await db.users.findUnique({ where: { id: params.userId } });
if (!user) {
throw new Response('User not found', { status: 404 });
}
return json({ user });
}
export function ErrorBoundary() {
const error = useRouteError();
if (isRouteErrorResponse(error)) {
if (error.status === 404) {
return (
<div>
<h1>User Not Found</h1>
<p>The user you're looking for doesn't exist.</p>
<a href="/users">Back to Users</a>
</div>
);
}
}
return (
<div>
<h1>Error Loading User</h1>
<p>{error instanceof Error ? error.message : 'Unknown error'}</p>
</div>
);
}表单处理与渐进增强
Form 组件
Remix 的 <Form> 组件是原生 <form> 的增强版:
import { Form, useSubmit, useNavigation } from '@remix-run/react';
export default function SearchForm() {
const navigation = useNavigation();
const isSearching = navigation.location?.search.includes('q=');
return (
<Form method="get" action="/search">
<input
type="search"
name="q"
placeholder="Search..."
/>
<button type="submit" disabled={isSearching}>
{isSearching ? 'Searching...' : 'Search'}
</button>
</Form>
);
}TIP
Remix 的
<Form>在禁用 JavaScript 时仍然正常工作,这使得网站在弱网环境下依然可用。
useNavigation 与 pending 状态
import { Form, useNavigation } from '@remix-run/react';
export default function ContactForm() {
const navigation = useNavigation();
const isSubmitting = navigation.state === 'submitting';
return (
<Form method="post">
<div>
<label htmlFor="name">Name</label>
<input
id="name"
name="name"
type="text"
disabled={isSubmitting}
/>
</div>
<div>
<label htmlFor="message">Message</label>
<textarea
id="message"
name="message"
rows={5}
disabled={isSubmitting}
/>
</div>
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? (
<span>Submitting...</span>
) : (
<span>Send Message</span>
)}
</button>
</Form>
);
}useFetcher - 非导航提交
当需要提交数据但不导航时,使用 useFetcher:
import { useFetcher } from '@remix-run/react';
function LikeButton({ postId, initialLikes }) {
const fetcher = useFetcher();
// fetcher.state: 'idle' | 'submitting' | 'loading'
const isLiking = fetcher.state !== 'idle';
const likes = fetcher.formData
? parseInt(fetcher.formData.get('likes'))
: initialLikes;
return (
<fetcher.Form method="post" action={`/api/posts/${postId}/like`}>
<button
type="submit"
name="likes"
value={likes + 1}
disabled={isLiking}
>
{isLiking ? '...' : '❤️'} {likes}
</button>
</fetcher.Form>
);
}useSubmit - 编程式提交
import { useSubmit, useNavigation } from '@remix-run/react';
function AutoSaveForm() {
const submit = useSubmit();
const navigation = useNavigation();
const formRef = useRef();
// 自动保存逻辑
useEffect(() => {
if (navigation.state === 'idle' && formRef.current) {
const formData = new FormData(formRef.current);
// 防抖:只保存有变化的表单
if (hasChanges(formData)) {
submit(formData, { method: 'post', action: '/api/autosave' });
}
}
}, [navigation.state]);
return (
<form ref={formRef}>
{/* 表单内容 */}
</form>
);
}Streaming SSR
defer 与 Await
Remix 支持流式渲染,允许部分数据延迟加载:
import { defer, LoaderFunctionArgs } from '@remix-run/node';
import { Await, useLoaderData } from '@remix-run/react';
import { Suspense } from 'react';
export async function loader({ params }: LoaderFunctionArgs) {
// 快速数据同步返回
const user = await db.users.findUnique({ where: { id: params.userId } });
// 慢速数据延迟加载
const postsPromise = db.posts.findMany({
where: { authorId: params.userId },
orderBy: { createdAt: 'desc' },
take: 50
});
const followersPromise = db.users.count({
where: { following: { some: { id: params.userId } } }
});
return defer({
user,
posts: postsPromise,
followers: followersPromise
});
}
export default function UserProfile() {
const { user, posts, followers } = useLoaderData<typeof loader>();
return (
<div>
<h1>{user.name}</h1>
<p>Followers: {followers}</p>
{/* 异步加载的帖子列表 */}
<Suspense fallback={<PostsSkeleton />}>
<Await resolve={posts}>
{(resolvedPosts) => (
<PostsList posts={resolvedPosts} />
)}
</Await>
</Suspense>
</div>
);
}errorElement 处理延迟数据的错误
import { defer, LoaderFunctionArgs } from '@remix-run/node';
import { Await, useLoaderData, useRouteLoaderData } from '@remix-run/react';
import { Suspense } from 'react';
export async function loader({ params }: LoaderFunctionArgs) {
const userPromise = getUser(params.userId);
const analyticsPromise = getAnalytics(params.userId);
return defer({
user: await userPromise,
analytics: analyticsPromise
});
}
export default function Dashboard() {
const { user, analytics } = useLoaderData<typeof loader>();
return (
<div>
<h1>{user.name}</h1>
<Suspense fallback={<AnalyticsSkeleton />}>
<Await
resolve={analytics}
errorElement={<AnalyticsError />}
>
<Analytics data={analytics} />
</Await>
</Suspense>
</div>
);
}
function AnalyticsError() {
return <p>Unable to load analytics</p>;
}部署与托管
适配器
Remix 支持多种部署目标:
| 适配器 | 部署环境 | 特点 |
|---|---|---|
@remix-run/node | Node.js 服务器 | 通用适配器 |
@remix-run/serve | 单文件部署 | 简单部署 |
@remix-run/cloudflare | Cloudflare Workers/ Pages | 边缘计算 |
@remix-run/vercel | Vercel | Serverless |
@remix-run/netlify | Netlify | Serverless/Edge |
package.json 配置
{
"scripts": {
"build": "remix vite:build",
"dev": "remix vite:dev",
"start": "remix-serve ./build/server/index.js"
},
"dependencies": {
"@remix-run/node": "^3.x",
"@remix-run/react": "^3.x",
"@remix-run/serve": "^3.x",
"isbot": "^5.x"
}
}Cloudflare Pages 部署
// server.ts
import { createPagesRequestHandler } from '@remix-run/cloudflare-pages';
import * as build from '@remix-run/dev/build';
const handler = createPagesRequestHandler({ build });
export { handler };Vercel 部署
// server.js
import { createRequestHandler } from '@remix-run/vercel';
export default createRequestHandler({
build: require('./build')
});Remix 的局限性
不适合的场景
| 场景 | 原因 | 替代方案 |
|---|---|---|
| 静态网站为主 | SSR 不是首选 | Astro, SvelteKit |
| 高度交互 SPA | 渐进增强哲学限制 | Next.js, Vite |
| 复杂客户端状态 | Remix 偏向服务端 | React 状态管理 |
| 大型表单工作流 | 状态管理复杂 | Next.js + React Hook Form |
与 React 的集成挑战
- Server Components 不兼容:Remix 不支持 React Server Components(RSC),无法享受其带来的零客户端 JavaScript 优势
- App Router 迁移:Next.js App Router 越来越受欢迎,Remix 的定位面临挑战
- 第三方库兼容性:部分依赖客户端状态的库在 Remix 中使用不便
社区与生态
| 维度 | Remix | Next.js |
|---|---|---|
| GitHub Stars | ~23K | ~125K |
| 周下载量 | ~150K | ~5M |
| 社区规模 | 较小但活跃 | 庞大 |
| 插件生态 | 有限 | 丰富 |
| 企业采用 | 增长中 | 主流 |
IMPORTANT
Remix 在 2024 年宣布将专注于 Vite 集成和性能优化,未来发展方向有待观察。
AI 应用实战场景
AI 聊天界面
import { json, LoaderFunctionArgs, ActionFunctionArgs } from '@remix-run/node';
import { useLoaderData, useFetcher } from '@remix-run/react';
import { OpenAI } from 'openai';
import { getSession } from '~/sessions';
const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
export async function loader({ request }: LoaderFunctionArgs) {
const session = await getSession(request.headers.get('Cookie'));
const messages = session.get('messages') || [];
return json({ messages });
}
export async function action({ request }: ActionFunctionArgs) {
const session = await getSession(request.headers.get('Cookie'));
const formData = await request.formData();
const userMessage = formData.get('message') as string;
// 获取历史消息
const messages = session.get('messages') || [];
// 添加用户消息
const updatedMessages = [
...messages,
{ role: 'user' as const, content: userMessage }
];
// 调用 OpenAI API
const completion = await openai.chat.completions.create({
model: 'gpt-4o',
messages: updatedMessages
});
const assistantMessage = completion.choices[0].message;
// 添加助手消息
session.set('messages', [...updatedMessages, assistantMessage]);
return json(
{ reply: assistantMessage.content },
{ headers: { 'Set-Cookie': await session.commit() } }
);
}
export default function ChatPage() {
const { messages } = useLoaderData<typeof loader>();
const fetcher = useFetcher();
const [input, setInput] = useState('');
const isLoading = fetcher.state === 'submitting';
return (
<div className="chat-container">
<div className="messages">
{messages.map((msg, i) => (
<div key={i} className={`message ${msg.role}`}>
<div className="avatar">{msg.role === 'user' ? '👤' : '🤖'}</div>
<div className="content">{msg.content}</div>
</div>
))}
{isLoading && (
<div className="message assistant">
<div className="avatar">🤖</div>
<div className="content">Thinking...</div>
</div>
)}
</div>
<fetcher.Form method="post" className="input-form">
<input
type="text"
name="message"
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="Ask AI..."
/>
<button type="submit" disabled={isLoading || !input}>
{isLoading ? 'Sending...' : 'Send'}
</button>
</fetcher.Form>
</div>
);
}流式响应
import { LoaderFunctionArgs, ActionFunctionArgs } from '@remix-run/node';
import { useFetcher } from '@remix-run/react';
import { OpenAI } from 'openai';
const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });
export async function action({ request }: ActionFunctionArgs) {
const formData = await request.formData();
const userMessage = formData.get('message') as string;
const stream = await openai.chat.completions.create({
model: 'gpt-4o',
messages: [{ role: 'user', content: userMessage }],
stream: true
});
// 创建流式响应
const encoder = new TextEncoder();
const stream2 = new ReadableStream({
async start(controller) {
for await (const chunk of stream) {
const text = chunk.choices[0]?.delta?.content || '';
if (text) {
controller.enqueue(encoder.encode(text));
}
}
controller.close();
}
});
return new Response(stream2, {
headers: {
'Content-Type': 'text/plain',
'Transfer-Encoding': 'chunked'
}
});
}
export default function StreamingChat() {
const fetcher = useFetcher();
const [streamedContent, setStreamedContent] = useState('');
const [isStreaming, setIsStreaming] = useState(false);
useEffect(() => {
if (fetcher.data) {
// 处理流式数据
// ...
}
}, [fetcher.data]);
return (
<div>
{/* 聊天界面 */}
</div>
);
}选型建议
何时选择 Remix
| 场景 | 推荐理由 |
|---|---|
| Web 标准优先 | 需要利用 HTTP 语义、浏览器能力 |
| 渐进增强需求 | 需要在弱网环境下工作 |
| 表单密集应用 | Loader/Action 模式非常适合 CRUD |
| 团队有 React Router 经验 | 上手成本低 |
| 需要嵌套路由 | 原生支持布局层级 |
何时考虑其他框架
| 场景 | 推荐框架 | 原因 |
|---|---|---|
| 静态内容为主 | Astro, SvelteKit | SSG 支持更好 |
| React Server Components | Next.js App Router | 原生支持 RSC |
| 复杂客户端状态 | Next.js + 状态库 | 客户端状态管理更灵活 |
| Vue 团队 | Nuxt | 框架生态更成熟 |
TIP
Remix 的渐进增强哲学在 2026 年仍然有价值,但 Next.js App Router 的崛起使其定位变得模糊。选择前需评估团队需求和长期维护计划。
SUCCESS
Remix 以 Web 标准优先的理念为前端开发提供了一种回归本真的选择。其 Loader/Action 模式、嵌套路由、渐进增强等特性使其特别适合表单密集、数据驱动的应用场景。虽然在 React Server Components 时代面临挑战,但 Remix 的核心理念——让 Web 回归 Web——仍然值得肯定。
完整安装指南
环境要求与前置准备
Node.js 版本要求:
- Remix: Node.js 18.0 或更高版本
- 推荐使用 Node.js 20 LTS 或 22 LTS
- Remix 支持 npm、pnpm、yarn、bun 等包管理器
# 使用 nvm 安装 Node.js 20
nvm install 20
nvm use 20
# 验证安装
node --version # 应显示 v20.x.x
npm --version # 应显示 10.x.x项目创建详细流程
方式一:使用 create-remix(推荐)
# 交互式创建
npx create-remix@latest
# 非交互式创建
npx create-remix@latest my-app --yes
# 指定模板
npx create-remix@latest my-app --template remix-run/remix/templates/remix
npx create-remix@latest my-app --template remix-run/indie-stack
# Indie Stack(包含认证、数据库、测试)
npx create-remix@latest my-app --template remix-run/indie-stackRemix CLI 选项:
| 选项 | 说明 | 默认值 |
|---|---|---|
--yes | 跳过所有确认 | false |
--template | 指定模板 | default |
--no-git | 不初始化 Git | false |
--install | 安装依赖 | true |
--no-install | 跳过安装 | false |
方式二:使用 Vite 模板创建
# Vite 模板(Remix v2+)
npm create vite@latest my-app -- --template remix
# 手动配置 Vite + Remix
mkdir my-app && cd my-app
npm init -y
npm install @remix-run/node @remix-run/react @remix-run/dev isbot
npm install -D vitepackage.json 配置:
{
"name": "my-app",
"private": true,
"sideEffects": false,
"type": "module",
"scripts": {
"build": "remix vite:build",
"dev": "remix vite:dev",
"lint": "eslint --ignore-path .gitignore --cache --cache-location ./node_modules/.cache/eslint .",
"start": "remix-serve ./build/server/index.js",
"typecheck": "tsc",
"test": "vitest"
},
"dependencies": {
"@remix-run/node": "^2.16.0",
"@remix-run/react": "^2.16.0",
"@remix-run/serve": "^2.16.0",
"isbot": "^5.1.0",
"react": "^18.3.0",
"react-dom": "^18.3.0"
},
"devDependencies": {
"@remix-run/dev": "^2.16.0",
"@types/react": "^18.3.0",
"@types/react-dom": "^18.3.0",
"typescript": "^5.6.0",
"vite": "^5.4.0",
"vite-tsconfig-paths": "^5.1.0"
}
}Vite 配置详解
vite.config.ts:
import { vitePlugin as remix } from '@remix-run/dev';
import { defineConfig } from 'vite';
import tsconfigPaths from 'vite-tsconfig-paths';
export default defineConfig({
plugins: [
remix({
// 未来标志
future: {
v3_fetcherPersist: true,
v3_relativeSplatPath: true,
v3_throwAbortReason: true,
v3_singleFetch: true,
v3_lazyRouteDiscovery: true,
},
}),
tsconfigPaths(),
],
server: {
port: 3000,
host: true,
},
build: {
target: 'esnext',
},
});目录结构最佳实践
推荐的 Remix 项目结构:
my-app/
├── app/
│ ├── root.tsx # 根组件
│ ├── entry.client.tsx # 客户端入口
│ ├── entry.server.tsx # 服务端入口
│ ├── routes/ # 路由
│ │ ├── _index.tsx # 首页 /
│ │ ├── about.tsx # 关于页 /about
│ │ ├── _layout.tsx # 布局
│ │ ├── _layout._index.tsx # 布局首页
│ │ ├── _layout.dashboard.tsx # 仪表板
│ │ ├── users.tsx # /users
│ │ ├── users._index.tsx # /users(索引)
│ │ ├── users.$userId.tsx # /users/:userId
│ │ ├── users.$userId.edit.tsx # /users/:userId/edit
│ │ ├── posts.$postId.tsx # /posts/:postId
│ │ └── api.webhooks.tsx # /api/webhooks(无布局)
│ ├── components/ # 组件
│ │ ├── ui/ # UI 组件
│ │ │ ├── Button.tsx
│ │ │ └── Input.tsx
│ │ ├── forms/ # 表单组件
│ │ │ ├── PostForm.tsx
│ │ │ └── UserForm.tsx
│ │ └── layout/ # 布局组件
│ │ ├── Header.tsx
│ │ └── Footer.tsx
│ ├── lib/ # 工具库
│ │ ├── db.server.ts # 数据库客户端(仅服务端)
│ │ ├── auth.server.ts # 认证工具
│ │ ├── session.server.ts # Session 管理
│ │ ├── validation.ts # 验证工具
│ │ └── utils.ts # 通用工具
│ ├── hooks/ # 自定义 Hooks
│ │ ├── useUser.ts
│ │ └── useNotification.ts
│ ├── styles/ # 样式
│ │ └── global.css
│ └── types/ # 类型定义
│ └── index.ts
├── public/ # 静态资源
│ ├── favicon.ico
│ └── robots.txt
├── prisma/ # Prisma(如果使用)
│ └── schema.prisma
├── tests/ # 测试
│ ├── setup-test-env.ts
│ └── example.spec.ts
├── .env # 环境变量
├── .env.example # 环境变量示例
├── remix.config.js # Remix 配置(可选)
├── vite.config.ts # Vite 配置
├── tsconfig.json # TypeScript 配置
└── package.json
认证方案详解
Cookie Session 认证
Remix 推荐使用 Cookie Session 进行认证,因为它简单、安全且易于理解。
Session 配置:
// app/lib/session.server.ts
import { createCookieSessionStorage, redirect } from '@remix-run/node';
const sessionSecret = process.env.SESSION_SECRET;
if (!sessionSecret) {
throw new Error('SESSION_SECRET must be set');
}
export const sessionStorage = createCookieSessionStorage({
cookie: {
name: '__session',
httpOnly: true,
maxAge: 60 * 60 * 24 * 30, // 30 天
path: '/',
sameSite: 'lax',
secrets: [sessionSecret],
secure: process.env.NODE_ENV === 'production',
},
});
export async function getSession(request: Request) {
const cookie = request.headers.get('Cookie');
return sessionStorage.getSession(cookie);
}
export async function createUserSession(userId: string, redirectTo: string) {
const session = await sessionStorage.getSession();
session.set('userId', userId);
return redirect(redirectTo, {
headers: {
'Set-Cookie': await sessionStorage.commitSession(session),
},
});
}
export async function getUserId(request: Request): Promise<string | null> {
const session = await getSession(request);
const userId = session.get('userId');
if (!userId || typeof userId !== 'string') return null;
return userId;
}
export async function requireUserId(
request: Request,
redirectTo: string = '/login'
): Promise<string> {
const userId = await getUserId(request);
if (!userId) {
const searchParams = new URLSearchParams([['redirectTo', new URL(request.url).pathname]]);
throw redirect(`${redirectTo}?${searchParams}`);
}
return userId;
}
export async function logout(request: Request) {
const session = await getSession(request);
return redirect('/login', {
headers: {
'Set-Cookie': await sessionStorage.destroySession(session),
},
});
}登录页面:
// app/routes/login.tsx
import type { ActionFunctionArgs, LoaderFunctionArgs, MetaFunction } from '@remix-run/node';
import { json, redirect } from '@remix-run/node';
import { Form, Link, useActionData, useSearchParams } from '@remix-run/react';
import { z } from 'zod';
import { createUserSession, getUserId } from '~/lib/session.server';
export const meta: MetaFunction = () => {
return [{ title: '登录' }];
};
const LoginSchema = z.object({
email: z.string().email('请输入有效的邮箱'),
password: z.string().min(6, '密码至少 6 个字符'),
});
export async function loader({ request }: LoaderFunctionArgs) {
const userId = await getUserId(request);
if (userId) return redirect('/dashboard');
return json({});
}
export async function action({ request }: ActionFunctionArgs) {
const formData = await request.formData();
const data = Object.fromEntries(formData);
const validated = LoginSchema.safeParse(data);
if (!validated.success) {
return json(
{ errors: validated.error.flatten().fieldErrors },
{ status: 400 }
);
}
const { email, password } = validated.data;
// 验证用户
const user = await validateCredentials(email, password);
if (!user) {
return json(
{ errors: { email: ['邮箱或密码错误'] } },
{ status: 400 }
);
}
return createUserSession(user.id, '/dashboard');
}
export default function LoginPage() {
const actionData = useActionData<typeof action>();
const [searchParams] = useSearchParams();
const redirectTo = searchParams.get('redirectTo') || '/dashboard';
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="max-w-md w-full space-y-8 p-8 bg-white rounded-xl shadow-lg">
<div className="text-center">
<h2 className="text-3xl font-bold">登录</h2>
<p className="mt-2 text-gray-600">
还没有账号?{' '}
<Link to="/register" className="text-blue-600 hover:underline">
注册
</Link>
</p>
</div>
<Form method="post" className="space-y-6">
<input type="hidden" name="redirectTo" value={redirectTo} />
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-700">
邮箱
</label>
<input
id="email"
name="email"
type="email"
autoComplete="email"
required
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-lg shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
/>
{actionData?.errors?.email && (
<p className="mt-1 text-sm text-red-600">{actionData.errors.email[0]}</p>
)}
</div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-gray-700">
密码
</label>
<input
id="password"
name="password"
type="password"
autoComplete="current-password"
required
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-lg shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
/>
{actionData?.errors?.password && (
<p className="mt-1 text-sm text-red-600">{actionData.errors.password[0]}</p>
)}
</div>
<button
type="submit"
className="w-full flex justify-center py-2 px-4 border border-transparent rounded-lg shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
>
登录
</button>
</Form>
</div>
</div>
);
}受保护的路由:
// app/routes/dashboard.tsx
import type { LoaderFunctionArgs } from '@remix-run/node';
import { json } from '@remix-run/node';
import { useLoaderData } from '@remix-run/react';
import { requireUserId } from '~/lib/session.server';
import { db } from '~/lib/db.server';
export async function loader({ request }: LoaderFunctionArgs) {
const userId = await requireUserId(request);
const user = await db.user.findUnique({
where: { id: userId },
select: { id: true, email: true, name: true },
});
if (!user) {
throw await logout(request);
}
const stats = await getUserStats(userId);
return json({ user, stats });
}
export default function Dashboard() {
const { user, stats } = useLoaderData<typeof loader>();
return (
<div className="max-w-7xl mx-auto py-12 px-4">
<h1 className="text-3xl font-bold">
欢迎回来,{user.name || user.email}
</h1>
<div className="mt-8 grid grid-cols-1 md:grid-cols-3 gap-6">
<StatCard title="总访问量" value={stats.visits} />
<StatCard title="文章数" value={stats.posts} />
<StatCard title="评论数" value={stats.comments} />
</div>
</div>
);
}OAuth 认证
GitHub OAuth 集成:
// app/lib/auth.server.ts
const githubClientId = process.env.GITHUB_CLIENT_ID;
const githubClientSecret = process.env.GITHUB_CLIENT_SECRET;
const githubCallbackUrl = `${process.env.APP_URL}/auth/github/callback`;
export async function githubAuth() {
const url = new URL('https://github.com/login/oauth/authorize');
url.searchParams.set('client_id', githubClientId!);
url.searchParams.set('redirect_uri', githubCallbackUrl);
url.searchParams.set('scope', 'user:email');
return redirect(url.toString());
}
export async function githubCallback(code: string) {
// 1. 用 code 换 access_token
const tokenResponse = await fetch('https://github.com/login/oauth/access_token', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: JSON.stringify({
client_id: githubClientId,
client_secret: githubClientSecret,
code,
}),
});
const { access_token } = await tokenResponse.json();
// 2. 用 access_token 获取用户信息
const userResponse = await fetch('https://api.github.com/user', {
headers: {
Authorization: `Bearer ${access_token}`,
Accept: 'application/json',
},
});
const githubUser = await userResponse.json();
// 3. 查找或创建用户
const user = await db.user.upsert({
where: { githubId: githubUser.id.toString() },
create: {
githubId: githubUser.id.toString(),
email: githubUser.email,
name: githubUser.name,
avatarUrl: githubUser.avatar_url,
},
update: {
email: githubUser.email,
name: githubUser.name,
avatarUrl: githubUser.avatar_url,
},
});
return user;
}完整 CRUD 示例
项目结构
本节展示一个完整的任务管理系统 CRUD 实现。
Prisma Schema:
// prisma/schema.prisma
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model User {
id String @id @default(cuid())
email String @unique
name String?
avatarUrl String?
tasks Task[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Task {
id String @id @default(cuid())
title String
description String?
completed Boolean @default(false)
priority Int @default(1) // 1=低, 2=中, 3=高
dueDate DateTime?
userId String
user User @relation(fields: [userId], references: [id])
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}创建任务(Create)
// app/routes/tasks.new.tsx
import type { ActionFunctionArgs, LoaderFunctionArgs, MetaFunction } from '@remix-run/node';
import { json, redirect } from '@remix-run/node';
import { Form, useActionData, useNavigation } from '@remix-run/react';
import { z } from 'zod';
import { requireUserId } from '~/lib/session.server';
import { db } from '~/lib/db.server';
export const meta: MetaFunction = () => {
return [{ title: '新建任务' }];
};
export async function loader({ request }: LoaderFunctionArgs) {
await requireUserId(request);
return json({});
}
const TaskSchema = z.object({
title: z.string().min(1, '标题不能为空').max(200, '标题过长'),
description: z.string().optional(),
priority: z.enum(['1', '2', '3']).transform(Number).default('1'),
dueDate: z.string().optional(),
});
export async function action({ request }: ActionFunctionArgs) {
const userId = await requireUserId(request);
const formData = await request.formData();
const data = Object.fromEntries(formData);
const validated = TaskSchema.safeParse(data);
if (!validated.success) {
return json(
{ errors: validated.error.flatten().fieldErrors },
{ status: 400 }
);
}
const { title, description, priority, dueDate } = validated.data;
try {
const task = await db.task.create({
data: {
title,
description: description || null,
priority,
dueDate: dueDate ? new Date(dueDate) : null,
userId,
},
});
return redirect(`/tasks/${task.id}`);
} catch (error) {
return json(
{ errors: { _form: ['创建任务失败'] } },
{ status: 500 }
);
}
}
export default function NewTaskPage() {
const actionData = useActionData<typeof action>();
const navigation = useNavigation();
const isSubmitting = navigation.state === 'submitting';
return (
<div className="max-w-2xl mx-auto py-8 px-4">
<div className="mb-8">
<h1 className="text-3xl font-bold">新建任务</h1>
</div>
{actionData?.errors?._form && (
<div className="mb-6 p-4 bg-red-100 text-red-700 rounded-lg">
{actionData.errors._form[0]}
</div>
)}
<Form method="post" className="space-y-6">
<div>
<label htmlFor="title" className="block text-sm font-medium text-gray-700">
标题 *
</label>
<input
id="title"
name="title"
type="text"
required
className="mt-1 block w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="输入任务标题"
/>
{actionData?.errors?.title && (
<p className="mt-1 text-sm text-red-600">{actionData.errors.title[0]}</p>
)}
</div>
<div>
<label htmlFor="description" className="block text-sm font-medium text-gray-700">
描述
</label>
<textarea
id="description"
name="description"
rows={4}
className="mt-1 block w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
placeholder="详细描述任务..."
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">
优先级
</label>
<div className="flex gap-4">
{[
{ value: '1', label: '低', color: 'gray' },
{ value: '2', label: '中', color: 'yellow' },
{ value: '3', label: '高', color: 'red' },
].map(({ value, label, color }) => (
<label key={value} className="flex items-center">
<input
type="radio"
name="priority"
value={value}
defaultChecked={value === '1'}
className="focus:ring-blue-500"
/>
<span className={`ml-2 text-${color}-600`}>{label}</span>
</label>
))}
</div>
</div>
<div>
<label htmlFor="dueDate" className="block text-sm font-medium text-gray-700">
截止日期
</label>
<input
id="dueDate"
name="dueDate"
type="date"
className="mt-1 block w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
</div>
<div className="flex gap-4 pt-4">
<button
type="submit"
disabled={isSubmitting}
className="flex-1 bg-blue-600 text-white py-3 px-6 rounded-lg font-medium hover:bg-blue-700 disabled:opacity-50"
>
{isSubmitting ? '创建中...' : '创建任务'}
</button>
<a
href="/tasks"
className="px-6 py-3 border border-gray-300 rounded-lg font-medium hover:bg-gray-50"
>
取消
</a>
</div>
</Form>
</div>
);
}读取任务(Read)
// app/routes/tasks._index.tsx
import type { LoaderFunctionArgs, MetaFunction } from '@remix-run/node';
import { json } from '@remix-run/node';
import { Link, useLoaderData, useSearchParams } from '@remix-run/react';
import { requireUserId } from '~/lib/session.server';
import { db } from '~/lib/db.server';
export const meta: MetaFunction = () => {
return [{ title: '我的任务' }];
};
export async function loader({ request }: LoaderFunctionArgs) {
const userId = await requireUserId(request);
const url = new URL(request.url);
const filter = url.searchParams.get('filter') || 'all';
const sort = url.searchParams.get('sort') || 'newest';
let where = { userId };
if (filter === 'completed') where.completed = true;
if (filter === 'active') where.completed = false;
const orderBy: any = {};
if (sort === 'newest') orderBy.createdAt = 'desc';
if (sort === 'oldest') orderBy.createdAt = 'asc';
if (sort === 'priority') orderBy.priority = 'desc';
if (sort === 'dueDate') orderBy.dueDate = 'asc';
const tasks = await db.task.findMany({
where,
orderBy,
});
const stats = {
total: await db.task.count({ where: { userId } }),
completed: await db.task.count({ where: { userId, completed: true } }),
active: await db.task.count({ where: { userId, completed: false } }),
};
return json({ tasks, stats, filter, sort });
}
export default function TasksIndexPage() {
const { tasks, stats, filter, sort } = useLoaderData<typeof loader>();
const [searchParams, setSearchParams] = useSearchParams();
const priorityLabels = { 1: '低', 2: '中', 3: '高' };
const priorityColors = { 1: 'gray', 2: 'yellow', 3: 'red' };
return (
<div className="max-w-4xl mx-auto py-8 px-4">
<div className="flex items-center justify-between mb-8">
<h1 className="text-3xl font-bold">我的任务</h1>
<Link
to="/tasks/new"
className="bg-blue-600 text-white px-6 py-3 rounded-lg font-medium hover:bg-blue-700"
>
新建任务
</Link>
</div>
{/* 统计 */}
<div className="grid grid-cols-3 gap-4 mb-8">
<div className="bg-white p-4 rounded-lg shadow">
<p className="text-sm text-gray-500">总计</p>
<p className="text-2xl font-bold">{stats.total}</p>
</div>
<div className="bg-white p-4 rounded-lg shadow">
<p className="text-sm text-gray-500">已完成</p>
<p className="text-2xl font-bold text-green-600">{stats.completed}</p>
</div>
<div className="bg-white p-4 rounded-lg shadow">
<p className="text-sm text-gray-500">进行中</p>
<p className="text-2xl font-bold text-blue-600">{stats.active}</p>
</div>
</div>
{/* 筛选和排序 */}
<div className="flex gap-4 mb-6">
<select
value={filter}
onChange={(e) => {
const params = new URLSearchParams(searchParams);
params.set('filter', e.target.value);
setSearchParams(params);
}}
className="px-4 py-2 border rounded-lg"
>
<option value="all">全部</option>
<option value="active">进行中</option>
<option value="completed">已完成</option>
</select>
<select
value={sort}
onChange={(e) => {
const params = new URLSearchParams(searchParams);
params.set('sort', e.target.value);
setSearchParams(params);
}}
className="px-4 py-2 border rounded-lg"
>
<option value="newest">最新创建</option>
<option value="oldest">最早创建</option>
<option value="priority">优先级</option>
<option value="dueDate">截止日期</option>
</select>
</div>
{/* 任务列表 */}
{tasks.length === 0 ? (
<div className="text-center py-16 text-gray-500">
<p>暂无任务</p>
<Link to="/tasks/new" className="text-blue-600 hover:underline mt-2 inline-block">
创建第一个任务
</Link>
</div>
) : (
<div className="space-y-3">
{tasks.map((task) => (
<Link
key={task.id}
to={`/tasks/${task.id}`}
className={`block bg-white p-4 rounded-lg shadow hover:shadow-md transition ${
task.completed ? 'opacity-60' : ''
}`}
>
<div className="flex items-start gap-4">
<TaskCheckbox taskId={task.id} completed={task.completed} />
<div className="flex-1">
<h3 className={`font-medium ${task.completed ? 'line-through' : ''}`}>
{task.title}
</h3>
{task.description && (
<p className="text-sm text-gray-500 mt-1 line-clamp-2">
{task.description}
</p>
)}
<div className="flex gap-4 mt-2 text-sm">
<span className={`text-${priorityColors[task.priority as 1|2|3]}-600`}>
{priorityLabels[task.priority as 1|2|3]}
</span>
{task.dueDate && (
<span className="text-gray-500">
截止:{new Date(task.dueDate).toLocaleDateString('zh-CN')}
</span>
)}
</div>
</div>
</div>
</Link>
))}
</div>
)}
</div>
);
}
// 任务复选框组件(使用 useFetcher)
function TaskCheckbox({ taskId, completed }: { taskId: string; completed: boolean }) {
const fetcher = useFetcher();
return (
<fetcher.Form method="post" action={`/tasks/${taskId}/toggle`}>
<input
type="checkbox"
defaultChecked={completed}
className="w-5 h-5 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
onChange={(e) => {
fetcher.submit(
{ completed: e.target.checked.toString() },
{ method: 'post' }
);
}}
/>
</fetcher.Form>
);
}更新任务(Update)
// app/routes/tasks.$taskId.tsx
import type { ActionFunctionArgs, LoaderFunctionArgs, MetaFunction } from '@remix-run/node';
import { json, redirect } from '@remix-run/node';
import { Form, Link, useActionData, useLoaderData, useNavigation } from '@remix-run/react';
import { z } from 'zod';
import { requireUserId } from '~/lib/session.server';
import { db } from '~/lib/db.server';
export const meta: MetaFunction<typeof loader> = ({ data }) => {
return [{ title: data?.task ? `编辑 ${data.task.title}` : '任务' }];
};
export async function loader({ request, params }: LoaderFunctionArgs) {
const userId = await requireUserId(request);
const task = await db.task.findUnique({
where: { id: params.taskId },
});
if (!task || task.userId !== userId) {
throw new Response('任务不存在', { status: 404 });
}
return json({ task });
}
const TaskSchema = z.object({
title: z.string().min(1).max(200),
description: z.string().optional(),
priority: z.number().int().min(1).max(3),
dueDate: z.string().optional().nullable(),
completed: z.boolean(),
});
export async function action({ request, params }: ActionFunctionArgs) {
const userId = await requireUserId(request);
const task = await db.task.findUnique({
where: { id: params.taskId },
});
if (!task || task.userId !== userId) {
throw new Response('任务不存在', { status: 404 });
}
const formData = await request.formData();
const data = Object.fromEntries(formData);
// 处理 dueDate
if (data.dueDate === '') {
data.dueDate = null;
}
const validated = TaskSchema.safeParse({
...data,
priority: Number(data.priority),
completed: data.completed === 'true',
});
if (!validated.success) {
return json(
{ errors: validated.error.flatten().fieldErrors },
{ status: 400 }
);
}
const { title, description, priority, dueDate, completed } = validated.data;
await db.task.update({
where: { id: params.taskId },
data: {
title,
description: description || null,
priority,
dueDate: dueDate ? new Date(dueDate) : null,
completed,
},
});
return redirect('/tasks');
}
export default function EditTaskPage() {
const { task } = useLoaderData<typeof loader>();
const actionData = useActionData<typeof action>();
const navigation = useNavigation();
const isSubmitting = navigation.state === 'submitting';
return (
<div className="max-w-2xl mx-auto py-8 px-4">
<div className="mb-8">
<Link to="/tasks" className="text-blue-600 hover:underline mb-4 inline-block">
← 返回列表
</Link>
<h1 className="text-3xl font-bold">编辑任务</h1>
</div>
<Form method="post" className="space-y-6">
<div>
<label htmlFor="title" className="block text-sm font-medium text-gray-700">
标题 *
</label>
<input
id="title"
name="title"
type="text"
defaultValue={task.title}
required
className="mt-1 block w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
/>
{actionData?.errors?.title && (
<p className="mt-1 text-sm text-red-600">{actionData.errors.title[0]}</p>
)}
</div>
<div>
<label htmlFor="description" className="block text-sm font-medium text-gray-700">
描述
</label>
<textarea
id="description"
name="description"
rows={4}
defaultValue={task.description || ''}
className="mt-1 block w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">优先级</label>
<div className="flex gap-4">
{[
{ value: 1, label: '低' },
{ value: 2, label: '中' },
{ value: 3, label: '高' },
].map(({ value, label }) => (
<label key={value} className="flex items-center">
<input
type="radio"
name="priority"
value={value}
defaultChecked={task.priority === value}
/>
<span className="ml-2">{label}</span>
</label>
))}
</div>
</div>
<div>
<label htmlFor="dueDate" className="block text-sm font-medium text-gray-700">
截止日期
</label>
<input
id="dueDate"
name="dueDate"
type="date"
defaultValue={task.dueDate ? task.dueDate.split('T')[0] : ''}
className="mt-1 block w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500"
/>
</div>
<div className="flex items-center">
<input
id="completed"
name="completed"
type="checkbox"
value="true"
defaultChecked={task.completed}
className="w-5 h-5 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
/>
<label htmlFor="completed" className="ml-2 text-sm font-medium text-gray-700">
已完成
</label>
</div>
<div className="flex gap-4 pt-4">
<button
type="submit"
disabled={isSubmitting}
className="flex-1 bg-blue-600 text-white py-3 px-6 rounded-lg font-medium hover:bg-blue-700 disabled:opacity-50"
>
{isSubmitting ? '保存中...' : '保存更改'}
</button>
<button
type="submit"
form="delete-form"
className="px-6 py-3 text-red-600 border border-red-200 rounded-lg hover:bg-red-50"
>
删除
</button>
</div>
</Form>
<Form
id="delete-form"
method="post"
action={`/tasks/${task.id}/delete`}
onSubmit={(e) => {
if (!confirm('确定要删除这个任务吗?')) {
e.preventDefault();
}
}}
/>
</div>
);
}删除任务(Delete)
// app/routes/tasks.$taskId.delete.tsx
import type { ActionFunctionArgs } from '@remix-run/node';
import { redirect } from '@remix-run/node';
import { requireUserId } from '~/lib/session.server';
import { db } from '~/lib/db.server';
export async function action({ request, params }: ActionFunctionArgs) {
const userId = await requireUserId(request);
const task = await db.task.findUnique({
where: { id: params.taskId },
});
if (!task || task.userId !== userId) {
throw new Response('任务不存在', { status: 404 });
}
await db.task.delete({
where: { id: params.taskId },
});
return redirect('/tasks');
}Streaming SSR 进阶
复杂数据流处理
// app/routes/dashboard.tsx
import { defer, LoaderFunctionArgs } from '@remix-run/node';
import { Await, useLoaderData, Link } from '@remix-run/react';
import { Suspense } from 'react';
export async function loader({ request }: LoaderFunctionArgs) {
const userId = await requireUserId(request);
// 1. 同步获取关键数据
const user = await db.user.findUnique({
where: { id: userId },
select: { name: true, email: true },
});
// 2. 异步获取非关键数据
const statsPromise = getUserStats(userId);
const notificationsPromise = getNotifications(userId);
const recentActivityPromise = getRecentActivity(userId);
// 3. 使用 defer 返回可延迟的数据
return defer({
user,
stats: await statsPromise,
notifications: notificationsPromise,
recentActivity: recentActivityPromise,
});
}
export default function Dashboard() {
const { user, stats, notifications, recentActivity } = useLoaderData<typeof loader>();
return (
<div className="max-w-7xl mx-auto py-8 px-4">
<h1 className="text-3xl font-bold mb-8">欢迎回来,{user.name}</h1>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{/* 统计卡片 - 同步加载 */}
<div className="lg:col-span-3 grid grid-cols-1 md:grid-cols-4 gap-4">
<StatCard title="访问量" value={stats.visits} icon="📊" />
<StatCard title="文章数" value={stats.posts} icon="📝" />
<StatCard title="评论数" value={stats.comments} icon="💬" />
<StatCard title="收藏数" value={stats.saves} icon="⭐" />
</div>
{/* 通知 - 延迟加载 */}
<div className="lg:col-span-2">
<h2 className="text-xl font-semibold mb-4">通知</h2>
<Suspense fallback={<NotificationSkeleton />}>
<Await resolve={notifications}>
{(data) => (
<div className="space-y-2">
{data.map((n) => (
<NotificationItem key={n.id} notification={n} />
))}
</div>
)}
</Await>
</Suspense>
</div>
{/* 最近活动 - 延迟加载 */}
<div className="lg:col-span-1">
<h2 className="text-xl font-semibold mb-4">最近活动</h2>
<Suspense fallback={<ActivitySkeleton />}>
<Await resolve={recentActivity} errorElement={<ErrorFallback />}>
{(data) => (
<div className="space-y-3">
{data.map((a) => (
<ActivityItem key={a.id} activity={a} />
))}
</div>
)}
</Await>
</Suspense>
</div>
</div>
</div>
);
}
function StatCard({ title, value, icon }: { title: string; value: number; icon: string }) {
return (
<div className="bg-white p-4 rounded-lg shadow">
<div className="flex items-center gap-3">
<span className="text-2xl">{icon}</span>
<div>
<p className="text-sm text-gray-500">{title}</p>
<p className="text-2xl font-bold">{value.toLocaleString()}</p>
</div>
</div>
</div>
);
}
function NotificationSkeleton() {
return (
<div className="space-y-2">
{[1, 2, 3].map((i) => (
<div key={i} className="h-16 bg-gray-200 rounded animate-pulse" />
))}
</div>
);
}
function ActivitySkeleton() {
return (
<div className="space-y-3">
{[1, 2, 3, 4, 5].map((i) => (
<div key={i} className="h-12 bg-gray-200 rounded animate-pulse" />
))}
</div>
);
}
function ErrorFallback() {
return (
<div className="p-4 bg-red-50 text-red-600 rounded-lg">
无法加载活动数据
</div>
);
}常见陷阱与解决方案
表单处理常见问题
1. useFetcher 与 useNavigation 混用
问题: 在同一组件中混用导致状态混乱。
解决方案: 明确区分导航操作和局部操作。
// ✅ 正确:导航使用 Form,局部操作使用 useFetcher
export default function CommentSection({ postId }: { postId: string }) {
const navigation = useNavigation();
const addComment = useFetcher();
const isAddingComment = addComment.state === 'submitting';
const isNavigating = navigation.state !== 'idle';
return (
<div>
{/* 导航到评论页 */}
<Link to={`/posts/${postId}/comments`}>
查看全部评论
</Link>
{/* 局部添加评论 */}
<addComment.Form method="post" action={`/api/posts/${postId}/comments`}>
<textarea name="content" />
<button type="submit" disabled={isAddingComment}>
{isAddingComment ? '发送中...' : '发送'}
</button>
</addComment.Form>
</div>
);
}2. 表单重复提交
问题: 用户快速点击导致多次提交。
解决方案: 使用 useNavigation 状态禁用按钮。
export default function CreatePostForm() {
const navigation = useNavigation();
const isSubmitting = navigation.state === 'submitting';
return (
<Form method="post">
{/* 禁用整个表单或按钮 */}
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? '提交中...' : '提交'}
</button>
</Form>
);
}错误处理常见问题
1. ErrorBoundary 不捕获 Loader 错误
问题: ErrorBoundary 不工作。
解决方案: 确保在正确的位置抛出错误。
// ✅ 正确:在 loader 中抛出 Response
export async function loader({ params }) {
const data = await fetchData(params.id);
if (!data) {
throw new Response('Not Found', { status: 404 });
}
return json(data);
}
// ✅ 或者使用 redirect
export async function loader({ request }) {
const user = await getUser(request);
if (!user) {
throw redirect('/login');
}
return json({ user });
}附录:资源链接
官方资源
学习资源
相关工具
- Zod - 数据验证
- Prisma - 数据库 ORM
- Playwright - E2E 测试