前端知库

怎样把 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 接受完整网址或只有查询的部分,重点看三处:怎样截取查询部分、怎样解码、重复的键怎样处理。

JavaScript
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 格式解析,开头的 ? 会被忽略;这种格式会解码百分号编码,并把 + 解码成空格。

JavaScript
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 这类键。