创见博客
React 路由是怎么工作的?从 History API 到 React Router
七崽爱吃小饼干2026/10/20阅读 0

传统多页应用里,点击一个链接,浏览器会向服务器请求新的 HTML,页面整体刷新。

单页应用不是这样。页面始终只有一份 HTML,点击链接时 URL 变了,内容也变了,但浏览器没有重新加载文档。这个“URL 与界面同步”的过程,就是前端路由。

React 本身没有路由。它只负责根据状态渲染视图,路由需要额外的库或框架来完成,例如 React Router、TanStack Router,或者 Next.js 这样的全栈框架。

本文以 React Router 为例,讲清路由怎么用,以及它背后的运行原理。

一、SPA 路由要解决什么问题

单页应用需要同时满足三件事:

  1. URL 变化时,界面跟着变化。
  2. URL 可以直接访问、刷新、前进后退。
  3. 切换页面时,不触发整页刷新。

如果只用一个状态变量控制页面:

js
const [page, setPage] = useState('home');

界面能切换,但 URL 不会变。用户刷新页面会回到首页,分享链接也带不上当前页面,浏览器的前进后退按钮同样无法工作。

所以前端路由的核心,是把“当前页面”这个状态同步到 URL 上,再从 URL 反推出应该渲染什么。

二、React Router 的基本用法

先看一个最常见的配置:

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

const router = createBrowserRouter([
  {
    path: '/',
    element: <App />,
    children: [
      { index: true, element: <Home /> },
      { path: 'users/:id', element: <UserDetail /> },
      { path: '*', element: <NotFound /> },
    ],
  },
]);

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

这段配置描述了一棵路由树:

  • / 对应 App 布局。
  • / 的默认子路由是 Home。
  • /users/:id 对应 UserDetail,:id 是动态参数。
  • * 是兜底路由,匹配不到时显示 NotFound。

布局组件通过 Outlet 渲染子路由:

jsx
import { Link, Outlet } from 'react-router-dom';

function App() {
  return (
    <div>
      <nav>
        <Link to="/">首页</Link>
        <Link to="/users/1">用户 1</Link>
      </nav>

      <Outlet />
    </div>
  );
}

访问 /users/1 时,实际渲染结构是:

text
App
└── UserDetail

App 不会重新挂载,只有 Outlet 的位置会切换成子路由内容。这就是嵌套路由的价值:公共布局保持不变,页面内容局部更新。

三、两种常见的 URL 模式

Browser 模式

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

const router = createBrowserRouter(routes);

Browser 模式使用 HTML5 History API,URL 看起来和普通网站一样:

text
https://example.com/users/1

优点是 URL 干净、利于分享。相比 Hash 模式,路径会随 HTTP 请求发送给服务器,具备按 URL 返回不同内容的基础;但要获得稳定的 SEO 效果,还需要 SSR、SSG 或预渲染。代价是服务器必须配合:当用户直接访问 /users/1 或刷新页面时,服务器也要返回 index.html,否则会得到一个 404。

Nginx 通常这样配置:

nginx
location / {
  try_files $uri $uri/ /index.html;
}

它的含义是:先找真实文件,找不到就回退到 index.html,把路由交给前端处理。

Hash 模式

jsx
import { createHashRouter } from 'react-router-dom';

const router = createHashRouter(routes);

Hash 模式的 URL 长这样:

text
https://example.com/#/users/1

# 后面的内容不会发送给服务器,所以刷新页面不会 404,部署到静态托管也更省事。缺点是 URL 不够美观,对 SEO 也不够友好。

为什么 Hash 路由对 SEO 不友好

Hash 路由的 SEO 问题,核心不是 # 不好看,而是 # 后面的内容在 HTTP 请求里根本不会发给服务器。

假设访问 https://example.com/#/users/1,浏览器向服务器请求的仍然是 https://example.com/。服务器永远只返回同一份 index.html,它不知道用户要看的是哪条路由。这会带来几个后果:

  1. 无法按路由返回独立 HTML:服务端渲染、预渲染、按页面生成 title 和 meta 都失去了路由信息,所有页面共用同一个入口。
  2. 不同页面容易被视为同一个 URL:搜索引擎通常不会把 # 后面的片段当成独立的页面地址,抓取和收录时容易把它们合并到同一个文档上。
  3. 依赖爬虫执行 JavaScript:Hash 路由的内容需要前端 JS 渲染后才会出现。现代搜索引擎的爬虫具备一定的 JS 渲染能力,但这不是所有爬虫和社交分享机器人都具备的能力,也不如直接返回 HTML 稳定。
  4. 分享与 canonical 处理更麻烦:社交平台和部分抓取工具可能只识别 # 前的地址,或者把不同 Hash 路由当成同一个页面。

现代搜索引擎并不是完全不能执行 JS,所以 Hash 路由不等于“绝对不收录”。但对于依赖自然搜索流量的内容站,它仍然不是可靠选择。需要 SEO 时,优先使用 Browser 模式,并配合 SSR、SSG 或预渲染,让每个 URL 都能返回对应内容和 meta 信息。

两种模式的核心差异:

模式URL 示例服务端配置刷新与直达SEO观感
Browser/users/1需要回退到 index.html依赖服务端配置更有利(配合 SSR/预渲染)干净
Hash/#/users/1不需要天然支持较弱带 #

四、原理一:URL 变化如何被感知

Browser 模式依赖 History API。

改变 URL 但不刷新页面的核心方法有两个:

js
window.history.pushState({ page: 1 }, '', '/users/1');
window.history.replaceState({}, '', '/login');
  • pushState 新增一条历史记录。
  • replaceState 替换当前历史记录。

用户点击浏览器前进或后退时,会触发 popstate 事件:

js
window.addEventListener('popstate', () => {
  console.log('当前路径:', location.pathname);
});

这里有一个容易忽略的细节:调用 pushState 或 replaceState 本身不会触发 popstate。

所以路由库不能只监听 popstate。它还要在程序化导航时主动通知订阅者,让 React 重新渲染。

Hash 模式则监听 hashchange:

js
window.addEventListener('hashchange', () => {
  console.log('当前 hash:', location.hash);
});

无论是哪种模式,本质都是同一件事:监听 URL 的变化,把 URL 转换成路由状态。

五、原理二:URL 如何匹配到路由

拿到当前路径后,路由库会把它和路由表进行匹配。

例如路径是:

text
/users/42

路由规则是:

jsx
{ path: 'users/:id', element: <UserDetail /> }

匹配结果会提取出参数:

js
{ id: '42' }

组件里通过 useParams 读取:

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

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

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

注意,id 是字符串。即使 URL 里写的是 42,拿到的也是 '42',需要数字时要自己转换。

React Router v6 之后会按照具体程度给路由排序,而不是完全依赖声明顺序。静态路径通常优先于动态参数,动态参数优先于通配符:

text
/users/new      // 更具体
/users/:id      // 次之
/users/*        // 兜底

这样才能保证 /users/new 不会先被 /users/:id 吃掉。

六、原理三:匹配之后如何渲染

路由匹配到的不是单个组件,而是一条从根到叶子的分支。

以 /users/1 为例,匹配链路是:

text
/          -> App
/users/:id -> UserDetail

React Router 会渲染这条链路上的组件,并把子路由放到父级 Outlet 的位置。

可以把它理解为:

jsx
<App>
  <UserDetail />
</App>

如果中间还有一层布局:

jsx
const routes = [
  {
    path: '/',
    element: <App />,
    children: [
      {
        path: 'settings',
        element: <SettingsLayout />,
        children: [
          { path: 'profile', element: <Profile /> },
        ],
      },
    ],
  },
];

访问 /settings/profile 时,渲染结果是:

text
App
└── SettingsLayout
    └── Profile

每一层只需要在自己的位置放一个 Outlet,子路由就会渲染进去。

七、原理四:Link 为什么不会刷新页面

React Router 提供了 Link 组件:

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

<Link to="/users/1">查看用户</Link>

它最终会渲染成一个 <a> 标签,但点击时会拦截默认行为:

js
event.preventDefault();
navigate('/users/1');

navigate 内部会:

  1. 调用 history.pushState 改变 URL。
  2. 通知路由订阅者 URL 已变化。
  3. 重新匹配路由。
  4. 触发 React 重新渲染。

如果用普通 <a href="/users/1">,浏览器会走默认行为,向服务器请求新文档,整页刷新,前端路由状态也就丢了。

所以在 SPA 内部导航时,应该使用 Link 或 navigate,而不是普通锚点。

八、数据路由:先加载数据,再渲染页面

React Router 6.4 之后引入了数据路由 API。路由可以声明 loader,在渲染前加载数据:

jsx
const router = createBrowserRouter([
  {
    path: '/users/:id',
    loader: async ({ params }) => {
      const res = await fetch(`/api/users/${params.id}`);

      if (!res.ok) {
        throw new Response('Not Found', { status: 404 });
      }

      return res.json();
    },
    element: <UserDetail />,
  },
]);

组件通过 useLoaderData 读取:

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

function UserDetail() {
  const user = useLoaderData();

  return <h1>{user.name}</h1>;
}

这样做的好处是,数据加载成为路由的一部分:

  • 路由切换时可以先等待数据。
  • 加载状态和错误状态可以由路由统一处理。
  • 页面组件不需要在 useEffect 里重复写请求逻辑。

对于写操作,还可以使用 action,配合 <Form> 提交表单,把“提交、重新验证、跳转”交给路由层协调。

九、路由守卫与权限控制

路由守卫本质上也是渲染逻辑。

例如要求登录后才能访问设置页:

jsx
import { Navigate } from 'react-router-dom';

function RequireAuth({ children }) {
  const user = useUser();

  if (!user) {
    return <Navigate to="/login" replace />;
  }

  return children;
}

在路由中使用:

jsx
{
  path: 'settings',
  element: (
    <RequireAuth>
      <Settings />
    </RequireAuth>
  ),
}

replace 的作用是替换当前历史记录,避免用户登录后点击后退又回到受保护页面。

如果使用数据路由,也可以在 loader 里检查权限并抛出重定向:

js
import { redirect } from 'react-router-dom';

async function requireAuth() {
  const user = await getUser();

  if (!user) {
    throw redirect('/login');
  }

  return user;
}

十、和 Next.js 路由的区别

React Router 是客户端路由库,路由表由前端代码定义。

Next.js 则提供文件系统路由:

text
app/
├── page.tsx
├── users/
│   └── [id]/
│       └── page.tsx

文件路径直接对应 URL 路径。Next.js 还能在服务端渲染页面、按路由拆分代码,并在 App Router 中组合布局、加载状态和服务端组件。

两者解决的问题有重叠,但定位不同:

  • React Router:给任意 React SPA 增加客户端路由能力。
  • Next.js:把路由、渲染、数据加载和构建集成到一个框架里。

十一、常见坑

1. 内部导航用了 <a>

普通 <a> 会触发整页刷新。SPA 内部跳转应使用 Link 或 navigate。

2. 生产环境刷新 404

Browser 模式需要服务器回退到 index.html。开发环境正常,不代表部署后正常。

3. 嵌套路由没有 Outlet

父路由组件里没有 Outlet,子路由即使匹配成功也不会显示。

4. 忘记处理通配路由

加一个 * 兜底路由,避免用户访问不存在路径时看到空白页。

5. 部署在子路径下

如果应用部署在 /admin/,需要配置 basename,否则路由匹配会从错误的根路径开始。

6. 把 useParams 的值当数字

路由参数默认是字符串,需要数字时显式转换。

最后

React 路由并不神秘。它围绕三件事运转:

  1. 用 History API 或 hash 改变 URL,同时监听前进后退。
  2. 把当前 URL 和路由表匹配,得到一条组件分支。
  3. 渲染匹配到的组件,并通过 Outlet 组合嵌套路由。

React 本身只负责渲染,路由库负责把 URL 变成可渲染的状态。理解这条链路后,Link 为什么不刷新、刷新为什么 404、嵌套路由为什么要 Outlet、路由守卫为什么只是一个条件渲染,都会变得顺理成章。

评论
0/100