Next.js 应用中路由路径的单一事实来源
了解如何在 Next.js 中使用编写的中央路由模块替代分散的字符串路径,该模块还提供了用于动态片段和分组路由导出的辅助函数。
大多数 Next.js 项目都是直接在 Link 组件和 router.push 调用中输入路径来开始的。对于最初的几页来说这很快,但随着应用规模的扩大,这些相同的字符串会在导航栏、页脚、重定向链接和邮件中重复出现,每次修改名称都变成一场查找替换的工作。当 AI 编码助手生成部分代码时,问题会变得更严重,因为它们很少能始终遵循统一的规范。将所有路径集中到一个模块中即可解决这个问题。
内联路径会带来哪些问题
以下是一个典型的导航组件,其中每个目标地址都是纯字符串:
// ❌ Problematic approach
import Link from 'next/link'
import { useRouter } from 'next/router'
export default function Navbar() {
const router = useRouter()
const handleNavigation = () => {
router.push('/dashboard/user/profile')
}
return (
<nav>
<Link href="/about">About</Link>
<Link href="/contact">Contact</Link>
<Link href="/blog">Blog</Link>
<Link href="/dashboard/analytics">Analytics</Link>
<button onClick={handleNavigation}>Profile</button>
</nav>
)
}
目前这里还没有问题,但随着代码库和团队的扩大,会出现四个问题。
修改名称变成一场苦差事
将 /blog 更改为 /articles 意味着需要在所有组件、服务器代码、测试用例以及配置文件中查找该路径的所有出现位置。若遗漏一处,就会留下失效链接。
拼写错误难以被发现
像 /dashbord 这样的路径仅是普通字符串,编译器与代码检查工具都无法识别它。这类错误通常会表现为404错误,有时仅在生产环境中才会显现。
编辑器无法提供帮助
由于路由是结构化的字符串,因此没有自动补全功能、没有“跳转至定义”的选项,也无法在路由不存在时发出警告。
团队成员之间出现分歧
不同的人以及不同的AI提示词会导致他们以略有差异的方式编写相同的路径地址:有的带尾斜杠,有的不带;有的手动构建查询字符串,等等。
将所有路由集中到单个模块中
解决方案是一个所有应用组件都会导入的配置文件。
文件的存放位置
将constants或config文件夹放在app和components旁边,这样便于查找:
/src
/app
/components
/constants
└── routes.ts
/lib
定义路由对象
下面的模块按功能区域对路由进行分类。静态页面为普通字符串,而动态页面则是小型函数,这些函数接收路径片段作为参数并返回完整路径,因此调用者无需手动拼接URL。API接口则存放在嵌套的API键下,以便在fetch调用中使用。末尾的as const能让TypeScript推断出确切的字面值,并将该对象标记为只读,从而实现精确的自动补全功能,同时防止意外重新赋值。
// constants/routes.ts
export const ROUTES = {
// Public routes
HOME: '/',
ABOUT: '/about',
CONTACT: '/contact',
PRICING: '/pricing',
// Auth routes
LOGIN: '/auth/login',
REGISTER: '/auth/register',
FORGOT_PASSWORD: '/auth/forgot-password',
// Blog routes
BLOG: '/blog',
BLOG_DETAIL: (slug: string) => `/blog/${slug}`,
BLOG_CATEGORY: (category: string) => `/blog/category/${category}`,
// Dashboard routes
DASHBOARD: '/dashboard',
DASHBOARD_ANALYTICS: '/dashboard/analytics',
DASHBOARD_SETTINGS: '/dashboard/settings',
DASHBOARD_PROFILE: '/dashboard/profile',
// Dynamic user routes
USER_PROFILE: (userId: string) => `/user/${userId}`,
USER_POSTS: (userId: string) => `/user/${userId}/posts`,
// API routes (useful for fetch calls)
API: {
USERS: '/api/users',
POSTS: '/api/posts',
AUTH: '/api/auth',
},
} as const
// Export route groups for better organization
export const AUTH_ROUTES = {
LOGIN: ROUTES.LOGIN,
REGISTER: ROUTES.REGISTER,
FORGOT_PASSWORD: ROUTES.FORGOT_PASSWORD,
} as const
export const DASHBOARD_ROUTES = {
HOME: ROUTES.DASHBOARD,
ANALYTICS: ROUTES.DASHBOARD_ANALYTICS,
SETTINGS: ROUTES.DASHBOARD_SETTINGS,
PROFILE: ROUTES.DASHBOARD_PROFILE,
} as const
另外两个导出项 AUTH_ROUTES 和 DASHBOARD_ROUTES 实际上是针对主要对象的视图,专为只关注特定功能的代码设计,比如用于保护控制面板的中间件。由于它们引用的是 ROUTES 而非重复的字符串,因此每个路径仍只有一份定义。
还有一个值得考虑的改进点:如果标识符或 ID 中可能包含空格或特殊字符,可在辅助函数中用 encodeURIComponent 将其包裹起来,这样每个调用者都能自动获得安全的 URL。
在组件中使用这些路由
现在导航栏导入的是名称而非字符串。Link 组件与命令式导航功能都从同一个模块读取数据,因此两者永远不会出现冲突:
// components/Navbar.tsx
import Link from 'next/link'
import { useRouter } from 'next/router'
import { ROUTES, DASHBOARD_ROUTES } from '@/constants/routes'
export default function Navbar() {
const router = useRouter()
const handleProfileNavigation = () => {
router.push(DASHBOARD_ROUTES.PROFILE)
}
return (
<nav className="flex space-x-4">
<Link href={ROUTES.ABOUT} className="hover:text-blue-500">
About
</Link>
<Link href={ROUTES.CONTACT} className="hover:text-blue-500">
Contact
</Link>
<Link href={ROUTES.BLOG} className="hover:text-blue-500">
Blog
</Link>
<Link href={DASHBOARD_ROUTES.ANALYTICS} className="hover:text-blue-500">
Analytics
</Link>
<button
onClick={handleProfileNavigation}
className="rounded bg-blue-500 px-4 py-2 text-white"
>
Profile
</button>
</nav>
)
}
请注意,此示例从属于 Pages Router 的 next/router 中导入 useRouter。在 App Router 项目中,则应在客户端组件中从 next/navigation 导入它;两种方式下路由模块的工作原理是相同的。
构建动态链接
动态片段正是这种模式发挥最大作用的地方。下方的列表通过使用文章数据调用辅助函数,从而生成指向每篇文章及其类别的链接:
// components/BlogList.tsx
import Link from 'next/link'
import { ROUTES } from '@/constants/routes'
interface Post {
slug: string
title: string
category: string
}
interface BlogListProps {
posts: Post[]
}
export default function BlogList({ posts }: BlogListProps) {
return (
<div className="space-y-4">
{posts.map((post) => (
<article key={post.slug} className="rounded border p-4">
<h2 className="text-xl font-bold">
<Link href={ROUTES.BLOG_DETAIL(post.slug)} className="hover:text-blue-500">
{post.title}
</Link>
</h2>
<Link
href={ROUTES.BLOG_CATEGORY(post.category)}
className="text-sm text-gray-500 hover:text-blue-500"
>
Category: {post.category}
</Link>
</article>
))}
</div>
)
}
如果文章的 URL 结构发生变更,例如增加了年份片段,只需更新一次 BLOG_DETAIL,所有调用者都会随之更新。TypeScript 也会标记出参数数量不正确的调用。
您将获得的好处
- 每个路径只需定义一次,即可在所有地方重复使用。
限制与替代方案
常量文件不会验证app或pages目录中是否存在对应的页面。如果文件夹被重命名而未更新模块,链接仍然会失效。Next.js还提供了类型化路由选项,能够从文件系统中生成类型;请查看最新文档以了解其现状以及是否适用于您的环境。许多团队会同时使用这两种方式:利用生成的类型确保代码正确性,再通过常量模块提供易于理解的名称和辅助功能。
当AI助手参与编写代码时,请在项目说明中要求他们从路由模块导入代码。
总结
应将URL视为应用程序API的一部分,而非零散的字符串。类型化且集中管理的路由模块能让文件夹重命名只需进行一次修改,在编辑器中就能发现错误,同时为开发人员和工具提供明确的查找位置。