Skip to content

15 · 路由与数据加载 ​

本篇解决什么问题: 路由不只是"切页面",它决定URL 怎么设计、数据什么时候取、竞态怎么防、权限在哪拦。 本篇聚焦实操决策,尤其是最容易出 bug 的请求竞态与数据预取。

📌 核心认知:URL 是应用状态的一部分——能放 URL 的状态就要放,这样才可分享、可回退、可刷新。


开篇速览 ​

主题关键决策
history vs hash用哪个模式,服务端要配什么
路由守卫权限拦截放哪一层,为什么不能只靠前端
数据加载时机导航前(阻塞)/ 导航后(先渲染骨架)
请求竞态快速切换为什么会显示旧数据(本篇重点)
懒加载按路由分包,配合预取

一、history 模式 vs hash 模式 ​

维度history(/ /user/1)hash(#/user/1)
URL 美观✅❌ 有个 #
SEO✅ 更好⚠️ 传统上较差
服务端要求❗ 必须配置 fallback(否则刷新 404)✅ 无需配置
部署复杂度需要 Nginx/CDN 配合最低

history 模式的服务端配置(必做) ​

nginx
# Nginx:所有路径回退到 index.html
location / {
  try_files $uri $uri/ /index.html;
}
json
// Cloudflare Pages:_redirects 文件
/*  /index.html  200

⚠️ 不做这步,history 模式刷新任意子路由都会 404——这是最经典的部署事故。 详见 A16 · CI/CD 与部署运维。

选型:能用 history 就用 history(体验与 SEO 更好),代价是部署时要配 fallback。


二、路由守卫:权限拦截 ​

2.1 三层拦截(缺一不可) ​

层作用能否绕过
① 服务端鉴权(接口层)真正的权限边界❌ 不可绕过
② 路由守卫(前端)阻止进入无权页面、重定向⚠️ 可绕过(改 JS)
③ 组件/按钮级隐藏无权操作⚠️ 可绕过

🚨 最重要的一条:前端守卫只是"用户体验",不是"安全"。 真正的权限校验必须在服务端每个接口上做。前端藏了按钮,攻击者直接调接口照样成功。

2.2 标准写法 ​

tsx
// React Router v6
function RequireAuth({ children }: { children: ReactNode }) {
  const { user, loading } = useAuth()
  const location = useLocation()

  if (loading) return <Skeleton/>                     // ✅ 鉴权未完成时不要误判为无权限
  if (!user) return <Navigate to="/login" state={{ from: location }} replace/>
  return children
}
js
// Vue Router
router.beforeEach((to, from, next) => {
  if (to.meta.requiresAuth && !store.user) next({ name: 'login', query: { redirect: to.fullPath } })
  else next()
})

要点:

  • 登录后回跳原页面(state.from / query.redirect)
  • 鉴权 loading 状态必须单独处理,否则刷新时会闪一下登录页
  • 权限数据不要只存在 localStorage(可被篡改),要服务端校验

三、数据加载时机:三种策略 ​

策略流程体验适用
导航后加载(Fetch-on-Render)先进页面 → 组件 useEffect 里请求先看到骨架/loading,再出内容默认选择,简单可靠
导航前加载(Fetch-then-Render)路由守卫里 await 数据 → 再进页面点击后有等待,进页面即完整内容页、避免闪烁
渲染即取(Render-as-You-Fetch)进入路由时同时发起请求与渲染组件两者并行,最快React Router 6.4 loader / Remix / Suspense
tsx
// React Router 6.4+ loader:导航与请求并行
const router = createBrowserRouter([{
  path: '/user/:id',
  loader: async ({ params }) => fetch(`/api/user/${params.id}`),  // 提前发起
  Component: UserPage,
  errorElement: <ErrorPage/>,   // ✅ 统一的错误边界
}])
function UserPage() {
  const user = useLoaderData()   // 数据已就绪
  return <Profile user={user}/>
}

💡 并行是关键:传统"组件挂载后才发请求"会造成请求瀑布(父组件取完才知道子组件要取什么)。 loader 在路由匹配时就发起,与组件代码下载、渲染并行。


四、请求竞态:最容易踩的坑 ⭐ ​

4.1 问题现象 ​

用户在 Tab 间快速切换(或搜索框连续输入),先发的请求后返回,把新数据覆盖成旧数据:

切换 A → 请求 A(慢)
切换 B → 请求 B(快)  → 显示 B ✅
请求 A 返回           → 显示 A ❌ 界面显示的是 A 的内容,但 URL 是 B

4.2 三种解法 ​

① AbortController 取消(推荐)

tsx
useEffect(() => {
  const controller = new AbortController()
  fetch(`/api/user/${id}`, { signal: controller.signal })
    .then(r => r.json())
    .then(setUser)
    .catch(e => { if (e.name !== 'AbortError') throw e })  // ✅ 忽略主动取消
  return () => controller.abort()   // 卸载/id 变化时取消上一个请求
}, [id])

② 忽略失效响应(序号法)

tsx
const reqIdRef = useRef(0)
async function load(id) {
  const myId = ++reqIdRef.current
  const data = await fetchUser(id)
  if (myId !== reqIdRef.current) return   // ✅ 不是最新的一次,丢弃
  setUser(data)
}

③ 交给数据层(最省事)

用 TanStack Query / SWR,它们内置了 key 隔离、取消、去重与竞态处理:

tsx
const { data } = useQuery({ queryKey: ['user', id], queryFn: () => fetchUser(id) })
// id 变化时自动隔离缓存,不会出现串数据

🎯 结论:能用 TanStack Query 就别手写;必须手写时优先 AbortController。 详见 A07 · 状态管理演进与选型(服务端状态不该进 Redux)。

4.3 顺带:防抖与竞态要配合 ​

搜索场景既要防抖(减少请求)也要取消/忽略(防止乱序),两者解决不同问题。防抖写法见 A18。


五、懒加载与预取 ​

5.1 按路由分包 ​

tsx
const UserPage = lazy(() => import('./pages/User'))
<Suspense fallback={<Skeleton/>}><UserPage/></Suspense>

效果:首屏只下载当前路由的代码,其余按需下载(配合 A21 的 Code Splitting)。

5.2 预取(Prefetch):消除"点了才下载"的等待 ​

tsx
// 鼠标悬停时预取(React Router 6.4+)
<Link to="/user/1" onMouseEnter={() => router.preload('/user/1')}>用户</Link>

// 或视口内预取
<Link prefetch="intent" to="/user/1"/>

💡 权衡:预取多用流量(尤其移动端)。建议仅对高概率点击的入口做,或仅在 WiFi 下做。


六、URL 状态设计原则 ​

该放 URL 的:页面标识、详情 ID、筛选条件、分页、排序、Tab、搜索关键词。 不该放的:弹窗开关(临时 UI 态)、表单草稿、滚动位置。

tsx
// 把筛选条件同步到 query string
const [params, setParams] = useSearchParams()
const keyword = params.get('q') ?? ''
const page = Number(params.get('page') ?? 1)
setParams({ q: keyword, page: String(page + 1) })

收益:链接可分享、刷新状态不丢、浏览器前进/后退语义正确、便于埋点分析。


自检清单 ​

  • [ ] 能说清 history 模式为什么必须配服务端 fallback,并会写 Nginx/Cloudflare 配置
  • [ ] 能说出前端路由守卫不是安全边界,真正的鉴权在服务端
  • [ ] 能区分三种数据加载时机,并知道 loader 为什么更快(并行)
  • [ ] 能复现竞态问题,并给出 AbortController / 序号法 / 数据层三种解法
  • [ ] 能说出哪些状态该放进 URL

常见误区 ​

❌ 误区✅ 事实
"history 模式配好前端就行"服务端必须配 fallback,否则刷新 404
"路由守卫拦住了就安全"前端可绕过;服务端接口鉴权才是边界
"接口返回慢才会竞态"任何返回顺序不确定的场景都会,与快慢无关
"弹窗状态也要同步到 URL"临时 UI 态不该进 URL(否则污染历史记录)
"懒加载一定更快"分包过碎会导致请求数增加;要配合预取

动手练习 ​

  1. 把项目部署到静态托管,不配 fallback,刷新子路由复现 404,然后配上 _redirects 修好。
  2. 写一个快速切换 Tab 的最小复现,观察"旧数据覆盖新数据",用 AbortController 修复。
  3. 把列表页的筛选/分页/排序全部同步到 query string,验证刷新与分享链接后状态一致。

相关文档 ​