Remix Hello World — 博客首页
目标
创建一个最简单的 Remix 博客首页,展示 loader 数据加载和页面渲染。
完整代码
路由文件 app/routes/_index.tsx
import { json } from "@remix-run/node";
import { useLoaderData, Link } from "@remix-run/react";
const posts = [
{ id: 1, title: "Remix 入门指南", author: "张三", date: "2024-01-15" },
{ id: 2, title: "为什么选择 Remix", author: "李四", date: "2024-02-20" },
{ id: 3, title: "Web 标准的力量", author: "王五", date: "2024-03-10" },
];
export function loader() {
return json({ posts });
}
export default function Index() {
const { posts } = useLoaderData<typeof loader>();
return (
<main style={mainStyle}>
<h1>📝 我的博客</h1>
<p style={{ color: "#666" }}>基于 Remix 构建 — 拥抱 Web 标准</p>
<ul style={{ listStyle: "none", padding: 0 }}>
{posts.map((post) => (
<li key={post.id} style={cardStyle}>
<Link to={`/post/${post.id}`} style={linkStyle}>
{post.title}
</Link>
<div style={{ fontSize: "0.85rem", color: "#888", marginTop: 4 }}>
{post.author} · {post.date}
</div>
</li>
))}
</ul>
</main>
);
}
const mainStyle: React.CSSProperties = {
maxWidth: 640, margin: "60px auto", padding: "0 16px",
fontFamily: "-apple-system, 'Microsoft YaHei', sans-serif",
};
const cardStyle: React.CSSProperties = {
padding: "16px 0",
borderBottom: "1px solid #eee",
};
const linkStyle: React.CSSProperties = {
color: "#6366f1", textDecoration: "none",
fontSize: "1.1rem", fontWeight: 600,
};
根布局 app/root.tsx
import { Links, Meta, Outlet, Scripts, ScrollRestoration } from "@remix-run/react";
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>
<Outlet />
<ScrollRestoration />
<Scripts />
</body>
</html>
);
}
文章详情页 app/routes/post.$postId.tsx
import { json } from "@remix-run/node";
import { useLoaderData, Link } from "@remix-run/react";
const posts = {
"1": { title: "Remix 入门指南", content: "Remix 是基于 Web 标准的全栈框架...", author: "张三" },
"2": { title: "为什么选择 Remix", content: "嵌套路由、数据加载、表单处理...", author: "李四" },
"3": { title: "Web 标准的力量", content: "Fetch API、FormData、Response...", author: "王五" },
};
export function loader({ params }: { params: { postId: string } }) {
const post = posts[params.postId as keyof typeof posts];
if (!post) throw new Response("Not Found", { status: 404 });
return json({ post });
}
export default function Post() {
const { post } = useLoaderData<typeof loader>();
return (
<main style={{ maxWidth: 640, margin: "60px auto", padding: "0 16px" }}>
<Link to="/" style={{ color: "#6366f1" }}>← 返回首页</Link>
<h1>{post.title}</h1>
<p>{post.content}</p>
<p style={{ color: "#888" }}>作者:{post.author}</p>
</main>
);
}
运行步骤
npm run dev
预期输出
- 首页显示 3 篇文章列表
- 点击文章标题进入详情页
- 详情页有返回链接
- 所有页面均为服务端渲染(查看源代码可见 HTML)
Remix Loader/Action 联系人管理
目标
展示 Remix 核心模式:loader 加载数据、action 处理表单、无需 useState 管理状态。
完整代码
app/routes/_index.tsx
import { type ActionFunctionArgs, type LoaderFunctionArgs, json } from '@remix-run/node';
import { Form, useLoaderData, useActionData, useNavigation } from '@remix-run/react';
type Contact = { id: number; name: string; phone: string; email: string };
let contacts: Contact[] = [
{ id: 1, name: '张三', phone: '13800138000', email: 'zs@example.com' },
{ id: 2, name: '李四', phone: '13900139000', email: 'ls@example.com' },
];
let nextId = 3;
export async function loader({ request }: LoaderFunctionArgs) {
const url = new URL(request.url);
const q = url.searchParams.get('q') || '';
const filtered = q ? contacts.filter(c => c.name.includes(q) || c.phone.includes(q)) : contacts;
return json({ contacts: filtered, search: q });
}
export async function action({ request }: ActionFunctionArgs) {
const fd = await request.formData();
const intent = fd.get('_intent');
if (intent === 'delete') {
const id = Number(fd.get('id'));
contacts = contacts.filter(c => c.id !== id);
return json({ ok: true, msg: '已删除' });
}
if (intent === 'add') {
const name = fd.get('name') as string;
const phone = fd.get('phone') as string;
const email = (fd.get('email') as string) || '';
if (!name || !phone) {
return json({ error: '姓名和电话为必填项' }, { status: 400 });
}
contacts.push({ id: nextId++, name, phone, email });
return json({ ok: true, msg: '添加成功' });
}
return null;
}
export default function Index() {
const { contacts: list, search } = useLoaderData<typeof loader>();
const actionData = useActionData<typeof action>();
const nav = useNavigation();
const busy = nav.state === 'submitting';
return (
<main style={{ maxWidth: 600, margin: '40px auto', fontFamily: 'system-ui' }}>
<h1>📞 联系人管理</h1>
<Form method="get" style={{ display: 'flex', gap: 8, marginBottom: 16 }}>
<input type="text" name="q" defaultValue={search} placeholder="搜索..." style={inputStyle} />
<button type="submit" style={btnStyle}>搜索</button>
</Form>
{actionData?.ok && <p style={{ color: 'green' }}>{actionData.msg}</p>}
{actionData?.error && <p style={{ color: 'red' }}>{actionData.error}</p>}
<Form method="post" style={{ display: 'flex', gap: 8, marginBottom: 20 }}>
<input type="hidden" name="_intent" value="add" />
<input name="name" placeholder="姓名*" required style={inputStyle} />
<input name="phone" placeholder="电话*" required style={inputStyle} />
<input name="email" placeholder="邮箱" style={inputStyle} />
<button type="submit" disabled={busy} style={btnPrimaryStyle}>
{busy ? '...' : '添加'}
</button>
</Form>
<div>
{list.map(c => (
<div key={c.id} style={{ display: 'flex', justifyContent: 'space-between', padding: 12, background: 'white', borderRadius: 8, marginBottom: 8, boxShadow: '0 1px 3px rgba(0,0,0,.1)' }}>
<div>
<strong>{c.name}</strong>
<div style={{ color: '#666', fontSize: '0.9em' }}>{c.phone} · {c.email}</div>
</div>
<Form method="post">
<input type="hidden" name="_intent" value="delete" />
<input type="hidden" name="id" value={c.id} />
<button type="submit" style={{ background: '#ef4444', color: 'white', border: 'none', borderRadius: 4, cursor: 'pointer' }}>删除</button>
</Form>
</div>
))}
</div>
</main>
);
}
const inputStyle = { flex: 1, padding: 8, border: '1px solid #ddd', borderRadius: 4 };
const btnStyle = { padding: '8px 16px', background: '#6b7280', color: 'white', border: 'none', borderRadius: 4, cursor: 'pointer' };
const btnPrimaryStyle = { padding: '8px 16px', background: '#3b82f6', color: 'white', border: 'none', borderRadius: 4, cursor: 'pointer' };
运行步骤
npx create-remix@latest remix-contacts
cd remix-contacts
npm run dev
预期输出
- 页面显示预设联系人,搜索框实时过滤
- 填写表单添加联系人,提交后自动刷新(无需手动 setState)
- 删除按钮直接提交 Form,Remix 自动重新验证 loader