Tanner Linsley 写于 2025年6月3日.

搜索参数在历史上一直被视为二等状态。它们是全局的、可序列化的和可共享的——但在大多数应用程序中,它们仍然是通过字符串解析、松散的约定和脆弱的工具拼凑而成的。
即使是像验证 sort 参数这样简单的事情,也会很快变得冗长:
const schema = z.object({
sort: z.enum(['asc', 'desc']),
})
const raw = Object.fromEntries(new URLSearchParams(location.href))
const result = schema.safeParse(raw)
if (!result.success) {
// 后备、重定向或显示错误
}
const schema = z.object({
sort: z.enum(['asc', 'desc']),
})
const raw = Object.fromEntries(new URLSearchParams(location.href))
const result = schema.safeParse(raw)
if (!result.success) {
// 后备、重定向或显示错误
}
这行得通,但它是手动的且重复的。没有类型推断,与路由本身没有连接,并且一旦您想添加更多类型、默认值、转换或结构,它就会崩溃。
更糟糕的是,URLSearchParams 仅支持字符串。它不支持嵌套 JSON、数组(超出简单的逗号分隔)或类型强制。因此,除非您的状态是扁平且简单的,否则您很快就会遇到瓶颈。
这就是为什么我们开始看到旨在使搜索参数更具类型安全性和人体工程学的工具和提案的兴起——例如 Nuqs、Next.js RFC 和用户空间模式。其中大多数都专注于改进从 URL 中读取数据。
但几乎没有一个能解决更深层次、更困难的问题:安全地、原子地写入搜索参数,并充分了解路由上下文。
从 URL 读取是一回事。从代码构造一个有效的、有意的 URL 则是另一回事。
当您尝试这样做时:
<Link to="/dashboards/overview" search={{ sort: 'asc' }} />
<Link to="/dashboards/overview" search={{ sort: 'asc' }} />
您会意识到您根本不知道哪些搜索参数对此路由有效,或者您是否正确格式化了它们。即使有辅助函数将其字符串化,也没有任何东西强制调用者和路由之间的契约。没有类型推断,没有验证,也没有安全保障。
这就是约束成为特性的地方。
如果不明确地在路由本身中声明搜索参数模式,您就只能猜测。您可能在一个地方进行验证,但没有什么能阻止另一个组件使用无效、部分或冲突的状态进行导航。
约束使协调成为可能。它允许非本地调用者安全地参与。
像 Nuqs 这样的工具就是一个很好的例子,说明了本地抽象如何改进搜索参数处理的人体工程学。您可以获得 Zod 驱动的解析、类型推断,甚至是可写 API——所有这些都限定在特定的组件或钩子范围内。
它们使独立地读取和写入搜索参数变得更容易——这很有价值。
但它们并没有解决更广泛的协调问题。您仍然会得到重复的模式、脱节的期望,并且无法在路由或组件之间强制执行一致性。默认值可能会冲突。类型可能会漂移。当路由演变时,没有任何东西能保证所有调用者都会随之更新。
这才是真正的碎片化问题——解决它需要将搜索参数模式引入路由层本身。
TanStack Router 全面地解决了这个问题。
您不是在整个应用程序中分散模式逻辑,而是在路由本身内部定义它:
export const Route = createFileRoute('/dashboards/overview')({
validateSearch: z.object({
sort: z.enum(['asc', 'desc']),
filter: z.string().optional(),
}),
})
export const Route = createFileRoute('/dashboards/overview')({
validateSearch: z.object({
sort: z.enum(['asc', 'desc']),
filter: z.string().optional(),
}),
})
此模式成为单一的事实来源。您可以在任何地方获得完整的推断、验证和自动完成:
<Link
to="/dashboards/overview"
search={{ sort: 'asc' }} // 完全类型化,完全验证
/>
<Link
to="/dashboards/overview"
search={{ sort: 'asc' }} // 完全类型化,完全验证
/>
想要只更新搜索状态的一部分?没问题:
navigate({
search: (prev) => ({ ...prev, page: prev.page + 1 }),
})
navigate({
search: (prev) => ({ ...prev, page: prev.page + 1 }),
})
它是 Reducer 风格的、事务性的,并直接与路由器的响应式模型集成。组件仅在其使用的特定搜索参数更改时才重新渲染——而不是每次 URL 发生变化时都重新渲染。
当您的搜索参数逻辑存在于用户空间中——分散在钩子、工具和辅助函数中——出现冲突的模式只是时间问题。
也许一个组件期望 sort: 'asc' | 'desc'。另一个组件添加了一个 filter。第三个组件默认假定 sort: 'desc'。它们都没有共享的事实来源。
这会导致:
TanStack Router 通过将模式直接绑定到您的路由定义来防止这种情况发生——分层地。
父路由可以定义共享的搜索参数验证。子路由继承该上下文,以类型安全的方式添加或扩展它。这使得在应用程序的不同部分意外创建重叠、不兼容的模式变得不可能。
以下是它在实践中的工作方式:
// routes/dashboard.tsx
export const Route = createFileRoute('/dashboard')({
validateSearch: z.object({
sort: z.enum(['asc', 'desc']).default('asc'),
}),
})
// routes/dashboard.tsx
export const Route = createFileRoute('/dashboard')({
validateSearch: z.object({
sort: z.enum(['asc', 'desc']).default('asc'),
}),
})
然后子路由可以安全地扩展该模式:
// routes/dashboard/$dashboardId.tsx
export const Route = createFileRoute('/dashboard/$dashboardId')({
validateSearch: z.object({
filter: z.string().optional(),
// ✅ `sort` 会自动从父级继承
}),
})
// routes/dashboard/$dashboardId.tsx
export const Route = createFileRoute('/dashboard/$dashboardId')({
validateSearch: z.object({
filter: z.string().optional(),
// ✅ `sort` 会自动从父级继承
}),
})
当您匹配 /dashboard/123?sort=desc&filter=active 时,父级验证 sort,子级验证 filter,一切都无缝协作。
尝试在子路由中将所需的父参数重新定义为完全不同的内容?类型错误。
validateSearch: z.object({
// ❌ 类型错误:布尔值不能扩展父级的 'asc' | 'desc'
sort: z.boolean(),
filter: z.string().optional(),
})
validateSearch: z.object({
// ❌ 类型错误:布尔值不能扩展父级的 'asc' | 'desc'
sort: z.boolean(),
filter: z.string().optional(),
})
这种强制执行使嵌套路由既可组合又安全——这是一种罕见的组合。
这里的诀窍在于您不需要教您的团队遵守约定。路由拥有模式。每个人都只是使用它。没有重复。没有漂移。没有无声的错误。没有猜测。
当您将验证、类型化和所有权引入路由器本身时,您就不再将 URL 视为字符串,而是开始将它们视为真正的状态——因为它们就是这样。
大多数路由系统都将搜索参数视为事后诸葛亮。您可以读取、也许解析、也许字符串化,但很少能真正信任它们。
TanStack Router 彻底改变了这一点。它使搜索参数成为路由契约的核心部分——经过验证、可推断、可写且具有响应性。
因为如果您不将搜索参数视为状态,您就会不断地泄漏它、破坏它并围绕它进行变通。
最好从一开始就正确对待它。
如果您对将搜索参数视为一等状态的可能性感兴趣,我们邀请您试用 TanStack Router。在您的路由逻辑中体验经过验证、可推断且具有响应性的搜索��数的强大功能。