概述
TanStack Router 的 Router Mask 是一个强大但相对隐蔽的功能,它允许开发者在保持 URL 语义化的同时,实现复杂的路由交互模式。本文将深入探讨这一功能的实现原理、使用方法和最佳实践。
什么是 Router Mask?
Router Mask(路由掩码)是 TanStack Router 提供的一种特殊路由机制,它允许你:
- 显示不同的 URL:用户在地址栏看到的 URL 与实际渲染的组件路由不同
- 实现模态路由:点击链接时显示模态框,但 URL 变为可分享的详情页地址
- 保持状态一致性:在模态和完整页面之间无缝切换
- SEO 友好:每个内容都有独立的 URL,便于分享和索引
核心概念
graph TD
A["用户点击链接"] --> B["Router Mask 激活"]
B --> C["URL 显示: /templates/123"]
B --> D["实际渲染: /templates/{$id}_modal"]
C --> E["用户可分享 URL"]
D --> F["模态框组件渲染"]
F --> G["检测 maskedLocation"]
G --> H["显示模态样式"]
style A fill:#4f46e5,stroke:#312e81,stroke-width:2px,color:#fff
style B fill:#059669,stroke:#047857,stroke-width:2px,color:#fff
style C fill:#dc2626,stroke:#991b1b,stroke-width:2px,color:#fff
style D fill:#7c3aed,stroke:#5b21b6,stroke-width:2px,color:#fff
style E fill:#ea580c,stroke:#c2410c,stroke-width:2px,color:#fff
style F fill:#0891b2,stroke:#0e7490,stroke-width:2px,color:#fff
style G fill:#65a30d,stroke:#4d7c0f,stroke-width:2px,color:#fff
style H fill:#be185d,stroke:#9d174d,stroke-width:2px,color:#fff
文件结构设计
在 file-based 路由系统中,Router Mask 需要特定的文件结构:
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 中显示的路径 |
🔍 官方文件命名约定详解
这是 TanStack Router file-based 路由系统的核心设计。让我们通过生成的 routeTree.gen.ts
来理解:
// 生成的路由树代码片段
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)
官方命名规则详解
-
点号
.
分隔符blog.post.tsx
→/blog/post
- 表示嵌套路由关系,post 是 blog 的子路由
-
$
标识符posts.$postId.tsx
→/posts/$postId
- 带有 $ 标识符的路由段会被参数化,从 URL 路径名中提取值作为路由参数
-
_
前缀_app.tsx
→ 无路径布局路由- 被视为无路径布局路由,在匹配其子路由与 URL 路径名时不会被使用
-
_
后缀blog_.tsx
→/blog
- 将该路由排除在任何父路由的嵌套之外
-
花括号
{}
特殊参数{$id}
用于特殊路由处理,常用于 Router Mask 功能- 与普通
$id
动态参数的区别在于处理方式
为什么不能使用 templates.$id.modal.tsx
?
// ❌ 如果使用 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 等高级功能
// - . 分隔符:表示嵌套路由关系
路由层级对比
graph TD
subgraph "正确的文件结构"
A1["templates.tsx<br/>/templates"] --> B1["templates.{$id}_modal.tsx<br/>/templates/{$id}_modal"]
A1 --> C1["templates_.$id.tsx<br/>/templates/$id"]
end
subgraph "错误的文件结构"
A2["templates.tsx<br/>/templates"] --> B2["templates.$id.tsx<br/>/templates/$id"]
B2 --> C2["templates.$id.modal.tsx<br/>/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 配置
// 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. 模态组件的状态检测
// 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. 共享组件的条件渲染
// 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>
);
};
工作流程详解
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 检测
const isMasked = router.state.location.maskedLocation !== undefined;
这是判断当前路由是否为掩码模式的关键代码。当 maskedLocation
存在时,说明当前是通过 mask 导航过来的。
2. 路由引用的正确方式
// ❌ 错误:硬编码路径
to="/templates/{$id}_modal"
// ✅ 正确:引用路由对象
import { Route as TemplateModalRoute } from './templates.{$id}_modal';
to={TemplateModalRoute.to}
3. 参数传递的双重配置
<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. 组件复用策略
// 创建共享的内容组件
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. 状态管理
// 使用 useEffect 管理模态状态
useEffect(() => {
if (isMasked) {
// 模态打开时的副作用
document.body.style.overflow = 'hidden';
document.addEventListener('keydown', handleKeyDown);
return () => {
// 清理副作用
document.body.style.overflow = 'unset';
document.removeEventListener('keydown', handleKeyDown);
};
}
}, [isMasked]);
适用场景
✅ 适合使用 Router Mask
- 内容详情模态:如商品详情、文章预览、用户资料等
- 媒体查看器:图片画廊、视频播放器等
- 表单编辑:需要独立 URL 的编辑表单
- 多步骤流程:向导式的用户流程
❌ 不适合使用 Router Mask
- 简单确认对话框:如删除确认、提示信息等
- 纯 UI 交互:如下拉菜单、工具提示等
- 临时状态显示:如加载状态、错误提示等
- 频繁切换的内容:如标签页内容切换
常见问题与解决方案
1. 文件命名错误
问题:路由无法正确生成或 mask 功能失效
常见错误命名:
# ❌ 错误的命名方式
templates.$id.modal.tsx # 会创建 /templates/$id/modal
templates-$id-modal.tsx # 无法识别为动态路由
templates/$id_modal.tsx # 文件系统不支持斜杠
templates.modal.$id.tsx # 错误的嵌套顺序
正确的命名:
# ✅ 正确的命名方式
templates.{$id}_modal.tsx # 子路由,特殊参数
templates_.$id.tsx # 平级路由,普通参数
templates.tsx # 父路由
解决方案:
// 检查生成的 routeTree.gen.ts 文件
// 确认路由 ID 和路径是否符合预期
export interface FileRoutesById {
'/templates/{$id}_modal': typeof TemplatesChar123idChar125_modalRoute
'/templates_/$id': typeof TemplatesIdRoute
}
2. 参数传递问题
问题:模态组件无法获取正确的参数
解决方案:
// 确保 mask 和实际路由都配置了参数
<Link
to={TemplateModalRoute.to}
params={{ id: template.id.toString() }} // 实际路由参数
mask={{
to: '/templates/$id',
params: { id: template.id.toString() }, // mask 路由参数
}}
/>
文件命名规则速查表
为了帮助开发者快速掌握 TanStack Router 的文件命名规则,这里提供一个完整的速查表:
基础规则
符号/模式 | 含义 | 示例 | 生成路径 | 说明 |
---|---|---|---|---|
__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 | 在目录结构中创建路由文件 |
关键记忆点
__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
= 目录路由:在目录结构中创建路由文件
总结
TanStack Router 的 Router Mask 功能虽然实现相对复杂,但它提供了一种优雅的方式来处理现代 Web 应用中常见的模态路由需求。通过合理的文件结构设计、正确的组件实现和适当的状态管理,可以创建出既用户友好又 SEO 友好的交互体验。
关键要点:
- 理解核心概念:掌握
maskedLocation
的工作原理 - 掌握文件命名规则:理解下划线、点号、花括号的不同含义
- 正确的文件结构:遵循命名约定和路由映射关系
- 组件复用:通过共享组件减少代码重复