Chapter 03

Loader 数据加载

掌握 Remix 的服务端数据加载机制,实现高效的并行请求与 HTTP 缓存控制

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 缓存。