常见陷阱与最佳实践
本章汇总 TanStack Router 开发中常见的陷阱和最佳实践,按类别整理以便速查。
安装与配置
| 陷阱 | 说明 | 正确做法 |
|---|---|---|
| 🚨 Vite 插件顺序错误 | react 插件在 tanstackRouter 之前会导致 routeTree.gen.ts 无法生成 |
tanstackRouter() 必须放在 react() 之前 |
| 🚨 忘记类型注册 | 没有 declare module '@tanstack/react-router' 则所有 Hook 失去类型推断 |
在 router.tsx 中完成 Register 接口声明 |
🚨 Zod v3 .catch() 而非 fallback() |
.catch() 使输出类型变为 unknown,破坏类型安全 |
使用 @tanstack/zod-adapter 的 fallback() |
| 💡 启用 autoCodeSplitting | 新项目直接从第一天就拆分代码 | tanstackRouter({ autoCodeSplitting: true }) |
路由定义
| 陷阱 | 说明 | 正确做法 |
|---|---|---|
| 🚨 路径参数冲突 | posts/new.tsx 和 posts/$postId.tsx 静态段优先,"new" 永远不会作为 postId |
使用不同的路径结构或搜索参数区分 |
| 🚨 可选路径参数 | TanStack Router 不支持 /:param? |
用搜索参数代替可选路径参数 |
| 🚨 路径无关布局的 URL 误解 | _auth/admin/users.tsx 的 URL 是 /users,不是 /admin/users |
理解 _ 前缀不参与 URL 构建 |
| 💡 虚拟路由减负 | loader/beforeLoad 简单的路由可以全用 .lazy.tsx |
删除主文件,让插件自动生成虚拟路由 |
导航与链接
| 陷阱 | 说明 | 正确做法 |
|---|---|---|
| 🚨 对象替换搜索参数 | <Link search={{ page: 2 }}> 会丢失所有其他搜索参数 |
始终用函数式更新:search={(prev) => ({ ...prev, page: 2 })} |
🚨 <Link> 用于外部 URL |
to="https://github.com" 会被当作内部路由 |
外部链接用原生 <a> 标签 |
🚨 state 不可持久化 |
navigate({ state: ... }) 在页面刷新后丢失 |
需要持久化的状态用搜索参数 |
💡 始终提供 from |
Link 和 useNavigate 提供 from 以获得最精确的类型推断 |
from="/current-route" |
搜索参数
| 陷阱 | 说明 | 正确做法 |
|---|---|---|
🚨 useSearch 标注返回类型 |
手动类型标注会覆盖推断,导致与实际验证不一致 | 永远不要标注 useSearch() 的返回值类型 |
🚨 父路由没有 validateSearch |
子路由无法继承搜索参数 | 需要跨路由共享的参数必须在祖先路由中定义 validateSearch |
| 🚨 loaderDeps 传入整个 search | 任何搜索参数变化都会触发 loader 重新执行 | loaderDeps 只选择 loader 真正依赖的字段 |
💡 用 stripSearchParams 清理 URL |
默认值不应出现在 URL 中 | search: { middlewares: [stripSearchParams(defaults)] } |
💡 用 retainSearchParams 保持参数 |
如 debug、theme 等应跨导航保留 |
search: { middlewares: [retainSearchParams(['debug'])] } |
Loader 与数据加载
| 陷阱 | 说明 | 正确做法 |
|---|---|---|
| 🚨 beforeLoad 中做耗时操作 | beforeLoad 是串行的,会阻塞所有子路由的导航 | beforeLoad 只做守卫,耗时数据在 loader 中并行加载 |
| 🚨 子路由直接访问父 loader 数据 | 子 loader 无法访问父 loader 的返回值 | 需要共享数据时,在父 beforeLoad 获取并放入 context |
| 🚨 Loader 错误未被 Error Boundary 捕获 | Loader 错误只能通过路由的 errorComponent 处理 |
每个路由都应定义 errorComponent |
💡 使用 abortController.signal |
避免组件卸载后的无效请求 | fetch(url, { signal: abortController.signal }) |
| 💡 合理设置 staleTime | 默认值可能导致数据过频或过旧 | 根据数据变化频率设定:高频 5-15s,稳定 30s-数分钟 |
认证与守卫
| 陷阱 | 说明 | 正确做法 |
|---|---|---|
| 🚨 只在组件内检查认证 | 受保护页面会短暂闪现后再跳转 | 始终在 beforeLoad 中进行认证检查 |
🚨 context.auth 初始化为 undefined! |
忘记这个断言会导致类型错误 | createRouter({ context: { auth: undefined! } }) |
| 🚨 混淆 redirect 和真实错误 | 代码中的 try/catch 可能误吞 redirect() |
使用 isRedirect(err) 区分并透传 redirect |
| 💡 三层防御 | 仅靠前端守卫不够 | 路由守卫 + UI 条件渲染 + API 鉴权 |
| 💡 登录后回跳 | 用户登录后应返回原页面 | redirect({ search: { redirect: location.href } }) |
嵌套与布局
| 陷阱 | 说明 | 正确做法 |
|---|---|---|
🚨 布局路由忘记 <Outlet> |
子路由组件不会渲染 | 每个布局路由都应有 <Outlet> |
| 🚨 多层布局的 beforeLoad 性能 | 每层 beforeLoad 都串行,多层叠加延迟明显 | 只在必要的层级设置 beforeLoad |
| 💡 布局路由放共享 UI | 侧边栏、Tab 导航等不变的部分放布局路由 | 随子路由变化的部分放子路由组件 |
代码分割
| 陷阱 | 说明 | 正确做法 |
|---|---|---|
| 🚨 分割 Loader | Loader 已是异步的,再分割会引入双重延迟 | 只分割 component/errorComponent,除非 loader 超大(10KB+) |
🚨 所有路由都手动 .lazy.tsx |
增加维护负担 | 用 autoCodeSplitting: true 替代手动拆分 |
| 💡 配合预加载 | 懒加载 + intent 预加载 = 用户几乎感知不到延迟 | defaultPreload: 'intent' |
类型安全
| 陷阱 | 说明 | 正确做法 |
|---|---|---|
🚨 不提供 from |
Link 和 useNavigate 的类型推断不够精确 |
始终提供 from 指明当前路由 |
🚨 用 as 类型断言 |
绕过类型检查,可能隐藏真实错误 | 修复类型定义而非绕过它们 |
💡 活用 getRouteApi |
跨组件访问路由的 loader params search 时保持类型安全 | const api = getRouteApi('/path') |
性能
| 建议 | 说明 |
|---|---|
| ⚡ 启用 autoCodeSplitting | 减少初始包体积约 50% |
⚡ 合理使用 preload: 'intent' |
在用户可能点击前预加载数据 |
⚡ 精确设置 loaderDeps |
避免无关搜索参数变化触发 loader |
⚡ 使用 abortController |
取消组件卸载后的无效请求 |
⚡ 避免 beforeLoad 中的网络请求 |
它会阻塞导航(串行),改用 loader(并行) |
最佳实践清单
项目初始化
- Vite 插件顺序正确(tanstackRouter 在 react 之前)
- 完成
declare module Register类型注册 - 启用
autoCodeSplitting: true - 安装并配置 Devtools
路由设计
- 用路径参数标识资源 ID,搜索参数控制展示方式
- 用路径无关布局(
_前缀)组织认证和守卫 - 每个布局路由都有
<Outlet> - 每个路由都有
errorComponent(至少全局的) - 参数
parse/stringify双向可逆
搜索参数
- 用
validateSearch(推荐 Zod +zod-adapter)验证所有搜索参数 - 导航时用函数式更新器保留现有参数
- 用
loaderDeps精确控制 loader 触发条件 - 用
stripSearchParams移除 URL 中的默认值
数据加载
- Loader 中传递
abortController.signal - 设置合理的
staleTime -
pendingComponent处理加载状态 - Loader 内部使用
notFound()处理资源不存在
认证
- Auth Context → Router Context → beforeLoad 三层模型
- beforeLoad 中进行认证检查,不只在组件中
- 使用
isRedirect()区分 redirect 和真实错误 - 登录页支持回跳(
search: { redirect: location.href })
代码质量
- 始终给
Link和useNavigate提供from - 不标注
useSearch()/useParams()的返回类型 - 外部链接用
<a>,不用<Link> - 定期检查构建产物大小(rollup-plugin-visualizer)
生产检查清单
-
autoCodeSplitting已启用,构建产物合理 - 所有路由的
errorComponent已定义 - Devtools 在生产环境关闭(或仅 staging 内部开放)
-
beforeLoad中无耗时网络请求 - 搜索参数默认值已通过
stripSearchParams清理 - 认证守卫覆盖所有需要保护的页面
- API 请求通过
abortController.signal取消 -
staleTime和preloadStaleTime基于业务需求合理配置 - 外部链接使用
<a>标签,下载链接使用download属性 - 路由类型已正确注册,
npm run build通过类型检查