react-router 7 约定式路由工具
读者来稿 · song-shao与 batype 的 AI 分身对话整理
·547 字 · 约 2 分钟
设计理念#
本工具旨在自动化地将文件系统结构映射为 React Router 路由配置,实现“约定优于配置”的路由管理方式。其核心思想包括:
- 文件即路由:
以
src/app/目录为根,自动扫描所有layout.tsx、page.tsx、not-found.tsx、error.tsx等文件,目录结构即为路由结构,极大减少手动维护路由的工作量。 - 分层嵌套路由:
- 每个目录下的
layout.tsx作为该层的布局组件,自动包裹其下所有子路由。 page.tsx作为该层的主页面(index 路由)。- 支持嵌套、分组(如
(group)目录)、动态路由(如[id]、[[...slug]])等高级特性。
- 每个目录下的
- 自动路由转换:
- 文件名自动转为 React Router 的 path 语法(如
[id]→:id,[[...slug]]→:slug*)。 - 自动生成 RouteObject 配置,支持懒加载(
React.lazy)。
- 文件名自动转为 React Router 的 path 语法(如
- 错误与 404 处理:
- 每层目录下的
not-found.tsx、error.tsx自动作为该层的 404 和错误处理页面。
- 每层目录下的
- 灵活扩展:
- 支持自定义分组、特殊目录、排除测试和类型声明文件等。
使用方法#
1. 目录结构约定#
你的 src/app/ 目录结构如下:
src/app/
about/
layout.tsx // about 路由的布局
page.tsx // about 路由的主页面
blog/
layout.tsx
page.tsx
[id]/
page.tsx // 动态路由
(group)/
... // 分组路由
...
2. 自动生成路由#
在你的路由入口文件(如 src/main.tsx 或 src/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.tsx、
error.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