TanStack Router 搜索参数完全指南:从 JSON 序列化到类型安全校验与搜索中间件
发布时间:2026/9/15 1:02:17来源:尧图网络
TanStack Router 搜索参数完全指南从 JSON 序列化到类型安全校验与搜索中间件【免费下载链接】router A client-first, server-capable, fully type-safe router and full-stack framework for the web (React and more).项目地址: https://gitcode.com/GitHub_Trending/ro/routerTanStack Router本仓库中即packages/react-router等包的源码项目将 URL 搜索参数视为一种第一公民的全局状态它提供了 JSON-first 的解析器、类型安全的validateSearch校验管道以及可组合的搜索中间件search middlewares。本文围绕 docs/router/guide/search-params.md 展开从为什么原生URLSearchParams不够用讲起完整覆盖如何用 Zod / Valibot / Arktype / Effect Schema 校验并推断搜索参数类型如何在 loader、组件与非路由组件中读取它们以及如何通过Link、navigate、Navigate和搜索中间件精确写入与转换 URL 查询串。读完本文你将能构建一套可分享、可收藏、可刷新恢复、完全类型安全的 URL 状态方案。为什么不能只依赖原生URLSearchParams多使用平台能力是前端社区常被鼓励的方向但在搜索参数这一场景下原生URLSearchParams对于进阶用例存在明显短板。它默认假设了几个前提搜索参数永远是字符串搜索参数基本是扁平的使用URLSearchParams完成序列化/反序列化已经足够其实并不搜索参数的修改必须与路径名一起更新即使路径名并未变化。而真实的应用需求与这些假设截然不同搜索参数本质上是应用状态因此开发者期望它拥有与其它状态管理器一致的开发体验DX能够区分原始值类型数字、布尔、字符串能够高效存取嵌套数组与对象序列化/反序列化的方式存在多种取舍开发者应当选择最适合自己应用的方式至少也应获得一个比URLSearchParams更好的默认实现不可变性与结构共享每次对 URL 搜索参数进行 stringify/parse引用完整性与对象同一性都会丢失——每一次解析都会产生一个全新内存引用的数据结构。在 React依赖不可变性追踪响应性或 Solid依赖 reconciliation 感知反序列化数据源的变化这类框架中持续的序列化解析会带来意外且不理想的性能问题搜索参数虽然属于 URL 的一部分却经常独立于路径名变化。例如用户只想翻页而完全不必改动路径名。仓库对此的默认实现位于 packages/router-core/src/searchParams.tsdefaultParseSearch parseSearchWith(JSON.parse)defaultStringifySearch stringifySearchWith(JSON.stringify, JSON.parse)。parseSearchWith会先去掉开头的?用decodeqss 编码拆出扁平键值再尝试把字符串值交给JSON.parse——并借助jsonStart正则/^(?:\s|[[{\d-]|fa|nu|tr)/快速跳过明显不是 JSON 的字符串避免无谓的解析开销解析失败则静默保留原始字符串。这保证了第一层扁平、字符串安全同时深层结构自动 JSON 化。 如果应用需要兼容 IE11 等老旧浏览器则可能需要为URLSearchParams引入 polyfill。搜索参数URL 中的元祖级状态管理器你一定见过?page3、?filter-nametanner这样的 URL它们本质上是住在 URL 里的全局状态。把特定状态放进 URL 之所以有价值是因为它同时服务了用户与开发者两端用户侧按住 Cmd/Ctrl 点击链接在新标签页打开时能可靠地看到期望的状态收藏、分享链接时对方打开后看到的正是复制链接那一刻的状态刷新页面或前进/后退时状态不会丢失。开发者侧以与其它状态管理器一致的 DX 增、删、改 URL 中的状态轻松地以应用可安全消费的格式与类型校验来自 URL 的搜索参数读写搜索参数时无需关心底层序列化格式。JSON-first 搜索参数默认的序列化策略为实现上述目标TanStack Router 内置的第一步就是一套强大的搜索参数解析器它自动把 URL 查询串解析为结构化 JSON。任何 JSON 可序列化的数据结构都能存入搜索参数并按 JSON 解析/序列化——这相比URLSearchParams对数组结构与嵌套数据支持有限是巨大的改进。例如渲染下面的Linkconst link ( Link to/shop search{{ pageIndex: 3, includeCategories: [electronics, gifts], sortBy: price, desc: true, }} / )会生成如下 URL/shop?pageIndex3includeCategories%5B%22electronics%22%2C%22gifts%22%5DsortBypricedesctrueURL 被解析后搜索参数会精确还原为以下 JSON{ pageIndex: 3, includeCategories: [electronics, gifts], sortBy: price, desc: true }这里有三个关键行为搜索参数的第一层保持扁平、字符串化与URLSearchParams兼容第一层的非字符串值数字、布尔被精确保留为原始类型嵌套数据结构自动转换为 URL 安全的 JSON 字符串。 其它工具常假设搜索参数是扁平且字符串化的因此 TanStack Router 刻意保持第一层与URLSearchParams兼容即便它把嵌套搜索参数当作 JSON 管理其它工具仍能正常写入 URL 并读取第一层参数。从源码看stringifySearchWithpackages/router-core/src/searchParams.ts#L67-L100对对象类型值调用JSON.stringify对字符串值则会先尝试用 parser 验证其是否为可解析 JSON是则重新序列化以保证与parseSearch的对称性。如果你需要完全自定义的序列化方案例如改用 base64 或其它格式可以基于parseSearchWith/stringifySearchWith组合自己的 parser 与 stringifier 后传入 Router 的parseSearch/stringifySearch选项见 packages/router-core/src/router.ts#L212-L220。校验与类型化搜索参数尽管 TanStack Router 能把搜索参数解析成可靠的 JSON它们终究来自用户可见的原始文本输入。与其他序列化边界一样消费前应当把它们校验成应用可信任的格式。走进校验 TypeScript这一切从Route的validateSearch选项开始type ProductSearchSortOptions newest | oldest | price type ProductSearch { page: number filter: string sort: ProductSearchSortOptions } export const Route createFileRoute(/shop/products)({ validateSearch: (search: Recordstring, unknown): ProductSearch { // 校验并把搜索参数解析成有类型的状态 return { page: Number(search?.page ?? 1), filter: (search.filter as string) || , sort: (search.sort as ProductSearchSortOptions) || newest, } }, })上面我们校验了Route的搜索参数并返回一个带类型的ProductSearch对象。这个带类型的对象对该路由的其它选项以及所有子路由都可用。在 packages/router-core/src/route.ts#L975 中validateSearch的类型被约束为ConstrainTSearchValidator, AnyValidator, DefaultValidator其输入正是 JSON 解析后但未校验的Recordstring, unknown。validateSearch是一个函数接收 JSON 解析后的搜索参数Recordstring, unknown返回你选择的有类型对象。通常最好为格式错误或意外的搜索参数提供合理的兜底值避免打断用户体验。用 Zod 同时完成校验与类型化可以使用任意校验库例如 Zod在一个步骤内完成校验与类型化import { z } from zod const productSearchSchema z.object({ page: z.number().catch(1), filter: z.string().catch(), sort: z.enum([newest, oldest, price]).catch(newest), }) type ProductSearch z.infertypeof productSearchSchema export const Route createFileRoute(/shop/products)({ validateSearch: (search) productSearchSchema.parse(search), })因为validateSearch也接受带有parse属性的对象所以可以进一步简写validateSearch: productSearchSchema这里推荐使用 Zod 的.catch()而不是.default()如果某个搜索参数格式错误你通常不想中断用户的浏览体验、弹出一个大错误提示。当然也存在确实需要展示错误的场景此时改用.default()即可。底层机制在于validateSearch函数抛出错误后路由的onError选项会被触发此时error.routerCode会被设为VALIDATE_SEARCH并且errorComponent会被渲染以替代路由的component——你可以在其中按需处理搜索参数错误。Adapters为校验库打通 input/output 类型当使用 Zod 之类的库校验搜索参数时你可能会希望在参数提交到 URL 之前执行transformZod 中常见的 transform 就是defaultimport { z } from zod const productSearchSchema z.object({ page: z.number().default(1), filter: z.string().default(), sort: z.enum([newest, oldest, price]).default(newest), }) export const Route createFileRoute(/shop/products/)({ validateSearch: productSearchSchema, })此时直接导航到该路由search却是必填的——下面的Link会因缺少search而报类型错误Link to/shop/products /因此对校验库官方推荐使用adapters来推断正确的input与output类型。adapter 的实现本质可以参照 packages/zod-adapter/src/index.tszodValidator读取 schema 的_input/_output或显式传入的input/output选项构造出ValidatorAdapterInput, Output并透传parse调用。Zod针对 Zod 提供了官方 adaptertanstack/zod-adapter会正确连通input与output类型。Zod v3import { zodValidator } from tanstack/zod-adapter import { z } from zod const productSearchSchema z.object({ page: z.number().default(1), filter: z.string().default(), sort: z.enum([newest, oldest, price]).default(newest), }) export const Route createFileRoute(/shop/products/)({ validateSearch: zodValidator(productSearchSchema), })Zod v4直接使用 schema 作为validateSearch即可import { z } from zod const productSearchSchema z.object({ page: z.number().default(1), filter: z.string().default(), sort: z.enum([newest, oldest, price]).default(newest), }) export const Route createFileRoute(/shop/products/)({ // Zod v4 下无需 adapter可直接使用 schema validateSearch: productSearchSchema, })关键变化是下面这个Link不再要求提供search参数Link to/shop/products /在 Zod v3 中catch会覆盖类型推断使page、filter、sort变成unknown从而丢失类型。adapter 包为此提供了fallback泛型函数保留类型的同时在校验失败时提供兜底值import { fallback, zodValidator } from tanstack/zod-adapter import { z } from zod const productSearchSchema z.object({ page: fallback(z.number(), 1).default(1), filter: fallback(z.string(), ).default(), sort: fallback(z.enum([newest, oldest, price]), newest).default( newest, ), }) export const Route createFileRoute(/shop/products/)({ validateSearch: zodValidator(productSearchSchema), })从实现上看fallback实际构造了一个z.customTSchema[_input]().pipe(schema.catch(fallback))管道packages/zod-adapter/src/index.ts#L61-L69先用z.custom保留输入类型再通过catch兜底。因此在导航到该路由时search变为可选且保留正确类型。Zod v4 中则可以直接使用catch类型推断全程保留。虽然不推荐但你也可以显式配置input/output类型当output类型比input更精确时const productSearchSchema z.object({ page: fallback(z.number(), 1).default(1), filter: fallback(z.string(), ).default(), sort: fallback(z.enum([newest, oldest, price]), newest).default( newest, ), }) export const Route createFileRoute(/shop/products/)({ validateSearch: zodValidator({ schema: productSearchSchema, input: output, output: input, }), })这为导航时推断哪种类型、读取搜索参数时推断哪种类型提供了灵活性。对应地zodValidator在传入选项对象时会读取options.schema._input/_output并依据input: output之类的开关在types.input/types.output之间交换packages/zod-adapter/src/index.ts#L42-L59。Valibot[!WARNING] Router 要求安装 valibot 1.0 包。Valibot 实现了 Standard Schema 规范因此无需 adapter即可保证导航与读取搜索参数时使用正确的input/output类型import * as v from valibot const productSearchSchema v.object({ page: v.optional(v.fallback(v.number(), 1), 1), filter: v.optional(v.fallback(v.string(), ), ), sort: v.optional( v.fallback(v.picklist([newest, oldest, price]), newest), newest, ), }) export const Route createFileRoute(/shop/products/)({ validateSearch: productSearchSchema, })Arktype[!WARNING] Router 要求安装 arktype 2.0-rc 包。ArkType 同样实现了 Standard Schema因此无需 adapterimport { type } from arktype const productSearchSchema type({ page: number 1, filter: string , sort: newest | oldest | price newest, }) export const Route createFileRoute(/shop/products/)({ validateSearch: productSearchSchema, })Effect/SchemaEffect/Schema 实现了 Standard Schema见其 standard-schema 文档同样无需 adapterimport { Schema as S } from effect const productSearchSchema S.standardSchemaV1( S.Struct({ page: S.NumberFromString.pipe( S.optional, S.withDefaults({ constructor: () 1, decoding: () 1, }), ), filter: S.String.pipe( S.optional, S.withDefaults({ constructor: () , decoding: () , }), ), sort: S.Literal(newest, oldest, price).pipe( S.optional, S.withDefaults({ constructor: () newest as const, decoding: () newest as const, }), ), }), ) export const Route createFileRoute(/shop/products/)({ validateSearch: productSearchSchema, })本仓库还在packages/下提供了独立的 valibot-adapter、arktype-adapter 与 zod-adapter 包并有对应tests/目录中的单元测试可参考。读取搜索参数一旦搜索参数完成校验与类型化就可以开始读写它们了。TanStack Router 提供了多种读取方式。在 Loaders 中使用搜索参数请阅读 Search Params in Loaders 一节了解如何通过loaderDeps选项在 loader 中读取搜索参数。搜索参数从父路由继承随着路由树的深入父路由的搜索参数与类型会合并到子路由因此子路由也能访问父级的搜索参数const productSearchSchema z.object({ page: z.number().catch(1), filter: z.string().catch(), sort: z.enum([newest, oldest, price]).catch(newest), }) type ProductSearch z.infertypeof productSearchSchema export const Route createFileRoute(/shop/products)({ validateSearch: productSearchSchema, })export const Route createFileRoute(/shop/products/$productId)({ beforeLoad: ({ search }) { search // ^? ProductSearch ✅ }, })在组件中读取搜索参数可以在路由的component中通过useSearchhook 访问已校验的搜索参数export const Route createFileRoute(/shop/products)({ validateSearch: productSearchSchema, }) const ProductList () { const { page, filter, sort } Route.useSearch() return div.../div }[!TIP] 如果组件被代码分割code-split可以使用 getRouteApi 函数 避免导入Route配置从而拿到带类型的useSearch()hook。在路由组件之外读取搜索参数你可以在应用的任何位置使用useSearchhook。通过传入源路由的fromid/路径可以获得更强的类型安全// src/routes/shop.products.tsx export const Route createFileRoute(/shop/products)({ validateSearch: productSearchSchema, // ... }) // 其它位置... // src/components/product-list-sidebar.tsx const routeApi getRouteApi(/shop/products) const ProductList () { const routeSearch routeApi.useSearch() // 或者 const { page, filter, sort } useSearch({ from: Route.fullPath, }) return div.../div }也可以放宽类型安全通过strict: false获得可选的search对象function ProductList() { const search useSearch({ strict: false, }) // { // page: number | undefined // filter: string | undefined // sort: newest | oldest | price | undefined // } return div.../div }写入搜索参数掌握了读取方法后会发现更新搜索参数的核心 API 你已经见过了。Link search /更新搜索参数的最佳方式是Link /组件的searchprop。如果只更新当前页面的搜索参数且指定了fromprop那么toprop 可以省略。例如export const Route createFileRoute(/shop/products)({ validateSearch: productSearchSchema, }) const ProductList () { return ( div Link from{Route.fullPath} search{(prev) ({ page: prev.page 1 })} Next Page /Link /div ) }如果你想在渲染于多个路由的通用组件中更新搜索参数指定from会比较麻烦。此时可以设置to.获得宽松类型的搜索参数// page 是定义在 __root 路由中的搜索参数因此对所有路由都可用 const PageSelector () { return ( div Link to. search{(prev) ({ ...prev, page: prev.page 1 })} Next Page /Link /div ) }如果通用组件只渲染在路由树的特定子树下可以用from指定该子树此时to.也可以省略// page 是定义在 /posts 路由中的搜索参数因此对其所有子路由都可用 const PageSelector () { return ( div Link from/posts to. search{(prev) ({ ...prev, page: prev.page 1 })} Next Page /Link /div ) }useNavigate(), navigate({ search })navigate函数同样接受search选项行为与Link /的searchprop 一致export const Route createFileRoute(/shop/products/$productId)({ validateSearch: productSearchSchema, }) const ProductList () { const navigate useNavigate({ from: Route.fullPath }) return ( div button onClick{() { navigate({ search: (prev) ({ page: prev.page 1 }), }) }} Next Page /button /div ) }router.navigate({ search })router.navigate的行为与上面的useNavigate/navigate完全一致。Navigate search /Navigate search /组件的行为与useNavigate/navigate一致只是把选项作为 props 传入而非函数参数。使用搜索中间件转换搜索参数在构建链接 href 时默认情况下查询串部分只取决于Link的search属性。TanStack Router 提供了搜索中间件search middlewares用于在生成 href 之前操纵搜索参数——在路由或其子路由生成新链接时转换搜索参数同时在导航完成搜索校验之后执行以便操纵查询串。下面这个例子保证每次构建链接时只要rootValue存在于当前搜索参数中就把它加入链接如果链接自身的search里显式指定了rootValue则优先使用该值import { z } from zod import { zodValidator } from tanstack/zod-adapter const searchSchema z.object({ rootValue: z.string().optional(), }) export const Route createRootRoute({ validateSearch: zodValidator(searchSchema), search: { middlewares: [ ({ search, next }) { const result next(search) return { rootValue: search.rootValue, ...result, } }, ], }, })retainSearchParams保留关键参数由于上述场景非常常见TanStack Router 提供了通用实现retainSearchParams// React import { z } from zod import { createFileRoute, retainSearchParams } from tanstack/react-router import { zodValidator } from tanstack/zod-adapter const searchSchema z.object({ rootValue: z.string().optional(), }) export const Route createRootRoute({ validateSearch: zodValidator(searchSchema), search: { middlewares: [retainSearchParams([rootValue])], }, })// Solid import { z } from zod import { createFileRoute, retainSearchParams } from tanstack/solid-router import { zodValidator } from tanstack/zod-adapter const searchSchema z.object({ rootValue: z.string().optional(), }) export const Route createRootRoute({ validateSearch: zodValidator(searchSchema), search: { middlewares: [retainSearchParams([rootValue])], }, })从实现packages/router-core/src/searchMiddleware.ts#L25-L89看retainSearchParams接受键数组或true保留全部。它先调用next(search)得到下游中间件的结果然后依据meta.explicit显式指定的键、meta.removed被移除且与当前值深度相等的键、meta.removedAny与meta.defaulted等元信息把当前搜索中需要保留的键合并进结果——keys true时对全量键做同样的合并逻辑。stripSearchParams剥离默认值参数另一个常见需求是当搜索参数取默认值时从链接中剥离该参数。TanStack Router 通过stripSearchParams提供通用实现// React import { z } from zod import { createFileRoute, stripSearchParams } from tanstack/react-router import { zodValidator } from tanstack/zod-adapter const defaultValues { one: abc, two: xyz, } const searchSchema z.object({ one: z.string().default(defaultValues.one), two: z.string().default(defaultValues.two), }) export const Route createFileRoute(/hello)({ validateSearch: zodValidator(searchSchema), search: { // 剥离默认值 middlewares: [stripSearchParams(defaultValues)], }, })// Solid import { z } from zod import { createFileRoute, stripSearchParams } from tanstack/solid-router import { zodValidator } from tanstack/zod-adapter const defaultValues { one: abc, two: xyz, } const searchSchema z.object({ one: z.string().default(defaultValues.one), two: z.string().default(defaultValues.two), }) export const Route createFileRoute(/hello)({ validateSearch: zodValidator(searchSchema), search: { // 剥离默认值 middlewares: [stripSearchParams(defaultValues)], }, })stripSearchParamspackages/router-core/src/searchMiddleware.ts#L101-L140支持三种输入形态传入true仅当不存在必填搜索参数时可用剥离全部传入键数组则总是移除这些可选键传入默认值对象则移除与默认值深度相等的键用deepEqual判断并记录到meta.removed/meta.removedAny供后续中间件感知。链式组合多个中间件中间件可以链式组合。下面的例子同时使用retainSearchParams与stripSearchParams// React import { Link, createFileRoute, retainSearchParams, stripSearchParams, } from tanstack/react-router import { z } from zod import { zodValidator } from tanstack/zod-adapter const defaultValues [foo, bar] export const Route createFileRoute(/search)({ validateSearch: zodValidator( z.object({ retainMe: z.string().optional(), arrayWithDefaults: z.string().array().default(defaultValues), required: z.string(), }), ), search: { middlewares: [ retainSearchParams([retainMe]), stripSearchParams({ arrayWithDefaults: defaultValues }), ], }, })// Solid import { Link, createFileRoute, retainSearchParams, stripSearchParams, } from tanstack/solid-router import { z } from zod import { zodValidator } from tanstack/zod-adapter const defaultValues [foo, bar] export const Route createFileRoute(/search)({ validateSearch: zodValidator( z.object({ retainMe: z.string().optional(), arrayWithDefaults: z.string().array().default(defaultValues), required: z.string(), }), ), search: { middlewares: [ retainSearchParams([retainMe]), stripSearchParams({ arrayWithDefaults: defaultValues }), ], }, })在 packages/router-core/src/router.ts#L2824-L2911 中可以看到中间件的执行机制路由从父到子收集routeOptions.search?.middlewares组成数组依次串联成洋葱模型——每个中间件通过next(search)调用链中的下一个最终返回稳定的字符串化 location 对象校验validate也被作为链中的一个环节参与执行。中间件顺序即数组顺序因此retainSearchParams会先于stripSearchParams生效先保留retainMe再剥离取默认值的arrayWithDefaults。小结TanStack Router 把搜索参数从扁平、字符串、与路径耦合的原始工具提升为结构化、可校验、可继承、可组合的一等状态源JSON-first 的默认解析/序列化保证了复杂结构的可存取实现于 searchParams.tsvalidateSearch与 Zod/Valibot/Arktype/Effect Schema 等校验管道配合 zod-adapter 等适配器保证了类型安全与容错useSearch、Link search、navigate等 API 让读写如状态管理器般自然而retainSearchParams/stripSearchParams等搜索中间件实现于 searchMiddleware.ts让链接生成与导航时的查询串变换高度可定制。读者可以在本仓库的packages/react-router、packages/solid-router、packages/vue-router及其tests/中进一步探索各框架下的具体实现与用例并在 docs/router/guide 中继续阅读 contenteditable="false">【免费下载链接】router A client-first, server-capable, fully type-safe router and full-stack framework for the web (React and more).项目地址: https://gitcode.com/GitHub_Trending/ro/router创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
网站建设高端定制企业官网