Remix 完全指南

NOTE

本文档最后更新于 2026年4月,涵盖 Remix 核心概念、嵌套路由、Web 标准优先哲学、与 Next.js/SvelteKit 的对比及在 AI 应用中的定位。


目录

  1. Remix 概述与核心哲学
  2. Remix vs Next.js vs SvelteKit 对比
  3. Loader vs Server Action
  4. 核心概念详解
  5. 嵌套路由与错误边界
  6. 表单处理与渐进增强
  7. Streaming SSR
  8. 部署与托管
  9. Remix 的局限性
  10. AI 应用实战场景
  11. 选型建议

Remix 概述与核心哲学

什么是 Remix

Remix 是一个全栈 Web 框架,以 Web 标准为核心,主张”利用浏览器原生能力,减少客户端 JavaScript 依赖”的开发理念。Remix 起源于 React Router 团队的经验总结,其核心理念是将服务端能力(数据加载、表单处理、错误处理)与客户端能力(交互反馈、离线支持)有机结合。

Remix 核心特点:

特性说明
Web 标准优先充分利用 HTTP 语义,不重复造轮子
嵌套路由基于文件系统的嵌套布局结构
渐进增强基础功能纯 HTML,增强依赖 JavaScript
SSR 优先服务端渲染,支持流式响应
错误边界嵌套路由级别的错误处理
数据加载Loader/Action 模式,服务端获取/提交数据

Remix 发展历程

版本发布年份核心特性
Remix 0.x2020开源框架发布,核心架构确立
Remix 1.02021稳定版发布
Remix 1.5-1.82022-2023性能优化,Vite 集成
Remix v22024新的路由约定,稳定 API
Remix v32026React 19 支持,性能大幅提升

核心理念

Remix 的设计哲学:

  1. Web 标准优先:Remix 拥抱 HTTP 协议本身,利用 <form>、HTTP 方法、缓存头部等原生能力,而非发明新的抽象
  2. 渐进增强:网站在无 JavaScript 时应能正常工作,JavaScript 只是增强层
  3. 嵌套路由:URL 结构映射到 UI 结构,每个路由片段独立管理数据
  4. 错误处理:将错误视为 UI 问题而非代码问题,提供优雅的错误展示

IMPORTANT

Remix 不追求”零 JavaScript”,而是追求”正确使用 JavaScript”。每个客户端决策都应该有明确的理由。


Remix vs Next.js vs SvelteKit 对比

框架定位对比

维度RemixNext.jsSvelteKit
核心哲学Web 标准优先功能全面编译时优化
路由约定基于文件 + 嵌套基于文件 + App Router基于文件 + 嵌套
数据获取Loader/ActionServer Components / Route Handlers+page.server.js / +server.js
渲染模式SSR + StreamingSSR/SSG/ISR + Server ComponentsSSR/SSG + Adapter
状态管理React 状态 + Cookie/SessionReact Context/Server StateSvelte Store
学习曲线中等较陡

功能对比表

特性RemixNext.jsSvelteKit
嵌套布局✅ 原生支持⚠️ 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✅ 原生支持✅ 原生支持✅ 原生支持

渲染模式对比

渲染模式RemixNext.jsSvelteKit
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 对比

维度LoaderAction
HTTP 方法GETPOST/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/nodeNode.js 服务器通用适配器
@remix-run/serve单文件部署简单部署
@remix-run/cloudflareCloudflare Workers/ Pages边缘计算
@remix-run/vercelVercelServerless
@remix-run/netlifyNetlifyServerless/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 的集成挑战

  1. Server Components 不兼容:Remix 不支持 React Server Components(RSC),无法享受其带来的零客户端 JavaScript 优势
  2. App Router 迁移:Next.js App Router 越来越受欢迎,Remix 的定位面临挑战
  3. 第三方库兼容性:部分依赖客户端状态的库在 Remix 中使用不便

社区与生态

维度RemixNext.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, SvelteKitSSG 支持更好
React Server ComponentsNext.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-stack

Remix CLI 选项:

选项说明默认值
--yes跳过所有确认false
--template指定模板default
--no-git不初始化 Gitfalse
--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 vite

package.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

认证方案详解

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 });
}

附录:资源链接

官方资源

学习资源

相关工具