首页 / 文章 / Next.js 应用中路由路径的单一事实来源

Next.js 应用中路由路径的单一事实来源

了解如何在 Next.js 中使用编写的中央路由模块替代分散的字符串路径,该模块还提供了用于动态片段和分组路由导出的辅助函数。

1222 词

大多数 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 也会标记出参数数量不正确的调用。

您将获得的好处

  • 每个路径只需定义一次,即可在所有地方重复使用。
  • 拼写错误的路由名称会在编译时出错,而不会返回404错误。
  • URL的修改只需进行单行编辑即可。
  • 随着页面数量增加,结构依然保持有序。
  • 编辑器会为每个目标地址提供自动补全和类型检查功能。
  • 相同的常量可用于导航栏、页脚、站点地图和面包屑导航。
  • 一个文件即可快速概览应用中的所有页面。
  • 限制与替代方案

    常量文件不会验证app或pages目录中是否存在对应的页面。如果文件夹被重命名而未更新模块,链接仍然会失效。Next.js还提供了类型化路由选项,能够从文件系统中生成类型;请查看最新文档以了解其现状以及是否适用于您的环境。许多团队会同时使用这两种方式:利用生成的类型确保代码正确性,再通过常量模块提供易于理解的名称和辅助功能。

    当AI助手参与编写代码时,请在项目说明中要求他们从路由模块导入代码。

    总结

    应将URL视为应用程序API的一部分,而非零散的字符串。类型化且集中管理的路由模块能让文件夹重命名只需进行一次修改,在编辑器中就能发现错误,同时为开发人员和工具提供明确的查找位置。