原创

URLSearchParams 的重复键不是普通对象:set 一次为什么丢掉多选条件

筛选页允许同时选Go和Python,复制出来的URL只有一个tag。问题可能不在路由,而在把查询参数转成普通对象的那一步:同名键可以出现多次,普通对象却只能给同一属性保留一个值。

URLSearchParams更接近一份有顺序的键值对列表。get取第一项,getAll取全部,append追加,set则把同名项收敛成一个。用错其中一个动作,多选条件就会静默变成单选。

先把真实文本与结构打印出来,再决定业务是否允许重复。重复键不天然非法,也不天然表示数组;接口需要为每个字段声明基数,不能由前后端各自猜。

'use strict';
const assert = (condition, message) => {
  if (!condition) throw new Error(message);
};
const source = new URLSearchParams('tag=go&tag=python&sort=new&q=a%2Bb+c');
console.log('first tag:', source.get('tag'));
console.log('all tags:', source.getAll('tag'));
console.log('plain object:', JSON.stringify(Object.fromEntries(source)));
console.log('decoded q:', source.get('q'));
assert(source.get('tag') === 'go', 'get first');
assert(source.getAll('tag').join(',') === 'go,python', 'keep duplicates');
assert(Object.fromEntries(source).tag === 'python', 'object keeps last value');
assert(source.get('q') === 'a+b c', 'form-style decoding');

const changed = new URLSearchParams(source);
changed.set('tag', 'redis');
assert(changed.getAll('tag').join(',') === 'redis', 'set removes other same-name values');
changed.append('tag', 'go');
assert(changed.getAll('tag').join(',') === 'redis,go', 'append adds one more');
console.log('after set then append:', changed.toString());

function buildFilter(tags, page) {
  const allowed = new Set(['go', 'python', 'redis']);
  if (!Array.isArray(tags) || !Number.isSafeInteger(page) || page < 1 || page > 1000) {
    throw new TypeError('tag list and page 1..1000 required');
  }
  const url = new URL('https://example.test/search');
  for (const tag of new Set(tags)) {
    if (!allowed.has(tag)) throw new TypeError('unknown tag');
    url.searchParams.append('tag', tag);
  }
  url.searchParams.set('page', String(page));
  return url;
}

function readPage(url) {
  const values = url.searchParams.getAll('page');
  if (values.length !== 1 || !/^[1-9][0-9]{0,3}$/.test(values[0])) {
    throw new TypeError('exactly one plain page number required');
  }
  const page = Number(values[0]);
  if (page > 1000) throw new RangeError('page outside supported range');
  return page;
}
const filter = buildFilter(['go', 'python', 'go'], 2);
assert(filter.searchParams.getAll('tag').join(',') === 'go,python', 'business deduplication');
assert(readPage(filter) === 2, 'single page');
console.log('filter URL:', filter.href);
filter.searchParams.append('page', '3');
try {
  readPage(filter);
  throw new Error('duplicate page should fail');
} catch (error) {
  if (!(error instanceof TypeError)) throw error;
  console.log('duplicate page: rejected');
}
const encoded = new URLSearchParams({ q: 'a+b c' }).toString();
assert(encoded === 'q=a%2Bb+c', 'value encoding');
console.log('encoded q:', encoded);

保存query_pairs.js,在现代浏览器控制台或Node.js执行。tag的first和all给出不同结构,转成普通对象后只剩python。最后一项又显示字面加号编码为%2B,空格编码为加号,两者在文本层不能混淆。

多选字段与单值字段,分别定义基数

buildFilter为tag采用多值合同,同样标签重复选择则按业务规则去重,并保留首次出现的顺序。URLSearchParams没有自动替它去重,new Set才是这里的产品决定。

page则要求恰好一项。若请求同时带page=2和page=3,直接get只会读第一份,另一个服务可能读取最后一份;链路中各层答案不一致,会让缓存、校验与业务行为分裂。明确拒绝重复单值项,比让工具库替接口选择优先级更清楚。

去重和排序也不是同一动作。两个标签顺序是否影响结果,应在接口中说明;若不影响,可以为缓存生成规范顺序,但展示时可能仍希望保留用户选择次序。不要为了URL看起来整齐,顺手改变带优先级字段的顺序。

对允许未知扩展参数的接口,也要决定它们是否进入缓存键、签名和转发。只保留认识字段可能丢掉未来功能,无条件转发又可能引入下游不认识的重复项。查询解析不是把文本拆成对象以后就结束。

set 适合替换,不适合追加一次选择

示例先set tag为redis,原有go、python被替换,再append go才重新出现两个值。筛选器每点一次标签都调用set,会不断覆盖先前条件;清除旧筛选后重建,则可以先delete再逐项append。

如果使用表单采集多选控件,应保留FormData中的重复项,再按接口规则转成查询。Object.fromEntries在这里同样会把多个同名值压成一个,不能因为它比循环短,就当成无损转换。

构造函数也需要给正确形式。new URLSearchParams适合查询部分,不会把任意完整URL自动当成URL对象解析;已有完整地址时先new URL,再使用searchParams,避免把路径文本误当参数名。

URL 上的 searchParams 是一份活视图

从URL对象取得searchParams以后,对它执行set、append或delete,会更新对应URL的查询。若只想试算候选条件,可以先new URLSearchParams复制一份;否则预览逻辑也可能改到稍后真正用于跳转的原对象。const只固定变量绑定,不会把其中参数冻结。

sort按键进行排序,并保留同名键之间相应顺序,不等于把每个字段的值也排序。业务若要求标签值规范化,应针对tag自己的列表处理,不能只调用一次sort就宣称所有等价多选URL都归一。排序还可能改变编码表示,因此签名与缓存需要采用明确规范。

构造函数接收普通记录对象时,也不会把数组属性自动展开成同名键列表。把tag数组塞进一个对象,可能先经过字符串转换,变成逗号连接的一项。需要重复键时应逐项append或传明确的键值对序列,再用getAll核对结果。null与布尔值也不能不加判断就传进去,它们可能被转换成普通文本,而不是自动省略。参数缺失应明确不添加,空值则按接口允许形式编码,避免用一个通用对象构造器猜所有字段的意思。

已经编码的文本,不要再冒充原值

append与set接收的是解码后的字符串值,并负责生成相应编码。把encodeURIComponent的结果先交给它,会让百分号再次编码,形成双重转义。下游再多解一次不是可靠补救,因为不同输入会经过不同路径。

反过来,把带加号的数据直接拼进原始查询文本,再交给构造函数,加号会按表单式查询规则被解释为空格。某些Base64或带加号的标识符因此被改坏。让工具从原值编码,比手工拼接更容易保持边界。

空字符串和缺失也需要区分。get缺失键返回null,已存在但值为空则返回空字符串;如果业务允许空搜索词,就不要用一个简单的if(!value)把两者合并。只有参数名、没有等号的文本,解析后的表示又可能与显式空值接近,不能把再次编码视为原字节恢复。

本例输出URL只证明语义字段按规则组成,不保证保留输入原文的空白、转义大小写或原始编码形式。涉及签名时,应明确签名的是原始字节还是规范参数表示,并让双方采用同一规则,不能先随意解析再编码以后仍期待签名相同。

把多选筛选回归写成getAll断言,再把单值参数回归写成基数拒绝断言,能同时防止“少选了一项”和“两个page各层解释不同”。这比只核对地址栏看起来有问号,更接近查询接口真正的合同。

正文到此结束
评论插件初始化中...
Loading...