第一个Remix应用 #
一、应用概述 #
我们将创建一个简单的待办事项应用,包含以下功能:
- 显示待办事项列表
- 添加新的待办事项
- 标记完成状态
- 删除待办事项
二、创建项目 #
bash
npx create-remix@latest todo-app --template remix
cd todo-app
npm run dev
三、构建首页 #
3.1 修改根组件 #
编辑 app/root.tsx:
tsx
import {
Links,
LiveReload,
Meta,
Outlet,
Scripts,
ScrollRestoration,
} from "@remix-run/react";
import type { LinksFunction } from "@remix-run/node";
import stylesheet from "~/tailwind.css?url";
export const links: LinksFunction = () => [
{ rel: "stylesheet", href: stylesheet },
];
export default function App() {
return (
<html lang="zh-CN">
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<Meta />
<Links />
</head>
<body className="bg-gray-100 min-h-screen">
<div className="container mx-auto px-4 py-8">
<Outlet />
</div>
<ScrollRestoration />
<Scripts />
<LiveReload />
</body>
</html>
);
}
3.2 创建首页路由 #
编辑 app/routes/_index.tsx:
tsx
import type { MetaFunction } from "@remix-run/node";
import { Link } from "@remix-run/react";
export const meta: MetaFunction = () => {
return [
{ title: "待办事项应用" },
{ name: "description", content: "一个简单的待办事项管理应用" },
];
};
export default function Index() {
return (
<div className="max-w-2xl mx-auto">
<h1 className="text-3xl font-bold text-center mb-8">
待办事项应用
</h1>
<nav className="flex justify-center gap-4 mb-8">
<Link
to="/todos"
className="bg-blue-500 text-white px-6 py-2 rounded hover:bg-blue-600"
>
查看待办事项
</Link>
</nav>
<p className="text-center text-gray-600">
欢迎使用 Remix 待办事项应用!
</p>
</div>
);
}
四、创建待办事项页面 #
4.1 创建数据模型 #
创建 app/models/todo.server.ts:
tsx
export interface Todo {
id: string;
title: string;
completed: boolean;
createdAt: Date;
}
let todos: Todo[] = [
{
id: "1",
title: "学习 Remix",
completed: false,
createdAt: new Date(),
},
{
id: "2",
title: "构建第一个应用",
completed: false,
createdAt: new Date(),
},
];
export async function getTodos(): Promise<Todo[]> {
return todos;
}
export async function createTodo(title: string): Promise<Todo> {
const todo: Todo = {
id: Date.now().toString(),
title,
completed: false,
createdAt: new Date(),
};
todos.push(todo);
return todo;
}
export async function toggleTodo(id: string): Promise<Todo | null> {
const todo = todos.find((t) => t.id === id);
if (todo) {
todo.completed = !todo.completed;
return todo;
}
return null;
}
export async function deleteTodo(id: string): Promise<boolean> {
const index = todos.findIndex((t) => t.id === id);
if (index > -1) {
todos.splice(index, 1);
return true;
}
return false;
}
4.2 创建待办事项列表页面 #
创建 app/routes/todos._index.tsx:
tsx
import type { LoaderFunctionArgs, MetaFunction } from "@remix-run/node";
import { json } from "@remix-run/node";
import { useLoaderData, Form, Link } from "@remix-run/react";
import { getTodos } from "~/models/todo.server";
export const meta: MetaFunction = () => {
return [{ title: "待办事项列表" }];
};
export async function loader({}: LoaderFunctionArgs) {
const todos = await getTodos();
return json({ todos });
}
export default function TodosIndex() {
const { todos } = useLoaderData<typeof loader>();
return (
<div className="max-w-2xl mx-auto">
<div className="flex justify-between items-center mb-6">
<h1 className="text-2xl font-bold">待办事项列表</h1>
<Link
to="/todos/new"
className="bg-green-500 text-white px-4 py-2 rounded hover:bg-green-600"
>
添加新事项
</Link>
</div>
{todos.length === 0 ? (
<p className="text-center text-gray-500 py-8">
暂无待办事项,点击上方按钮添加。
</p>
) : (
<ul className="space-y-3">
{todos.map((todo) => (
<li
key={todo.id}
className="flex items-center justify-between bg-white p-4 rounded shadow"
>
<div className="flex items-center gap-3">
<Form method="post" action="/todos/toggle">
<input type="hidden" name="id" value={todo.id} />
<button
type="submit"
className={`w-5 h-5 rounded border ${
todo.completed
? "bg-green-500 border-green-500"
: "border-gray-300"
}`}
/>
</Form>
<span
className={
todo.completed ? "line-through text-gray-400" : ""
}
>
{todo.title}
</span>
</div>
<Form method="post" action="/todos/delete">
<input type="hidden" name="id" value={todo.id} />
<button
type="submit"
className="text-red-500 hover:text-red-700"
>
删除
</button>
</Form>
</li>
))}
</ul>
)}
</div>
);
}
4.3 创建添加待办事项页面 #
创建 app/routes/todos.new.tsx:
tsx
import type { ActionFunctionArgs, MetaFunction } from "@remix-run/node";
import { redirect } from "@remix-run/node";
import { Form, Link } from "@remix-run/react";
import { createTodo } from "~/models/todo.server";
export const meta: MetaFunction = () => {
return [{ title: "添加待办事项" }];
};
export async function action({ request }: ActionFunctionArgs) {
const formData = await request.formData();
const title = formData.get("title");
if (typeof title !== "string" || title.trim() === "") {
return redirect("/todos/new");
}
await createTodo(title.trim());
return redirect("/todos");
}
export default function NewTodo() {
return (
<div className="max-w-2xl mx-auto">
<h1 className="text-2xl font-bold mb-6">添加待办事项</h1>
<Form method="post" className="bg-white p-6 rounded shadow">
<div className="mb-4">
<label className="block text-gray-700 mb-2">
待办事项标题
</label>
<input
type="text"
name="title"
className="w-full border rounded px-3 py-2"
placeholder="输入待办事项..."
required
/>
</div>
<div className="flex gap-3">
<button
type="submit"
className="bg-blue-500 text-white px-4 py-2 rounded hover:bg-blue-600"
>
添加
</button>
<Link
to="/todos"
className="bg-gray-300 text-gray-700 px-4 py-2 rounded hover:bg-gray-400"
>
取消
</Link>
</div>
</Form>
</div>
);
}
4.4 创建Action处理 #
创建 app/routes/todos.toggle.ts:
tsx
import type { ActionFunctionArgs } from "@remix-run/node";
import { redirect } from "@remix-run/node";
import { toggleTodo } from "~/models/todo.server";
export async function action({ request }: ActionFunctionArgs) {
const formData = await request.formData();
const id = formData.get("id");
if (typeof id === "string") {
await toggleTodo(id);
}
return redirect("/todos");
}
创建 app/routes/todos.delete.ts:
tsx
import type { ActionFunctionArgs } from "@remix-run/node";
import { redirect } from "@remix-run/node";
import { deleteTodo } from "~/models/todo.server";
export async function action({ request }: ActionFunctionArgs) {
const formData = await request.formData();
const id = formData.get("id");
if (typeof id === "string") {
await deleteTodo(id);
}
return redirect("/todos");
}
五、添加样式 #
5.1 安装 Tailwind CSS #
bash
npm install -D tailwindcss postcss autoprefixer
npx tailwindcss init -p
5.2 配置 Tailwind #
编辑 tailwind.config.js:
javascript
/** @type {import('tailwindcss').Config} */
export default {
content: ["./app/**/*.{js,jsx,ts,tsx}"],
theme: {
extend: {},
},
plugins: [],
};
5.3 创建CSS文件 #
创建 app/tailwind.css:
css
@tailwind base;
@tailwind components;
@tailwind utilities;
六、运行应用 #
bash
npm run dev
访问 http://localhost:5173,你将看到:
- 首页有导航链接
- 待办事项列表页面显示所有事项
- 可以添加新的待办事项
- 可以切换完成状态
- 可以删除待办事项
七、项目结构总览 #
text
todo-app/
├── app/
│ ├── models/
│ │ └── todo.server.ts # 数据模型
│ ├── routes/
│ │ ├── _index.tsx # 首页
│ │ ├── todos._index.tsx # 待办列表
│ │ ├── todos.new.tsx # 添加页面
│ │ ├── todos.toggle.ts # 切换状态
│ │ └── todos.delete.ts # 删除处理
│ ├── root.tsx # 根组件
│ └── tailwind.css # 样式文件
├── public/
├── package.json
└── vite.config.ts
八、学到的核心概念 #
8.1 路由 #
- 文件系统路由:文件名决定URL路径
_index.tsx:路由的默认页面
8.2 Loader #
- 服务端数据加载函数
- 使用
useLoaderData获取数据
8.3 Action #
- 处理表单提交
- 使用
formData获取表单数据
8.4 Form #
- 原生表单增强
- 自动处理表单提交
九、下一步 #
恭喜你完成了第一个 Remix 应用!接下来你可以:
- 添加数据库持久化存储
- 添加用户认证
- 添加表单验证
- 部署到生产环境
让我们继续学习 Remix 的更多功能!
最后更新:2026-03-28