主题
第 15 章 · 前端路由
一句话开篇:SPA 路由就是"不刷新页面,凭 URL 切换内容"——靠 hash 或 history API 监听 URL 变化,再由 JS 决定渲染什么。
0. 生活类比
类比一:传统多页应用 vs SPA
- 传统多页应用(MPA) 像"看电视换台":每按一下遥控器,电视黑屏一下、再加载新频道,节目从头开始。每个 URL 对应服务器返回一份新 HTML。
- SPA 路由 像"翻杂志":同一本杂志,翻到不同页就显示不同内容,杂志本身(HTML 容器)从未离开你的手。
类比二:hash 路由 vs history 路由
URL https://shop.com/...:
- hash 模式:
https://shop.com/#/about锚点#about后面的部分浏览器不会发请求给服务器——天生适合做前端路由。像"书的目录跳转",永远是同一本书。 - history 模式:
https://shop.com/about跟服务器路径长得一样,浏览器 API 让你"在地址栏改 URL 但不刷新"。像"在博物馆里走动",门牌变了但其实还在同一栋楼。
1. SPA 路由原理
浏览器路由的两种实现
| 模式 | 监听事件 | URL 形态 | 服务器配置 | 兼容性 |
|---|---|---|---|---|
| hash | hashchange | /#/about | 无需配置 | 几乎所有浏览器 |
| history | popstate | /about | 需配置 fallback 到 index.html | 现代浏览器(IE10+) |
hash 模式核心 API
js
window.addEventListener('hashchange', () => {
console.log('当前 hash:', location.hash); // '#/about'
});
// 切换路由
location.hash = '#/profile';history 模式核心 API
js
// 推入新历史记录(不刷新)
history.pushState({ from: 'home' }, '', '/about');
// 替换当前记录
history.replaceState(null, '', '/login');
// 监听浏览器前进/后退按钮
window.addEventListener('popstate', (e) => {
console.log('回退/前进到:', location.pathname);
});⚠️
pushState和replaceState不会触发popstate!手动切换路由后要自己派发更新。
history 模式的"刷新 404 问题"
用户访问 /about 时,浏览器会真的请求 /about 这个路径。如果服务器没配置,会返回 404。
解决方案:服务器把所有未知路径都返回 index.html。
nginx
# Nginx
location / {
try_files $uri $uri/ /index.html;
}2. 渲染流程(ASCII 图)
用户点链接 / 输入 URL
│
▼
┌─────────────────────┐
│ 改 location.hash │ hash 模式
│ history.pushState() │ history 模式
└────────┬────────────┘
│
▼
┌─────────────────────┐
│ 触发 hashchange / │
│ popstate 事件 │
└────────┬────────────┘
│
▼
┌─────────────────────┐
│ 路由库匹配 URL → │
│ 找到对应组件 │
└────────┬────────────┘
│
▼
┌─────────────────────┐
│ React/Vue 渲染该组件 │
└─────────────────────┘3. React Router 6 实战
安装
bash
npm i react-router-dom基本路由
jsx
import { BrowserRouter, Routes, Route, Link, NavLink } from 'react-router-dom';
function App() {
return (
<BrowserRouter>
<nav>
<Link to="/">首页</Link>
<Link to="/about">关于</Link>
<NavLink to="/users" className={({isActive}) => isActive ? 'on' : ''}>用户</NavLink>
</nav>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/about" element={<About />} />
<Route path="/users" element={<Users />} />
<Route path="*" element={<NotFound />} />
</Routes>
</BrowserRouter>
);
}嵌套路由 + Outlet
jsx
<Route path="/dashboard" element={<DashboardLayout />}>
<Route index element={<DashboardHome />} />
<Route path="settings" element={<Settings />} />
<Route path="profile" element={<Profile />} />
</Route>
// DashboardLayout.jsx
import { Outlet } from 'react-router-dom';
function DashboardLayout() {
return (
<>
<Sidebar />
<main><Outlet /></main> {/* 子路由渲染在这里 */}
</>
);
}动态参数
jsx
<Route path="/user/:id" element={<UserDetail />} />
// UserDetail.jsx
import { useParams, useSearchParams, useNavigate } from 'react-router-dom';
function UserDetail() {
const { id } = useParams(); // /user/123 → id = '123'
const [params] = useSearchParams(); // ?tab=info → params.get('tab')
const navigate = useNavigate();
return (
<>
<h2>用户 {id}</h2>
<button onClick={() => navigate('/')}>回首页</button>
<button onClick={() => navigate(-1)}>后退</button>
</>
);
}编程式导航
jsx
const navigate = useNavigate();
navigate('/about'); // 跳转
navigate('/about', { replace: true }); // 替换历史记录(不能后退回来)
navigate(-1); // 后退一步
navigate('/user/1', { state: { from: 'home' } }); // 携带 stateLoader(v6.4+,数据路由)
jsx
const router = createBrowserRouter([
{
path: '/user/:id',
element: <UserDetail />,
loader: async ({ params }) => {
return fetch(`/api/user/${params.id}`).then(r => r.json());
}
}
]);
// 组件中读取
const data = useLoaderData();导航守卫(路由守卫)
React Router 6 没有"全局 beforeEach",要靠"包装组件"实现:
jsx
function RequireAuth({ children }) {
const isLogin = useAuth();
const location = useLocation();
if (!isLogin) {
return <Navigate to="/login" state={{ from: location }} replace />;
}
return children;
}
<Route path="/admin" element={<RequireAuth><Admin /></RequireAuth>} />4. Vue Router 4 实战
安装
bash
npm i vue-router@4基本配置
js
import { createRouter, createWebHistory } from 'vue-router';
import Home from './views/Home.vue';
import About from './views/About.vue';
const router = createRouter({
history: createWebHistory(), // 或 createWebHashHistory()
routes: [
{ path: '/', component: Home },
{ path: '/about', component: About },
{ path: '/user/:id', component: () => import('./views/User.vue') }, // 懒加载
{ path: '/:pathMatch(.*)*', component: NotFound } // 404
]
});
export default router;
// main.js
createApp(App).use(router).mount('#app');模板里使用
vue
<template>
<nav>
<router-link to="/">首页</router-link>
<router-link to="/about" active-class="on">关于</router-link>
</nav>
<router-view />
</template>编程式导航
js
import { useRouter, useRoute } from 'vue-router';
const router = useRouter();
const route = useRoute();
router.push('/about');
router.replace('/login');
router.go(-1);
console.log(route.params.id, route.query.tab);嵌套路由
js
{
path: '/dashboard',
component: DashboardLayout,
children: [
{ path: '', component: DashboardHome },
{ path: 'settings', component: Settings }
]
}全局守卫
js
router.beforeEach((to, from) => {
if (to.meta.requiresAuth && !isLogin()) {
return '/login';
}
});路由守卫种类
| 类型 | 钩子 | 触发时机 |
|---|---|---|
| 全局前置 | beforeEach | 每次导航前 |
| 全局解析 | beforeResolve | 所有路由组件解析后 |
| 全局后置 | afterEach | 导航完成 |
| 路由独享 | beforeEnter | 单条路由进入前 |
| 组件内 | onBeforeRouteEnter | 组件被复用前 |
5. React Router 6 vs Vue Router 4 对比
| 维度 | React Router 6 | Vue Router 4 |
|---|---|---|
| 配置方式 | JSX <Route> 或对象式 | 对象式 |
| 嵌套子路由出口 | <Outlet /> | <router-view /> |
| 跳转链接 | <Link> <NavLink> | <router-link> |
| 编程式 | useNavigate() | useRouter() |
| 路由参数 | useParams() | useRoute().params |
| 查询参数 | useSearchParams() | useRoute().query |
| 全局守卫 | 无(用包装组件) | beforeEach 等 |
| 数据加载 | loader(v6.4+) | 守卫里 await + Pinia |
| 懒加载 | lazy: () => import(...) | component: () => import(...) |
| 模式 | history(默认) / hash | createWebHistory / createWebHashHistory |
6. 手写极简路由(hash 模式 50 行)
js
class MiniRouter {
constructor() {
this.routes = {}; // path → handler
this.current = location.hash.slice(1) || '/';
window.addEventListener('hashchange', () => this.onChange());
window.addEventListener('load', () => this.onChange());
}
on(path, handler) {
this.routes[path] = handler;
return this;
}
push(path) {
location.hash = path; // 触发 hashchange
}
onChange() {
this.current = location.hash.slice(1) || '/';
const handler = this.routes[this.current] || this.routes['*'];
if (handler) handler(this.current);
}
}
// 使用
const router = new MiniRouter();
router
.on('/', () => render('<h1>首页</h1>'))
.on('/about', () => render('<h1>关于</h1>'))
.on('*', () => render('<h1>404</h1>'));
function render(html) {
document.getElementById('app').innerHTML = html;
}完整可运行版见 examples/mini-router.js 和 demo/index.html。
7. 路由懒加载
避免首屏就把所有页面代码都打包进来——按需加载:
React
jsx
import { lazy, Suspense } from 'react';
const About = lazy(() => import('./pages/About'));
<Suspense fallback={<Loading />}>
<Routes>
<Route path="/about" element={<About />} />
</Routes>
</Suspense>Vue
js
{ path: '/about', component: () => import('./pages/About.vue') }构建工具会自动把每个 import 切成一个独立 chunk。
8. 实战案例
详见 examples/ 目录:
react-router.jsx— React Router 6 完整路由示例(嵌套 + 动态参数 + 守卫)vue-router.html— Vue Router 4 完整路由示例mini-router.js— 手写极简 hash 路由
demo/index.html — 单文件 hash 模式 SPA(首页 / 列表 / 详情 三个路由),无依赖,纯原生 JS
9. ⚠️ 易踩的坑
history 模式刷新 404 服务器没配置 fallback。务必把所有未知路径转发到
index.html。pushState不触发popstate只有用户点浏览器前进/后退、或history.go(-1)才会触发。手动 push 后要自己 dispatch。嵌套路由忘记写
<Outlet />/<router-view />子路由没地方渲染,看着很疑惑——以为路由不工作。NavLink active 样式失效 末尾斜杠匹配规则:
/users和/users/在路由库里可能不等价。React Router 6 的 NavLink 默认部分匹配,子路径也算 active。动态参数变化时组件不重新挂载
/user/1→/user/2是同一个组件实例,useEffect要把id加进依赖数组。路由懒加载没包 Suspense React 中 lazy 必须配合 Suspense,否则报错。
路由守卫里跳来跳去引发死循环
beforeEach里跳转必须考虑跳转目标的守卫是否又会跳回来。SSR 中
useNavigate/useRouter报错 服务端没有浏览器历史 API,路由要用StaticRouter/createMemoryHistory。混用
<a href>和<Link><a href>会真的刷新页面,破坏 SPA 体验。除非要打开新标签或外链。hash 路由的 SEO 不友好 搜索引擎对
#后面的内容支持差。SEO 重要的项目用 history 模式 + 服务器渲染。
10. 一句话总结
SPA 路由 = 监听 URL 变化 + JS 决定渲染什么。React Router 用
<Routes>+useNavigate,Vue Router 用<router-view>+useRouter,原理都是 hash 或 history API。
11. 延伸阅读
- React Router 文档:https://reactrouter.com/
- Vue Router 文档:https://router.vuejs.org/zh/
- 进阶:嵌套布局 + 数据路由 + 服务端渲染 + 流式路由