Appearance
Remix 路由与 Loader
课程概述
本课程深入讲解Remix的路由系统和Loader数据加载机制。Remix的嵌套路由和Loader模式是其核心特性,提供了优雅的数据加载和页面组织方式。
学习目标:
- 掌握Remix路由系统
- 理解嵌套路由
- 学习动态路由参数
- 掌握Loader数据加载
- 理解并行数据加载
- 学习路由优先级
- 掌握路由布局
- 构建复杂的路由结构
一、路由基础
1.1 文件系统路由
app/routes/
├── _index.tsx # GET /
├── about.tsx # GET /about
├── contact.tsx # GET /contact
├── blog._index.tsx # GET /blog
├── blog.$slug.tsx # GET /blog/:slug
├── blog.new.tsx # GET /blog/new
├── users._index.tsx # GET /users
├── users.$userId.tsx # GET /users/:userId
├── users.$userId.posts.$postId.tsx # GET /users/:userId/posts/:postId
└── $.tsx # 捕获所有未匹配路由1.2 基础路由示例
typescript
// app/routes/_index.tsx
export default function Index() {
return (
<div>
<h1>Welcome to Remix</h1>
<nav>
<a href="/about">About</a>
<a href="/blog">Blog</a>
<a href="/contact">Contact</a>
</nav>
</div>
)
}
// app/routes/about.tsx
export default function About() {
return (
<div>
<h1>About Us</h1>
<p>Learn more about our company</p>
</div>
)
}
// app/routes/contact.tsx
export default function Contact() {
return (
<div>
<h1>Contact</h1>
<p>Get in touch with us</p>
</div>
)
}1.3 Link 组件
typescript
// app/routes/_index.tsx
import { Link } from "@remix-run/react"
export default function Index() {
return (
<div>
<h1>Welcome to Remix</h1>
<nav>
{/* Link 组件提供客户端路由 */}
<Link to="/about">About</Link>
<Link to="/blog">Blog</Link>
<Link to="/contact">Contact</Link>
{/* prefetch 预加载 */}
<Link to="/dashboard" prefetch="intent">
Dashboard
</Link>
{/* 外部链接 */}
<Link to="https://remix.run" reloadDocument>
Remix Docs
</Link>
</nav>
</div>
)
}二、动态路由
2.1 单个参数
typescript
// app/routes/posts.$postId.tsx
import { json, LoaderFunctionArgs } from "@remix-run/node"
import { useLoaderData } from "@remix-run/react"
interface Post {
id: string
title: string
content: string
author: string
createdAt: string
}
export async function loader({ params }: LoaderFunctionArgs) {
const { postId } = params
const post = await db.post.findUnique({
where: { id: 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>By {post.author}</p>
<div>{post.content}</div>
<time>{new Date(post.createdAt).toLocaleDateString()}</time>
</article>
)
}2.2 多个参数
typescript
// app/routes/users.$userId.posts.$postId.tsx
// URL: /users/123/posts/456
import { json, LoaderFunctionArgs } from "@remix-run/node"
import { useLoaderData } from "@remix-run/react"
export async function loader({ params }: LoaderFunctionArgs) {
const { userId, postId } = params
const [user, post] = await Promise.all([
db.user.findUnique({ where: { id: userId } }),
db.post.findUnique({ where: { id: postId } })
])
if (!user || !post) {
throw new Response("Not found", { status: 404 })
}
// 验证文章是否属于该用户
if (post.authorId !== userId) {
throw new Response("Forbidden", { status: 403 })
}
return json({ user, post })
}
export default function UserPost() {
const { user, post } = useLoaderData<typeof loader>()
return (
<div>
<header>
<h2>{user.name}'s Post</h2>
</header>
<article>
<h1>{post.title}</h1>
<p>{post.content}</p>
</article>
</div>
)
}2.3 可选参数
typescript
// app/routes/shop.$.tsx
// 匹配: /shop, /shop/electronics, /shop/electronics/laptops
import { json, LoaderFunctionArgs } from "@remix-run/node"
import { useLoaderData } from "@remix-run/react"
export async function loader({ params }: LoaderFunctionArgs) {
const splat = params["*"] // 获取所有路径段
const segments = splat ? splat.split("/") : []
// 根据路径段加载不同数据
if (segments.length === 0) {
// /shop
const categories = await db.category.findMany()
return json({ type: "categories", data: categories })
} else if (segments.length === 1) {
// /shop/electronics
const [category] = segments
const products = await db.product.findMany({
where: { category }
})
return json({ type: "products", data: products, category })
} else {
// /shop/electronics/laptops
const [category, subcategory] = segments
const products = await db.product.findMany({
where: { category, subcategory }
})
return json({ type: "products", data: products, category, subcategory })
}
}
export default function Shop() {
const data = useLoaderData<typeof loader>()
if (data.type === "categories") {
return (
<div>
<h1>Shop Categories</h1>
<ul>
{data.data.map(cat => (
<li key={cat.id}>
<Link to={`/shop/${cat.slug}`}>{cat.name}</Link>
</li>
))}
</ul>
</div>
)
}
return (
<div>
<h1>{data.category} Products</h1>
{data.subcategory && <h2>{data.subcategory}</h2>}
<div>
{data.data.map(product => (
<div key={product.id}>
<h3>{product.name}</h3>
<p>${product.price}</p>
</div>
))}
</div>
</div>
)
}三、嵌套路由
3.1 布局路由
typescript
// app/routes/blog.tsx (父布局)
import { Outlet, Link } from "@remix-run/react"
export default function BlogLayout() {
return (
<div className="blog-layout">
<header className="blog-header">
<h1>My Blog</h1>
<nav>
<Link to="/blog">All Posts</Link>
<Link to="/blog/new">New Post</Link>
<Link to="/blog/categories">Categories</Link>
</nav>
</header>
<main className="blog-content">
{/* 子路由渲染在这里 */}
<Outlet />
</main>
<aside className="blog-sidebar">
<h3>Recent Posts</h3>
{/* 侧边栏内容 */}
</aside>
</div>
)
}
// 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' }
})
return json({ posts })
}
export default function BlogIndex() {
const { posts } = useLoaderData<typeof loader>()
return (
<div>
<h2>All Posts</h2>
<div className="posts-grid">
{posts.map(post => (
<article key={post.id}>
<h3>
<Link to={`/blog/${post.slug}`}>{post.title}</Link>
</h3>
<p>{post.excerpt}</p>
</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("Not found", { status: 404 })
}
return json({ post })
}
export default function BlogPost() {
const { post } = useLoaderData<typeof loader>()
return (
<article>
<h2>{post.title}</h2>
<div>{post.content}</div>
</article>
)
}3.2 多层嵌套
typescript
// app/routes/dashboard.tsx (一级布局)
import { Outlet, Link } from "@remix-run/react"
export default function DashboardLayout() {
return (
<div className="dashboard">
<nav className="sidebar">
<Link to="/dashboard">Overview</Link>
<Link to="/dashboard/settings">Settings</Link>
<Link to="/dashboard/users">Users</Link>
</nav>
<main>
<Outlet />
</main>
</div>
)
}
// app/routes/dashboard._index.tsx
export default function DashboardIndex() {
return <h2>Dashboard Overview</h2>
}
// app/routes/dashboard.settings.tsx (二级布局)
import { Outlet, Link } from "@remix-run/react"
export default function SettingsLayout() {
return (
<div>
<h2>Settings</h2>
<nav>
<Link to="/dashboard/settings">Profile</Link>
<Link to="/dashboard/settings/account">Account</Link>
<Link to="/dashboard/settings/security">Security</Link>
</nav>
<Outlet />
</div>
)
}
// app/routes/dashboard.settings._index.tsx
export default function SettingsIndex() {
return <div>Profile Settings</div>
}
// app/routes/dashboard.settings.account.tsx
export default function AccountSettings() {
return <div>Account Settings</div>
}
// app/routes/dashboard.settings.security.tsx
export default function SecuritySettings() {
return <div>Security Settings</div>
}3.3 无布局路由
typescript
// app/routes/_auth.tsx (下划线前缀 = 无布局)
import { Outlet } from "@remix-run/react"
export default function AuthLayout() {
return (
<div className="auth-layout">
<div className="auth-card">
<Outlet />
</div>
</div>
)
}
// app/routes/_auth.login.tsx
// URL: /login (不包含 _auth 前缀)
export default function Login() {
return (
<div>
<h1>Login</h1>
<form method="post">
<input type="email" name="email" />
<input type="password" name="password" />
<button type="submit">Login</button>
</form>
</div>
)
}
// app/routes/_auth.register.tsx
// URL: /register
export default function Register() {
return (
<div>
<h1>Register</h1>
<form method="post">
<input type="text" name="name" />
<input type="email" name="email" />
<input type="password" name="password" />
<button type="submit">Register</button>
</form>
</div>
)
}四、Loader 数据加载
4.1 基础 Loader
typescript
// app/routes/products._index.tsx
import { json } from "@remix-run/node"
import { useLoaderData } from "@remix-run/react"
interface Product {
id: string
name: string
price: number
image: string
}
export async function loader() {
const products = await db.product.findMany()
return json({ products })
}
export default function Products() {
const { products } = useLoaderData<typeof loader>()
return (
<div>
<h1>Products</h1>
<div className="products-grid">
{products.map(product => (
<div key={product.id} className="product-card">
<img src={product.image} alt={product.name} />
<h3>{product.name}</h3>
<p>${product.price}</p>
</div>
))}
</div>
</div>
)
}4.2 访问请求对象
typescript
// app/routes/search.tsx
import { json, LoaderFunctionArgs } from "@remix-run/node"
import { useLoaderData, useSearchParams } from "@remix-run/react"
export async function loader({ request }: LoaderFunctionArgs) {
const url = new URL(request.url)
const query = url.searchParams.get("q") || ""
const page = parseInt(url.searchParams.get("page") || "1")
const limit = 20
if (!query) {
return json({ results: [], query: "", page, totalPages: 0 })
}
const [results, total] = await Promise.all([
db.product.findMany({
where: {
OR: [
{ name: { contains: query, mode: 'insensitive' } },
{ description: { contains: query, mode: 'insensitive' } }
]
},
skip: (page - 1) * limit,
take: limit
}),
db.product.count({
where: {
OR: [
{ name: { contains: query, mode: 'insensitive' } },
{ description: { contains: query, mode: 'insensitive' } }
]
}
})
])
return json({
results,
query,
page,
totalPages: Math.ceil(total / limit)
})
}
export default function Search() {
const { results, query, page, totalPages } = useLoaderData<typeof loader>()
const [searchParams, setSearchParams] = useSearchParams()
return (
<div>
<h1>Search Results for "{query}"</h1>
<form>
<input
type="text"
name="q"
defaultValue={query}
placeholder="Search products..."
/>
<button type="submit">Search</button>
</form>
{results.length > 0 ? (
<>
<div>
{results.map(result => (
<div key={result.id}>
<h3>{result.name}</h3>
<p>{result.description}</p>
</div>
))}
</div>
{totalPages > 1 && (
<div className="pagination">
{Array.from({ length: totalPages }, (_, i) => i + 1).map(p => (
<Link
key={p}
to={`?q=${query}&page=${p}`}
className={p === page ? 'active' : ''}
>
{p}
</Link>
))}
</div>
)}
</>
) : query ? (
<p>No results found</p>
) : (
<p>Enter a search query</p>
)}
</div>
)
}4.3 Headers 和 Cookies
typescript
// app/routes/profile.tsx
import { json, LoaderFunctionArgs } from "@remix-run/node"
import { useLoaderData } from "@remix-run/react"
export async function loader({ request }: LoaderFunctionArgs) {
// 访问 cookies
const cookieHeader = request.headers.get("Cookie")
const cookies = parseCookies(cookieHeader)
const sessionId = cookies.session
if (!sessionId) {
throw redirect("/login")
}
// 访问其他 headers
const userAgent = request.headers.get("User-Agent")
const acceptLanguage = request.headers.get("Accept-Language")
const user = await getUserBySession(sessionId)
if (!user) {
throw redirect("/login")
}
return json({
user,
userAgent,
language: acceptLanguage
})
}
export default function Profile() {
const { user, userAgent } = useLoaderData<typeof loader>()
return (
<div>
<h1>Profile: {user.name}</h1>
<p>Email: {user.email}</p>
<p>Browser: {userAgent}</p>
</div>
)
}
function parseCookies(cookieHeader: string | null): Record<string, string> {
if (!cookieHeader) return {}
return Object.fromEntries(
cookieHeader.split('; ').map(cookie => {
const [name, ...rest] = cookie.split('=')
return [name, rest.join('=')]
})
)
}4.4 并行数据加载
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, recentActivity] = await Promise.all([
getUser(),
getStats(),
getNotifications(),
getRecentActivity()
])
return json({
user,
stats,
notifications,
recentActivity
})
}
export default function Dashboard() {
const { user, stats, notifications, recentActivity } =
useLoaderData<typeof loader>()
return (
<div className="dashboard">
<header>
<h1>Welcome, {user.name}</h1>
</header>
<div className="stats-grid">
<div className="stat-card">
<h3>Total Views</h3>
<p>{stats.totalViews}</p>
</div>
<div className="stat-card">
<h3>New Users</h3>
<p>{stats.newUsers}</p>
</div>
<div className="stat-card">
<h3>Revenue</h3>
<p>${stats.revenue}</p>
</div>
</div>
<div className="notifications">
<h2>Notifications ({notifications.length})</h2>
{notifications.map(notif => (
<div key={notif.id}>{notif.message}</div>
))}
</div>
<div className="activity">
<h2>Recent Activity</h2>
{recentActivity.map(activity => (
<div key={activity.id}>{activity.description}</div>
))}
</div>
</div>
)
}五、嵌套路由数据加载
5.1 父子数据传递
typescript
// app/routes/projects.tsx (父路由)
import { json } from "@remix-run/node"
import { Outlet, useLoaderData } from "@remix-run/react"
export async function loader() {
const projects = await db.project.findMany()
return json({ projects })
}
export default function ProjectsLayout() {
const { projects } = useLoaderData<typeof loader>()
return (
<div className="projects-layout">
<aside>
<h2>Projects</h2>
<ul>
{projects.map(project => (
<li key={project.id}>
<Link to={`/projects/${project.id}`}>
{project.name}
</Link>
</li>
))}
</ul>
</aside>
<main>
<Outlet /> {/* 子路由可以访问父路由的数据 */}
</main>
</div>
)
}
// app/routes/projects.$projectId.tsx (子路由)
import { json, LoaderFunctionArgs } from "@remix-run/node"
import { useLoaderData, useMatches } from "@remix-run/react"
export async function loader({ params }: LoaderFunctionArgs) {
const project = await db.project.findUnique({
where: { id: params.projectId },
include: { tasks: true }
})
if (!project) {
throw new Response("Project not found", { status: 404 })
}
return json({ project })
}
export default function ProjectDetail() {
const { project } = useLoaderData<typeof loader>()
// 访问父路由的数据
const matches = useMatches()
const projectsRoute = matches.find(m => m.id === "routes/projects")
const allProjects = projectsRoute?.data.projects
return (
<div>
<h1>{project.name}</h1>
<p>{project.description}</p>
<h2>Tasks</h2>
<ul>
{project.tasks.map(task => (
<li key={task.id}>{task.title}</li>
))}
</ul>
<aside>
<h3>Other Projects</h3>
<ul>
{allProjects?.filter(p => p.id !== project.id).map(p => (
<li key={p.id}>
<Link to={`/projects/${p.id}`}>{p.name}</Link>
</li>
))}
</ul>
</aside>
</div>
)
}5.2 useMatches 访问所有路由数据
typescript
// app/routes/docs.$category.$slug.tsx
import { json, LoaderFunctionArgs } from "@remix-run/node"
import { useLoaderData, useMatches } from "@remix-run/react"
export async function loader({ params }: LoaderFunctionArgs) {
const doc = await getDoc(params.category, params.slug)
return json({ doc })
}
export default function DocPage() {
const { doc } = useLoaderData<typeof loader>()
const matches = useMatches()
// 从所有匹配的路由获取数据
// matches[0] = root route
// matches[1] = docs layout
// matches[2] = current route
return (
<div>
<nav>
{matches.map((match, index) => (
<span key={match.id}>
{index > 0 && ' > '}
{match.pathname}
</span>
))}
</nav>
<article>
<h1>{doc.title}</h1>
<div>{doc.content}</div>
</article>
</div>
)
}六、高级路由技巧
6.1 资源路由
typescript
// app/routes/api.posts.$postId.json.tsx
// URL: /api/posts/123.json
import { json, LoaderFunctionArgs } from "@remix-run/node"
export async function loader({ params }: LoaderFunctionArgs) {
const post = await db.post.findUnique({
where: { id: params.postId }
})
if (!post) {
throw new Response("Not found", { status: 404 })
}
return json(post)
}
// 没有默认导出 = 资源路由 (只返回数据,不渲染UI)6.2 PDF 下载路由
typescript
// app/routes/invoices.$id.pdf.tsx
import { LoaderFunctionArgs } from "@remix-run/node"
import PDFDocument from 'pdfkit'
export async function loader({ params }: LoaderFunctionArgs) {
const invoice = await db.invoice.findUnique({
where: { id: params.id }
})
if (!invoice) {
throw new Response("Not found", { status: 404 })
}
// 生成 PDF
const doc = new PDFDocument()
const chunks: Buffer[] = []
doc.on('data', chunk => chunks.push(chunk))
doc.on('end', () => {})
doc.fontSize(20).text(`Invoice #${invoice.id}`, 100, 100)
doc.fontSize(12).text(`Total: $${invoice.total}`, 100, 150)
doc.end()
const pdfBuffer = Buffer.concat(chunks)
return new Response(pdfBuffer, {
headers: {
'Content-Type': 'application/pdf',
'Content-Disposition': `attachment; filename="invoice-${invoice.id}.pdf"`
}
})
}6.3 路由优先级
typescript
// Remix 路由优先级规则:
// 1. 精确匹配优先于动态匹配
// 2. 更具体的路由优先于通用路由
// 优先级从高到低:
// app/routes/blog.new.tsx # /blog/new (精确)
// app/routes/blog.$slug.tsx # /blog/:slug (动态)
// app/routes/blog.$.tsx # /blog/* (通配符)
// app/routes/$.tsx # * (全局通配符)
// 示例:
// GET /blog/new → blog.new.tsx
// GET /blog/my-post → blog.$slug.tsx
// GET /blog/a/b/c → blog.$.tsx
// GET /random/path → $.tsx七、实战案例
7.1 电商产品系统
typescript
// app/routes/shop.tsx (布局)
import { json } from "@remix-run/node"
import { Outlet, useLoaderData, Link } from "@remix-run/react"
export async function loader() {
const categories = await db.category.findMany()
return json({ categories })
}
export default function ShopLayout() {
const { categories } = useLoaderData<typeof loader>()
return (
<div className="shop-layout">
<header>
<h1>Shop</h1>
<nav>
<Link to="/shop">All Products</Link>
{categories.map(cat => (
<Link key={cat.id} to={`/shop/category/${cat.slug}`}>
{cat.name}
</Link>
))}
</nav>
</header>
<main>
<Outlet />
</main>
</div>
)
}
// app/routes/shop._index.tsx
import { json } from "@remix-run/node"
import { useLoaderData } from "@remix-run/react"
export async function loader() {
const products = await db.product.findMany({
take: 20,
orderBy: { createdAt: 'desc' }
})
return json({ products })
}
export default function ShopIndex() {
const { products } = useLoaderData<typeof loader>()
return (
<div>
<h2>All Products</h2>
<div className="products-grid">
{products.map(product => (
<Link key={product.id} to={`/shop/product/${product.slug}`}>
<div className="product-card">
<img src={product.image} alt={product.name} />
<h3>{product.name}</h3>
<p>${product.price}</p>
</div>
</Link>
))}
</div>
</div>
)
}
// app/routes/shop.category.$slug.tsx
import { json, LoaderFunctionArgs } from "@remix-run/node"
import { useLoaderData } from "@remix-run/react"
export async function loader({ params }: LoaderFunctionArgs) {
const category = await db.category.findUnique({
where: { slug: params.slug },
include: { products: true }
})
if (!category) {
throw new Response("Category not found", { status: 404 })
}
return json({ category })
}
export default function CategoryPage() {
const { category } = useLoaderData<typeof loader>()
return (
<div>
<h2>{category.name}</h2>
<p>{category.description}</p>
<div className="products-grid">
{category.products.map(product => (
<Link key={product.id} to={`/shop/product/${product.slug}`}>
<div className="product-card">
<img src={product.image} alt={product.name} />
<h3>{product.name}</h3>
<p>${product.price}</p>
</div>
</Link>
))}
</div>
</div>
)
}
// app/routes/shop.product.$slug.tsx
import { json, LoaderFunctionArgs } from "@remix-run/node"
import { useLoaderData } from "@remix-run/react"
export async function loader({ params }: LoaderFunctionArgs) {
const product = await db.product.findUnique({
where: { slug: params.slug },
include: {
category: true,
reviews: {
include: { user: true }
}
}
})
if (!product) {
throw new Response("Product not found", { status: 404 })
}
return json({ product })
}
export default function ProductPage() {
const { product } = useLoaderData<typeof loader>()
return (
<div className="product-detail">
<div className="product-images">
<img src={product.image} alt={product.name} />
</div>
<div className="product-info">
<h1>{product.name}</h1>
<p className="category">{product.category.name}</p>
<p className="price">${product.price}</p>
<p className="description">{product.description}</p>
<form method="post">
<button type="submit">Add to Cart</button>
</form>
</div>
<div className="product-reviews">
<h2>Reviews ({product.reviews.length})</h2>
{product.reviews.map(review => (
<div key={review.id} className="review">
<div className="review-header">
<strong>{review.user.name}</strong>
<span>★ {review.rating}/5</span>
</div>
<p>{review.content}</p>
</div>
))}
</div>
</div>
)
}八、最佳实践
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
// 在 loader 中抛出 Response
export async function loader({ params }: LoaderFunctionArgs) {
const post = await getPost(params.id)
if (!post) {
throw new Response("Post not found", { status: 404 })
}
return json({ post })
}
// ErrorBoundary 会捕获错误
export function ErrorBoundary() {
const error = useRouteError()
if (isRouteErrorResponse(error)) {
return (
<div>
<h1>{error.status} {error.statusText}</h1>
<p>{error.data}</p>
</div>
)
}
return <div>Unknown error</div>
}8.3 学习资源
官方文档
示例项目
- Remix Examples
- Remix Stacks
课后练习
- 创建嵌套路由结构
- 实现动态路由参数
- 使用 Loader 加载数据
- 实现并行数据加载
- 构建完整的路由系统
通过本课程的学习,你应该能够熟练使用Remix的路由系统和Loader,构建复杂的Web应用!