陌上人如玉
公子世无双

6.9 数据 API

6.9 数据 API

一句话:数据路由(createBrowserRouter)提供 loader(渲染前取数据) 与 action(表单提交),配合 useLoaderData / useActionData / useNavigation 把「数据获取 + 提交状态 + 错误处理」统一到路由层。

一、loader:渲染前取数据

// router.tsx
const router = createBrowserRouter([
  {
    path: 'users/:id',
    element: <UserDetail />,
    loader: async ({ params, request, signal }) => {
      // ⭐ loader 的上下文参数
      return fetchUser(params.id!, { signal });
    },
  },
]);
loader 参数 说明
params 路由参数(同 useParams)
request 一个 Request 对象(可读 url、method、body)
signal AbortSignal——导航切换时会被 abort(要传给 fetch)
context 路由上下文(createBrowserRouter 的第二个参数可提供)
// 组件里同步读取(不需要 loading 状态)
import { useLoaderData } from 'react-router-dom';

function UserDetail() {
  const user = useLoaderData() as User;      // ⭐ 数据已经就绪
  return <div>{user.name}</div>;
}
// ✅ 读查询参数(loader 里没有 useSearchParams,要用 request.url)
loader: async ({ request }) => {
  const url = new URL(request.url);
  const page = Number(url.searchParams.get('page') ?? '1');
  const keyword = url.searchParams.get('q') ?? '';
  return fetchUsers({ page, keyword });
}

// ✅ 并行请求(避免瀑布)
loader: async ({ params }) => {
  const [user, orders] = await Promise.all([
    fetchUser(params.id!),
    fetchOrders(params.id!),
  ]);
  return { user, orders };
}
⭐ loader 的核心价值:
① 数据在「渲染前」就绪 → 组件里不需要 loading 状态、没有骨架闪烁
② 请求与「路由切换」绑定 → 进入页面即开始加载
③ 可并行发起多个请求 → 避免「组件内 useEffect 的瀑布」

⚠️ 代价:导航会「等数据就绪」才完成 → 需要用 useNavigation 显示进度

二、action:表单提交

// ✅ action 处理写操作
{
  path: 'todos/new',
  element: <NewTodo />,
  action: async ({ request }) => {
    const formData = await request.formData();
    const text = formData.get('text') as string;

    if (!text?.trim()) {
      return { error: '请输入内容' };              // ⭐ 返回错误(不跳转)
    }

    await createTodo({ text });
    return redirect('/todos');                    // ⭐ 成功后重定向
  },
}
// ✅ 组件里用 React Router 的 <Form>(提交时自动调用 action)
import { Form, useActionData, useNavigation, useSubmit } from 'react-router-dom';

function NewTodo() {
  const actionData = useActionData() as { error?: string } | undefined;
  const navigation = useNavigation();
  const isSubmitting = navigation.state === 'submitting';    // ⭐ 提交中

  return (
    <Form method="post">
      <input name="text" required />
      <button disabled={isSubmitting}>{isSubmitting ? '提交中…' : '添加'}</button>
      {actionData?.error && <p role="alert">{actionData.error}</p>}
    </Form>
  );
}
组件 说明
<Form> React Router 的表单(提交时走 action,不刷新页面)
<Form method="get"> 提交时不走 action,而是「更新 URL 查询参数」(适合搜索表单)
useActionData() 读取 action 的返回值
useSubmit() 命令式提交表单(如「保存」按钮在表单外)
useFetcher() 不触发导航的提交(如「点赞」、行内编辑)
// ✅ method="get" 的用法:搜索表单(把输入变成查询参数)
<Form method="get" action="/search">
  <input name="q" defaultValue={initialKeyword} />
  <button>搜索</button>
</Form>
// 提交后 URL 变成 /search?q=xxx(不需要自己写 setSearchParams)
// ✅ useFetcher:不导航的表单提交(如列表里的「删除」「点赞」)
import { useFetcher } from 'react-router-dom';

function TodoItem({ todo }: { todo: Todo }) {
  const fetcher = useFetcher();

  return (
    <fetcher.Form method="post" action={`/todos/${todo.id}/toggle`}>
      <button disabled={fetcher.state !== 'idle'}>
        {todo.done ? '✅' : '⬜'} {todo.text}
      </button>
    </fetcher.Form>
  );
}
// ⭐ 提交不会触发路由跳转,但仍会走 action 并重新执行相关 loader
⭐ action 的隐藏能力:
   提交成功后,React Router 会【自动重新执行该路由的 loader】
   → 列表数据自动刷新(不需要手动 invalidate)

三、useNavigation:导航/提交状态

import { useNavigation } from 'react-router-dom';

function Layout() {
  const navigation = useNavigation();

  return (
    <>
      {navigation.state !== 'idle' && <TopProgressBar />}
      <Outlet />
    </>
  );
}
navigation.state 含义
'idle' 空闲
'loading' 正在「加载目标路由的 loader」
'submitting' 正在「执行 action」
// ✅ 更细的信息
const navigation = useNavigation();

navigation.location           // 正在导航到的位置
navigation.formData           // 正在提交的表单数据(submitting 时)
navigation.formAction         // 提交的目标 action
navigation.formMethod         // 提交方法(get/post)

四、重定向与错误

// ① 重定向
import { redirect } from 'react-router-dom';

loader: async () => {
  const user = await getCurrentUser();
  if (!user) throw redirect('/login');            // ⭐ loader 里重定向
  return user;
}

action: async ({ request }) => {
  await save(await request.formData());
  return redirect('/list');                       // ⭐ action 返回值重定向
}
// ② 抛「路由级错误」(交给最近的 errorElement)
loader: async ({ params }) => {
  const user = await fetchUser(params.id!);
  if (!user) {
    throw new Response('Not Found', { status: 404 });     // ⭐ 必须是 Response
  }
  return user;
}
// ③ 读取错误
import { useRouteError, isRouteErrorResponse } from 'react-router-dom';

function UserError() {
  const error = useRouteError();

  if (isRouteErrorResponse(error)) {
    // ⭐ 处理 Response 类错误(有 status / statusText / data)
    if (error.status === 404) return <NotFound />;
    if (error.status === 401) return <Navigate to="/login" replace />;
    return <div>{error.status} {error.statusText}</div>;
  }

  // ⭐ 其它错误(如代码里 throw new Error)
  return <div>出错了:{error instanceof Error ? error.message : String(error)}</div>;
}
⚠️ 关键区别:
· loader/action 里「返回」一个 Response → React Router 会【渲染】它(用于「代理请求」场景)
· loader/action 里「throw」一个 Response → 交给【errorElement】(用于「错误状态」)
· throw 非 Response 的错误 → errorElement 收到 Error 对象(用 isRouteErrorResponse 判断会失败)

五、与 TanStack Query 的取舍

维度 数据路由(loader/action) TanStack Query
数据就绪时机 渲染前(无骨架闪烁) 渲染后(需要 loading 态)
与路由绑定 ✅(导航即加载) ⚠️(自己用 key 关联)
缓存策略 由 loader + revalidate 控制 精细(staleTime/gcTime/invalidate)
重试 自己实现 ✅ 内置
窗口聚焦重验证 ❌ ✅
无限滚动/分页 较麻烦 ✅ useInfiniteQuery
乐观更新 需自己写 ✅ onMutate
导航状态 ✅ useNavigation ⚠️ 自己管理
错误边界 ✅ errorElement ⚠️ 自己接
生态与工具 官方路由内置 生态成熟、DevTools
三种组合策略:

① 只用 loader/action
   适合「数据获取与路由强绑定」的应用(后台系统、内容站点)
   → 简单统一,但缓存能力弱

② 只用 TanStack Query
   适合「数据频繁交互、需要精细缓存」的应用
   → 失去「渲染前预取」的体验

③ 桥接:loader 里预热 Query 缓存 ⭐(推荐给已用 Query 的项目)
   loader: ({ params }) =>
     queryClient.ensureQueryData({
       queryKey: ['user', params.id],
       queryFn: () => fetchUser(params.id!),
     })
   → 组件里仍用 useQuery(命中已预热的缓存,不重复请求)
   → 既有「渲染前预取」,又有 Query 的缓存能力
// ③ 的完整示例
const queryClient = new QueryClient();

const router = createBrowserRouter([
  {
    path: 'users/:id',
    element: <UserDetail />,
    loader: ({ params }) =>
      queryClient.ensureQueryData({
        queryKey: ['user', params.id],
        queryFn: () => fetchUser(params.id!),
      }),
  },
]);

function UserDetail() {
  const { id } = useParams();

  // ⭐ 数据已被 loader 预热 → 首次渲染就有数据(不会闪骨架)
  const { data, isLoading } = useQuery({
    queryKey: ['user', id],
    queryFn: () => fetchUser(id!),
  });

  if (isLoading) return <Skeleton />;         // 仅「后续导航」时可能短暂出现
  return <div>{data?.name}</div>;
}

六、常见坑

// ① loader 里没把 signal 传给 fetch(导航切换时请求不会取消)
loader: async ({ params, signal }) => {
  return fetch(`/api/users/${params.id}`, { signal });     // ✅
}

// ② loader 返回「不可序列化」的东西(类实例、函数、Date 之外的复杂对象)
// ⚠️ 如果用了「服务端渲染 + 流式传输」,返回值需要可序列化

// ③ 用 <form> 而不是 <Form>(action 不生效)
<form method="post">...</form>          // ⚠️ 浏览器原生提交
<Form method="post">...</Form>          // ✅

// ④ 在 action 里「只返回数据」但组件没读 useActionData
// ⑤ throw 了普通 Error 却用 isRouteErrorResponse 判断(拿不到 status)
throw new Error('404');                  // ⚠️ 没有 status
throw new Response('Not Found', { status: 404 });   // ✅

// ⑥ loader 里做「副作用」(它是「取数据」的,不该改数据)
// ⑦ 把「所有数据」都在 loader 里请求(首屏阻塞时间变长)
// ⑧ 忘记「loader 会在每次导航时重新执行」(包括查询参数变化)
// ⑨ 在 loader 里读 window/localStorage(SSR 时会报错)
loader: () => localStorage.getItem('token')     // ⚠️ 服务端没有 window
// ✅ 用 cookie(通过 request.headers)或「注入上下文」

// ⑩ 用 loader 但忘了配置 errorElement(错误时显示默认错误页)
// ⑪ 期待 loader 的数据「自动缓存」(它不缓存,每次导航都重新执行)
// ⑫ 在 useActionData 里读「上一次」的提交结果(导航后会清空)
// ⑬ useFetcher 与 useSubmit 混用不当(前者不导航,后者会)
// ⑭ loader 里 await 多个请求「串行」而不是并行(瀑布)
// ⑭ 的修复对比
// ❌ 串行(慢)
const user = await fetchUser(id);
const orders = await fetchOrders(id);

// ✅ 并行(快)
const [user, orders] = await Promise.all([fetchUser(id), fetchOrders(id)]);

七、面试延伸

  1. 「loader 是什么?它的执行时机是什么?」
    loader 是数据路由(createBrowserRouter)提供的「在路由匹配后、组件渲染前执行的函数」,用于取数据。它的参数包括 params(路由参数)、request(Request 对象,可读 URL 与查询参数)、signal(AbortSignal,导航切换时会被 abort,应传给 fetch)。组件里用 useLoaderData() 同步读取,因此不需要 loading 状态、没有骨架闪烁。代价是「导航会等数据就绪才完成」,需要用 useNavigation 显示进度。

  2. 「loader 和组件里的 useEffect 请求有什么区别?」
    ① 时机——loader 在渲染前执行(数据就绪才渲染);useEffect 在渲染后执行(先渲染一次无数据的状态)。② 体验——loader 避免了「骨架闪烁 + 请求瀑布」;useEffect 需要自己管理 loading/error 与竞态。③ 错误处理——loader 抛错会交给 errorElement;useEffect 里要自己 try/catch。④ 代价——loader 会阻塞导航(等数据),数据慢时用户会觉得「点了没反应」。

  3. 「action 是做什么的?」
    action 处理「写操作」(表单提交、数据变更),与 loader 对应。它的四个特点:① 与 React Router 的 <Form method="post"> 配合(提交时自动调用对应路由的 action);② 可以返回数据(用 useActionData 读取,适合返回校验错误)或 redirect()(提交成功后跳转);③ 提交过程中可以用 useNavigation().state === 'submitting' 显示「提交中」;④ 提交成功后 React Router 会自动重新执行该路由的 loader,从而刷新列表数据(不需要手动失效)。

  4. 「useFetcher 和 useSubmit 有什么区别?」
    useSubmit 是「命令式提交表单」——它会触发导航(因为提交目标是一个路由的 action);useFetcher 是「不触发导航的提交」——它也会走 action 并重新执行相关的 loader,但不会改变当前 URL 或组件树(所以适合「列表里的删除按钮」「点赞」「行内编辑」这类「提交后还要留在当前页面」的场景)。

  5. 「loader 的数据会自动缓存吗?」
    不会。loader 在每次导航(包括「查询参数变化」「同路由重复进入」)时都会重新执行,React Router 本身不提供「时间维度的缓存」(staleTime 那类)。如果需要缓存,应该:① 在 loader 里调用「你自己的缓存层」(如 queryClient.ensureQueryData——它会在缓存新鲜时直接返回);② 或改用 TanStack Query 在组件里取数(但要接受「渲染后才有数据」)。这也是「数据路由要与查询库桥接」的原因。

  6. 「loader 里怎么读查询参数?」
    用 request.url 自己解析:const url = new URL(request.url); url.searchParams.get('page')(loader 里不能用 useSearchParams,因为它是 Hook)。这也是数据路由的一个特点——「数据获取是路由层的事」,所以查询参数通过 Request 传递。如果参数变化需要重新执行 loader,React Router 会自动处理(因为 URL 变化 = 导航)。

  7. 「throw Response 和 return Response 有什么区别?」
    在 loader/action 里:① throw new Response(...) —— 表示「这是一个错误状态」,会交给最近的 errorElement 处理(用 useRouteError + isRouteErrorResponse 读取 status/statusText/data);② return new Response(...) —— 表示「把响应直接返回给浏览器」,React Router 会把它作为结果处理(典型用途是「代理一个下载请求」「返回非 HTML 内容」)。判断错误时用 throw,转发响应时用 return。

  8. 「数据路由的 loader 和 TanStack Query 怎么选/结合?」
    三种策略:① 只用 loader/action —— 适合「数据获取与路由强绑定」的应用,简单统一但缓存能力弱;② 只用 TanStack Query —— 适合「数据交互频繁、需要精细缓存(staleTime/失效/重试/乐观更新/无限滚动)」的应用,但失去「渲染前预取」的体验;③ 桥接(推荐给已用 Query 的项目) —— 在 loader 里调用 queryClient.ensureQueryData({ queryKey, queryFn }) 预热缓存,组件里仍用 useQuery(命中缓存、不重复请求),这样既有「渲染前数据就绪」又有 Query 的缓存能力。

一句话速记

数据路由提供 loader(渲染前取数据,参数 params/request/signal,用 useLoaderData 同步读取,不缓存、每次导航都执行)与 action(配合 <Form method="post">,可用 useActionData 读返回、redirect() 跳转、useNavigation 看 loading/submitting,成功后自动重跑 loader);useFetcher 提交不导航(用于列表内操作);throw new Response() 交给 errorElement(配 useRouteError + isRouteErrorResponse),return Response 是转发响应;loader 里读查询参数用 request.url、多请求要 Promise.all 并行、把 signal 传给 fetch;与 TanStack Query 可「二选一」或「ensureQueryData 桥接」(推荐)。

赞(0) 打赏
未经允许不得转载:陌上寒 » 6.9 数据 API

评论 抢沙发

觉得文章有用就打赏一下文章作者

非常感谢你的打赏,我们将继续给力更多优质内容,让我们一起创建更加美好的网络世界!

微信扫一扫

支付宝扫一扫