概述
TanStack Router 的 Router Mask 是一个强大但相对隐蔽的功能,它允许开发者在保持 URL 语义化的同时,实现复杂的路由交互模式。本文将深入探讨这一功能的实现原理、使用方法和最佳实践。
什么是 Router Mask?
Router Mask(路由掩码)是 TanStack Router 提供的一种特殊路由机制,它允许你:
- 显示不同的 URL:用户在地址栏看到的 URL 与实际渲染的组件路由不同
- 实现模态路由:点击链接时显示模态框,但 URL 变为可分享的详情页地址
- 保持状态一致性:在模态和完整页面之间无缝切换
- SEO 友好:每个内容都有独立的 URL,便于分享和索引
核心概念
文件结构设计
在 file-based 路由系统中,Router Mask 需要特定的文件结构:
bash
src/routes/
├── templates.tsx # 列表页面(父路由)
├── templates.{$id}_modal.tsx # 模态路由(实际渲染)
└── templates_.$id.tsx # 详情页面(URL 显示)
路由映射关系
文件名 | 路由路径 | 用途 |
---|---|---|
templates.tsx |
/templates |
模板列表页,包含 <Outlet /> |
templates.{$id}_modal.tsx |
/templates/{$id}_modal |
模态组件,实际渲染的路由 |
templates_.$id.tsx |
/templates_/$id |
详情页,URL 中显示的路径 |
🔍 官方文件命名约定详解 0
这是 TanStack Router file-based 路由系统的核心设计。让我们通过生成的 routeTree.gen.ts
来理解:
dart
// 生成的路由树代码片段
const TemplatesIdRoute = TemplatesIdRouteImport.update({
id: '/templates_/$id', // 文件 ID(保持下划线)
path: '/templates/$id', // 实际 URL 路径(转换为斜杠)
getParentRoute: () => rootRouteImport,
} as any)
const TemplatesChar123idChar125_modalRoute = TemplatesChar123idChar125_modalRouteImport.update({
id: '/templates/{$id}_modal', // 文件 ID(保持花括号和下划线)
path: '/{$id}_modal', // 相对路径
getParentRoute: () => TemplatesRoute, // 父路由是 templates
} as any)
官方命名规则详解
-
点号
.
分隔符 0blog.post.tsx
→/blog/post
- 表示嵌套路由关系,post 是 blog 的子路由
-
$
标识符 0posts.$postId.tsx
→/posts/$postId
- 带有 $ 标识符的路由段会被参数化,从 URL 路径名中提取值作为路由参数
-
_
前缀 0_app.tsx
→ 无路径布局路由- 被视为无路径布局路由,在匹配其子路由与 URL 路径名时不会被使用
-
_
后缀 0blog_.tsx
→/blog
- 将该路由排除在任何父路由的嵌套之外
-
花括号
{}
特殊参数{$id}
用于特殊路由处理,常用于 Router Mask 功能- 与普通
$id
动态参数的区别在于处理方式
为什么不能使用 templates.$id.modal.tsx
? 0
ruby
// ❌ 如果使用 templates.$id.modal.tsx
// TanStack Router 会将其解释为:
// - templates (父路由)
// - $id (子路由,动态参数)
// - modal (孙子路由)
// 这会创建三层嵌套:/templates/$id/modal
// ✅ 使用 templates.{$id}_modal.tsx
// TanStack Router 解释为:
// - templates (父路由)
// - {$id}_modal (子路由,特殊参数处理)
// 这创建正确的结构:/templates/{$id}_modal
// 📝 官方规则说明:
// - $ 标识符:创建参数化路由段,从 URL 路径名提取值作为路由参数
// - {} 包裹:用于特殊参数处理,常用于 Router Mask 等高级功能
// - . 分隔符:表示嵌套路由关系
路由层级对比
/templates"] --> B1["templates.{$id}_modal.tsx
/templates/{$id}_modal"] A1 --> C1["templates_.$id.tsx
/templates/$id"] end subgraph "错误的文件结构" A2["templates.tsx
/templates"] --> B2["templates.$id.tsx
/templates/$id"] B2 --> C2["templates.$id.modal.tsx
/templates/$id/modal"] end style A1 fill:#059669,stroke:#047857,stroke-width:2px,color:#fff style B1 fill:#7c3aed,stroke:#5b21b6,stroke-width:2px,color:#fff style C1 fill:#dc2626,stroke:#991b1b,stroke-width:2px,color:#fff style A2 fill:#6b7280,stroke:#374151,stroke-width:2px,color:#fff style B2 fill:#6b7280,stroke:#374151,stroke-width:2px,color:#fff style C2 fill:#ef4444,stroke:#dc2626,stroke-width:2px,color:#fff
实现原理
1. Link 组件的 Mask 配置
ini
// templates.tsx
import { Route as TemplateModalRoute } from './templates.{$id}_modal';
<Link
key={template.id}
to={TemplateModalRoute.to} // 实际导航到模态路由
mask={{
to: '/templates/$id', // URL 中显示的路径
params: { id: template.id.toString() },
}}
params={{ id: template.id.toString() }}
className="group"
>
{/* 模板卡片内容 */}
</Link>
2. 模态组件的状态检测
ini
// templates.{$id}_modal.tsx
const TemplateModalComponent = () => {
const { id } = useParams({ from: '/templates/{$id}_modal' });
const router = useRouter();
const navigate = useNavigate();
// 关键:检测是否来自掩码路由
const isMasked = router.state.location.maskedLocation !== undefined;
const handleCloseModal = () => {
navigate({ to: '/templates' });
};
useEffect(() => {
if (isMasked) {
// 模态模式:阻止背景滚动
document.body.style.overflow = 'hidden';
return () => {
document.body.style.overflow = 'unset';
};
}
}, [isMasked]);
return <TemplateContent templateId={id} isMasked={isMasked} />;
};
3. 共享组件的条件渲染
ini
// components/TemplateContent.tsx
interface TemplateContentProps {
templateId: string;
isMasked?: boolean;
}
const TemplateContent = ({ templateId, isMasked = false }: TemplateContentProps) => {
const navigate = useNavigate();
const handleDialogClose = () => {
navigate({ to: '/templates' });
};
const content = (
<div className="max-w-6xl mx-auto">
{/* 面包屑导航 - 只在完整页面显示 */}
{!isMasked && (
<nav className="flex items-center space-x-2 text-sm text-gray-500 mb-6">
{/* 面包屑内容 */}
</nav>
)}
{/* 主要内容 */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{/* 内容区域 */}
</div>
</div>
);
// 根据是否为模态模式选择渲染方式
if (isMasked) {
return (
<Dialog open={true} onOpenChange={handleDialogClose}>
<DialogContent className="max-w-4xl max-h-[90vh] overflow-y-auto">
<DialogTitle className="sr-only">模板详情</DialogTitle>
{content}
</DialogContent>
</Dialog>
);
}
return (
<div className="container mx-auto px-4 py-8">
{content}
</div>
);
};
工作流程详解
rust
sequenceDiagram
participant U as 用户
participant L as Link组件
participant R as Router
participant M as Modal组件
participant C as Content组件
U->>L: 点击模板卡片
L->>R: 导航到 /templates/{id}_modal
R->>R: 设置 maskedLocation = /templates/123
R->>M: 渲染模态组件
M->>M: 检测 isMasked = true
M->>C: 传递 isMasked=true
C->>C: 渲染模态样式
C->>U: 显示模态框
Note over R: URL 显示: /templates/123
Note over M: 实际组件: TemplateModalComponent
U->>C: 点击关闭按钮
C->>R: navigate({ to: '/templates' })
R->>R: 清除 maskedLocation
R->>U: 返回列表页
关键技术点
1. maskedLocation 检测
ini
const isMasked = router.state.location.maskedLocation !== undefined;
这是判断当前路由是否为掩码模式的关键代码。当 maskedLocation
存在时,说明当前是通过 mask 导航过来的。
2. 路由引用的正确方式
dart
// ❌ 错误:硬编码路径
to="/templates/{$id}_modal"
// ✅ 正确:引用路由对象
import { Route as TemplateModalRoute } from './templates.{$id}_modal';
to={TemplateModalRoute.to}
3. 参数传递的双重配置
css
<Link
to={TemplateModalRoute.to}
mask={{
to: '/templates/$id',
params: { id: template.id.toString() }, // mask 的参数
}}
params={{ id: template.id.toString() }} // 实际路由的参数
>
最佳实践
1. 文件命名约定
- 模态路由 :使用
{$param}_modal.tsx
格式 - 详情页 :使用
_.$param.tsx
格式 - 列表页 :使用简单的名称,如
templates.tsx
2. 组件复用策略
javascript
// 创建共享的内容组件
const SharedContent = ({ data, isMasked }) => {
// 共同的业务逻辑和 UI
};
// 模态组件
const ModalComponent = () => {
const isMasked = router.state.location.maskedLocation !== undefined;
return <SharedContent data={data} isMasked={isMasked} />;
};
// 详情页组件
const DetailComponent = () => {
return <SharedContent data={data} isMasked={false} />;
};
3. 状态管理
javascript
// 使用 useEffect 管理模态状态
useEffect(() => {
if (isMasked) {
// 模态打开时的副作用
document.body.style.overflow = 'hidden';
document.addEventListener('keydown', handleKeyDown);
return () => {
// 清理副作用
document.body.style.overflow = 'unset';
document.removeEventListener('keydown', handleKeyDown);
};
}
}, [isMasked]);
4. 键盘交互
ini
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape' && isMasked) {
navigate({ to: '/templates' });
}
};
与传统方案对比
特性 | Router Mask | 传统模态 | 查询参数模态 |
---|---|---|---|
URL 语义化 | ✅ | ❌ | ⚠️ |
可分享性 | ✅ | ❌ | ✅ |
SEO 友好 | ✅ | ❌ | ⚠️ |
实现复杂度 | ⚠️ | ✅ | ✅ |
状态管理 | ✅ | ⚠️ | ⚠️ |
浏览器历史 | ✅ | ❌ | ✅ |
官方文件命名约定完整指南 0
无路径布局路由详解
无路径路由(Pathless Routes)是 TanStack Router 的一个强大功能,它允许你创建逻辑或组件包裹器,而不影响 URL 路径。
使用 _
前缀创建无路径路由
bash
# 文件结构
_app.tsx # 无路径布局路由
_app.dashboard.tsx # /dashboard
_app.settings.tsx # /settings
_app.profile.tsx # /profile
组件输出结构
文件名 | 路由路径 | 组件输出 |
---|---|---|
_app.tsx |
无路径 | <Root><App> |
_app.dashboard.tsx |
/dashboard |
<Root><App><Dashboard> |
_app.settings.tsx |
/settings |
<Root><App><Settings> |
实际应用示例
javascript
// _app.tsx - 无路径布局组件
import { createFileRoute, Outlet } from '@tanstack/react-router';
const AppLayout = () => {
return (
<div className="min-h-screen bg-gray-50">
{/* 全局导航栏 */}
<nav className="bg-white shadow-sm border-b">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
{/* 导航内容 */}
</div>
</nav>
{/* 主要内容区域 */}
<main className="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">
<Outlet /> {/* 子路由内容 */}
</main>
{/* 全局页脚 */}
<footer className="bg-white border-t">
{/* 页脚内容 */}
</footer>
</div>
);
};
export const Route = createFileRoute('/_app')({
component: AppLayout,
});
路由组 (Route Groups) 0
路由组使用 (folder)
命名模式,允许你组织文件而不影响 URL 结构。
路由组示例
bash
# 文件结构
(dashboard)/
analytics.tsx # /analytics
reports.tsx # /reports
settings.tsx # /settings
(auth)/
login.tsx # /login
register.tsx # /register
forgot-password.tsx # /forgot-password
(admin)/
users.tsx # /users
roles.tsx # /roles
路由组的优势
- 文件组织:按功能模块组织文件,提高可维护性
- URL 简洁:文件夹名不会出现在 URL 中
- 代码分离:不同模块的路由文件分离,避免单个目录过于庞大
目录路由文件 (.route.tsx) 0
当使用目录组织路由时,可以使用 .route.tsx
后缀创建路由文件。
目录结构示例
bash
# 方式一:扁平文件结构
blog.post.tsx # /blog/post
# 方式二:目录结构 + .route.tsx
blog/
post/
route.tsx # /blog/post 的路由文件
components/
PostHeader.tsx
PostContent.tsx
PostComments.tsx
hooks/
usePost.ts
types/
post.types.ts
目录结构的优势
- 相关文件集中:路由相关的组件、hooks、类型定义都在同一目录
- 更好的组织:大型项目中避免根目录文件过多
- 团队协作:不同团队成员可以专注于不同的路由目录
索引路由 (Index Routes) 0
索引路由使用 index
标识符,在 URL 路径名与父路由完全匹配时匹配父路由。
索引路由示例
bash
# 文件结构
blog.tsx # /blog (父路由)
blog.index.tsx # /blog (索引路由,默认内容)
blog.post.tsx # /blog/post
blog.$slug.tsx # /blog/$slug
索引路由的作用
javascript
// blog.tsx - 父路由
import { createFileRoute, Outlet } from '@tanstack/react-router';
const BlogLayout = () => {
return (
<div className="blog-layout">
<header className="blog-header">
<h1>我的博客</h1>
<nav>{/* 博客导航 */}</nav>
</header>
<main>
<Outlet /> {/* 子路由或索引路由内容 */}
</main>
</div>
);
};
export const Route = createFileRoute('/blog')({
component: BlogLayout,
});
// blog.index.tsx - 索引路由
import { createFileRoute } from '@tanstack/react-router';
const BlogIndex = () => {
return (
<div>
<h2>欢迎来到我的博客</h2>
<p>这里是博客首页内容...</p>
{/* 最新文章列表 */}
</div>
);
};
export const Route = createFileRoute('/blog/')({
component: BlogIndex,
});
适用场景
✅ 适合使用 Router Mask
- 内容详情模态:如商品详情、文章预览、用户资料等
- 媒体查看器:图片画廊、视频播放器等
- 表单编辑:需要独立 URL 的编辑表单
- 多步骤流程:向导式的用户流程
❌ 不适合使用 Router Mask
- 简单确认对话框:如删除确认、提示信息等
- 纯 UI 交互:如下拉菜单、工具提示等
- 临时状态显示:如加载状态、错误提示等
- 频繁切换的内容:如标签页内容切换
常见问题与解决方案
1. 文件命名错误
问题:路由无法正确生成或 mask 功能失效
常见错误命名:
bash
# ❌ 错误的命名方式
templates.$id.modal.tsx # 会创建 /templates/$id/modal
templates-$id-modal.tsx # 无法识别为动态路由
templates/$id_modal.tsx # 文件系统不支持斜杠
templates.modal.$id.tsx # 错误的嵌套顺序
正确的命名:
bash
# ✅ 正确的命名方式
templates.{$id}_modal.tsx # 子路由,特殊参数
templates_.$id.tsx # 平级路由,普通参数
templates.tsx # 父路由
解决方案:
typescript
// 检查生成的 routeTree.gen.ts 文件
// 确认路由 ID 和路径是否符合预期
export interface FileRoutesById {
'/templates/{$id}_modal': typeof TemplatesChar123idChar125_modalRoute
'/templates_/$id': typeof TemplatesIdRoute
}
2. 模态状态检测失败
问题 :isMasked
始终为 false
解决方案:
javascript
// 确保正确引用路由对象
import { Route as TemplateModalRoute } from './templates.{$id}_modal';
// 检查 Link 配置
<Link
to={TemplateModalRoute.to} // 必须使用路由对象
mask={{ /* ... */ }}
/>
3. 参数传递问题
问题:模态组件无法获取正确的参数
解决方案:
css
// 确保 mask 和实际路由都配置了参数
<Link
to={TemplateModalRoute.to}
params={{ id: template.id.toString() }} // 实际路由参数
mask={{
to: '/templates/$id',
params: { id: template.id.toString() }, // mask 路由参数
}}
/>
4. 路由嵌套层级错误
问题:路由层级不符合预期,导致 URL 结构错误
调试方法:
arduino
// 在组件中打印路由信息
const router = useRouter();
console.log('Current route:', router.state.location);
console.log('Masked location:', router.state.location.maskedLocation);
console.log('Route ID:', router.state.matches[0]?.routeId);
5. 样式冲突
问题:模态和详情页样式互相影响
解决方案:
arduino
// 使用条件样式
const containerClass = isMasked
? "modal-container"
: "page-container";
// 或使用 CSS-in-JS
const styles = {
container: {
maxWidth: isMasked ? '800px' : '1200px',
padding: isMasked ? '0' : '2rem',
}
};
性能优化建议
1. 代码分割
dart
// 使用动态导入
const TemplateContent = lazy(() => import('../components/TemplateContent'));
// 在路由配置中启用预加载
export const Route = createFileRoute('/templates/{$id}_modal')({
component: TemplateModalComponent,
preload: true, // 预加载组件
});
2. 数据预取
dart
// 在父路由中预取数据
export const Route = createFileRoute('/templates')({
component: TemplatesComponent,
loader: async () => {
// 预取模板列表数据
return await fetchTemplates();
},
});
3. 状态缓存
php
// 使用 React Query 缓存数据
const { data: template } = useQuery({
queryKey: ['template', templateId],
queryFn: () => fetchTemplate(templateId),
staleTime: 5 * 60 * 1000, // 5分钟缓存
});
文件命名规则速查表
为了帮助开发者快速掌握 TanStack Router 的文件命名规则,这里提供一个完整的速查表:
基础规则 0
符号/模式 | 含义 | 示例 | 生成路径 | 说明 |
---|---|---|---|---|
__root.tsx |
根路由文件 | __root.tsx |
/ |
必须放在 routesDirectory 根目录 |
. |
嵌套关系(子路由) | blog.post.tsx |
/blog/post |
表示 post 是 blog 的子路由 |
_ 前缀 |
无路径布局路由 | _layout.tsx |
无路径 | 包裹子路由但不影响 URL |
_ 后缀 |
排除父路由嵌套 | blog_.tsx |
/blog |
排除在任何父路由嵌套之外 |
$ |
动态路径参数 | posts.$postId.tsx |
/posts/$postId |
从 URL 提取参数值 |
{} |
特殊参数处理 | posts.{$id}_modal.tsx |
/posts/{$id}_modal |
用于 Router Mask 等特殊功能 |
(folder) |
路由组 | (auth)/login.tsx |
/login |
文件夹不包含在 URL 路径中 |
index |
索引路由 | blog.index.tsx |
/blog |
匹配父路由的完全路径 |
.route.tsx |
目录路由文件 | blog/post/route.tsx |
/blog/post |
在目录结构中创建路由文件 |
实际应用示例 0
基础路由结构
bash
# 根路由
__root.tsx # / (根路由,必需)
# 博客系统
blog.tsx # /blog (博客首页)
blog.post.tsx # /blog/post (嵌套子路由)
blog.$slug.tsx # /blog/$slug (动态参数路由)
blog.{$slug}_edit.tsx # /blog/{$slug}_edit (特殊参数,用于模态)
# 用户系统
users.tsx # /users
users.$id.tsx # /users/$id (用户详情页)
users.{$id}_profile.tsx # /users/{$id}_profile (资料模态)
无路径布局路由示例
bash
# 使用 _ 前缀创建无路径布局
_app.tsx # 无路径,布局组件
_app.dashboard.tsx # /dashboard (被 _app 包裹)
_app.settings.tsx # /settings (被 _app 包裹)
# 认证布局示例
_auth.tsx # 无路径认证布局
_auth.login.tsx # /login (使用认证布局)
_auth.register.tsx # /register (使用认证布局)
路由组和目录结构
bash
# 使用 (folder) 路由组
(dashboard)/
analytics.tsx # /analytics (文件夹名不在 URL 中)
reports.tsx # /reports
# 使用 .route.tsx 文件类型
blog/
post/
route.tsx # /blog/post 的路由文件
components/ # 相关组件
索引路由示例
bash
blog.tsx # /blog (父路由)
blog.index.tsx # /blog (索引路由,匹配父路由完全路径)
blog.post.tsx # /blog/post (子路由)
命名决策流程图
parent.{$param}_name.tsx"] C -->|否| F["使用点号
parent.child.tsx"] D --> G["parent_child.tsx"] E --> H["/parent/{param}_name"] F --> I["/parent/child"] G --> J["/parent/child"] style A fill:#4f46e5,stroke:#312e81,stroke-width:2px,color:#fff style E fill:#059669,stroke:#047857,stroke-width:2px,color:#fff style F fill:#7c3aed,stroke:#5b21b6,stroke-width:2px,color:#fff style G fill:#dc2626,stroke:#991b1b,stroke-width:2px,color:#fff style H fill:#ea580c,stroke:#c2410c,stroke-width:2px,color:#fff style I fill:#0891b2,stroke:#0e7490,stroke-width:2px,color:#fff style J fill:#65a30d,stroke:#4d7c0f,stroke-width:2px,color:#fff
关键记忆点 0
__root.tsx
:根路由文件,必须存在且位于 routesDirectory 根目录- 点号
.
= 嵌套关系 :blog.post.tsx
→/blog/post
(post 是 blog 的子路由) _
前缀 = 无路径布局 :_app.tsx
→ 包裹子路由但不影响 URL_
后缀 = 排除嵌套 :blog_.tsx
→ 排除在父路由嵌套之外$
= 动态参数 :posts.$id.tsx
→/posts/$id
{}
= 特殊处理:用于 Router Mask 等高级功能(folder)
= 路由组:文件夹名不包含在 URL 中index
= 索引路由:匹配父路由的完全路径.route.tsx
= 目录路由:在目录结构中创建路由文件
实用决策树
_layout.tsx"] D -->|否| F{"是否为子路由?"} F -->|是| G{"需要特殊参数?"} F -->|否| H{"需要动态参数?"} G -->|是| I["使用点号 + 花括号
parent.{$param}_name.tsx"] G -->|否| J["使用点号
parent.child.tsx"] H -->|是| K["使用 $ 标识符
posts.$id.tsx"] H -->|否| L["普通路由
about.tsx"] style A fill:#4f46e5,stroke:#312e81,stroke-width:2px,color:#fff style C fill:#059669,stroke:#047857,stroke-width:2px,color:#fff style E fill:#7c3aed,stroke:#5b21b6,stroke-width:2px,color:#fff style I fill:#dc2626,stroke:#991b1b,stroke-width:2px,color:#fff style J fill:#ea580c,stroke:#c2410c,stroke-width:2px,color:#fff style K fill:#0891b2,stroke:#0e7490,stroke-width:2px,color:#fff style L fill:#65a30d,stroke:#4d7c0f,stroke-width:2px,color:#fff
总结
TanStack Router 的 Router Mask 功能虽然实现相对复杂,但它提供了一种优雅的方式来处理现代 Web 应用中常见的模态路由需求。通过合理的文件结构设计、正确的组件实现和适当的状态管理,可以创建出既用户友好又 SEO 友好的交互体验。
关键要点:
- 理解核心概念 :掌握
maskedLocation
的工作原理 - 掌握文件命名规则:理解下划线、点号、花括号的不同含义
- 正确的文件结构:遵循命名约定和路由映射关系
- 组件复用:通过共享组件减少代码重复
- 状态管理:正确处理模态状态和副作用
- 性能优化:合理使用代码分割和数据缓存
虽然这种方法的学习曲线较陡,但一旦掌握,它将为你的应用带来显著的用户体验提升。特别是文件命名规则的理解,是成功实现 Router Mask 功能的基础。