React: React Router 进阶

最后更新:2026-08-26

Tom 的基础路由已经跑通了,但新需求接踵而来:登录后需要自动跳转到仪表盘、某些页面需要登录才能访问、应用越来越大首页加载越来越慢、商品列表页需要支持 URL 中的筛选参数……他需要学习 React Router 的进阶功能来应对这些真实场景。


1. 你将学到



2. 概念图解

100%
flowchart TD
    U[用户操作] --> A{是否登录?}
    A -->|未登录| B[ProtectedRoute<br/>→ 重定向到 /login]
    A -->|已登录| C{有无权限?}
    C -->|无权限| D[→ 403 页面]
    C -->|有权限| E[加载目标页面]
    E --> F{组件是否懒加载?}
    F -->|是| G[Suspense<br/>显示 loading]
    G --> H[渲染页面]
    F -->|否| H
    style A fill:#fff3e0,stroke:#f57c00
    style B fill:#ffcdd2,stroke:#d32f2f
    style G fill:#e1f5fe,stroke:#0288d1
    style H fill:#e8f5e9,stroke:#388e3c

用户操作请求 → 路由守卫检查登录/权限 → 通过后加载页面(懒加载时显示 loading)→ 最终渲染。



3. 一个真实场景

Tom 的应用需要:登录成功后自动跳转到仪表盘、仪表盘页面需要登录保护、管理后台只有 admin 角色能访问、首页首屏加载速度要快(大页面按需加载)、商品列表页 URL 需要携带筛选和分页参数以便分享。

(1) 编程式导航:useNavigate

Tom 在登录页面需要处理表单提交,提交成功后根据用户角色跳转到不同页面。这种由代码逻辑触发的导航不能用 <Link>(Link 只能由用户点击触发),必须用 useNavigate

useNavigate 返回一个 navigate 函数,支持三种调用方式:

调用方式 效果 示例
navigate('/path') 跳转到指定路径(向历史栈添加新记录) navigate('/dashboard')
navigate('/path', { replace: true }) 替换当前历史记录(不能后退到这里) 登录后跳转
navigate(-1) 后退 返回上一页

▶ 示例 1:登录后编程式导航

JSX
import { useNavigate, useLocation } from 'react-router-dom'

function LoginPage() {
  const navigate = useNavigate()
  const location = useLocation()

  // 从 URL 查询参数中获取登录后要跳转的地址
  const from = location.state?.from?.pathname || '/dashboard'

  function handleLogin(event) {
    event.preventDefault()
    const formData = new FormData(event.target)
    const username = formData.get('username')

    // 模拟登录请求
    fakeLogin(username).then(user => {
      if (user.role === 'admin') {
        navigate('/admin', { replace: true })     // 替换记录,不能后退到登录页
      } else {
        navigate(from, { replace: true })          // 跳转到登录前试图访问的页面
      }
    })
  }

  // 登录表单
  return (
    <form onSubmit={handleLogin}>
      <input name="username" placeholder="用户名" required />
      <button type="submit">登录</button>
      <button type="button" onClick={() => navigate(-1)}>返回</button>
    </form>
  )
}

// 模拟登录 API
async function fakeLogin(username) {
  await new Promise(r => setTimeout(r, 500))
  return { name: username, role: username === 'admin' ? 'admin' : 'user' }
}
▶ 试一试

useLocation 的 role: location.state 可以从 Link 或 navigate 传递过来的状态数据。例如,在 ProtectedRoute 中重定向到登录页时,可以把用户原本要访问的页面路径存在 state 中,登录后自动跳转回去。

(2) 路由守卫:ProtectedRoute

守卫类型 检查逻辑 不通过时行为 典型场景
登录守卫 isAuthenticated 重定向到 /login 仪表盘、个人中心
权限守卫 user.role === 'admin' 重定向到 /403 管理后台
功能开关守卫 featureFlags.xEnabled 重定向到 /upgrade 付费功能
条件守卫 profileComplete 重定向到 /onboarding 首次登录引导

Tom 的仪表盘页面需要登录才能访问,管理后台需要 admin 角色才能访问。他需要一种"守卫"机制——在渲染页面之前先检查用户状态,不满足条件就重定向。

路由守卫的本质是一个包装组件:它接收子组件,在渲染前执行检查逻辑。如果检查通过,渲染子组件;如果不通过,用 <Navigate> 重定向到其他页面。

▶ 示例 2:多层路由守卫

JSX 📖 仅展示
import { Navigate, useLocation } from 'react-router-dom'

// 模拟认证 Hook
function useAuth() {
  return {
    user: { name: 'Tom', role: 'admin' },  // 实际项目中从 Context 或 store 获取
    isAuthenticated: true
  }
}

// 第一层:登录守卫
function ProtectedRoute({ children }) {
  const { isAuthenticated } = useAuth()
  const location = useLocation()

  if (!isAuthenticated) {
    // 把用户想去的位置存到 state 中,登录后跳转回来
    return <Navigate to="/login" state={{ from: location }} replace />
  }

  return children
}

// 第二层:角色守卫
function AdminRoute({ children }) {
  const { user } = useAuth()

  if (user.role !== 'admin') {
    return <Navigate to="/403" replace />
  }

  return children
}

// 路由配置中使用
function AppRoutes() {
  return (
    <Routes>
      <Route path="/" element={<Home />} />
      <Route path="/login" element={<LoginPage />} />
      <Route path="/403" element={<AccessDenied />} />

      {/* 需要登录 */}
      <Route path="/dashboard" element={
        <ProtectedRoute>
          <Dashboard />
        </ProtectedRoute>
      } />

      {/* 需要登录 + admin 角色 */}
      <Route path="/admin" element={
        <ProtectedRoute>
          <AdminRoute>
            <AdminPanel />
          </AdminRoute>
        </ProtectedRoute>
      } />
    </Routes>
  )
}
逻辑代码 45 行(超过 40 行限制,仅展示)

守卫嵌套模式: 外层 ProtectedRoute 检查"是否登录",内层 AdminRoute 检查"是否有 admin 权限"。职责分离,可复用。如果以后需要"编辑"角色,只需增加一个 EditorRoute 即可。

(3) 懒加载:React.lazy + Suspense

随着应用增长,Tom 发现首页加载时间越来越长——因为所有页面的代码都在一个 bundle 中,用户访问首页也需要下载管理后台的代码。React.lazy 允许将组件代码拆分成独立的 chunk,只在需要时才加载。

JSX
import { lazy, Suspense } from 'react'

// 这些组件不会被打包到主 bundle 中
const Dashboard = lazy(() => import('./pages/Dashboard'))
const Settings = lazy(() => import('./pages/Settings'))
const AdminPanel = lazy(() => import('./pages/AdminPanel'))
const UserList = lazy(() => import('./pages/UserList'))
▶ 试一试

▶ 示例 3:路由级别的懒加载

JSX
import { lazy, Suspense } from 'react'
import { BrowserRouter, Routes, Route } from 'react-router-dom'

// 懒加载所有页面组件
const Home = lazy(() => import('./pages/Home'))
const Dashboard = lazy(() => import('./pages/Dashboard'))
const ProductList = lazy(() => import('./pages/ProductList'))
const ProductDetail = lazy(() => import('./pages/ProductDetail'))
const Settings = lazy(() => import('./pages/Settings'))
const NotFound = lazy(() => import('./pages/NotFound'))

// 加载中组件
function PageLoader() {
  return (
    <div style={{
      display: 'flex', justifyContent: 'center', alignItems: 'center',
      height: '100vh', fontSize: '1.2rem', color: '#666'
    }}>
      <div className="spinner" />
      <span style={{ marginLeft: '12px' }}>页面加载中...</span>
    </div>
  )
}

function App() {
  return (
    <BrowserRouter>
      {/* Suspense 包裹所有懒加载路由 */}
      <Suspense fallback={<PageLoader />}>
        <Routes>
          <Route path="/" element={<Home />} />
          <Route path="/dashboard" element={<Dashboard />} />
          <Route path="/products" element={<ProductList />} />
          <Route path="/products/:id" element={<ProductDetail />} />
          <Route path="/settings" element={<Settings />} />
          <Route path="*" element={<NotFound />} />
        </Routes>
      </Suspense>
    </BrowserRouter>
  )
}

export default App
▶ 试一试

懒加载的工作原理: 当用户首次访问 /dashboard 时,React 才会动态加载 Dashboard.js 这个 chunk。加载期间,Suspense 的 fallback 组件会显示。加载完成后,替换成真正的 Dashboard 组件。

性能收益: 假设原 bundle 为 500KB,懒加载后主 bundle 降到 100KB,每个页面约 80KB。用户访问首页只需下载 100KB,而非 500KB——首屏加载速度提升约 5 倍。



4. useSearchParams:URL 查询参数管理

URL 参数方式 API 语法示例 适用数据
路径参数 useParams() /products/:id{ id: '42' } 必需的标识符(资源 ID)
查询参数 useSearchParams() ?sort=price&page=2 可选的筛选/排序/分页
Hash useLocation().hash #section-3 页内锚点定位
State useLocation().state navigate('/path', { state }) 跨页面传递隐藏数据

Tom 的商品列表页需要支持分类筛选、价格排序、分页等功能,而且这些筛选条件要体现在 URL 中——这样用户可以把筛选后的链接分享给其他人。

(1) 查询参数的读写

useSearchParams 返回一个类 Map 的对象,可以像操作 Map 一样读写 URL 查询参数。它和 useState 类似,返回一个值和一个 setter。

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

function ProductList() {
  const [searchParams, setSearchParams] = useSearchParams()

  const category = searchParams.get('category') || '全部'
  const sort = searchParams.get('sort') || 'default'
  const page = Number(searchParams.get('page')) || 1

  function updateFilter(key, value) {
    setSearchParams(prev => {
      if (value) prev.set(key, value)
      else prev.delete(key)
      return prev
    })
  }

  return (
    <div>
      <p>当前分类:{category} | 排序:{sort} | 页码:{page}</p>
      <button onClick={() => updateFilter('category', '电子')}>电子分类</button>
      <button onClick={() => updateFilter('sort', 'price')}>按价格排序</button>
      <button onClick={() => updateFilter('page', String(page + 1))}>下一页</button>
      <button onClick={() => setSearchParams({})}>清除筛选</button>
    </div>
  )
}
// URL 会实时更新:/products?category=电子&sort=price&page=2
▶ 试一试

▶ 示例 4:完整的商品筛选功能

JSX 📖 仅展示
import { useSearchParams } from 'react-router-dom'

// 模拟商品数据
const allProducts = [
  { id: 1, name: 'iPhone 16', category: '电子', price: 6999 },
  { id: 2, name: 'React 编程书', category: '图书', price: 79 },
  { id: 3, name: '机械键盘', category: '电子', price: 399 },
  { id: 4, name: '设计模式入门', category: '图书', price: 59 },
  { id: 5, name: '蓝牙耳机', category: '电子', price: 899 },
]

function ProductListPage() {
  const [searchParams, setSearchParams] = useSearchParams()

  // 从 URL 读取筛选条件
  const category = searchParams.get('category') || ''
  const sortBy = searchParams.get('sort') || 'name'
  const page = parseInt(searchParams.get('page') || '1', 10)
  const pageSize = 3

  // 筛选
  let filtered = category
    ? allProducts.filter(p => p.category === category)
    : allProducts

  // 排序
  if (sortBy === 'price') {
    filtered = [...filtered].sort((a, b) => a.price - b.price)
  } else {
    filtered = [...filtered].sort((a, b) => a.name.localeCompare(b.name))
  }

  // 分页
  const totalPages = Math.ceil(filtered.length / pageSize)
  const paged = filtered.slice((page - 1) * pageSize, page * pageSize)

  // 更新筛选条件
  function setFilter(key, value) {
    setSearchParams(prev => {
      const next = new URLSearchParams(prev)
      if (value) {
        next.set(key, value)
      } else {
        next.delete(key)
      }
      next.set('page', '1')  // 切换筛选时回到第一页
      return next
    })
  }

  return (
    <div>
      <div style={{ marginBottom: '16px' }}>
        <label>分类:
          <select value={category} onChange={e => setFilter('category', e.target.value)}>
            <option value="">全部</option>
            <option value="电子">电子</option>
            <option value="图书">图书</option>
          </select>
        </label>
        <label style={{ marginLeft: '16px' }}>排序:
          <select value={sortBy} onChange={e => setFilter('sort', e.target.value)}>
            <option value="name">名称</option>
            <option value="price">价格</option>
          </select>
        </label>
      </div>

      <ul>
        {paged.map(p => (
          <li key={p.id}>{p.name} — ${p.price}({p.category})</li>
        ))}
      </ul>

      <div>
        {Array.from({ length: totalPages }, (_, i) => (
          <button
            key={i}
            onClick={() => setSearchParams(prev => {
              const next = new URLSearchParams(prev)
              next.set('page', String(i + 1))
              return next
            })}
            style={{ fontWeight: page === i + 1 ? 'bold' : 'normal' }}
          >
            {i + 1}
          </button>
        ))}
      </div>

      <p>当前 URL:/products?category={category}&sort={sortBy}&page={page}</p>
    </div>
  )
}
逻辑代码 77 行(超过 40 行限制,仅展示)

关键词: URL 查询参数是"可共享的状态"。用户筛选后复制 URL 发给同事,同事打开看到的筛选结果完全一致。这是用 useState 管理筛选条件做不到的。



5. 路由配置的组织策略

当项目路由增加到几十个时,把所有 Route 写在一个组件中会让代码难以维护。Tom 需要将路由配置抽取成独立模块。

(1) 路由配置文件

JSX
// src/routes/index.js
import { lazy } from 'react'

// 集中管理所有路由定义
const routes = [
  {
    path: '/',
    component: lazy(() => import('../pages/Home')),
    exact: true
  },
  {
    path: '/login',
    component: lazy(() => import('../pages/Login')),
  },
  {
    path: '/dashboard',
    component: lazy(() => import('../pages/Dashboard')),
    protected: true  // 需要登录
  },
  {
    path: '/admin',
    component: lazy(() => import('../pages/AdminPanel')),
    protected: true,
    adminOnly: true   // 需要 admin 权限
  },
  {
    path: '/products',
    component: lazy(() => import('../pages/ProductList')),
  },
  {
    path: '/products/:id',
    component: lazy(() => import('../pages/ProductDetail')),
  },
  {
    path: '*',
    component: lazy(() => import('../pages/NotFound')),
  }
]

export default routes
▶ 试一试

▶ 示例 5:路由渲染器

JSX
// src/routes/AppRouter.jsx
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
import { Suspense } from 'react'
import routes from './index'
import ProtectedRoute from '../components/ProtectedRoute'
import AdminRoute from '../components/AdminRoute'

function renderRoutes(routeList) {
  return routeList.map(route => {
    const Component = route.component

    let element = <Component />

    // 按需包裹守卫
    if (route.protected) {
      element = <ProtectedRoute>{element}</ProtectedRoute>
    }
    if (route.adminOnly) {
      element = <AdminRoute>{element}</AdminRoute>
    }

    return (
      <Route key={route.path} path={route.path} element={element} />
    )
  })
}

function AppRouter() {
  return (
    <BrowserRouter>
      <Suspense fallback={<div>加载中...</div>}>
        <Routes>
          {renderRoutes(routes)}
        </Routes>
      </Suspense>
    </BrowserRouter>
  )
}

export default AppRouter
▶ 试一试

这种模式的好处是:路由配置集中在一处,守卫逻辑自动包裹,新增页面只需在 routes/index.js 中添加一个对象即可。

(2) useLocation 监听路由变化

除了导航,Tom 还需要在某些场景下监听路由变化——例如页面路径变更时发送埋点数据、关闭弹窗、重置表单状态。useLocation 可以获取当前 URL 信息,配合 useEffect 监听路径变化。

JSX
import { useLocation } from 'react-router-dom'
import { useEffect } from 'react'

function PageTracker() {
  const location = useLocation()

  useEffect(() => {
    // 每次路径变化时触发
    console.log('页面访问:', location.pathname + location.search)

    // 埋点上报
    analytics.pageView({
      path: location.pathname,
      search: location.search,
      timestamp: Date.now()
    })
  }, [location])  // 依赖 location 对象
  // 注意:location.pathname 或 location.search 任意一个变化都会触发

  return null  // 这个组件不渲染任何 UI
}

// 在 App 中使用
function App() {
  return (
    <BrowserRouter>
      <PageTracker />  {/* 放在 Routes 外部,始终监听 */}
      <Routes>
        <Route path="/" element={<Home />} />
        <Route path="/dashboard" element={<Dashboard />} />
      </Routes>
    </BrowserRouter>
  )
}
▶ 试一试

useLocation 返回的关键属性: pathname(路径,如 /products/42)、search(查询字符串,如 ?category=电子)、hash(URL hash,如 #section-2)、state(通过 Link 或 navigate 传递的状态数据)。


❓ 常见问题

Q useNavigate 和 Link 怎么选择?
A Link 用于用户点击触发的导航(导航栏、面包屑、文章列表中的链接)。useNavigate 用于代码逻辑触发的导航(登录后跳转、表单提交后跳转、定时跳转、后退/前进)。基本原则:用户交互 → Link,代码逻辑 → useNavigate。
Q 路由守卫可以嵌套多层吗?有什么注意事项?
A 可以。常见模式是外层 ProtectedRoute 检查"是否登录",内层 AdminRoute 检查"是否有 admin 权限"。嵌套时注意每层守卫的职责要单一,不要在一个守卫中同时检查登录和角色。如果守卫过多,可以考虑用配置数组 + 循环渲染的方式自动包裹多层守卫。
Q React.lazy 懒加载有什么限制?
A 三点限制:(1) 只能用于默认导出(export default)的组件;(2) 必须在 Suspense 组件内部使用;(3) 不支持服务端渲染(SSR 需要用 @loadable/component 替代)。另外,懒加载组件加载期间如果网络慢,用户会看到 fallback 内容,建议 fallback 设计得小而快。
Q useSearchParams 和 useState 管理筛选条件有什么区别?
A useSearchParams 将状态同步到 URL——用户可分享链接、可收藏、可前进后退。useState 的状态只在内存中,刷新页面就丢失。但 useSearchParams 有性能开销(URL 变化会触发组件重渲染),高频更新场景(如拖动滑块)建议先用 useState,确认时再同步到 URL。
Q 路由守卫和中间件有什么区别?
A 路由守卫是 React 组件层面的拦截——在目标组件外面包一层,检查条件后决定渲染组件还是重定向。中间件是 Next.js 服务端层面的拦截——请求到达服务器后、页面渲染前执行逻辑(如鉴权、重定向、A/B 测试)。React Router 的守卫运行在浏览器端,Next.js 的 Middleware 运行在 Edge Runtime 服务端。

📖 小节


📝 作业

  1. 实现一个登录页面:用户输入用户名后,用 useNavigate 跳转到仪表盘,并在跳转时用 replace: true 防止后退到登录页。
  2. 用 ProtectedRoute 保护仪表盘路由,未登录时跳转到登录页,并在登录成功后自动跳转到用户原先想访问的地址。
  3. 用 useSearchParams 实现一个商品列表页:支持分类筛选、价格排序、分页,筛选条件反映在 URL 中,页面刷新后筛选条件不丢失。
Web-Tutorial.com

Web-Tutorial 技术团队

由多位开发者共同维护的编程教程平台。每篇教程由对应领域的开发者编写和审核,确保内容准确可靠。如发现任何问题,欢迎向我们反馈。

100%

🙏 帮我们做得更好

我们是刚上线的编程教程站,几个人的小团队,精力有限。页面虽经检查,难免还有疏漏——链接失效、排版错乱、内容有误、语言生硬……

如果您发现了,麻烦告诉我们,我们会在收到反馈后第一时间进行修复,再次感谢您的光临 🙏