1. 项目概述:一个基于T3 Stack的现代化路由管理方案
最近在折腾一个全栈项目,选型时又瞄上了T3 Stack。这个由Theo大神力推的技术栈,用Next.js、Prisma、tRPC、Tailwind CSS和NextAuth.js等一揽子方案,确实让全栈开发变得清爽不少。但在实际搭建过程中,我发现了一个不大不小的问题:当项目规模稍微膨胀,页面和API路由一多,管理起来就有点头疼。文件散落在pages/或app/目录下,业务逻辑和路由结构耦合在一起,尤其是在需要实现一些复杂的路由守卫、权限校验或数据预加载逻辑时,代码很容易变得臃肿。
就在这个当口,我发现了GitHub上一个叫vibheksoni/t3router的项目。光看名字,t3router,直觉告诉我这玩意儿应该和T3 Stack的路由管理有关。点进去一看,果然,这是一个旨在为T3 Stack应用提供更结构化、更强大路由管理能力的库。它不是要取代Next.js自带的路由系统,而是在其之上,提供一层更符合企业级应用需求的抽象和组织方式。简单来说,它帮你把路由定义、中间件、数据加载和权限控制这些事儿,从页面组件里剥离出来,用一个清晰、可维护的方式去管理。
这正好切中了我当下的痛点。一个中等复杂的后台管理系统,可能有几十个页面,涉及管理员、运营、普通用户等多种角色,每个页面的访问权限、需要预加载的数据都不同。如果把这些逻辑都写在getServerSideProps或者页面组件里,维护起来简直是噩梦。t3router的出现,提供了一种“配置即路由”的思路,让我眼前一亮。接下来,我就结合自己的实践,从头到尾拆解一下这个工具的核心设计、如何使用,以及我踩过的一些坑。
2. 核心设计理念与架构解析
2.1 为什么需要额外的路由层?
首先得明确,Next.js的文件系统路由已经非常优秀,对于许多项目来说完全够用。那t3router的价值在哪里?我认为核心在于复杂应用下的关注点分离和逻辑复用。
在标准的Next.js应用中,一个页面的路由(URL路径)、数据获取(getServerSideProps)、权限校验和页面渲染是高度耦合的。比如,一个用户详情页/app/user/[id]/page.tsx,你可能需要在getServerSideProps里检查用户是否登录、是否有权限查看这个ID的用户、然后去数据库获取用户数据。这些逻辑全部写在这个文件里。当你有十个类似的详情页时,权限检查的逻辑就要复制粘贴十次,或者抽象成一个函数,但调用和错误处理的样板代码依然存在。
t3router的思路是,借鉴了后端框架(如Express.js、NestJS)中路由控制器的概念,将路由的元数据和行为逻辑集中管理。它允许你在一个地方(比如一个routes/目录)定义所有路由:它们的路径、需要的HTTP方法、关联的中间件(如认证、日志、限流)、数据加载函数,以及最终的页面组件。这样做有几个明显好处:
- 路由声明集中化:所有路由的定义一目了然,新成员能快速了解整个应用的结构和权限设计。
- 逻辑复用最大化:认证中间件只需要写一次,就可以应用到无数个需要登录的路由上。
- 测试更友好:路由的处理逻辑(中间件、数据加载)可以被单独抽取和测试,而不必依赖Next.js的页面渲染环境。
- 类型安全增强:结合tRPC和TypeScript,它能够提供从路由参数到页面props的端到端类型安全,减少运行时错误。
2.2 t3router 的架构与核心概念
t3router的架构可以理解为在Next.js应用和你的页面组件之间插入了一个路由处理层。这个层负责接收请求,按顺序执行一系列管道(中间件和数据加载器),最后将处理好的数据传递给页面组件进行渲染。
它的核心概念包括:
- 路由定义(Route Definition): 这是最基本的单元,描述了一个路由端点。它至少包含一个路径模式(如
/user/:id)和一个页面组件。在t3router中,路由定义通常是一个配置对象,可以扩展很多属性。 - 中间件(Middleware): 这是架构的支柱。中间件是一个函数,接收请求上下文(包含Next.js的
req、res,以及tRPC的上下文等),并可以对其进行修改、验证或决定是否中断请求。典型的中间件包括身份验证(authMiddleware)、权限检查(aclMiddleware)、请求日志(loggingMiddleware)等。中间件可以全局应用,也可以针对特定路由应用。 - 数据加载器(Loader): 这是一个专用于为页面准备数据的特殊中间件或函数。它运行在所有中间件之后,页面渲染之前。在这里,你可以安全地访问数据库(通过Prisma)、调用外部API,或者进行任何复杂的数据聚合。加载器返回的数据会自动作为props注入到页面组件中。这完美替代了
getServerSideProps或getStaticProps,并且因为处在路由层,可以更方便地复用和组合。 - 路由守卫(Guard): 有时也称为“验证器”,是中间件的一种特定形式,用于保护路由。例如,一个守卫可以检查用户角色,如果不符合要求,则重定向到登录页或403页面。
t3router通常提供优雅的方式来定义和组合守卫。
这套架构使得应用的路由逻辑变得像组装乐高积木:你定义好积木(中间件、加载器),然后按需将它们组合到不同的路由上,最终构建出健壮且易于维护的应用流程。
3. 从零开始集成与基础配置
3.1 环境准备与安装
假设你已经有一个使用T3 Stack创建的Next.js项目(使用create-t3-app)。如果没有,可以快速创建一个:
npm create t3-app@latest my-t3-router-app cd my-t3-router-app npm install接下来,安装t3router。需要注意的是,vibheksoni/t3router可能是一个个人仓库,你需要检查其README中的安装说明。通常,这类库会发布到npm,或者你需要从GitHub直接安装。这里假设它已发布到npm(包名可能是t3router或@vibheksoni/t3router,请以实际为准):
npm install t3router # 或者,如果从GitHub安装 npm install vibheksoni/t3router同时,由于t3router深度依赖tRPC的上下文来进行类型安全和依赖注入,确保你的T3项目中的tRPC设置是完整的。create-t3-app已经帮你做好了这一切。
3.2 创建路由定义与中央路由器
安装完成后,第一步是创建一个地方来集中管理你的路由。我习惯在项目根目录下创建一个lib/router或server/router目录。
在lib/router/index.ts中,我们将创建并导出一个中央路由器实例:
// lib/router/index.ts import { createRouter, defineRoute } from 't3router'; import { authMiddleware } from './middleware/auth'; import { logMiddleware } from './middleware/log'; import { userLoader } from './loaders/user'; // 使用从tRPC继承的上下文类型,确保全栈类型安全 import type { Context } from '~/server/api/trpc'; // 创建路由器实例,传入tRPC的上下文类型 export const router = createRouter<Context>(); // 接下来,我们将在这里定义所有路由这里的关键是createRouter<Context>()。泛型Context来自你的tRPC上下文定义(通常在~/server/api/trpc.ts)。这确保了在路由中间件和加载器中,你可以访问到tRPC上下文中的所有东西,比如数据库实例prisma、会话信息session等,并且享受完整的TypeScript类型提示。
3.3 定义你的第一个路由
现在,让我们用defineRoute函数来定义一个简单的路由。假设我们有一个公开的“关于我们”页面。
首先,在lib/router/routes/目录下创建about.ts:
// lib/router/routes/about.ts import { defineRoute } from 't3router'; import AboutPage from '~/pages/about'; // 你的Next.js页面组件 export const aboutRoute = defineRoute({ // 路由路径,支持Next.js的动态路由语法,如 `/user/[id]` path: '/about', // 关联的页面组件 page: AboutPage, // 可以在这里定义该路由特定的中间件 middleware: [], // 对于公开页面,可能不需要中间件 // 可以在这里定义数据加载器 loader: async ({ ctx }) => { // 这里可以获取一些需要在“关于”页面显示的数据,比如公司信息 // 假设我们有一个获取公司信息的tRPC过程 const companyInfo = await ctx.prisma.companyInfo.findFirst(); return { companyInfo, }; }, });然后,在主路由器文件lib/router/index.ts中注册这个路由:
// lib/router/index.ts (续) import { aboutRoute } from './routes/about'; export const router = createRouter<Context>() // 使用 .add() 方法注册路由 .add(aboutRoute);注意:
defineRoute返回的路由对象包含了丰富的配置。loader函数返回的对象,会自动作为props传递给AboutPage组件。在页面组件里,你可以直接通过props.companyInfo访问到数据,类型也是自动推断的,非常方便。
3.4 与Next.js App Router集成
这是最关键的一步:如何让Next.js使用我们定义的路由。t3router通常需要一个“适配器”或“入口页面”来桥接。
在App Router(/app目录结构)下,常见的做法是创建一个“万能”的[...slug]路由文件。因为t3router要接管所有路由的匹配和渲染。
- 创建适配器页面:在
/app/目录下,创建[[...slug]]/page.tsx。注意是双括号[[...slug]],这表示它是一个可选的全捕获路由,能匹配根路径/。
// /app/[[...slug]]/page.tsx import { router } from '~/lib/router'; import { notFound } from 'next/navigation'; // 这个函数会在服务端运行,为页面准备数据 export async function generateMetadata({ params }: { params: { slug?: string[] } }) { // 你可以在这里根据路由动态生成页面的meta标签,需要从router获取相关信息 // 简化起见,这里可能返回一个通用配置或由路由loader提供 return { title: 'My T3 App', }; } export default async function CatchAllPage({ params, }: { params: { slug?: string[] }; }) { // 将Next.js的params转换为路由路径 const pathname = '/' + (params.slug?.join('/') || ''); // 使用路由器匹配当前路径 const match = router.match(pathname); // 如果没有匹配的路由,返回404 if (!match) { notFound(); } // 执行匹配路由的中间件链和loader const result = await match.execute(); // 如果中间件或loader中发生了重定向或错误,result会包含相应信息 if (result.redirect) { // 使用Next.js的redirect函数 redirect(result.redirect.destination); } if (result.error) { // 处理错误,可以渲染一个错误页面 throw new Error(result.error.message); } // 获取路由对应的页面组件和loader返回的数据 const { Page, data } = result; // 渲染页面组件,并传入数据作为props return <Page {...data} />; }- 修改全局布局:为了确保路由正常工作,你可能需要在
app/layout.tsx中提供tRPC的上下文。T3 Stack的create-t3-app通常已经设置好了api提供者。确保你的布局组件包裹了api提供者。
// /app/layout.tsx import { Inter } from "next/font/google"; import { TRPCReactProvider } from "~/trpc/react"; // T3 App自带的Provider const inter = Inter({ subsets: ["latin"] }); export default function RootLayout({ children, }: Readonly<{ children: React.ReactNode; }>) { return ( <html lang="en"> <body className={inter.className}> <TRPCReactProvider>{children}</TRPCReactProvider> </body> </html> ); }经过以上步骤,一个最基本的t3router集成就算完成了。当你访问/about时,Next.js会将请求交给我们的CatchAllPage,t3router会匹配到aboutRoute,执行其loader,获取数据,最后渲染AboutPage组件。
4. 高级功能实战:中间件、守卫与数据加载
4.1 构建可复用的中间件系统
中间件是t3router的灵魂。让我们创建几个实用的中间件。
1. 认证中间件 (authMiddleware)这是最常用的中间件,用于检查用户是否登录。
// lib/router/middleware/auth.ts import { createMiddleware } from 't3router'; import { redirect } from 'next/navigation'; export const authMiddleware = createMiddleware(async ({ ctx, next }) => { // 从tRPC上下文中获取会话。T3 Stack默认使用NextAuth.js,会话信息在ctx.session中 const session = ctx.session; if (!session || !session.user) { // 用户未登录,可以抛出一个错误,或者更优雅地重定向 // t3router通常支持在中间件中返回一个“中断结果” // 这里我们模拟一个重定向结果(具体API取决于t3router的实现) // 假设我们可以通过 `next()` 函数的某种方式中断,或者直接返回一个结果对象 // 以下是一种可能的实现方式(需查阅t3router具体文档): // return { redirect: { destination: '/api/auth/signin', permanent: false } }; // 作为示例,我们假设调用 `next()` 并传入一个“停止”信号或直接处理重定向。 // 更常见的模式是,在路由执行后的 `result` 中检查。 // 在实际中,你可能需要在中间件内部使用Next.js的`redirect`(注意:`redirect`在服务端组件中会抛出一个特殊错误)。 // 为了简化,我们可以在中间件中设置一个标志,在最终的 `CatchAllPage` 中处理重定向。 console.warn('Unauthenticated access attempt.'); // 一种方式:修改上下文,添加一个认证错误标记 ctx.authError = 'UNAUTHENTICATED'; } // 如果认证通过,将用户信息放入上下文,供后续中间件和loader使用 if (session?.user) { ctx.user = session.user; } // 调用 next() 继续执行下一个中间件或loader return next(); });2. 日志中间件 (logMiddleware)记录每个请求的基本信息。
// lib/router/middleware/log.ts import { createMiddleware } from 't3router'; import type { NextRequest } from 'next/server'; export const logMiddleware = createMiddleware(async ({ ctx, req, next }) => { const start = Date.now(); // 注意:在App Router中,`req` 的类型可能是 `NextRequest` const url = req?.url || 'unknown'; const method = req?.method || 'GET'; console.log(`[${new Date().toISOString()}] ${method} ${url} - Started`); // 执行后续操作 const result = await next(); const duration = Date.now() - start; console.log(`[${new Date().toISOString()}] ${method} ${url} - Completed in ${duration}ms`); return result; });3. 使用中间件现在,我们可以在定义路由时应用这些中间件。修改about.ts,为管理员的“关于”编辑页面添加认证和日志。
// lib/router/routes/admin-about.ts import { defineRoute } from 't3router'; import AdminAboutPage from '~/pages/admin/about'; import { authMiddleware } from '../middleware/auth'; import { logMiddleware } from '../middleware/log'; export const adminAboutRoute = defineRoute({ path: '/admin/about', page: AdminAboutPage, // 中间件按数组顺序执行 middleware: [logMiddleware, authMiddleware], loader: async ({ ctx }) => { // 到了这里,authMiddleware已经执行,如果未登录,ctx.authError会有值 // 我们可以在loader开始处进行统一检查(或者由路由守卫处理更优雅) if (ctx.authError) { // 返回一个错误或重定向标记,由页面组件或适配器处理 return { __authError: ctx.authError, }; } // 确保user存在(TypeScript会提示可能为undefined,需要处理) if (!ctx.user) { throw new Error('Unauthorized'); // 或返回错误信息 } // 只有管理员才能访问 if (ctx.user.role !== 'ADMIN') { return { __authError: 'FORBIDDEN' }; } const editableContent = await ctx.prisma.aboutPage.findUnique({ /* ... */ }); return { editableContent }; }, });4.2 实现精细化的路由守卫
上面的例子中,我们在loader里做了角色检查。这可行,但不够优雅。更好的做法是使用路由守卫。守卫本质上也是中间件,但更专注于权限验证。
我们可以创建一个adminGuard:
// lib/router/guards/admin.ts import { createMiddleware } from 't3router'; export const adminGuard = createMiddleware(async ({ ctx, next }) => { if (!ctx.user) { // 返回未认证信息,由上层处理重定向 return { error: { code: 'UNAUTHENTICATED', message: 'Please sign in.' } }; } if (ctx.user.role !== 'ADMIN') { // 返回权限不足信息 return { error: { code: 'FORBIDDEN', message: 'Insufficient permissions.' } }; } // 用户是管理员,放行 return next(); });然后在路由中使用它,可以放在authMiddleware之后:
// lib/router/routes/admin-about.ts (更新版) export const adminAboutRoute = defineRoute({ path: '/admin/about', page: AdminAboutPage, middleware: [logMiddleware, authMiddleware, adminGuard], // 守卫放在认证之后 loader: async ({ ctx }) => { // 现在这里可以安全地假设用户是管理员 const editableContent = await ctx.prisma.aboutPage.findUnique({ /* ... */ }); return { editableContent }; }, });这样,权限逻辑就从数据加载器中剥离出来,结构更清晰,也更容易复用。adminGuard可以用在任何需要管理员权限的路由上。
4.3 高效的数据加载器模式
loader是替代getServerSideProps的利器。它的强大之处在于可以组合和复用。
1. 组合式加载器假设多个页面都需要用户基本信息。我们可以创建一个通用的userProfileLoader。
// lib/router/loaders/userProfile.ts import type { LoaderFunction } from 't3router'; export const userProfileLoader: LoaderFunction = async ({ ctx }) => { // 假设ctx.user.id已经在authMiddleware中设置 if (!ctx.user?.id) { return { profile: null }; } const profile = await ctx.prisma.user.findUnique({ where: { id: ctx.user.id }, select: { name: true, email: true, avatar: true }, }); return { profile }; };然后在具体路由的loader中合并数据:
// lib/router/routes/dashboard.ts import { defineRoute } from 't3router'; import DashboardPage from '~/pages/dashboard'; import { authMiddleware } from '../middleware/auth'; import { userProfileLoader } from '../loaders/userProfile'; export const dashboardRoute = defineRoute({ path: '/dashboard', page: DashboardPage, middleware: [authMiddleware], loader: async (args) => { // 先加载用户资料 const profileData = await userProfileLoader(args); // 再加载仪表板特定数据 const dashboardStats = await args.ctx.prisma.order.aggregate({ /* ... */ }); // 合并数据 return { ...profileData, stats: dashboardStats, }; }, });2. 并行数据加载为了提高页面加载速度,我们可以在loader中使用Promise.all进行并行数据获取。
loader: async ({ ctx }) => { const [recentOrders, topProducts, userMessages] = await Promise.all([ ctx.prisma.order.findMany({ take: 10, orderBy: { createdAt: 'desc' } }), ctx.prisma.product.findMany({ where: { featured: true }, take: 5 }), ctx.prisma.message.findMany({ where: { userId: ctx.user!.id }, take: 5 }), ]); return { recentOrders, topProducts, userMessages }; },3. 处理加载器错误loader中的错误应该被妥善处理,而不是直接抛出导致页面崩溃。t3router通常允许在loader中返回错误状态。
loader: async ({ ctx }) => { try { const data = await fetchSomeData(); return { data }; } catch (error) { console.error('Loader failed:', error); // 返回一个错误标识,页面组件可以根据此标识显示错误UI return { __error: 'Failed to load data. Please try again later.' }; } },然后在页面组件中:
// ~/pages/dashboard.tsx interface DashboardProps { __error?: string; data: SomeDataType; } export default function DashboardPage({ __error, data }: DashboardProps) { if (__error) { return <div className="alert alert-error">{__error}</div>; } // 正常渲染... }5. 生产环境部署与性能优化实战
5.1 构建优化与代码分割
当使用t3router定义了大量路由后,一个潜在的担忧是打包体积。如果所有页面的loader逻辑和中间件都打包进主包,会导致初始加载缓慢。
幸运的是,t3router的设计通常能与Next.js的动态导入(dynamic import)很好地结合。关键在于动态导入页面组件。
在路由定义中,不要直接导入页面组件,而是使用Next.js的dynamic函数:
// lib/router/routes/heavy-page.ts import dynamic from 'next/dynamic'; import { defineRoute } from 't3router'; // 使用动态导入,并设置 loading 状态组件 const HeavyPageComponent = dynamic(() => import('~/pages/heavy-page'), { loading: () => <div>Loading heavy page...</div>, ssr: true, // 如果需要在服务端渲染,保持为true }); export const heavyPageRoute = defineRoute({ path: '/heavy', // 将动态导入的组件赋值给 page page: HeavyPageComponent, loader: async ({ ctx }) => { // loader 逻辑依然在服务端执行 const data = await fetchHeavyData(); return { data }; }, });这样,HeavyPageComponent及其依赖的代码会被自动分割到独立的chunk中,只有当用户访问/heavy路径时才会加载,有效优化了首屏加载性能。
对于中间件和loader函数,由于它们主要在服务端运行,对客户端包体积影响较小。但如果某个loader依赖了很大的客户端库(这种情况较少),也需要考虑将其动态导入或确保其被tree-shaking。
5.2 缓存策略与状态管理
在服务端渲染(SSR)场景下,loader函数可能会被频繁调用。为了提高性能,必须实施合理的缓存策略。
1. 使用React Cache (unstable_cache)Next.js 14+ 提供了unstable_cache函数,用于缓存服务端函数的结果。我们可以在loader中使用它来缓存数据库查询或API调用结果。
import { unstable_cache } from 'next/cache'; // 定义一个被缓存的查询函数 const getCachedDashboardData = unstable_cache( async (userId: string) => { console.log('Cache miss: Fetching dashboard data for', userId); return await prisma.dashboardData.findUnique({ where: { userId } }); }, ['dashboard-data'], // 缓存键前缀 { revalidate: 60, tags: [`dashboard:${userId}`] } // 60秒后重新验证,并打上标签 ); // 在loader中使用 loader: async ({ ctx }) => { const userId = ctx.user!.id; const data = await getCachedDashboardData(userId); return { data }; },2. 利用tRPC响应缓存如果你的T3 Stack项目使用了tRPC,并且loader中调用了tRPC过程,可以利用tRPC的服务器端缓存(通过ssg助手或responseMeta设置缓存头)。不过,在t3router的loader中直接操作数据库更常见,所以unstable_cache是更通用的选择。
3. 客户端状态管理对于从loader注入到页面的数据,如果它们是相对静态的(如用户资料、站点配置),可以考虑使用React Context或状态管理库(如Zustand)将其提升到全局,避免在页面切换时重复请求。但要注意,这可能会增加状态同步的复杂性。一个简单的模式是在_app.tsx或根布局中使用tRPC的useQuery预取数据并放入Context。
5.3 错误处理与监控
一个健壮的生产应用必须有完善的错误处理。
1. 全局错误边界在app/[[...slug]]/page.tsx中,我们已经处理了路由匹配失败(404)和loader执行后的重定向/错误。但还需要处理页面组件自身的渲染错误。可以在app/layout.tsx或app/[[...slug]]/page.tsx外层包裹React的ErrorBoundary。
// /app/[[...slug]]/page.tsx (部分) import { ErrorBoundary } from 'react-error-boundary'; function ErrorFallback({ error, resetErrorBoundary }: any) { return ( <div role="alert"> <p>Something went wrong:</p> <pre>{error.message}</pre> <button onClick={resetErrorBoundary}>Try again</button> </div> ); } export default async function CatchAllPage(/* ... */) { // ... 前面的匹配和执行逻辑 ... const { Page, data } = result; return ( <ErrorBoundary FallbackComponent={ErrorFallback} onReset={/* 重置逻辑 */}> <Page {...data} /> </ErrorBoundary> ); }2. 记录与监控在中间件和loader中,应该记录关键错误和性能指标,并发送到监控平台(如Sentry, LogRocket)。
// lib/router/middleware/log.ts (增强版) export const logMiddleware = createMiddleware(async ({ ctx, req, next }) => { const start = Date.now(); const url = req?.url || 'unknown'; try { const result = await next(); const duration = Date.now() - start; // 发送成功日志和性能指标到监控系统 monitor.logRequest({ url, duration, status: 'success' }); return result; } catch (error) { const duration = Date.now() - start; // 发送错误日志到监控系统 monitor.captureException(error, { extra: { url, duration } }); // 重新抛出错误,让上层错误边界处理 throw error; } });5.4 安全加固建议
- 输入验证: 在
loader中,对从动态路由参数(ctx.params)或查询字符串(ctx.query)获取的数据进行严格的验证和清理,防止注入攻击。可以使用Zod等库进行模式验证。import { z } from 'zod'; const paramsSchema = z.object({ id: z.string().uuid() }); loader: async ({ ctx }) => { const { id } = paramsSchema.parse(ctx.params); // 如果验证失败会抛出错误 // ... 使用安全的id进行查询 }, - 速率限制: 为敏感或高消耗的API路由(如果
t3router也用于API路由定义)添加速率限制中间件,防止暴力攻击。 - CORS配置: 如果你的应用需要服务第三方前端,确保在全局或特定路由上正确配置CORS中间件。
- 安全头: 使用类似
helmet的中间件或Next.js的安全头配置,为响应添加Content-Security-Policy,X-Frame-Options等安全头。
6. 常见问题排查与调试技巧
在实际使用t3router的过程中,你可能会遇到一些问题。以下是我总结的一些常见坑点和解决方法。
6.1 路由匹配失败(404)
- 症状: 访问任何路径都返回404,或者某些特定路径匹配不到。
- 排查步骤:
- 检查
[[...slug]]页面: 确保/app/[[...slug]]/page.tsx文件存在且路径正确。双括号[[...]]是必须的。 - 检查路径格式: 在
defineRoute中,path属性是否以/开头?动态路由参数(如[id])的语法是否正确?确保与Next.js的文件系统路由规则一致。 - 调试匹配逻辑: 在
CatchAllPage的router.match(pathname)前后打印pathname和match对象,看路径是否被正确解析和匹配。 - 路由注册顺序: 某些路由器库有路由顺序优先级(如先定义的优先)。检查是否有更宽泛的路由(如
/user/[id])覆盖了更具体的路由(如/user/create)。通常应该把具体路由放在前面,通用路由放在后面。
- 检查
6.2 类型错误:ctx上属性不存在
- 症状: TypeScript报错,提示
ctx.user、ctx.prisma等属性不存在。 - 解决方法:
- 扩展上下文类型:
createRouter<Context>()中的Context需要包含你自定义的属性。你需要在tRPC的上下文定义文件中进行扩展。
// ~/server/api/trpc.ts import { getServerAuthSession } from '~/server/auth'; export const createTRPCContext = async (opts: CreateNextContextOptions) => { const session = await getServerAuthSession(opts); return { session, prisma, // 来自全局的prisma实例 // 你可以在这里添加其他全局上下文,比如redis客户端等 }; }; // 然后,在 `t3router` 的中间件或loader中,你需要通过模块扩充(module augmentation)来告诉TypeScript你的ctx上有哪些自定义属性。 // 创建一个类型声明文件,例如 `lib/router/types.d.ts`// lib/router/types.d.ts import type { Context as TRPCContext } from '~/server/api/trpc'; declare module 't3router' { interface Context extends TRPCContext { user?: { id: string; role: string; name: string }; // 由authMiddleware添加 authError?: string; // 由authMiddleware添加 } }- 确保中间件正确修改上下文: 在中间件中,你向
ctx对象添加属性后,需要确保调用next()时传递了修改后的上下文。createMiddleware通常会自动处理这一点,但请查阅t3router的具体API。
- 扩展上下文类型:
6.3 中间件或Loader执行顺序不符合预期
- 症状: 认证中间件没生效,或者日志中间件在错误之后才记录。
- 解决方法:
- 检查中间件数组顺序: 中间件是按照在
middleware: []数组中定义的顺序执行的。确保依赖关系正确的顺序。例如,authMiddleware应该在adminGuard之前。 - 理解
next()的调用: 每个中间件必须调用await next()来将控制权传递给下一个中间件或最终的loader。如果某个中间件没有调用next()(比如直接返回了一个重定向或错误),那么后续的中间件和loader都不会执行。 - 使用调试日志: 在每个中间件的开始和结束处添加
console.log,清晰地看到执行流。
- 检查中间件数组顺序: 中间件是按照在
6.4 性能问题:页面加载缓慢
- 症状: 使用
t3router后,页面加载时间变长。 - 排查与优化:
- 分析
loader性能: 使用console.time或监控工具,测量每个loader函数的执行时间。找出慢查询。 - 实施缓存: 如5.2节所述,对昂贵的数据库查询或API调用使用
unstable_cache。 - 检查N+1查询问题: 在
loader中,如果循环调用Prisma,可能会导致N+1查询。使用Prisma的include或select进行关联查询优化。 - 代码分割: 确保页面组件使用了动态导入(
dynamic import),如5.1节所述。 - 中间件开销: 检查全局中间件是否做了不必要的复杂操作。如果某个中间件只对少数路由需要,不要将其设为全局。
- 分析
6.5 开发环境热重载(HMR)不工作
- 症状: 修改了路由定义或中间件后,需要手动重启开发服务器才能生效。
- 解决方法:
- 检查导入方式: 确保在
lib/router/index.ts中导入路由文件时没有使用动态导入或导致缓存失效的写法。通常,直接import即可。 - Next.js配置: 确保
next.config.js中没有禁用某些文件的热重载。通常不需要特殊配置。 - 重启开发服务器: 有时Next.js的HMR对于服务端文件的更改检测会有些延迟,尝试重启
npm run dev通常能解决。
- 检查导入方式: 确保在
6.6 与Next.js原生API路由冲突
- 症状: 定义了
/api/auth/[...nextauth]等Next.js原生API路由,但被t3router的[[...slug]]页面捕获。 - 解决方法:
- 路径排除: 在
CatchAllPage的router.match调用前,检查pathname是否以/api/开头。如果是,则直接返回,让Next.js处理。
export default async function CatchAllPage({ params }: { params: { slug?: string[] } }) { const pathname = '/' + (params.slug?.join('/') || ''); // 排除API路由 if (pathname.startsWith('/api/')) { // 返回一个空页面或null,Next.js会回退到其文件系统路由 // 但更好的方法是,在定义router时就不匹配/api路径。 // 这取决于t3router是否支持路由前缀排除。 // 如果不行,可以在这里直接返回一个简单的组件,或者尝试调用默认的Next.js处理(这比较复杂)。 // 一个简单粗暴的方法是:不处理/api路径,让它404,然后由Next.js自己的/api目录处理。 // 实际上,Next.js的文件系统路由优先级高于`app/[[...slug]]/page.tsx`吗?对于`/app/api`下的路由,是的。 // 所以,只要你的API路由放在`/app/api`目录下,它应该优先被匹配,不会走到这里。 // 确保你的API路由确实在`/app/api`下。 notFound(); // 或者 return null; } // ... 其余逻辑 }- 确保API路由位置正确: 在App Router下,API路由应放在
/app/api/目录下,例如/app/api/auth/[...nextauth]/route.ts。Next.js会优先匹配这些路由,然后才会落到[[...slug]]页面。
- 路径排除: 在
集成t3router确实需要一些前期配置和思维转换,但一旦跑通,它带来的结构清晰度、代码复用性和类型安全性,对于管理复杂的中大型T3 Stack项目来说是极具价值的。它迫使你更早地思考应用的路由架构和权限模型,从长远看,这是提高项目可维护性的关键投资。