前置知识: JavaScript、HTML5、CSS

路由与数据获取

3 min中级

React Router v7、嵌套路由、布局路由、数据加载、Next.js App Router、Server Actions、SWR/React Query。

前置知识

学习目标

  • 掌握「1. React Router v7」的核心机制、典型用法与常见陷阱
  • 掌握「2. 嵌套路由与布局路由」的核心机制、典型用法与常见陷阱
  • 掌握「3. 数据加载(loader/action)」的核心机制、典型用法与常见陷阱
  • 掌握「4. Next.js App Router」的核心机制、典型用法与常见陷阱
  • 掌握「5. Server Actions」的核心机制、典型用法与常见陷阱

1. React Router v7

React Router v7 是 React 生态中最流行的路由库,整合了 Remix 的数据加载能力。

1.1 安装与基础配置

npm install react-router
import { createBrowserRouter, RouterProvider } from 'react-router';
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';

const router = createBrowserRouter([
  {
    path: '/',
    element: <Layout />,
    children: [
      { index: true, element: <Home /> },
      { path: 'about', element: <About /> },
      { path: 'users', element: <Users /> },
      { path: 'users/:id', element: <UserDetail /> },
    ],
  },
]);

createRoot(document.getElementById('root')!).render(
  <StrictMode>
    <RouterProvider router={router} />
  </StrictMode>
);

1.2 声明式路由(框架模式)

// routes.ts
import { type RouteConfig, index, route } from '@react-router/dev/routes';

export default [
  index('routes/home.tsx'),
  route('about', 'routes/about.tsx'),
  route('users', 'routes/users.tsx'),
  route('users/:id', 'routes/user-detail.tsx'),
] satisfies RouteConfig;

1.3 导航组件

import { Link, NavLink, useNavigate } from 'react-router';

function Navigation() {
  const navigate = useNavigate();

  return (
    <nav>
      {/* Link — 基础导航 */}
      <Link to="/">首页</Link>
      <Link to="/about">关于</Link>

      {/* NavLink — 带激活状态 */}
      <NavLink
        to="/users"
        className={({ isActive, isPending }) => (isActive ? 'active' : isPending ? 'pending' : '')}
      >
        用户
      </NavLink>

      {/* 编程式导航 */}
      <button onClick={() => navigate('/login')}>登录</button>
      <button onClick={() => navigate(-1)}>返回</button>
    </nav>
  );
}

1.4 路由参数

import { useParams } from 'react-router';

function UserDetail() {
  const { id } = useParams<{ id: string }>();

  return <h1>用户 ID:{id}</h1>;
}

1.5 查询参数

import { useSearchParams } from 'react-router';

function ProductList() {
  const [searchParams, setSearchParams] = useSearchParams();
  const page = searchParams.get('page') ?? '1';
  const category = searchParams.get('category') ?? '';

  const setPage = (p: number) => {
    setSearchParams((prev) => {
      prev.set('page', p.toString());
      return prev;
    });
  };

  return (
    <div>
      <p>
        第 {page} 页 | 分类:{category}
      </p>
      <button onClick={() => setPage(Number(page) + 1)}>下一页</button>
    </div>
  );
}

2. 嵌套路由与布局路由

2.1 嵌套路由

const router = createBrowserRouter([
  {
    path: '/',
    element: <Layout />, // 布局组件
    children: [
      { index: true, element: <Home /> },
      {
        path: 'dashboard',
        element: <DashboardLayout />, // 子布局
        children: [
          { index: true, element: <DashboardHome /> },
          { path: 'analytics', element: <Analytics /> },
          { path: 'settings', element: <Settings /> },
        ],
      },
    ],
  },
]);

2.2 Outlet

import { Outlet } from 'react-router';

function Layout() {
  return (
    <div>
      <header>
        <nav>导航栏</nav>
      </header>
      <main>
        <Outlet /> {/* 子路由渲染在这里 */}
      </main>
      <footer>页脚</footer>
    </div>
  );
}

2.3 布局路由(无路径)

const router = createBrowserRouter([
  {
    // 无 path,仅作为布局容器
    element: <AuthLayout />,
    children: [
      { path: '/login', element: <Login /> },
      { path: '/register', element: <Register /> },
      { path: '/forgot-password', element: <ForgotPassword /> },
    ],
  },
]);

function AuthLayout() {
  return (
    <div className="auth-layout">
      <div className="auth-sidebar">
        <h2>欢迎</h2>
      </div>
      <div className="auth-content">
        <Outlet />
      </div>
    </div>
  );
}

3. 数据加载(loader/action)

3.1 Loader — 路由加载时获取数据

import { createBrowserRouter, RouterProvider, useLoaderData } from 'react-router';

// 定义 loader
async function userLoader({ params }: { params: { id: string } }) {
  const res = await fetch(`/api/users/${params.id}`);
  if (!res.ok) throw new Response('用户不存在', { status: 404 });
  return res.json();
}

// 在路由配置中使用
const router = createBrowserRouter([
  {
    path: '/users/:id',
    element: <UserDetail />,
    loader: userLoader,
    errorElement: <UserNotFound />,
  },
]);

// 在组件中消费数据
function UserDetail() {
  const user = useLoaderData() as User;

  return (
    <div>
      <h1>{user.name}</h1>
      <p>{user.email}</p>
    </div>
  );
}

3.2 Action — 表单提交处理

import { Form, useActionData, redirect } from 'react-router';

async function createPostAction({ request }: { request: Request }) {
  const formData = await request.formData();
  const title = formData.get('title') as string;
  const content = formData.get('content') as string;

  if (!title.trim()) {
    return { error: '标题不能为空' };
  }

  const post = await createPostAPI({ title, content });
  return redirect(`/posts/${post.id}`);
}

function NewPost() {
  const actionData = useActionData() as { error?: string };

  return (
    <Form method="post">
      <input name="title" placeholder="标题" />
      {actionData?.error && <p className="error">{actionData.error}</p>}
      <textarea name="content" placeholder="内容" />
      <button type="submit">发布</button>
    </Form>
  );
}

3.3 延迟数据(Deferred)

import { defer, Await } from 'react-router';
import { Suspense } from 'react';

function postLoader({ params }: { params: { id: string } }) {
  // 关键数据立即加载,非关键数据延迟加载
  const post = getPost(params.id); // Promise
  const comments = getComments(params.id); // Promise

  return defer({
    post, // 等待完成
    comments, // 延迟加载
  });
}

function PostPage() {
  const data = useLoaderData() as { post: Post; comments: Promise<Comment[]> };

  return (
    <div>
      <h1>{data.post.title}</h1>
      <div>{data.post.content}</div>

      <Suspense fallback={<p>加载评论...</p>}>
        <Await resolve={data.comments}>
          {(comments) => (
            <ul>
              {comments.map((c) => (
                <li key={c.id}>{c.text}</li>
              ))}
            </ul>
          )}
        </Await>
      </Suspense>
    </div>
  );
}

4. Next.js App Router

4.1 文件系统路由

flowchart TD
    T0["app/"]
    T1["layout.tsx          # 根布局"]
    T2["page.tsx            # 首页 (/)"]
    T3["loading.tsx         # 全局加载状态"]
    T4["error.tsx           # 全局错误处理"]
    T5["not-found.tsx       # 404 页面"]
    T6["users/"]
    T7["layout.tsx      # 用户布局"]
    T8["page.tsx        # 用户列表 (/users)"]
    T9["[id]/"]
    T10["page.tsx    # 用户详情 (/users/:id)"]
    T11["edit/"]
    T12["page.tsx # 编辑用户 (/users/:id/edit)"]
    T13["new/"]
    T14["page.tsx    # 新建用户 (/users/new)"]
    T15["api/"]
    T16["health/"]
    T17["route.ts    # API 路由 (/api/health)"]
    T0 --> T1
    T0 --> T2
    T0 --> T3
    T0 --> T4
    T0 --> T5
    T0 --> T6
    T14 --> T15
    T15 --> T16
    T16 --> T17

4.2 布局与模板

// app/layout.tsx — 根布局(不会重新挂载)
export default function RootLayout({ children }: { children: React.ReactNode }) {
  return (
    <html lang="zh-CN">
      <body>
        <nav>全局导航</nav>
        {children}
      </body>
    </html>
  );
}

// app/template.tsx — 模板(路由切换时重新挂载)
export default function Template({ children }: { children: React.ReactNode }) {
  return <div className="animate-fadeIn">{children}</div>;
}

4.3 加载与错误状态

// app/users/loading.tsx — 自动显示加载状态
export default function Loading() {
  return <UserListSkeleton />;
}

// app/users/error.tsx — 错误处理
('use client');

export default function Error({
  error,
  reset,
}: {
  error: Error & { digest?: string };
  reset: () => void;
}) {
  return (
    <div>
      <h2>出错了</h2>
      <p>{error.message}</p>
      <button onClick={reset}>重试</button>
    </div>
  );
}

5. Server Actions

Next.js Server Actions 允许从客户端直接调用服务端函数。

5.1 定义与调用

// app/actions.ts
'use server';

import { revalidatePath } from 'next/cache';
import { redirect } from 'next/navigation';

export async function createPost(formData: FormData) {
  const title = formData.get('title') as string;
  const content = formData.get('content') as string;

  await db.post.create({ data: { title, content } });
  revalidatePath('/posts'); // 刷新缓存
  redirect('/posts');
}

export async function deletePost(id: string) {
  await db.post.delete({ where: { id } });
  revalidatePath('/posts');
}
// app/posts/new/page.tsx
import { createPost } from '../actions';

export default function NewPostPage() {
  return (
    <form action={createPost}>
      <input name="title" required />
      <textarea name="content" required />
      <button type="submit">发布</button>
    </form>
  );
}

5.2 useActionState 配合 Server Actions

'use client';

import { useActionState } from 'react';
import { createPost } from './actions';

export default function NewPostPage() {
  const [state, formAction, isPending] = useActionState(createPost, null);

  return (
    <form action={formAction}>
      <input name="title" required />
      <textarea name="content" required />
      <button type="submit" disabled={isPending}>
        {isPending ? '发布中...' : '发布'}
      </button>
      {state?.error && <p className="error">{state.error}</p>}
    </form>
  );
}

6. SWR

SWR 是 Vercel 开发的数据获取库,名称来自 stale-while-revalidate 缓存策略。

6.1 基本用法

import useSWR from 'swr';

const fetcher = (url: string) => fetch(url).then((res) => res.json());

function UserProfile({ id }: { id: string }) {
  const { data, error, isLoading, mutate } = useSWR<User>(`/api/users/${id}`, fetcher);

  if (isLoading) return <Spinner />;
  if (error) return <Error message={error.message} />;

  return (
    <div>
      <h1>{data!.name}</h1>
      <button onClick={() => mutate()}>刷新</button>
    </div>
  );
}

6.2 全局配置

import { SWRConfig } from 'swr';

function App() {
  return (
    <SWRConfig
      value={{
        fetcher: (url: string) => fetch(url).then((r) => r.json()),
        revalidateOnFocus: false,
        dedupingInterval: 60000,
      }}
    >
      <Router />
    </SWRConfig>
  );
}

6.3 乐观更新

function TodoList() {
  const { data: todos, mutate } = useSWR<Todo[]>('/api/todos', fetcher);

  const toggleTodo = async (id: string) => {
    // 乐观更新
    await mutate(
      todos?.map((t) => (t.id === id ? { ...t, completed: !t.completed } : t)),
      false // 不重新验证
    );

    // 实际请求
    await fetch(`/api/todos/${id}/toggle`, { method: 'POST' });

    // 重新验证
    mutate();
  };

  return (
    <ul>
      {todos?.map((todo) => (
        <li key={todo.id} onClick={() => toggleTodo(todo.id)}>
          {todo.completed ? '' : '□'} {todo.text}
        </li>
      ))}
    </ul>
  );
}

7. React Query (TanStack Query)

React Query 是功能最全面的数据获取库,适合复杂场景。

7.1 基本用法

import { QueryClient, QueryClientProvider, useQuery, useMutation } from '@tanstack/react-query';

const queryClient = new QueryClient();

function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <Users />
    </QueryClientProvider>
  );
}

function Users() {
  const { data, isLoading, error } = useQuery({
    queryKey: ['users'],
    queryFn: () => fetch('/api/users').then((r) => r.json()),
    staleTime: 5 * 60 * 1000, // 5 分钟内不重新获取
  });

  if (isLoading) return <Spinner />;
  if (error) return <Error />;

  return (
    <ul>
      {data.map((user: User) => (
        <li key={user.id}>{user.name}</li>
      ))}
    </ul>
  );
}

7.2 Mutation

function CreateUser() {
  const mutation = useMutation({
    mutationFn: (newUser: { name: string; email: string }) =>
      fetch('/api/users', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(newUser),
      }).then((r) => r.json()),
    onSuccess: () => {
      queryClient.invalidateQueries({ queryKey: ['users'] }); // 刷新列表
    },
  });

  const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    const formData = new FormData(e.currentTarget);
    mutation.mutate({
      name: formData.get('name') as string,
      email: formData.get('email') as string,
    });
  };

  return (
    <form onSubmit={handleSubmit}>
      <input name="name" />
      <input name="email" />
      <button type="submit" disabled={mutation.isPending}>
        {mutation.isPending ? '创建中...' : '创建'}
      </button>
    </form>
  );
}

7.3 SWR vs React Query

特性SWRReact Query
体积~4 KB~13 KB
学习曲线低中
Mutation 支持基础完善
离线支持需要插件内置
分页/无限滚动基础完善
DevTools有完善
适用场景简单数据获取复杂数据管理

useNavigate 编程式导航

useNavigate const <navigate> = useNavigate();

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

function LoginButton() {
  const navigate = useNavigate();
  return <button onClick={() => navigate('/dashboard')}>登录</button>;
}

navigate 签名 navigate(<to>, [<options>]);

navigate('/users');                          // 字符串路径
navigate('/users', { replace: true });        // 替换历史
navigate(-1);                                 // 后退
navigate(1);                                  // 前进
navigate({ pathname: '/u', search: '?id=1' });// 对象路径

navigate options

navigate('/login', {
  replace: true,                              // 替换历史记录
  state: { from: '/dashboard' },             // 路由状态
});

useParams 路径参数

useParams const <params> = useParams<<T>>();

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

function User() {
  const { id } = useParams<{ id: string }>();
  return <div>User ID: {id}</div>;
}

多个参数

// 路由:/users/:userId/posts/:postId
const { userId, postId } = useParams<{ userId: string; postId: string }>();

useLocation 当前位置

useLocation const <location> = useLocation();

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

function Page() {
  const location = useLocation();
  // location.pathname  当前路径
  // location.search    查询字符串
  // location.hash      哈希
  // location.state     路由状态
  // location.key       唯一标识
  return <div>Current: {location.pathname}</div>;
}

useSearchParams 查询参数

useSearchParams const [<searchParams>, <setSearchParams>] = useSearchParams();

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

function Filter() {
  const [searchParams, setSearchParams] = useSearchParams();
  const page = searchParams.get('page') ?? '1';

  const setPage = (p: number) => {
    setSearchParams({ page: String(p) });
  };
  return <button onClick={() => setPage(2)}>第 2 页</button>;
}

读取多值

searchParams.get('q');          // 单值
searchParams.getAll('tag');     // 多值
searchParams.has('sort');       // 是否存在

设置方式

setSearchParams({ page: '2', sort: 'desc' });
setSearchParams(prev => {
  prev.set('page', '2');
  return prev;
});

useLoaderData 加载器数据

useLoaderData const <data> = useLoaderData() as <T>;

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

type User = { id: string; name: string };

function UserPage() {
  const user = useLoaderData() as User;
  return <h1>{user.name}</h1>;
}

类型化 Loader

import type { LoaderFunctionArgs } from 'react-router-dom';

export async function loader({ params }: LoaderFunctionArgs) {
  const user = await fetchUser(params.id!);
  return user;
}

useRouteError 路由错误

useRouteError const <error> = useRouteError();

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

function ErrorBoundary() {
  const error = useRouteError() as Error;
  return <div>错误:{error.message}</div>;
}

useRouteLoaderData 嵌套路由数据

useRouteLoaderData const <data> = useRouteLoaderData('<routeId>');

const rootData = useRouteLoaderData('root') as RootData;

useNavigation 导航状态

useNavigation const <navigation> = useNavigation();

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

function LoadingBar() {
  const navigation = useNavigation();
  // navigation.state: 'idle' | 'submitting' | 'loading'
  // navigation.location: 目标 location
  // navigation.formData: 提交的表单数据
  return navigation.state !== 'idle' ? <Spinner /> : null;
}

useMatch 路由匹配

useMatch const <match> = useMatch('<pattern>');

const match = useMatch('/users/:id');
// match: { params: { id: '123' }, pathname: '/users/123', ... } | null

useOutlet 获取 Outlet

useOutlet const <outlet> = useOutlet();

function Layout() {
  const outlet = useOutlet();
  return outlet ? <main>{outlet}</main> : <Empty />;
}

useOutletContext 上下文传递

useOutletContext const <ctx> = useOutletContext<<T>>();

// 父组件
function Parent() {
  const [count, setCount] = useState(0);
  return <Outlet context={{ count, setCount }} />;
}

// 子组件
function Child() {
  const { count, setCount } = useOutletContext<{
    count: number;
    setCount: (n: number) => void;
  }>();
  return <button onClick={() => setCount(count + 1)}>{count}</button>;
}

Link <Link to=<path> [state=<obj>] [replace]>...</Link>

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

<Link to="/users/1">用户 1</Link>
<Link to="/login" state={{ from: '/dashboard' }} replace>登录</Link>

NavLink 高亮链接 <NavLink to=<path> [className=<fn>]>...</NavLink>

<NavLink
  to="/users"
  className={({ isActive, isPending }) =>
    isActive ? 'active' : isPending ? 'pending' : ''
  }
>
  用户列表
</NavLink>

Outlet 与 Navigate

Outlet <Outlet context={<value>} />

<Outlet />
<Outlet context={{ user }} />

Navigate 编程式重定向 <Navigate to=<path> [replace] [state=<obj>] />

<Navigate to="/login" replace state={{ from: location.pathname }} />

Router 配置 API

createBrowserRouter const <router> = createBrowserRouter([<routes>], [<options>]);

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

const router = createBrowserRouter([
  {
    path: '/',
    element: <Layout />,
    errorElement: <ErrorBoundary />,
    children: [
      { index: true, element: <Home /> },
      { path: 'users/:id', element: <User />, loader: userLoader },
    ],
  },
]);

RouterProvider <RouterProvider router={<router>} />

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

createRoot(container).render(<RouterProvider router={router} />);

defer 流式加载

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

export async function loader() {
  return defer({
    users: fetchUsers(),           // Promise
    summary: fetchSummary(),       // Promise
  });
}