Appearance
Remix 框架概述
课程概述
本课程全面介绍Remix全栈Web框架。Remix是一个基于React和Web标准的现代框架,专注于Web基础、用户体验和性能优化。
学习目标:
- 理解Remix的核心理念
- 掌握Remix的架构
- 学习Remix与Next.js的区别
- 理解路由系统
- 掌握数据加载和提交
- 学习嵌套路由
- 理解Web标准优先
- 构建第一个Remix应用
一、Remix 简介
1.1 什么是Remix
typescript
// Remix 是一个全栈Web框架
// 核心特点:
// 1. 基于Web标准
// 2. 服务端渲染
// 3. 嵌套路由
// 4. 渐进增强
// 5. 优化的数据加载
// 6. 内置表单处理
// 7. 错误边界
// 8. 优秀的开发体验Remix的优势:
| 特性 | 说明 |
|---|---|
| Web标准 | 基于标准Web API |
| 性能优异 | 优化的资源加载 |
| SEO友好 | 完整的SSR支持 |
| 渐进增强 | JavaScript禁用也能工作 |
| 嵌套路由 | 强大的路由系统 |
| 简化数据流 | loader/action模式 |
1.2 Remix vs Next.js
typescript
// Next.js - 多种渲染模式
export const dynamic = 'force-dynamic' // SSR
export const revalidate = 60 // ISR
export default function Page() {} // SSG
// Remix - 统一的loader/action模式
export async function loader() {
// 所有数据加载
return json(data)
}
export async function action() {
// 所有数据提交
return redirect('/success')
}
export default function Route() {
const data = useLoaderData()
// 渲染
}对比表:
| 特性 | Remix | Next.js 15 |
|---|---|---|
| 路由 | 文件系统 + 嵌套 | 文件系统 + App Router |
| 数据加载 | loader | Server Components |
| 数据提交 | action | Server Actions |
| 表单 | 原生表单增强 | Server Actions |
| 嵌套 | 原生支持 | layout嵌套 |
| 流式 | 支持 | Suspense |
| 边缘部署 | 支持 | 支持 |
1.3 核心概念
typescript
// 1. Loader - 数据加载
export async function loader({ request, params }) {
const data = await fetchData(params.id)
return json(data)
}
// 2. Action - 数据提交
export async function action({ request }) {
const formData = await request.formData()
await updateData(formData)
return redirect('/success')
}
// 3. Component - 组件渲染
export default function Route() {
const data = useLoaderData()
return <div>{data.title}</div>
}
// 4. ErrorBoundary - 错误处理
export function ErrorBoundary() {
const error = useRouteError()
return <div>Error: {error.message}</div>
}二、安装与设置
2.1 创建新项目
bash
# 使用官方CLI
npx create-remix@latest my-remix-app
# 选择选项:
# - TypeScript or JavaScript
# - Deployment target (Vercel, Cloudflare, etc.)
# - Install dependencies
# 进入项目
cd my-remix-app
# 启动开发服务器
npm run dev2.2 项目结构
my-remix-app/
├── app/
│ ├── routes/
│ │ ├── _index.tsx # 首页
│ │ ├── about.tsx # /about
│ │ └── posts.$id.tsx # /posts/123
│ ├── root.tsx # 根组件
│ ├── entry.client.tsx # 客户端入口
│ └── entry.server.tsx # 服务端入口
├── public/
│ └── favicon.ico
├── remix.config.js # Remix 配置
├── tsconfig.json
└── package.json2.3 基础配置
javascript
// remix.config.js
/** @type {import('@remix-run/dev').AppConfig} */
module.exports = {
// 应用目录
appDirectory: "app",
// 资源构建目录
assetsBuildDirectory: "public/build",
// 服务器构建目录
serverBuildPath: "build/index.js",
// 公共路径
publicPath: "/build/",
// 忽略的文件
ignoredRouteFiles: ["**/.*"],
// 服务器依赖
serverDependenciesToBundle: [],
// 未来特性
future: {
v3_fetcherPersist: true,
v3_relativeSplatPath: true,
v3_throwAbortReason: true,
},
}三、路由系统
3.1 基础路由
typescript
// app/routes/_index.tsx (首页 /)
export default function Index() {
return (
<div>
<h1>Welcome to Remix</h1>
<p>This is the home page</p>
</div>
)
}
// app/routes/about.tsx (/about)
export default function About() {
return (
<div>
<h1>About Us</h1>
</div>
)
}
// app/routes/contact.tsx (/contact)
export default function Contact() {
return (
<div>
<h1>Contact</h1>
</div>
)
}3.2 动态路由
typescript
// app/routes/posts.$postId.tsx (/posts/123)
import { json, LoaderFunctionArgs } from "@remix-run/node"
import { useLoaderData } from "@remix-run/react"
export async function loader({ params }: LoaderFunctionArgs) {
const post = await getPost(params.postId)
return json({ post })
}
export default function Post() {
const { post } = useLoaderData<typeof loader>()
return (
<article>
<h1>{post.title}</h1>
<p>{post.content}</p>
</article>
)
}
// app/routes/users.$userId.posts.$postId.tsx
// URL: /users/123/posts/456
export async function loader({ params }: LoaderFunctionArgs) {
const { userId, postId } = params
// ...
}3.3 嵌套路由
typescript
// app/routes/blog.tsx (父路由)
import { Outlet } from "@remix-run/react"
export default function BlogLayout() {
return (
<div>
<header>
<h1>My Blog</h1>
<nav>
<a href="/blog">All Posts</a>
<a href="/blog/new">New Post</a>
</nav>
</header>
<main>
<Outlet /> {/* 子路由渲染在这里 */}
</main>
</div>
)
}
// app/routes/blog._index.tsx (子路由 /blog)
export default function BlogIndex() {
return <div>Blog post list</div>
}
// app/routes/blog.$slug.tsx (子路由 /blog/post-slug)
export default function BlogPost() {
return <div>Blog post detail</div>
}3.4 路由命名约定
app/routes/
├── _index.tsx # /
├── about.tsx # /about
├── posts._index.tsx # /posts
├── posts.$postId.tsx # /posts/:postId
├── posts.new.tsx # /posts/new
├── blog.tsx # /blog (布局)
├── blog._index.tsx # /blog (索引)
├── blog.$slug.tsx # /blog/:slug
├── _auth.login.tsx # /login (无布局)
├── _auth.register.tsx # /register (无布局)
└── $.tsx # 捕获所有路由四、数据加载 (Loader)
4.1 基础Loader
typescript
// app/routes/posts._index.tsx
import { json, LoaderFunctionArgs } from "@remix-run/node"
import { useLoaderData } from "@remix-run/react"
interface Post {
id: string
title: string
excerpt: string
}
export async function loader() {
const posts: Post[] = await db.post.findMany()
return json({ posts })
}
export default function Posts() {
const { posts } = useLoaderData<typeof loader>()
return (
<div>
<h1>Posts</h1>
<ul>
{posts.map(post => (
<li key={post.id}>
<a href={`/posts/${post.id}`}>{post.title}</a>
<p>{post.excerpt}</p>
</li>
))}
</ul>
</div>
)
}4.2 访问请求数据
typescript
// app/routes/search.tsx
import { json, LoaderFunctionArgs } from "@remix-run/node"
import { useLoaderData } from "@remix-run/react"
export async function loader({ request }: LoaderFunctionArgs) {
const url = new URL(request.url)
const query = url.searchParams.get("q")
if (!query) {
return json({ results: [] })
}
const results = await searchPosts(query)
return json({ results, query })
}
export default function Search() {
const { results, query } = useLoaderData<typeof loader>()
return (
<div>
<h1>Search Results for "{query}"</h1>
{results.length > 0 ? (
<ul>
{results.map(result => (
<li key={result.id}>{result.title}</li>
))}
</ul>
) : (
<p>No results found</p>
)}
</div>
)
}4.3 并行数据加载
typescript
// app/routes/dashboard.tsx
import { json } from "@remix-run/node"
import { useLoaderData } from "@remix-run/react"
export async function loader() {
// 并行获取多个数据源
const [user, stats, notifications] = await Promise.all([
getUser(),
getStats(),
getNotifications()
])
return json({ user, stats, notifications })
}
export default function Dashboard() {
const { user, stats, notifications } = useLoaderData<typeof loader>()
return (
<div>
<h1>Welcome, {user.name}</h1>
<div>Stats: {stats.totalViews}</div>
<div>Notifications: {notifications.length}</div>
</div>
)
}五、数据提交 (Action)
5.1 基础Action
typescript
// app/routes/posts.new.tsx
import { json, redirect, ActionFunctionArgs } from "@remix-run/node"
import { Form, useActionData } from "@remix-run/react"
export async function action({ request }: ActionFunctionArgs) {
const formData = await request.formData()
const title = formData.get("title")
const content = formData.get("content")
if (!title || !content) {
return json({ error: "Title and content are required" }, { status: 400 })
}
const post = await createPost({ title, content })
return redirect(`/posts/${post.id}`)
}
export default function NewPost() {
const actionData = useActionData<typeof action>()
return (
<Form method="post">
<h1>Create New Post</h1>
{actionData?.error && (
<div style={{ color: 'red' }}>{actionData.error}</div>
)}
<div>
<label>
Title:
<input type="text" name="title" required />
</label>
</div>
<div>
<label>
Content:
<textarea name="content" required />
</label>
</div>
<button type="submit">Create Post</button>
</Form>
)
}5.2 表单验证
typescript
// app/routes/register.tsx
import { json, redirect, ActionFunctionArgs } from "@remix-run/node"
import { Form, useActionData } from "@remix-run/react"
interface ActionData {
errors?: {
email?: string
password?: string
}
}
export async function action({ request }: ActionFunctionArgs) {
const formData = await request.formData()
const email = formData.get("email") as string
const password = formData.get("password") as string
const errors: ActionData['errors'] = {}
// 验证
if (!email || !email.includes("@")) {
errors.email = "Valid email is required"
}
if (!password || password.length < 8) {
errors.password = "Password must be at least 8 characters"
}
if (Object.keys(errors).length > 0) {
return json<ActionData>({ errors }, { status: 400 })
}
// 创建用户
await createUser({ email, password })
return redirect("/login")
}
export default function Register() {
const actionData = useActionData<typeof action>()
return (
<Form method="post">
<h1>Register</h1>
<div>
<label>
Email:
<input type="email" name="email" required />
</label>
{actionData?.errors?.email && (
<p style={{ color: 'red' }}>{actionData.errors.email}</p>
)}
</div>
<div>
<label>
Password:
<input type="password" name="password" required />
</label>
{actionData?.errors?.password && (
<p style={{ color: 'red' }}>{actionData.errors.password}</p>
)}
</div>
<button type="submit">Register</button>
</Form>
)
}六、错误处理
6.1 ErrorBoundary
typescript
// app/routes/posts.$postId.tsx
import { json, LoaderFunctionArgs } from "@remix-run/node"
import { useLoaderData, useRouteError, isRouteErrorResponse } from "@remix-run/react"
export async function loader({ params }: LoaderFunctionArgs) {
const post = await getPost(params.postId)
if (!post) {
throw new Response("Post not found", { status: 404 })
}
return json({ post })
}
export default function Post() {
const { post } = useLoaderData<typeof loader>()
return (
<article>
<h1>{post.title}</h1>
<p>{post.content}</p>
</article>
)
}
export function ErrorBoundary() {
const error = useRouteError()
if (isRouteErrorResponse(error)) {
return (
<div>
<h1>{error.status} {error.statusText}</h1>
<p>{error.data}</p>
</div>
)
} else if (error instanceof Error) {
return (
<div>
<h1>Error</h1>
<p>{error.message}</p>
<p>The stack trace is:</p>
<pre>{error.stack}</pre>
</div>
)
} else {
return <h1>Unknown Error</h1>
}
}6.2 全局错误处理
typescript
// app/root.tsx
import {
Links,
Meta,
Outlet,
Scripts,
ScrollRestoration,
useRouteError,
isRouteErrorResponse,
} from "@remix-run/react"
export default function App() {
return (
<html lang="en">
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<Meta />
<Links />
</head>
<body>
<Outlet />
<ScrollRestoration />
<Scripts />
</body>
</html>
)
}
export function ErrorBoundary() {
const error = useRouteError()
return (
<html lang="en">
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Oops!</title>
<Meta />
<Links />
</head>
<body>
<div style={{ padding: "2rem" }}>
<h1>Application Error</h1>
{isRouteErrorResponse(error) ? (
<>
<h2>{error.status} {error.statusText}</h2>
<p>{error.data}</p>
</>
) : error instanceof Error ? (
<>
<h2>Error</h2>
<p>{error.message}</p>
</>
) : (
<h2>Unknown Error</h2>
)}
</div>
<Scripts />
</body>
</html>
)
}七、完整示例
7.1 博客应用
typescript
// app/routes/blog._index.tsx
import { json } from "@remix-run/node"
import { useLoaderData, Link } from "@remix-run/react"
export async function loader() {
const posts = await db.post.findMany({
orderBy: { createdAt: 'desc' },
take: 10
})
return json({ posts })
}
export default function BlogIndex() {
const { posts } = useLoaderData<typeof loader>()
return (
<div className="max-w-4xl mx-auto p-4">
<h1 className="text-4xl font-bold mb-8">Blog</h1>
<div className="space-y-6">
{posts.map(post => (
<article key={post.id} className="border rounded-lg p-6">
<h2 className="text-2xl font-semibold mb-2">
<Link to={`/blog/${post.slug}`} className="hover:text-blue-600">
{post.title}
</Link>
</h2>
<p className="text-gray-600 mb-4">{post.excerpt}</p>
<div className="text-sm text-gray-500">
{new Date(post.createdAt).toLocaleDateString()}
</div>
</article>
))}
</div>
</div>
)
}
// app/routes/blog.$slug.tsx
import { json, LoaderFunctionArgs } from "@remix-run/node"
import { useLoaderData } from "@remix-run/react"
export async function loader({ params }: LoaderFunctionArgs) {
const post = await db.post.findUnique({
where: { slug: params.slug }
})
if (!post) {
throw new Response("Post not found", { status: 404 })
}
return json({ post })
}
export default function BlogPost() {
const { post } = useLoaderData<typeof loader>()
return (
<article className="max-w-4xl mx-auto p-4">
<h1 className="text-5xl font-bold mb-4">{post.title}</h1>
<div className="text-gray-600 mb-8">
{new Date(post.createdAt).toLocaleDateString()}
</div>
<div className="prose max-w-none">{post.content}</div>
</article>
)
}八、最佳实践
8.1 类型安全
typescript
// 使用 TypeScript
export async function loader() {
const posts = await getPosts()
return json({ posts })
}
export default function Posts() {
// 自动类型推导
const { posts } = useLoaderData<typeof loader>()
// posts 是类型安全的
}8.2 渐进增强
typescript
// 表单在没有 JavaScript 时也能工作
export default function ContactForm() {
return (
<Form method="post">
<input type="text" name="name" required />
<input type="email" name="email" required />
<button type="submit">Submit</button>
</Form>
)
}8.3 学习资源
官方文档
- Remix Docs: https://remix.run/docs
- Remix Blog: https://remix.run/blog
示例项目
- Indie Stack
- Blues Stack
- Grunge Stack
课后练习
- 创建一个Remix应用
- 实现CRUD功能
- 添加表单验证
- 实现嵌套路由
- 部署到Vercel/Cloudflare
通过本课程的学习,你应该对Remix框架有了全面的理解,可以开始构建现代Web应用!