怎样把 URL 查询字符串解析成对象?
先取出 ? 和
简答
搜索页地址是
/s?q=hello+world&tag=js&tag=css,要拿到{ q: 'hello world', tag: ['js', 'css'] }。做法是取出?后面、#前面的部分,按&拆开,每段再按第一个=分成键和值。键和值都要解码:先把
+换成空格,因为表单格式里+表示空格,再用decodeURIComponent还原%E5%BC%A0这类百分号编码。同名参数出现多次时合成数组,只有键没有=的参数,值记为空字符串。要注意两个坑。
decodeURIComponent遇到不完整的%序列会抛URIError,要try...catch兜底;键名是__proto__时,直接给普通对象赋值会改它的原型而不是加属性,用Map收集再Object.fromEntries可以避开。实际项目里用
URLSearchParams就够了,它按表单格式解码,getAll能拿到重复参数,手写版主要用来说明背后的规则。
手写解析函数
下面的 parseQuery 接受完整网址或只有查询的部分,重点看三处:怎样截取查询部分、怎样解码、重复的键怎样处理。
function decode(s) {
try {
return decodeURIComponent(s.replace(/\+/g, ' '));
} catch {
return s;
}
}
function parseQuery(input) {
const start = input.indexOf('?');
const query = (start === -1 ? input : input.slice(start + 1)).split('#')[0];
const map = new Map();
for (const part of query.split('&')) {
if (!part) continue;
const eq = part.indexOf('=');
const key = decode(eq === -1 ? part : part.slice(0, eq));
const value = decode(eq === -1 ? '' : part.slice(eq + 1));
if (map.has(key)) {
map.set(key, [].concat(map.get(key), value));
} else {
map.set(key, value);
}
}
return Object.fromEntries(map);
}
console.log(parseQuery('https://a.com/s?q=hello+world&tag=js&tag=css&empty&name=%E5%BC%A0#top'));
// { q: 'hello world', tag: [ 'js', 'css' ], empty: '', name: '张' }
console.log(parseQuery('?bad=%E0%A4%A')); // { bad: '%E0%A4%A' }
const evil = parseQuery('__proto__=x');
console.log(Object.hasOwn(evil, '__proto__'), Object.getPrototypeOf(evil) === Object.prototype); // true true
const plain = {};
plain['__proto__'] = 'x';
console.log(Object.keys(plain).length); // 0
为什么按“第一个” = 拆?因为值里也可能有 =,比如 token=a=b,按第一个拆才能得到完整的值 a=b。为什么先换 + 再解码?decodeURIComponent 本身不会把 + 变成空格,而表单提交和很多网址里空格写成 +;顺序反过来的话,%2B 解码出的真加号也会被错误地换成空格。
%E0%A4%A 少了一位,decodeURIComponent 会抛 URIError。MDN 写明,% 后面不是两位十六进制数字,或者编码出的不是合法 UTF-8 时都会抛这个错。网址来自用户,不能让一个坏参数让整个页面报错,所以这里退回原文。
最后几行对比了 __proto__ 键。普通对象上 obj['__proto__'] = 'x' 走的是原型的 setter,字符串不是对象,赋值被忽略,plain 一个自有属性都没有,参数就丢了;如果值是对象,还会改掉原型。Object.fromEntries 是定义属性而不是赋值,所以 evil 上确实有一个叫 __proto__ 的自有属性,原型也没变。
直接用 URLSearchParams
浏览器和 Node 都内置了 URLSearchParams。MDN 写明,传字符串时按 application/x-www-form-urlencoded 格式解析,开头的 ? 会被忽略;这种格式会解码百分号编码,并把 + 解码成空格。
const params = new URLSearchParams('q=hello+world&tag=js&tag=css&empty');
console.log(params.getAll('tag'), params.get('q'), params.get('empty')); // [ 'js', 'css' ] hello world
console.log(Object.fromEntries(params)); // { q: 'hello world', tag: 'css', empty: '' }
get('empty') 返回空字符串,所以第一行最后打印的是空的。第二行要注意,Object.fromEntries 遇到同名键时后面的覆盖前面的,tag 只剩 'css',需要多个值时要用 getAll。更多用法见《URLSearchParams 怎样处理筛选状态、重复参数和编码?》。
常见追问
为什么不用 decodeURI 解码参数?
MDN 写明
decodeURI把输入当作完整网址,不解码属于网址语法的字符,比如decodeURI('%26%3D')还是%26%3D,参数值里本来编码过的%26就还原不出&。解析单个参数的键和值,要用decodeURIComponent。
解析结果能直接拿来改原型链上的东西吗?
用普通对象逐个赋值时,
__proto__这样的键可能改掉对象的原型;后续再把结果深合并到别的对象里,还可能污染Object.prototype。所以解析用户输入要么用Map、Object.create(null),要么用Object.fromEntries这种定义属性的方式,合并时也要跳过__proto__、constructor、prototype这类键。