Remix Web 框架
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>
);
}
当 loader、action 或组件渲染过程中抛出异常时,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 为例:
- 安装 Fly CLI 并登录:
brew install flyctl # macOS
flyctl auth login
- 在项目根目录初始化部署配置:
flyctl launch
根据提示选择区域和配置,完成后会自动生成 fly.toml 文件。
- 部署:
flyctl deploy