Remix Web 框架

FreeGuideOnline 最新 2026-07-11

bash npx create-remix@latest


进入交互式引导,选择以下配置:

- **应用类型**:Just the basics(基础模板,适合学习)
- **部署目标**:Remix App Server(内置服务器,便于本地开发)
- **语言**:TypeScript
- **是否运行 npm install**:是

完成安装后,进入项目目录并启动开发服务器:

```bash
cd my-remix-app
npm run dev

浏览器访问 http://localhost:3000,看到 Remix 欢迎页即表示环境搭建成功。

核心概念解析

文件路由系统

Remix 采用约定式文件路由,app/routes 目录下的文件会自动映射为 URL 路径。

文件路径 对应路由
app/routes/_index.tsx /(首页)
app/routes/about.tsx /about
app/routes/posts.$slug.tsx /posts/:slug(动态参数)
app/routes/dashboard._index.tsx /dashboard(无布局嵌套的首页)

创建第一个自定义路由:在 app/routes/ 下新建 hello.tsx

export default function Hello() {
  return <h1>Hello, Remix!</h1>;
}

访问 /hello,即可看到页面。文件名即是路由,无需额外配置。

加载数据:loader 函数

每个路由文件都可以导出一个 loader 函数,用于在服务器端获取页面所需的数据。数据会在组件渲染之前加载,从而实现快速、SEO 友好的页面。

// app/routes/posts.tsx
import { json } from "@remix-run/node";
import { useLoaderData } from "@remix-run/react";

export async function loader() {
  // 模拟从数据库或 API 获取数据
  const posts = [
    { id: 1, title: "Remix 入门指南" },
    { id: 2, title: "构建全栈应用" },
  ];
  return json({ posts });
}

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

useLoaderData 钩子用于在组件中获取 loader 返回的数据,类型由 TypeScript 自动推断。

处理表单:action 与 Form 组件

Remix 使用原生的 <form> 增强组件来处理数据变更,无需手动管理状态和 API 调用。

// app/routes/contact.tsx
import { ActionFunctionArgs, json, redirect } from "@remix-run/node";
import { Form, useActionData } from "@remix-run/react";

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

  // 验证逻辑
  if (!name || !email) {
    return json({ error: "姓名和邮箱不能为空" });
  }

  // 实际项目中这里会将数据保存到数据库
  console.log("收到联系信息:", { name, email });

  // 成功后重定向到感谢页
  return redirect("/thanks");
}

export default function Contact() {
  const actionData = useActionData<typeof action>();
  return (
    <Form method="post">
      <div>
        <label htmlFor="name">姓名</label>
        <input type="text" id="name" name="name" />
      </div>
      <div>
        <label htmlFor="email">邮箱</label>
        <input type="email" id="email" name="email" />
      </div>
      {actionData?.error && <p style={{ color: "red" }}>{actionData.error}</p>}
      <button type="submit">提交</button>
    </Form>
  );
}

要点:

  • action 函数处理 POST 请求,通过 request.formData() 读取表单数据。
  • useActionData 获取 action 返回的数据(如验证错误)。
  • <Form> 组件是原生 <form> 的增强版,支持渐进增强:即使 JavaScript 被禁用,表单仍可正常工作。

嵌套路由与布局

Remix 通过文件命名实现嵌套布局。使用点号(.)将子路由文件连接,表示一个路由层级。

示例结构:

app/routes/
├── dashboard.tsx          # 布局组件,所有 /dashboard/* 共享
├── dashboard._index.tsx   # /dashboard 主页面
├── dashboard.settings.tsx # /dashboard/settings

父级路由 dashboard.tsx 使用 <Outlet /> 渲染子路由:

// app/routes/dashboard.tsx
import { Outlet } from "@remix-run/react";

export default function DashboardLayout() {
  return (
    <div>
      <nav>仪表盘导航栏</nav>
      <Outlet /> {/* 子路由将在此处渲染 */}
    </div>
  );
}

这样访问任何 /dashboard/* 路径时,都会自动应用该布局。

错误处理与边界

Remix 为路由提供细粒度的错误处理机制,通过导出 ErrorBoundary 组件。

// app/routes/dashboard.tsx
export function ErrorBoundary() {
  const error = useRouteError();
  console.error(error);
  return (
    <div>
      <h2>出错了!</h2>
      <p>{error.message}</p>
    </div>
  );
}

loaderaction 或组件渲染过程中抛出异常时,Remix 会渲染对应路由的 ErrorBoundary,而布局和其余部分保持可用。

项目实战:构建一个待办事项应用

本实战将融合上述概念,创建一个完整的待办事项(Todo)App。

数据模型与模拟存储

app 目录下创建 models/todo.server.ts,提供服务器端数据操作函数。

// app/models/todo.server.ts
export interface Todo {
  id: number;
  title: string;
  completed: boolean;
}

let todos: Todo[] = [
  { id: 1, title: "学习 Remix 路由", completed: true },
  { id: 2, title: "理解 loader 和 action", completed: false },
];
let nextId = 3;

export function getTodos(): Todo[] {
  return todos;
}

export function addTodo(title: string): Todo {
  const newTodo = { id: nextId++, title, completed: false };
  todos.push(newTodo);
  return newTodo;
}

export function toggleTodo(id: number): Todo | null {
  const todo = todos.find((t) => t.id === id);
  if (todo) {
    todo.completed = !todo.completed;
  }
  return todo ?? null;
}

export function deleteTodo(id: number): boolean {
  const index = todos.findIndex((t) => t.id === id);
  if (index !== -1) {
    todos.splice(index, 1);
    return true;
  }
  return false;
}

页面路由设置

创建以下文件结构:

app/routes/
├── todos.tsx          # 主布局和列表
├── todos._index.tsx   # 默认显示列表(可省略,直接在 todos 中处理)
└── todos.new.tsx      # 添加待办的表单(可选独立页面)

为了简化,我们在 todos.tsx 中直接实现列表与新增功能。

实现 todos 路由

// app/routes/todos.tsx
import { json, type ActionFunctionArgs } from "@remix-run/node";
import {
  Form,
  useLoaderData,
  useSubmit,
  Link,
} from "@remix-run/react";
import { getTodos, addTodo, toggleTodo, deleteTodo } from "~/models/todo.server";

export async function loader() {
  const todos = getTodos();
  return json({ todos });
}

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

  switch (intent) {
    case "create": {
      const title = formData.get("title");
      if (typeof title !== "string" || !title.trim()) {
        return json({ error: "标题不能为空" }, { status: 400 });
      }
      addTodo(title.trim());
      return json({ success: true });
    }
    case "toggle": {
      const id = Number(formData.get("id"));
      toggleTodo(id);
      return json({ success: true });
    }
    case "delete": {
      const id = Number(formData.get("id"));
      deleteTodo(id);
      return json({ success: true });
    }
    default:
      return json({ error: "未知操作" }, { status: 400 });
  }
}

export default function Todos() {
  const { todos } = useLoaderData<typeof loader>();
  const submit = useSubmit();

  // 辅助函数:使用 useSubmit 编程式提交表单实现即时更新
  const handleToggle = (id: number) => {
    const formData = new FormData();
    formData.set("intent", "toggle");
    formData.set("id", String(id));
    submit(formData, { method: "post" });
  };

  const handleDelete = (id: number) => {
    const formData = new FormData();
    formData.set("intent", "delete");
    formData.set("id", String(id));
    submit(formData, { method: "post" });
  };

  return (
    <div>
      <h1>待办事项</h1>

      <Form method="post">
        <input type="hidden" name="intent" value="create" />
        <input type="text" name="title" placeholder="添加新任务..." required />
        <button type="submit">添加</button>
      </Form>

      <ul>
        {todos.map((todo) => (
          <li key={todo.id} style={{ textDecoration: todo.completed ? "line-through" : "none" }}>
            <span onClick={() => handleToggle(todo.id)} style={{ cursor: "pointer" }}>
              {todo.title}
            </span>
            <button onClick={() => handleDelete(todo.id)}>删除</button>
          </li>
        ))}
      </ul>
    </div>
  );
}

解释:

  • 通过 intent 字段区分同一个表单内的不同操作,符合 Web 标准。
  • 使用 useSubmit 实现无页面刷新的数据提交(点击切换和删除),用户获得即时反馈。
  • 表单提交时无需手动处理 fetch,Remix 在后台模拟原生表单提交并返回最新数据,实现自动重新渲染。

样式与资源管理

Remix 支持多种样式方案,包括 CSS 模块、普通 CSS、Tailwind CSS 等。推荐使用 Tailwind CSS,只需在项目中运行安装命令:

npx remix init --tailwind

或手动配置,然后通过 links 导出函数引入样式:

// app/root.tsx
import stylesheet from "~/tailwind.css";
import type { LinksFunction } from "@remix-run/node";

export const links: LinksFunction = () => [
  { rel: "stylesheet", href: stylesheet },
];

每个路由也可以导出自己的 links,实现代码分割和按需加载样式。

部署你的应用

Remix 支持多种部署环境,包括 Vercel、Netlify、Fly.io、Cloudflare Pages 等。以 Fly.io 为例:

  1. 安装 Fly CLI 并登录:
brew install flyctl  # macOS
flyctl auth login
  1. 在项目根目录初始化部署配置:
flyctl launch

根据提示选择区域和配置,完成后会自动生成 fly.toml 文件。

  1. 部署:
flyctl deploy