react-router 7 约定式路由工具

读者来稿 · song-shao与 batype 的 AI 分身对话整理

·547 字 · 约 2 分钟

设计理念#

本工具旨在自动化地将文件系统结构映射为 React Router 路由配置,实现“约定优于配置”的路由管理方式。其核心思想包括:

  1. 文件即路由: 以 src/app/ 目录为根,自动扫描所有 layout.tsxpage.tsxnot-found.tsxerror.tsx 等文件,目录结构即为路由结构,极大减少手动维护路由的工作量。
  2. 分层嵌套路由
    • 每个目录下的 layout.tsx 作为该层的布局组件,自动包裹其下所有子路由。
    • page.tsx 作为该层的主页面(index 路由)。
    • 支持嵌套、分组(如 (group) 目录)、动态路由(如 [id][[...slug]])等高级特性。
  3. 自动路由转换
    • 文件名自动转为 React Router 的 path 语法(如 [id]:id[[...slug]]:slug*)。
    • 自动生成 RouteObject 配置,支持懒加载(React.lazy)。
  4. 错误与 404 处理
    • 每层目录下的 not-found.tsxerror.tsx 自动作为该层的 404 和错误处理页面。
  5. 灵活扩展
    • 支持自定义分组、特殊目录、排除测试和类型声明文件等。

使用方法#

1. 目录结构约定#

你的 src/app/ 目录结构如下:

src/app/
  about/
    layout.tsx      // about 路由的布局
    page.tsx        // about 路由的主页面
  blog/
    layout.tsx
    page.tsx
    [id]/
      page.tsx      // 动态路由
  (group)/
    ...             // 分组路由
  ...

2. 自动生成路由#

在你的路由入口文件(如 src/main.tsxsrc/router/index.tsx)中:

import ViteProtocolRouter from '@/router/autoRouter'

const router = new ViteProtocolRouter()
const routes = router.buildRoutes()
// routes 就是 React Router v6/v7 的 RouteObject[] 配置

然后将 routes 传递给你的 <RouterProvider><Routes> 组件即可。

3. 组件开发约定#

  • layout.tsx:作为该目录下所有子路由的布局,内部可用 <Outlet /> 渲染子页面。
  • page.tsx:作为该目录的主页面(index 路由),会被自动作为 index: true 的子路由。
  • [id].tsx[[...slug]].tsx:自动转为动态路由。
  • not-found.tsxerror.tsx:自动作为 404 和错误处理页面。

4. 动态路由与分组#

  • [id]:id
  • [[...slug]]:slug*
  • (group) 目录仅用于分组,不影响最终路由路径。

5. 懒加载#

所有页面组件均通过 React.lazy 懒加载,提升首屏性能。


示例#

假设有如下结构:

src/app/
  user/
    layout.tsx
    page.tsx
    [id]/
      page.tsx

自动生成的路由配置大致为:

[
  {
    path: '/user',
    Component: UserLayout,
    children: [
      { index: true, Component: UserPage },
      {
        path: ':id',
        Component: UserIdPage
      }
    ]
  }
]

总结#

  • 无需手写路由表,目录结构即路由结构
  • 支持嵌套、动态、分组、错误处理等高级特性
  • 极大提升开发效率和一致性

代码#

import { lazy } from 'react'
import type { RouteObject } from 'react-router'

// fileAnalyzer.ts
interface FileInfo {
  path: string
  type: string
  Component: any //React.LazyExoticComponent<React.ComponentType<any>>
}

interface DirectoryInfo {
  path: string
  files?: FileInfo[]
  children?: DirectoryInfo[]
  group?: string
}

class ViteProtocolRouter {
  private files: Record<string, any>

  constructor() {
    // 查询所有文件
    this.files = import.meta.glob([
      '../app/**/layout.{tsx,jsx}',
      '../app/**/Layout.{tsx,jsx}',
      '../app/**/page.{tsx,jsx}',
      '../app/**/not-found.{tsx,jsx}',
      '../app/**/error.{tsx,jsx}',
      '!../app/**/*.test.{ts,tsx}', // 排除测试文件
      '!../app/**/*.d.ts' // 排除类型声明文件
    ])
  }

  // 获取文件类型
  private getFileType(path: string): string {
    if (path.includes('layout') || path.includes('Layout')) return 'layout'
    if (path.includes('page') || path.includes('index')) return 'page'
    if (path.includes('not-found')) return 'not-found'
    if (path.includes('error')) return 'error'
    return 'react'
  }

  public convertPath(path): string {
    path = path.replace(/\[\[\.\.\.(.*?)\]\]/g, '*?') // [[...slug]] => :slug*
    path = path.replace(/\[\.\.\.(.*?)\]/g, '*?') // [...slug] => :slug*
    path = path.replace(/\[(.*?)\]/g, ':$1') // [slug] => :slug
    return path
  }
  public parsePathGroups(path: string) {
    const groupPattern = /\((.*?)\)/g
    const groups: string[] = []
    let match

    while ((match = groupPattern.exec(path)) !== null) {
      groups.push(match[1])
    }

    return {
      groups,
      // 清除括号的路径
      cleanPath: path.replace(groupPattern, ''),
      // 原始路径
      originalPath: path
    }
  }

  // 构建目录树
  public buildRouterTree(): DirectoryInfo {
    const root: DirectoryInfo = {
      path: '/',
      files: [],
      children: []
    }

    Object.entries(this.files).forEach(([path]) => {
      // if (!path.includes('(doc)')) {
      //   return
      // }
      // 处理路径
      const normalizedPath = path.replace('../app/', '')
      const parts = normalizedPath.split('/')
      let current = root

      // 处理目录结构
      for (let i = 0; i < parts.length - 1; i++) {
        const part = this.parsePathGroups(parts[i])
        const [group] = part.groups

        let found = current?.children?.find(d => {
          const isFind = d.path?.endsWith(
            part?.cleanPath
            // part?.cleanPath?.startsWith('/')
            //   ? part?.cleanPath
            //   : `/${part?.cleanPath}`
          )
          if (group) {
            return isFind && d.group === group
          }
          return isFind
        })

        if (!found) {
          found = {
            path: part?.cleanPath
              ? [
                  current.path === '/' ? '' : current.path,
                  part?.cleanPath
                ].join('/')
              : current.path,
            group: group,
            files: [],
            children: []
          }
          current?.children?.push(found)
        }

        current = found
      }

      const newPath = [
        current.path === '/' ? '' : current.path,
        parts[parts.length - 1]?.replace(/\.[^/.]+$/, '')
      ].join('/')

      if (path.includes('src/app/(doc)/document/file/[[...slug]]')) {
        console.log('found')
      }

      // 添加文件信息
      current?.files?.push({
        path: newPath,
        type: this.getFileType(path),
        Component: lazy(() =>
          this.files[path]().then(module => ({
            default: module.default
          }))
        )
      })
    })

    return root
  }

  public convertToRoutes(node: DirectoryInfo): RouteObject {
    let route: RouteObject = { path: this.convertPath(node.path) }
    const page = node.files?.find(file => file.type === 'page')
    const layout = node.files?.find(file => file.type === 'layout')
    const notFound = node.files?.find(file => file.type === 'not-found')
    const error = node.files?.find(file => file.type === 'error')

    if (layout?.Component || page?.Component) {
      route.Component = layout?.Component || page?.Component
    }
    // 处理错误节点
    if (error?.Component) {
      route.errorElement = error?.Component
    }
    // 处理当前主节点
    if (page && layout) {
      route = {
        ...route,
        Component: layout.Component,
        children: [
          {
            index: true,
            Component: page.Component
          }
        ]
      }
    }

    // if (layout?.Component) {
    //   const LazyComponent = lazy(layout?.Component as any)
    //   route.element = createElement(LazyComponent)
    // }
    // if (page?.Component) {
    //   const LazyComponent = lazy(page?.Component as any)
    //   route.element = createElement(LazyComponent)
    // }
    // // 处理错误节点
    // if (error?.Component) {
    //   route.errorElement = error?.Component as unknown as React.ReactNode
    // }
    // // 处理当前主节点
    // if (page && layout) {
    //   const LazyComponent = lazy(layout?.Component as any)
    //   const PageComponent = lazy(page?.Component as any)
    //   route = {
    //     ...route,
    //     element: createElement(LazyComponent, {
    //       children: createElement(PageComponent)
    //     })
    //   }
    // }

    // 处理子节点
    if (node.children?.length) {
      node.children.forEach(child => {
        const childRoute = this.convertToRoutes(child)
        if (route?.children) {
          route?.children?.push(childRoute)
        } else {
          route.children = [childRoute]
        }
      })
    }
    // 处理 not-found 节点
    if (notFound?.Component) {
      route.children = [
        ...(route.children || []),
        {
          path: '*',
          Component: notFound?.Component
        }
      ]
    }
    return route
  }

  public buildRoutes(): RouteObject[] {
    const routerTree = this.buildRouterTree()
    const routes = this.convertToRoutes(routerTree)
    console.log('Generated routes:', routes)
    return routes ? [routes] : []
  }
}

export default ViteProtocolRouter