3.1 Loader 基础
在 Remix 中,Loader 是路由的数据加载函数,在服务端执行,将数据传递给 React 组件。每条路由都可以导出一个 loader,它在页面渲染之前运行,确保组件拿到完整数据后再渲染,避免客户端的"加载中"闪烁。
Loader 接收一个参数对象,包含 request(原生 Web Request 对象)和 params(路径参数),返回任意可序列化的数据(通常用 json() 工具函数包装)。
// app/routes/blog._index.tsx
import { type LoaderFunctionArgs, json } from "@remix-run/node";
import { useLoaderData } from "@remix-run/react";
// 1. 服务端执行的 loader 函数
export async function loader({ request, params }: LoaderFunctionArgs) {
// request 是原生 Web Request 对象
const url = new URL(request.url);
const page = Number(url.searchParams.get("page")) || 1;
const posts = await getPosts({ page });
return json({ posts, page });
}
// 2. 组件中通过 useLoaderData 获取数据
export default function BlogIndex() {
const { posts, page } = useLoaderData<typeof loader>();
return (
<div>
{posts.map(post => (
<article key={post.id}>
<h2>{post.title}</h2>
</article>
))}
</div>
);
}
TSX
useLoaderData 类型推断使用 useLoaderData<typeof loader>(),TypeScript 会自动推断返回数据的完整类型,无需手动编写接口定义。这是 Remix 最优雅的 TypeScript 集成特性之一。
3.2 从 request 读取查询参数
Loader 收到的 request 是标准的 Web Request 对象,URL 查询参数通过 URLSearchParams 读取,与浏览器端完全一致。
export async function loader({ request }: LoaderFunctionArgs) {
const url = new URL(request.url);
// /search?q=remix&category=tutorial
const query = url.searchParams.get("q") ?? "";
const category = url.searchParams.get("category") ?? "all";
const sort = url.searchParams.get("sort") ?? "newest";
const results = await searchPosts({ query, category, sort });
return json({ results, query });
}
TSX
3.3 并行请求 Promise.all
当一个页面需要多份独立数据时,用 Promise.all 并行请求,避免瀑布式串行等待。Remix 的每层嵌套路由的 loader 之间本身就是并行的,同一 loader 内的多个请求同样应该并行化。
// 串行(慢!总耗时 = 200ms + 150ms + 100ms = 450ms)
const user = await getUser(userId); // 200ms
const posts = await getUserPosts(userId); // 150ms
const stats = await getUserStats(userId); // 100ms
// 并行(快!总耗时 = max(200, 150, 100) = 200ms)
const [user, posts, stats] = await Promise.all([
getUser(userId),
getUserPosts(userId),
getUserStats(userId),
]);
TSX
// 完整示例:用户主页 loader
export async function loader({ params }: LoaderFunctionArgs) {
const userId = params.userId!;
const [user, posts, stats] = await Promise.all([
getUser(userId),
getUserPosts(userId),
getUserStats(userId),
]);
if (!user) throw new Response("用户不存在", { status: 404 });
return json({ user, posts, stats });
}
TSX
3.4 错误处理 — 抛出 Response
在 loader 中抛出一个 Response 对象(而非 Error)是处理 HTTP 错误的 Remix 惯用方式。被抛出的 Response 会被路由的 ErrorBoundary 捕获,Remix 会将其转化为对应的 HTTP 状态码。
export async function loader({ params }: LoaderFunctionArgs) {
const post = await getPost(params.slug!);
// 抛出 Response — 触发 ErrorBoundary,返回 404 状态码
if (!post) {
throw new Response("文章不存在", {
status: 404,
statusText: "Not Found",
});
}
// 权限验证失败 → 403
if (!post.isPublic) {
throw new Response("无权访问", { status: 403 });
}
return json({ post });
}
TSX
3.5 Cache-Control 缓存控制
Remix 的 loader 返回标准 HTTP Response,因此可以在 headers 中设置 Cache-Control,充分利用浏览器和 CDN 缓存。这是 Remix "拥抱 Web 标准"的典型体现。
// 方式一:通过 json() 第二参数设置响应头
export async function loader({ params }: LoaderFunctionArgs) {
const post = await getPost(params.slug!);
return json({ post }, {
headers: {
// 浏览器缓存 5 分钟,CDN 缓存 1 小时
"Cache-Control": "public, max-age=300, s-maxage=3600",
},
});
}
// 方式二:通过 headers export 统一设置
export function headers() {
return {
"Cache-Control": "public, max-age=60, stale-while-revalidate=600",
};
}
TSX
认证路由禁用缓存对于需要登录才能访问的页面,务必设置 Cache-Control: private, no-store,防止 CDN 缓存用户私有数据,造成数据泄露。
3.6 实战:博客文章列表与详情页
将本章知识点整合,构建一个完整的博客数据加载流程。
// app/routes/blog._index.tsx — 文章列表
import { json, type LoaderFunctionArgs } from "@remix-run/node";
import { useLoaderData, Link } from "@remix-run/react";
import { getPosts } from "~/models/post.server";
export async function loader({ request }: LoaderFunctionArgs) {
const url = new URL(request.url);
const page = Number(url.searchParams.get("page")) || 1;
const { posts, total } = await getPosts({ page, limit: 10 });
return json({ posts, total, page }, {
headers: { "Cache-Control": "public, max-age=60" },
});
}
export default function BlogIndex() {
const { posts, total, page } = useLoaderData<typeof loader>();
return (
<div>
<h1>文章列表(共 {total} 篇)</h1>
{posts.map(post => (
<article key={post.id}>
<Link to={`/blog/${post.slug}`>
<h2>{post.title}</h2>
</Link>
<p>{post.excerpt}</p>
<time>{new Date(post.createdAt).toLocaleDateString("zh-CN")}</time>
</article>
))}
<nav>
{page > 1 && <Link to={`?page=${page - 1}`>上一页</Link>}
{posts.length === 10 && <Link to={`?page=${page + 1}`>下一页</Link>}
</nav>
</div>
);
}
TSX
// app/routes/blog.$slug.tsx — 文章详情
import { json, type LoaderFunctionArgs, type MetaFunction } from "@remix-run/node";
import { useLoaderData } from "@remix-run/react";
import { getPost } from "~/models/post.server";
export async function loader({ params }: LoaderFunctionArgs) {
const post = await getPost(params.slug!);
if (!post) throw new Response("Not Found", { status: 404 });
return json({ post }, {
headers: { "Cache-Control": "public, max-age=300, s-maxage=3600" },
});
}
export const meta: MetaFunction<typeof loader> = ({ data }) => [
{ title: `${data?.post.title ?? "文章"} — 我的博客` },
{ name: "description", content: data?.post.excerpt },
];
export default function BlogPost() {
const { post } = useLoaderData<typeof loader>();
return (
<article>
<h1>{post.title}</h1>
<time>{new Date(post.createdAt).toLocaleDateString("zh-CN")}</time>
<div dangerouslySetInnerHTML={{ __html: post.html }} />
</article>
);
}
TSX
Loader 只在服务端运行Loader 中的代码(如数据库查询、读取环境变量、调用内部 API)不会暴露给浏览器。Remix 在构建时会进行 tree-shaking,确保服务端代码不出现在客户端 bundle 中。文件名后缀 .server.ts 是额外的保险标记。
本章小结Loader 是 Remix 的数据加载核心:在服务端运行,接收标准 Request 对象,返回 JSON 数据;组件用 useLoaderData<typeof loader>() 取数据并获得完整 TypeScript 类型;用 Promise.all 并行多个请求;通过抛出 Response 处理 404/403 等错误;在响应头中设置 Cache-Control 利用 HTTP 缓存。