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:所有路径回退到 index.html
location / {
try_files $uri $uri/ /index.html;
}// Cloudflare Pages:_redirects 文件
/* /index.html 200⚠️ 不做这步,history 模式刷新任意子路由都会 404——这是最经典的部署事故。 详见 A16 · CI/CD 与部署运维。
选型:能用 history 就用 history(体验与 SEO 更好),代价是部署时要配 fallback。
二、路由守卫:权限拦截
2.1 三层拦截(缺一不可)
| 层 | 作用 | 能否绕过 |
|---|---|---|
| ① 服务端鉴权(接口层) | 真正的权限边界 | ❌ 不可绕过 |
| ② 路由守卫(前端) | 阻止进入无权页面、重定向 | ⚠️ 可绕过(改 JS) |
| ③ 组件/按钮级 | 隐藏无权操作 | ⚠️ 可绕过 |
🚨 最重要的一条:前端守卫只是"用户体验",不是"安全"。 真正的权限校验必须在服务端每个接口上做。前端藏了按钮,攻击者直接调接口照样成功。
2.2 标准写法
// 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
}// 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 |
// 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 是 B4.2 三种解法
① AbortController 取消(推荐)
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])② 忽略失效响应(序号法)
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 隔离、取消、去重与竞态处理:
const { data } = useQuery({ queryKey: ['user', id], queryFn: () => fetchUser(id) })
// id 变化时自动隔离缓存,不会出现串数据🎯 结论:能用 TanStack Query 就别手写;必须手写时优先
AbortController。 详见 A07 · 状态管理演进与选型(服务端状态不该进 Redux)。
4.3 顺带:防抖与竞态要配合
搜索场景既要防抖(减少请求)也要取消/忽略(防止乱序),两者解决不同问题。防抖写法见 A18。
五、懒加载与预取
5.1 按路由分包
const UserPage = lazy(() => import('./pages/User'))
<Suspense fallback={<Skeleton/>}><UserPage/></Suspense>效果:首屏只下载当前路由的代码,其余按需下载(配合 A21 的 Code Splitting)。
5.2 预取(Prefetch):消除"点了才下载"的等待
// 鼠标悬停时预取(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 态)、表单草稿、滚动位置。
// 把筛选条件同步到 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(否则污染历史记录) |
| "懒加载一定更快" | 分包过碎会导致请求数增加;要配合预取 |
动手练习
- 把项目部署到静态托管,不配 fallback,刷新子路由复现 404,然后配上
_redirects修好。 - 写一个快速切换 Tab 的最小复现,观察"旧数据覆盖新数据",用
AbortController修复。 - 把列表页的筛选/分页/排序全部同步到 query string,验证刷新与分享链接后状态一致。
相关文档
- 部署侧的 fallback 与缓存 → A16 · CI/CD 与部署运维
- 服务端状态管理(推荐 TanStack Query)→ A07 · 状态管理演进与选型
- 防抖节流实现 → A18 · 手写题与代码设计
- 分包与 Tree Shaking → A21 · 构建工具原理