传统多页应用里,点击一个链接,浏览器会向服务器请求新的 HTML,页面整体刷新。
单页应用不是这样。页面始终只有一份 HTML,点击链接时 URL 变了,内容也变了,但浏览器没有重新加载文档。这个“URL 与界面同步”的过程,就是前端路由。
React 本身没有路由。它只负责根据状态渲染视图,路由需要额外的库或框架来完成,例如 React Router、TanStack Router,或者 Next.js 这样的全栈框架。
本文以 React Router 为例,讲清路由怎么用,以及它背后的运行原理。
一、SPA 路由要解决什么问题
单页应用需要同时满足三件事:
- URL 变化时,界面跟着变化。
- URL 可以直接访问、刷新、前进后退。
- 切换页面时,不触发整页刷新。
如果只用一个状态变量控制页面:
const [page, setPage] = useState('home');
界面能切换,但 URL 不会变。用户刷新页面会回到首页,分享链接也带不上当前页面,浏览器的前进后退按钮同样无法工作。
所以前端路由的核心,是把“当前页面”这个状态同步到 URL 上,再从 URL 反推出应该渲染什么。
二、React Router 的基本用法
先看一个最常见的配置:
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 渲染子路由:
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 时,实际渲染结构是:
App
└── UserDetail
App 不会重新挂载,只有 Outlet 的位置会切换成子路由内容。这就是嵌套路由的价值:公共布局保持不变,页面内容局部更新。
三、两种常见的 URL 模式
Browser 模式
import { createBrowserRouter } from 'react-router-dom';
const router = createBrowserRouter(routes);
Browser 模式使用 HTML5 History API,URL 看起来和普通网站一样:
https://example.com/users/1
优点是 URL 干净、利于分享。相比 Hash 模式,路径会随 HTTP 请求发送给服务器,具备按 URL 返回不同内容的基础;但要获得稳定的 SEO 效果,还需要 SSR、SSG 或预渲染。代价是服务器必须配合:当用户直接访问 /users/1 或刷新页面时,服务器也要返回 index.html,否则会得到一个 404。
Nginx 通常这样配置:
location / {
try_files $uri $uri/ /index.html;
}
它的含义是:先找真实文件,找不到就回退到 index.html,把路由交给前端处理。
Hash 模式
import { createHashRouter } from 'react-router-dom';
const router = createHashRouter(routes);
Hash 模式的 URL 长这样:
https://example.com/#/users/1
# 后面的内容不会发送给服务器,所以刷新页面不会 404,部署到静态托管也更省事。缺点是 URL 不够美观,对 SEO 也不够友好。
为什么 Hash 路由对 SEO 不友好
Hash 路由的 SEO 问题,核心不是 # 不好看,而是 # 后面的内容在 HTTP 请求里根本不会发给服务器。
假设访问 https://example.com/#/users/1,浏览器向服务器请求的仍然是 https://example.com/。服务器永远只返回同一份 index.html,它不知道用户要看的是哪条路由。这会带来几个后果:
- 无法按路由返回独立 HTML:服务端渲染、预渲染、按页面生成 title 和 meta 都失去了路由信息,所有页面共用同一个入口。
- 不同页面容易被视为同一个 URL:搜索引擎通常不会把
#后面的片段当成独立的页面地址,抓取和收录时容易把它们合并到同一个文档上。 - 依赖爬虫执行 JavaScript:Hash 路由的内容需要前端 JS 渲染后才会出现。现代搜索引擎的爬虫具备一定的 JS 渲染能力,但这不是所有爬虫和社交分享机器人都具备的能力,也不如直接返回 HTML 稳定。
- 分享与 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 但不刷新页面的核心方法有两个:
window.history.pushState({ page: 1 }, '', '/users/1');
window.history.replaceState({}, '', '/login');
pushState新增一条历史记录。replaceState替换当前历史记录。
用户点击浏览器前进或后退时,会触发 popstate 事件:
window.addEventListener('popstate', () => {
console.log('当前路径:', location.pathname);
});
这里有一个容易忽略的细节:调用 pushState 或 replaceState 本身不会触发 popstate。
所以路由库不能只监听 popstate。它还要在程序化导航时主动通知订阅者,让 React 重新渲染。
Hash 模式则监听 hashchange:
window.addEventListener('hashchange', () => {
console.log('当前 hash:', location.hash);
});
无论是哪种模式,本质都是同一件事:监听 URL 的变化,把 URL 转换成路由状态。
五、原理二:URL 如何匹配到路由
拿到当前路径后,路由库会把它和路由表进行匹配。
例如路径是:
/users/42
路由规则是:
{ path: 'users/:id', element: <UserDetail /> }
匹配结果会提取出参数:
{ id: '42' }
组件里通过 useParams 读取:
import { useParams } from 'react-router-dom';
function UserDetail() {
const { id } = useParams();
return <h1>用户 ID: {id}</h1>;
}
注意,id 是字符串。即使 URL 里写的是 42,拿到的也是 '42',需要数字时要自己转换。
React Router v6 之后会按照具体程度给路由排序,而不是完全依赖声明顺序。静态路径通常优先于动态参数,动态参数优先于通配符:
/users/new // 更具体
/users/:id // 次之
/users/* // 兜底
这样才能保证 /users/new 不会先被 /users/:id 吃掉。
六、原理三:匹配之后如何渲染
路由匹配到的不是单个组件,而是一条从根到叶子的分支。
以 /users/1 为例,匹配链路是:
/ -> App
/users/:id -> UserDetail
React Router 会渲染这条链路上的组件,并把子路由放到父级 Outlet 的位置。
可以把它理解为:
<App>
<UserDetail />
</App>
如果中间还有一层布局:
const routes = [
{
path: '/',
element: <App />,
children: [
{
path: 'settings',
element: <SettingsLayout />,
children: [
{ path: 'profile', element: <Profile /> },
],
},
],
},
];
访问 /settings/profile 时,渲染结果是:
App
└── SettingsLayout
└── Profile
每一层只需要在自己的位置放一个 Outlet,子路由就会渲染进去。
七、原理四:Link 为什么不会刷新页面
React Router 提供了 Link 组件:
import { Link } from 'react-router-dom';
<Link to="/users/1">查看用户</Link>
它最终会渲染成一个 <a> 标签,但点击时会拦截默认行为:
event.preventDefault();
navigate('/users/1');
navigate 内部会:
- 调用
history.pushState改变 URL。 - 通知路由订阅者 URL 已变化。
- 重新匹配路由。
- 触发 React 重新渲染。
如果用普通 <a href="/users/1">,浏览器会走默认行为,向服务器请求新文档,整页刷新,前端路由状态也就丢了。
所以在 SPA 内部导航时,应该使用 Link 或 navigate,而不是普通锚点。
八、数据路由:先加载数据,再渲染页面
React Router 6.4 之后引入了数据路由 API。路由可以声明 loader,在渲染前加载数据:
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 读取:
import { useLoaderData } from 'react-router-dom';
function UserDetail() {
const user = useLoaderData();
return <h1>{user.name}</h1>;
}
这样做的好处是,数据加载成为路由的一部分:
- 路由切换时可以先等待数据。
- 加载状态和错误状态可以由路由统一处理。
- 页面组件不需要在
useEffect里重复写请求逻辑。
对于写操作,还可以使用 action,配合 <Form> 提交表单,把“提交、重新验证、跳转”交给路由层协调。
九、路由守卫与权限控制
路由守卫本质上也是渲染逻辑。
例如要求登录后才能访问设置页:
import { Navigate } from 'react-router-dom';
function RequireAuth({ children }) {
const user = useUser();
if (!user) {
return <Navigate to="/login" replace />;
}
return children;
}
在路由中使用:
{
path: 'settings',
element: (
<RequireAuth>
<Settings />
</RequireAuth>
),
}
replace 的作用是替换当前历史记录,避免用户登录后点击后退又回到受保护页面。
如果使用数据路由,也可以在 loader 里检查权限并抛出重定向:
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 则提供文件系统路由:
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 路由并不神秘。它围绕三件事运转:
- 用 History API 或 hash 改变 URL,同时监听前进后退。
- 把当前 URL 和路由表匹配,得到一条组件分支。
- 渲染匹配到的组件,并通过
Outlet组合嵌套路由。
React 本身只负责渲染,路由库负责把 URL 变成可渲染的状态。理解这条链路后,Link 为什么不刷新、刷新为什么 404、嵌套路由为什么要 Outlet、路由守卫为什么只是一个条件渲染,都会变得顺理成章。