TanStack Router File-Based Router Mask 完全指南

26 阅读5分钟

概述

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)

官方命名规则详解

  1. 点号 . 分隔符

    • blog.post.tsx/blog/post
    • 表示嵌套路由关系,post 是 blog 的子路由
  2. $ 标识符

    • posts.$postId.tsx/posts/$postId
    • 带有 $ 标识符的路由段会被参数化,从 URL 路径名中提取值作为路由参数
  3. _ 前缀

    • _app.tsx → 无路径布局路由
    • 被视为无路径布局路由,在匹配其子路由与 URL 路径名时不会被使用
  4. _ 后缀

    • blog_.tsx/blog
    • 将该路由排除在任何父路由的嵌套之外
  5. 花括号 {} 特殊参数

    • {$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

  1. 内容详情模态:如商品详情、文章预览、用户资料等
  2. 媒体查看器:图片画廊、视频播放器等
  3. 表单编辑:需要独立 URL 的编辑表单
  4. 多步骤流程:向导式的用户流程

❌ 不适合使用 Router Mask

  1. 简单确认对话框:如删除确认、提示信息等
  2. 纯 UI 交互:如下拉菜单、工具提示等
  3. 临时状态显示:如加载状态、错误提示等
  4. 频繁切换的内容:如标签页内容切换

常见问题与解决方案

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在目录结构中创建路由文件

关键记忆点

  1. __root.tsx:根路由文件,必须存在且位于 routesDirectory 根目录
  2. 点号 . = 嵌套关系blog.post.tsx/blog/post(post 是 blog 的子路由)
  3. _ 前缀 = 无路径布局_app.tsx → 包裹子路由但不影响 URL
  4. _ 后缀 = 排除嵌套blog_.tsx → 排除在父路由嵌套之外
  5. $ = 动态参数posts.$id.tsx/posts/$id
  6. {} = 特殊处理:用于 Router Mask 等高级功能
  7. (folder) = 路由组:文件夹名不包含在 URL 中
  8. index = 索引路由:匹配父路由的完全路径
  9. .route.tsx = 目录路由:在目录结构中创建路由文件

总结

TanStack Router 的 Router Mask 功能虽然实现相对复杂,但它提供了一种优雅的方式来处理现代 Web 应用中常见的模态路由需求。通过合理的文件结构设计、正确的组件实现和适当的状态管理,可以创建出既用户友好又 SEO 友好的交互体验。

关键要点:

  1. 理解核心概念:掌握 maskedLocation 的工作原理
  2. 掌握文件命名规则:理解下划线、点号、花括号的不同含义
  3. 正确的文件结构:遵循命名约定和路由映射关系
  4. 组件复用:通过共享组件减少代码重复