TanStack Router 自定义搜索参数序列化指南:用 parseSearchWith 与 stringifySearchWith 替换默认 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/router
TanStack Router 默认使用JSON.stringify/JSON.parse自动解析和序列化 URL 中的搜索参数(Search Params),但这一行为并不适合所有场景——你需要 base64 编码以保证跨浏览器与 URL 分享解析器(unfurlers)的兼容性,或希望引入query-string、JSURL2、Zipson等专用压缩/解析库。本文基于 custom-search-param-serialization.md 官方指南展开,完整覆盖默认行为、幂等性原则、四种自定义序列化方案与安全二进制编解码工具函数,并结合 router-core 源码 与 测试用例 深入剖析其底层调用链,帮助你在 React、Solid(以及同样导出这两个辅助函数的 Vue)项目中安全地定制 URL 搜索参数格式。
默认序列化行为:JSON 编解码 + URL 转义
路由器创建后,每一个包含搜索参数的链接生成都要经过“序列化(stringify)”这一步;而每次 URL 变化时,当前 location 的搜索字符串又要经过“反序列化(parse)”还原为对象。默认实现即:
// React import { createRouter, parseSearchWith, stringifySearchWith, } from '@tanstack/react-router' const router = createRouter({ // ... parseSearch: parseSearchWith(JSON.parse), stringifySearch: stringifySearchWith(JSON.stringify), })// Solid import { createRouter, parseSearchWith, stringifySearchWith, } from '@tanstack/solid-router' const router = createRouter({ // ... parseSearch: parseSearchWith(JSON.parse), stringifySearch: stringifySearchWith(JSON.stringify), })也就是说,给定如下搜索对象:
const search = { page: 1, sort: 'asc', filters: { author: 'tanner', min_words: 800 }, }默认配置下它会先被JSON.stringify序列化,再经过 URL 转义(escaping),最终得到:
?page=1&sort=asc&filters=%7B%22author%22%3A%22tanner%22%2C%22min_words%22%3A800%7D可以看到,filters这个嵌套对象被整体编码成了一段%7B...%7D(即{"author":"tanner","min_words":800})的百分号转义串,而page、sort这类原始值则保持可读。
源码视角:默认实现如何工作
在仓库中,默认行为定义于 searchParams.ts:
/** 默认 `parseSearch`:剥掉开头的 '?' 并尝试对值做 JSON.parse。 */ export const defaultParseSearch = parseSearchWith(JSON.parse) /** 默认 `stringifySearch`:复杂值使用 JSON.stringify 序列化。 */ export const defaultStringifySearch = stringifySearchWith( JSON.stringify, JSON.parse, )parseSearchWith的核心逻辑(searchParams.ts#L26-L53):
- 若搜索串以
?开头则先剥掉; - 调用 qss.ts 中的
decode函数(基于URLSearchParams重写,负责解转义)将字符串解成键值对象; - 对每个字符串值尝试用你提供的
parser(默认JSON.parse)解析,解析失败则静默保留原字符串。
值得注意的是,源码里有一个性能与健壮性并重的守卫:
// JSON 值只可能以空白、"、[、{、数字、- 或 fa/nu/tr 开头。 // 误报会安全地落入 JSON.parse。 const jsonStart = /^(?:\s|["[{\d-]|fa|nu|tr)/只有当解析器恰好是JSON.parse时,才会先用jsonStart正则预判:像filter=foo、path=/products这类不可能构成合法 JSON 的值会被直接跳过,避免无谓的JSON.parse开销。这一行为由测试 searchParams.test.ts#L113-L133 中的parseSpy断言验证——对?empty=&filter=foo&tab=specs&sort=newest这类输入,JSON.parse一次都不会被调用。而当你传入自定义解析器(非JSON.parse)时,该守卫不生效,每个字符串值都会交给你的解析器处理,这给了你完全的自由度,也意味着你需要保证解析器对任意输入都是安全的。
stringifySearchWith(searchParams.ts#L67-L100)则对称地工作:
- 对象值直接交给你的
stringify函数; - 若提供了可选的
parser参数,它会先尝试parser(val)——如果字符串本身可被解析(例如字符串"123"),就重新stringify一次,从而保证“序列化后反序列化能拿回原对象”的对称性; - 其余原始值原样返回,最终由
qss.ts的encode(基于URLSearchParams)完成转义与拼接。
这个对称性在 测试用例 中被逐条验证,例如{ foo: 123 }序列化为?foo=123,而字符串{ foo: '123' }会被额外包上引号编码为?foo=%22123%22,防止往返后类型被错误地还原成数字;重复键(如?foo=1&foo=2)会被解析为数组{ foo: [1, 2] }。
在路由器中的实际调用点
parseSearch与stringifySearch并非只影响展示,它们嵌入在路由器的 URL 处理主链路上。从 router.ts 源码结构看,至少有以下几处调用点:
buildLocation/buildHref中生成链接时:先parseSearch(search)再stringifySearch(parsedSearch),保证 href 与 URL 规范化后的 canonical 形式一致(见 router.ts#L1448-L1455);- 启用
rewrite重写规则时,对重写后的 URL 同样执行 parse→stringify 往返(见 router.ts#L1473-L1479); - 导航计算下一个位置时,将合并后的搜索对象经
stringifySearch落到新的 URL 上(见 router.ts#L2071-L2075)。
同时,router.ts 的 createRouter 中可以看到这两个选项的默认值注入:
stringifySearch: options.stringifySearch ?? defaultStringifySearch, parseSearch: options.parseSearch ?? defaultParseSearch,这与 API 文档 RouterOptionsType.md 中的描述一致:
stringifySearch:类型为(search: Record<string, any>) => string,可选,用于生成链接时序列化搜索参数,默认defaultStringifySearch;parseSearch:类型为(search: string) => Record<string, any>,可选,用于解析当前 location 时反序列化搜索参数,默认defaultParseSearch。
幂等性:自定义序列化必须满足的底线
自定义序列化时最重要的一条原则是:反序列化必须能拿回与序列化前完全一致的对象。一旦序列化与反序列化过程不对称,就会丢失信息——例如使用一个不支持嵌套对象的库时,嵌套对象可能在往返后直接丢失或变形。这也是为什么下文每个方案都给出配套的parseSearch与stringifySearch两侧实现。
为此,官方提供两个内置辅助函数来简化工作:
parseSearchWith(parser):接收一个“字符串 → 值”的解析器,返回符合Router选项要求的parseSearch函数,你无需自己处理?前缀剥离与 URL 解转义;stringifySearchWith(stringify, parser?):接收一个“值 → 字符串”的序列化器(可选第二个参数用于对称性检测),返回stringifySearch函数,你无需自己处理 URL 转义。
这两个函数从 router-core 入口 统一导出,并被 react-router、solid-router 与 vue-router 各自再导出,因此三种框架下的用法完全一致。
方案一:Base64 编码
对 URL 分享卡片(unfurlers)、跨浏览器兼容性要求高的场景,常见做法是把搜索参数做 base64 编码:
import { createRouter, parseSearchWith, stringifySearchWith, } from '@tanstack/react-router' const router = createRouter({ parseSearch: parseSearchWith((value) => JSON.parse(decodeFromBinary(value))), stringifySearch: stringifySearchWith((value) => encodeToBinary(JSON.stringify(value)), ), }) function decodeFromBinary(str: string): string { return decodeURIComponent( Array.prototype.map .call(atob(str), function (c) { return '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2) }) .join(''), ) } function encodeToBinary(str: string): string { return btoa( encodeURIComponent(str).replace(/%([0-9A-F]{2})/g, function (match, p1) { return String.fromCharCode(parseInt(p1, 16)) }), ) }Solid 版本只需将导入源换成@tanstack/solid-router,函数体完全相同。此时,开篇的搜索对象会变为:
?page=1&sort=asc&filters=eyJhdXRob3IiOiJ0YW5uZXIiLCJtaW5fd29yZHMiOjgwMH0%3Dfilters值从百分号转义串变成了 base64(解码后即 JSON 字符串{"author":"tanner","min_words":800}),URL 可读性虽下降,但对链接分享工具更友好。
警告:如果直接把用户输入序列化成 Base64,可能与 URL 本身的解码过程发生冲突(collision),导致 URL 解析错误或值被误解。为避免这个问题,务必使用下文“安全二进制编解码”中的
encodeToBinary/decodeFromBinary,而不是裸用btoa/atob。
方案二:query-string 库
query-string(sindresorhus 出品)是解析/序列化查询串的流行选择,可以按需求定制序列化格式(如嵌套对象的展开方式、数组编码风格等):
import { createRouter } from '@tanstack/react-router' import qs from 'query-string' const router = createRouter({ // ... stringifySearch: stringifySearchWith((value) => qs.stringify(value, { // ...options }), ), parseSearch: parseSearchWith((value) => qs.parse(value, { // ...options }), ), })同样的搜索对象在此配置下会输出:
?page=1&sort=asc&filters=author%3Dtanner%26min_words%3D800与默认行为(整个对象 JSON 化)不同,query-string默认把嵌套对象展开为filters[author]=tanner&filters[min_words]=800这类键路径形式(示例中filters值本身是被JSON.stringify后的字符串再经 URL 编码)。你可以通过{ ...options }传入库的选项调整具体风格,但需要自行验证qs.parse(qs.stringify(x))的往返幂等性——这是上文强调的底线。
方案三:JSURL2 压缩库
JSURL2是一个非标准但可读性良好的 URL 压缩方案,能在压缩体积的同时保留一定可读性:
import { createRouter, parseSearchWith, stringifySearchWith, } from '@tanstack/react-router' import { parse, stringify } from 'jsurl2' const router = createRouter({ // ... parseSearch: parseSearchWith(parse), stringifySearch: stringifySearchWith(stringify), })此时搜索对象会变成:
?page=1&sort=asc&filters=(author~tanner~min*_words~800)~嵌套对象被压缩成(author~tanner~min*_words~800)这样的紧凑语法。由于jsurl2的stringify/parse本身就是互逆的一对,直接作为两个辅助函数的入参即可满足幂等性要求。
方案四:Zipson JSON 压缩库
Zipson是一个兼顾运行时性能与压缩率的 JSON 压缩库。它的stringify输出仍可能需要转义/解转义与 base64 编解码,因此同样要搭配安全二进制工具函数:
import { createRouter, parseSearchWith, stringifySearchWith, } from '@tanstack/react-router' import { stringify, parse } from 'zipson' const router = createRouter({ parseSearch: parseSearchWith((value) => parse(decodeFromBinary(value))), stringifySearch: stringifySearchWith((value) => encodeToBinary(stringify(value)), ), }) function decodeFromBinary(str: string): string { return decodeURIComponent( Array.prototype.map .call(atob(str), function (c) { return '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2) }) .join(''), ) } function encodeToBinary(str: string): string { return btoa( encodeURIComponent(str).replace(/%([0-9A-F]{2})/g, function (match, p1) { return String.fromCharCode(parseInt(p1, 16)) }), ) }此时搜索对象会变为:
?page=1&sort=asc&filters=JTdCJUMyJUE4YXV0aG9yJUMyJUE4JUMyJUE4dGFubmVyJUMyJUE4JUMyJUE4bWluX3dvcmRzJUMyJUE4JUMyJUEyQ3UlN0Q%3D这是四种方案中 URL 最短、但可读性最低的形态,适合对体积敏感、又希望保留 JSON 数据完整性的场景。
安全二进制编解码:为什么不能裸用 atob/btoa
浏览器中的atob和btoa对非 UTF-8 字符(例如中文、emoji 等多字节字符)并不保证行为正确——直接btoa('雪')会抛异常。官方指南因此推荐以下两个工具函数配合使用:
字符串 → 二进制字符串(编码):
export function encodeToBinary(str: string): string { return btoa( encodeURIComponent(str).replace(/%([0-9A-F]{2})/g, function (match, p1) { return String.fromCharCode(parseInt(p1, 16)) }), ) }原理:先用encodeURIComponent把每个非 ASCII 字符变成%XX形式,再把百分号替换回原始字节,最后交给btoa只处理纯 Latin-1 字符,保证不抛错。
二进制字符串 → 字符串(解码):
export function decodeFromBinary(str: string): string { return decodeURIComponent( Array.prototype.map .call(atob(str), function (c) { return '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2) }) .join(''), ) }原理:atob还原出字节序列后,把每个字节重新编码为%XX,最后由decodeURIComponent统一还原为正确的 Unicode 字符串。
这一对函数是上文 Base64 与 Zipson 两个方案代码中decodeFromBinary/encodeToBinary的来源,也是你自定义任何“先压缩、再 base64”方案时的推荐基座。
选型建议与实践要点
结合四个方案与源码行为,可以总结出以下实践要点:
- 往返幂等是硬约束:无论选哪种方案,都要保证
parse(stringify(search))能拿回原对象,尤其是嵌套对象与“长得像 JSON 的字符串”('123'、'true'、'{}')这类边界值。仓库测试 searchParams.test.ts#L14-L47 中“isomorphism”系列用例就是针对默认实现的这一约束,引入第三方库时建议用同样的方法自测; - 只换一侧是不够的:
parseSearch与stringifySearch必须成对替换为同一套编解码协议,否则路由器在生成 href(stringify 侧)与解析 location(parse 侧)之间会出现协议错配; - 非 JSON 解析器会收到所有字符串:源码中
jsonStart守卫仅对JSON.parse生效(见 searchParams.test.ts#L144-L154 的验证),自定义解析器必须对任意字符串保持安全(失败时静默返回原值或抛错由框架兜底——框架侧try/catch只保证不中断,不会帮你修正语义); - URL 会被人手工编辑:测试中的“alien deserialization”系列(searchParams.test.ts#L240-L254)验证了反序列化端要能优雅处理“不可能由序列化器产生”的输入,比如用户手改的
?foo={}。选择可读性更强的格式(如 query-string、JSURL2)时,这类手工编辑场景的容错会更自然; - 压缩换可读性的取舍:query-string 保持扁平可读、JSURL2 紧凑可读、Base64/Zipson 最短但不可读。选择时优先考虑目标场景——是程序间传递(选压缩)、还是用户可见与手工分享(选可读)。
参考路径
- 指南原文:docs/router/guide/custom-search-param-serialization.md
parseSearch/stringifySearch选项定义:docs/router/api/router/RouterOptionsType.md- 核心实现:packages/router-core/src/searchParams.ts、packages/router-core/src/qss.ts
- 路由器调用链:packages/router-core/src/router.ts
- 单元测试:packages/router-core/tests/searchParams.test.ts
【免费下载链接】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),仅供参考