
接口联调里有一类问题反复出现:前端拼好的查询串,服务端解出来跟原始输入不一样。搜索词里带加号(比如编程语言的名字)时最明显,发出去是加号,收到是空格。下面按规范和文档的说法分条走,每条后面跟着在本机跑出来的实测结果,脚本是 tools/verify-urlsearchparams.mjs,Node v26.8.1。
规范条目一:表单编码把空格写成加号
application/x-www-form-urlencoded 这套编码在 WHATWG 的 URL 标准里写得很直接,序列化时空格替换成加号。MDN 上 URLSearchParams.toString() 的说明也是同一句。实测批注:这条没问题,但同一个空格走 encodeURIComponent 出来的是 %20,走 new URL() 的查询串也是 %20,三种写法里只有表单编码会产出加号。
规范条目二:解析时加号先变成空格
反过来的方向才是坑所在。percent-decode 之前,加号会被替换成空格,所以 q=a+b 与 q=a%20b 解析出来完全一样。想让加号原样保留,必须发 %2B。实测批注:这一对行为合起来就是加号在两头各变形一次,如果服务端的解析器只做 percent-decode 而没做加号替换(有些语言的库默认不替换),前端用 URLSearchParams 拼的串就会多出一堆空格。
规范条目三:往返一致,但只对能编码的值成立
拿八个刁钻的值各走一遍「编码再解析」,结果全都一致,包括加号,与号,等号,百分号,井号,含中文的值也对。实测批注:往返一致说明这套编码本身是自洽的,所以出问题的地方不在编码算法,而在链路两端对同一段字节的解释方式不同。
规范条目四:不完整的百分号不会被拦
decodeURIComponent('%') 会抛 URIError,但把一个畸形的查询串交给 URLSearchParams,它不抛,原样把 % 和 %zz 留给调用方。实测批注:这条常被误当成「解析器会顺手做校验」。校验要在业务层做,指望解析器报错是等不到的。
规范条目五到八:重复键与对象构造,还有排序和键数上限
get 只取第一个值,getAll 取全部,set 覆盖掉所有同名键,append 追加。用普通对象构造时值会被字符串化,undefined 和 null 变成对应的字面文本,数组变成逗号连接的字符串,遇到 Symbol 直接抛 TypeError。sort() 按名称重排,能用来消除顺序差异。
和 Node 的 querystring.parse 对照这一段差别最大:同一串输入,URLSearchParams 给的是有序的键值对列表,重复键各占一位;querystring.parse 给的是对象,重复键合并成数组,顺序信息丢掉。更隐蔽的是默认上限,一千零五组键值传进去,querystring.parse 只留一千组,超出部分静默丢弃,URLSearchParams 一组不少。实测批注:批量提交表单类接口一旦超过一千个字段,这两个解析器给出的结果不是「略有不同」,是一个丢数据一个不丢。
规范条目九:手拼字符串与外链拼接
同一个值 a b&x=1,用字符串拼出来是 /api/search?q=a b&x=1,空格原样躺在 URL 里,与号还把后面的 x=1 变成了一个新参数;交给 URLSearchParams 之后是 /api/search?q=a+b%26x%3D1,值被完整地关在 q 里。实测批注:这就是参数注入的最小复现,凡是把用户输入串进 query 的地方都属于同一类问题。
node v26.8.1
=== 1. 序列化:空格变成什么 ===
URLSearchParams.set('q','a b').toString() -> q=a+b
encodeURIComponent('a b') -> a%20b
new URL('https://x/?q=a b').search -> ?q=a%20b
=== 2. 反序列化:+ 被当成什么 ===
new URLSearchParams('q=a+b').get('q') -> "a b"
new URLSearchParams('q=a%2Bb').get('q') -> "a+b"
new URLSearchParams('q=a%20b').get('q') -> "a b"
new URL('https://x/?q=a+b').searchParams.get('q') -> "a b"
=== 3. 往返:哪些值不是等价的 ===
原始 "a b" -> 编码 q=a+b -> 还原 "a b" 往返一致: true
原始 "a+b" -> 编码 q=a%2Bb -> 还原 "a+b" 往返一致: true
原始 "a&b" -> 编码 q=a%26b -> 还原 "a&b" 往返一致: true
原始 "a=b" -> 编码 q=a%3Db -> 还原 "a=b" 往返一致: true
原始 "a%b" -> 编码 q=a%25b -> 还原 "a%b" 往返一致: true
原始 "a#b" -> 编码 q=a%23b -> 还原 "a#b" 往返一致: true
原始 "a 100%" -> 编码 q=a+100%25 -> 还原 "a 100%" 往返一致: true
原始 "中 文" -> 编码 q=%E4%B8%AD+%E6%96%87 -> 还原 "中 文" 往返一致: true
=== 4. % 不完整时会不会抛 ===
decodeURIComponent('%') -> 抛 URIError: URI malformed
new URLSearchParams('q=%').get('q') -> "%" (没有抛)
new URLSearchParams('q=%zz').get('q') -> "%zz"
=== 5. 重复键:get / getAll / set / append ===
输入 tag=a&tag=b&tag=c
.get('tag') -> "a"
.getAll('tag') -> ["a","b","c"]
改 set('tag','z') 之后 -> tag=z
改 append('tag','z') 之后 -> tag=a&tag=b&tag=c&tag=z
=== 6. 对象构造:非字符串值会怎样 ===
new URLSearchParams({n:1,b:true,u:undefined,nul:null,arr:[1,2],o:{a:1},s:Symbol(),big:10n})
-> 抛 TypeError: Cannot convert a Symbol value to a string
去掉 Symbol 和 BigInt 之后 -> n=1&b=true&u=undefined&nul=null&arr=1%2C2&o=%5Bobject+Object%5D
=== 7. 排序与遍历顺序 ===
原样 -> b=2&a=1&c=3
sort() 之后 -> a=1&b=2&c=3
=== 8. 和 Node 的 querystring.parse 对照 ===
输入 tag=a&tag=b&q=a+b&empty=&nul
URLSearchParams -> [["tag","a"],["tag","b"],["q","a b"],["empty",""],["nul",""]]
querystring.parse -> {"tag":["a","b"],"q":"a b","empty":"","nul":""}
输入 1005 组 key=value 时,querystring.parse 得到 1000 个键
同一串给 URLSearchParams,得到 1005 组
=== 9. 直接读 location.search 与外链拼接的差别(字符串拼接的坑)===
字符串直接拼 -> /api/search?q=a b&x=1
URLSearchParams 拼 -> /api/search?q=a+b%26x%3D1
落到写法上
拼查询串只用 URLSearchParams,不要手拼字符串,这一点没什么可犹豫的,上面第九组就是理由。解析端要么用同一个标准实现,要么至少把加号替换和 percent-decode 两步都做对。
手头这九组数据全部来自 Node v26.8.1 的 URLSearchParams,浏览器里是同一份 WHATWG 标准的实现,但没在浏览器里复验过,签名、缓存 key 这类对字节敏感的链路建议两边各跑一遍再定。至于 querystring.parse,我的取舍是只在不关心键顺序、字段数远小于一千的内部脚本里用,对外接口一律换掉。