Skip to content

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()
  // 渲染
}

对比表:

特性RemixNext.js 15
路由文件系统 + 嵌套文件系统 + App Router
数据加载loaderServer Components
数据提交actionServer 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 dev

2.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.json

2.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 学习资源

  1. 官方文档

  2. 示例项目

    • Indie Stack
    • Blues Stack
    • Grunge Stack

课后练习

  1. 创建一个Remix应用
  2. 实现CRUD功能
  3. 添加表单验证
  4. 实现嵌套路由
  5. 部署到Vercel/Cloudflare

通过本课程的学习,你应该对Remix框架有了全面的理解,可以开始构建现代Web应用!