Skip to content

第 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 形态服务器配置兼容性
hashhashchange/#/about无需配置几乎所有浏览器
historypopstate/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);
});

⚠️ pushStatereplaceState 不会触发 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' } }); // 携带 state

Loader(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 6Vue 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(默认) / hashcreateWebHistory / 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.jsdemo/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. ⚠️ 易踩的坑

  1. history 模式刷新 404 服务器没配置 fallback。务必把所有未知路径转发到 index.html

  2. pushState 不触发 popstate 只有用户点浏览器前进/后退、或 history.go(-1) 才会触发。手动 push 后要自己 dispatch。

  3. 嵌套路由忘记写 <Outlet /> / <router-view /> 子路由没地方渲染,看着很疑惑——以为路由不工作。

  4. NavLink active 样式失效 末尾斜杠匹配规则:/users/users/ 在路由库里可能不等价。React Router 6 的 NavLink 默认部分匹配,子路径也算 active。

  5. 动态参数变化时组件不重新挂载/user/1/user/2 是同一个组件实例,useEffect 要把 id 加进依赖数组。

  6. 路由懒加载没包 Suspense React 中 lazy 必须配合 Suspense,否则报错。

  7. 路由守卫里跳来跳去引发死循环beforeEach 里跳转必须考虑跳转目标的守卫是否又会跳回来。

  8. SSR 中 useNavigate / useRouter 报错 服务端没有浏览器历史 API,路由要用 StaticRouter / createMemoryHistory

  9. 混用 <a href><Link><a href>真的刷新页面,破坏 SPA 体验。除非要打开新标签或外链。

  10. hash 路由的 SEO 不友好 搜索引擎对 # 后面的内容支持差。SEO 重要的项目用 history 模式 + 服务器渲染。


10. 一句话总结

SPA 路由 = 监听 URL 变化 + JS 决定渲染什么。React Router 用 <Routes> + useNavigate,Vue Router 用 <router-view> + useRouter,原理都是 hash 或 history API。


11. 延伸阅读