
标签筛选这种功能很容易撞上这件事:前端的数组参数拼起来就长这样 ?tag=前端&tag=后端,接口里写 req.query.tag.map(...) 就会出事。一个标签都没勾的时候一切正常,勾了两个直接 500。下面这些问题都是被那一行代码问出来的,答案全是本机跑出来的输出,node v26.8.1。
后端到底收到了什么?
错误是 TypeError: req.query.tag.map is not a function。第一反应一般是前端传错了,但先 console.log(req.query) 看一眼就会发现,两个标签的时候 tag 是数组,一个标签的时候是字符串。
为什么勾一个标签正常,勾两个就 500?
这事跟用什么框架没关系。query string 本身没有类型,类型是各家的解析器自己猜出来的。用 node 内置的 querystring 解析四种 query,结果是这样:
import querystring from 'node:querystring';
querystring.parse('tag=a&tag=b'); // { tag: [ 'a', 'b' ] }
querystring.parse('tag=a'); // { tag: 'a' }
querystring.parse('tag[]=a'); // { 'tag[]': 'a' }
querystring.parse('tag=a,b'); // { tag: 'a,b' }
第二个就是要命的地方。同一个 key 只出现一次,值是字符串;出现两次以上才是数组。用户从勾一个标签变成勾两个标签,后端这边的类型就跟着变,req.query.tag.map 那一行自然炸掉。而单测里如果只造了两个标签的用例,正好把这个分支绕过去。
同一个数组,前端有几种拼法?
四种,下面都跑过:
const tags = ['前端', '后端'];
// 1. 重复键:URLSearchParams 的 append
const q1 = new URLSearchParams();
for (const t of tags) q1.append('tag', t);
// q1.toString() 得到
// 'tag=%E5%89%8D%E7%AB%AF&tag=%E5%90%8E%E7%AB%AF'
// 2. 方括号
new URLSearchParams([['tag[]', '前端'], ['tag[]', '后端']]).toString();
// 'tag%5B%5D=%E5%89%8D%E7%AB%AF&tag%5B%5D=%E5%90%8E%E7%AB%AF'
// 3. 逗号分隔
new URLSearchParams({ tag: tags.join(',') }).toString();
// 'tag=%E5%89%8D%E7%AB%AF%2C%E5%90%8E%E7%AB%AF'
// 4. 把数组原样塞进去
new URLSearchParams({ tag: tags }).toString();
// 'tag=%E5%89%8D%E7%AB%AF%2C%E5%90%8E%E7%AB%AF'
// 和第 3 种的结果一模一样
第 4 种在别人的代码里见得最多。它不报错,因为 URLSearchParams 对数组值会调用 toString(),而 ['前端','后端'].toString() 的结果正好是 '前端,后端'。源码里写的是 { tag: tags },看着像传了数组,实际发出去的是一个逗号分隔的字符串。要等某个标签名里真的带逗号,这行才会炸。
三种取值方式摆在一起,谁少了一个标签?
起个真实服务用 curl 打一遍更直观。三种常见的后端取值方式并排放在响应里:
import http from 'node:http';
import querystring from 'node:querystring';
http.createServer((req, res) => {
const { searchParams } = new URL(req.url, 'http://x');
const qs = querystring.parse(req.url.split('?')[1] ?? '');
res.setHeader('content-type', 'application/json; charset=utf-8');
res.end(JSON.stringify({
rawUrl: req.url,
qs_tag: qs.tag,
qs_tag_type: Array.isArray(qs.tag) ? 'array' : typeof qs.tag,
sp_get: searchParams.get('tag'),
sp_getAll: searchParams.getAll('tag'),
fromEntries: Object.fromEntries(searchParams),
}, null, 2));
}).listen(39101);
curl -s 'http://127.0.0.1:39101/api/list?tag=a&tag=b'
{
"rawUrl": "/api/list?tag=a&tag=b",
"qs_tag": ["a", "b"],
"qs_tag_type": "array",
"sp_get": "a",
"sp_getAll": ["a", "b"],
"fromEntries": { "tag": "b" }
}
fromEntries 里的 "tag": "b" 值得单独拎出来说。Object.fromEntries(searchParams) 是把 query 转成普通对象最短的写法,很多人顺手就这么用了,碰上重复键它只保留最后一个,前一个静默消失。拿它当请求参数对象用的话,用户勾了前端和后端两个标签,后端只收到第二个,而且不报错。
tag[] 这种写法 node 内置的解析器认不认?
不认。看上面那种拼法单独打出来的结果:
{
"rawUrl": "/api/list?tag%5B%5D=a&tag%5B%5D=b",
"qs_tag_type": "undefined",
"sp_get": null,
"sp_getAll": [],
"fromEntries": { "tag[]": "b" }
}
key 是字面的 tag[],不叫 tag。qs.tag 是 undefined,searchParams.get('tag') 是 null,只能拿到 searchParams.get('tag[]')。这套约定要靠 qs 或者 Express 默认的 extended 解析器才认。
这条没在本机装 Express 跑过,它默认用 qs 是按文档推的,要用这个写法就自己起个 Express 项目确认一遍。我倾向于不用它,理由是「谁负责把 tag[] 还原成数组」这件事,在前后端之间没有一个写下来的共识,换个语言换个网关就又是一套规矩。
空格和加号,path 和 query 的规则一样吗?
不一样,顺手测了一组编码行为,几个结果和原先以为的不同:
new URLSearchParams({ q: 'a b' }).toString(); // 'q=a+b'
new URLSearchParams({ q: 'a+b' }).toString(); // 'q=a%2Bb'
new URL('http://h/?q=a+b').searchParams.get('q'); // 'a b'
new URL('http://h/?q=a%20b').searchParams.get('q'); // 'a b'
decodeURIComponent('a+b'); // 'a+b' 没有还原成空格
decodeURI('a%20b'); // 'a b'
new URL('http://h/a+b').pathname; // '/a+b' 路径里加号还是加号
query 里 + 和 %20 都当空格,path 里 + 就是个普通加号。所以后端如果手写解码、用 decodeURIComponent 去解 query 参数,?q=a+b 会原样得到 a+b,跟浏览器发出去时的意思对不上。
用 decodeURIComponent 解 query,为什么搜不到东西?
这个 bug 踩过一次。接口用 decodeURIComponent 解搜索词,用户在搜索框里输入「hello world」,前端编码后是 q=hello+world,后端解出来是 hello+world,永远搜不到东西。当时以为是索引的问题,查了半小时才回头看编码。用 URLSearchParams 或 new URL().searchParams 解就没有这个问题。
前后端各退一步,约定成什么样最稳?
前端这边,数组一律用重复键,也就是 append 循环或者 URLSearchParams 数组形式里的重复 key。后端一律用 getAll,配合一个入口处的规整函数:
export function toArray(v) {
if (v === undefined || v === null) return [];
return Array.isArray(v) ? v : [v];
}
一行的事,比在每个接口里到处写 Array.isArray 判断省心。Express 里常见 req.query.tag = [].concat(req.query.tag) 这种直接改的写法,新版 Express 把 req.query 做成了 getter 包装,在那儿直接赋值会不会生效没实测过,所以习惯先取到局部变量再处理,不依赖它。
如果前后端不是同一个人写的,退回逗号分隔反而最稳:不管对面用什么解析器,?tag=a,b 拿到的都是一个字符串,split(',') 就能变数组,代价是标签本身不能含逗号。这个交换划算,因为「不能含逗号」是个能写进文档、能加校验的约束,而「类型可能变」不是。
方括号那套不推荐。它把数组这个概念塞进了一个本来没有类型的地方,能不能还原全看对面用什么解析器,出了问题两边都得查。