做前端的老哥应该都写过从 URL 里取参数的代码。早期我见过太多这种实现:拿 window.location.href 或者 location.search,再用 substring、split("&")、正则 match 一套组合拳打下来,费劲不说,遇到中文得手动 decodeURIComponent,遇到 + 号还要再处理一层,重复参数直接傻眼。后来浏览器原生给出了一个正经方案——URLSearchParams,这个 API 平时看似不起眼,但在处理 URL 查询字符串的场景里,它比任何手写解析都靠谱得多。
这篇文章就把 URLSearchParams 从创建到遍历、从修改到序列化、从基础用法到项目实战中的坑,完整过一遍。无论你是刚开始学 JS 的入门者,还是天天跟 URL 参数打交道的老手,看完都能直接把里面的写法用到自己的代码里。
1. 为什么要用 URLSearchParams:旧写法的三个致命伤
1.1 正则截取方案到底哪里不行
先看看大家最常写的“传统方案”长什么样:
javascript复制function getQueryParam(name) {
const regex = new RegExp('[?&]' + name + '=([^&]*)');
const match = window.location.search.match(regex);
if (match) return decodeURIComponent(match[1]);
return null;
}
这段代码看起来还行,但实际用起来全是坑。
第一,正则里的 name 没有转义。如果参数名里带了 .、?、* 这类正则元字符,这个正则就直接废了。你可能会说“参数名怎么会带这些”,但真实业务里我见过参数名带 . 的接口,比如 game.version=1.2,这种名字一进去,匹配结果立刻出错。
第二,decodeURIComponent 对 + 号的处理不符合表单规范。URL 查询字符串里 + 号表示空格,这是 application/x-www-form-urlencoded 的规则,但 decodeURIComponent 不会把 + 转成空格。于是 ?keyword=hello+world 被解析出来变成了 hello+world,正确结果应该是 hello world。
第三,重复参数只能拿到第一个。?tag=js&tag=css 这种写法在后端接口里很常见,前端用正则去匹配,最多只能拿到 js,第二个 css 直接丢了。你要是自己在 matchAll 里循环处理,代码量又上去了。
第四,也是最容易被忽视的:location.search 只在当前页面 URL 里存在。你想解析一个独立的 URL 字符串,比如接口返回了一个带参数的重定向地址,这招就完全没用了,你还得先想办法从字符串里抠出 ? 后面的部分,再走一遍上面的流程。
1.2 URLSearchParams 是什么、能解决什么问题
URLSearchParams 是浏览器提供的一个原生接口,专门用来解析、构造、更新 URL 的查询字符串。它把“从 ? 后面到 # 前面”这段内容当成一组键值对来处理,并且内部自动完成了编码和解码,基本可以认为是前端处理查询参数的标准工具。
它解决的核心问题,就是上面那一堆手写方案的麻烦事:
- 不需要自己拼接正则,也不需要手动
split("&")。 - 自动解码,并且能正确处理
+号转空格。 - 天然支持重复键,一个名字可以存多个值。
- 可以随时增删改查,改完直接
toString()输出完整的查询字符串。
它不挑环境,浏览器原生支持,Node.js 从 10.0.0 开始也内置了同样实现的 URLSearchParams,所以在前后端都能用同一套逻辑。唯一的“硬伤”是 IE 全系列不支持,这个后面专门说兼容性的时候再聊。
1.3 什么场景下优先选它
我自己的判断标准很简单:只要你的代码里出现“获取 URL 参数”这个需求,一律用 URLSearchParams,不需要犹豫。
具体来说,下面这些场景是它的主场:
- 从当前浏览器地址栏拿参数,比如页面之间的跳转传参。
- 构建带参数的 GET 请求地址。
- 修改当前 URL 里的某个参数,但不刷新页面。
- 把对象序列化成表单格式的字符串。
- 在 Node.js 里解析第三方回调地址中的参数。
有些朋友可能觉得“我这项目里就取一个参数,用正则更快”,但实际不是这样。用原生 API 的边际成本几乎为零,还能顺手豁免各种边界情况,这笔账怎么算都划算。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 基础用法一次讲透:构造、读取和遍历
2.1 构造函数的四种传参方式
URLSearchParams 的构造函数接收的参数比较灵活,我总结了四种常用方式,实际开发中基本覆盖了 99% 的需求。
第一种:从当前页面的查询字符串构造。
javascript复制const params = new URLSearchParams(window.location.search);
这里要注意,location.search 是带 ? 的完整查询字符串,比如 ?page=2&size=20。URLSearchParams 会自动把开头的 ? 过滤掉,所以你也可以直接传不带 ? 的部分,甚至传空字符串,都不会报错。
第二种:从任意 URL 字符串中提取参数。
javascript复制const url = new URL('https://example.com/path?keyword=js&page=1');
const params = url.searchParams;
const keyword = params.get('keyword'); // 'js'
这种用 URL 对象配合 searchParams 的方式,是我在项目里最常用的。它不需要你手动截取字符串,URL 构造函数会自动帮你把查询字符串拆出来,而且同时也把路径、域名、哈希都解析好了。
第三种:从对象构造。
javascript复制const params = new URLSearchParams({
keyword: 'hello world',
page: 2,
size: 20
});
params.toString(); // 'keyword=hello+world&page=2&size=20'
对象的值会被自动编码,字符串里的空格在输出时会变成 +,然后 toString() 拼出来就是可以直接塞到 URL 后面的完整字符串。
第四种:从二维数组构造。
javascript复制const params = new URLSearchParams([
['tag', 'js'],
['tag', 'css'],
['page', '1']
]);
params.toString(); // 'tag=js&tag=css&page=1'
二维数组的好处是允许重复键存在,这是对象做不到的。当你需要构造一个带重复参数的查询字符串时,这种写法最直接。
提示:传给构造函数的值都会先经过
String()转成字符串,所以传数字、布尔值都能正常处理,但如果传入对象,它会变成"[object Object]",这种属于误用。
2.2 get、getAll、has:读取参数的三个核心方法
读取参数是这个 API 最高频的操作,三个方法对应三种场景。
get(name) 返回第一个匹配的值,没有则返回 null。这个方法默认做了 URL 解码,所以拿到的中文、空格都是人类可读的真实内容。
javascript复制const params = new URLSearchParams('keyword=hello+world&page=2');
params.get('keyword'); // 'hello world'
params.get('page'); // '2' 注意,这里拿到的是字符串
注意 get 返回值永远是字符串,哪怕你传进去的时候是数字。这跟从 URL 参数里取值的语义一致,因为 URL 本来就是字符串,所以后续做数值比较时要自己 Number() 转一下。
getAll(name) 返回一个数组,包含该键名下的所有值。如果参数不存在,返回空数组而不是 null。这个返回值设计的差异在判断时容易踩坑,后面会详细说。
javascript复制const params = new URLSearchParams('tag=js&tag=css&tag=html');
params.getAll('tag'); // ['js', 'css', 'html']
has(name) 返回布尔值,用来判断参数是否存在。有一个容易被忽略的点:has('keyword') 对 ?keyword= 和 ?keyword 两种写法都会返回 true,因为 keyword 这个键在查询字符串里确实出现了。
javascript复制const params = new URLSearchParams('keyword=&empty');
params.has('keyword'); // true
params.has('empty'); // true
params.get('keyword'); // ''
params.get('empty'); // ''
这里 empty 这种没写等号的参数,用 get 拿到的也是空字符串。如果你的业务需要区分“参数带值”和“参数存在但没值”,只靠 URLSearchParams 是分不出来的,得再结合原字符串判断,不过这种场景非常少见。
2.3 keys、values、entries、forEach:遍历参数的四种姿势
有些场景需要一次性把所有参数拿出来处理,URLSearchParams 提供了四个遍历相关的方法。
keys() 返回一个迭代器,只包含所有键名。values() 返回所有值。entries() 返回键值对数组的迭代器。forEach 则是最直观的遍历方式。
javascript复制const params = new URLSearchParams('a=1&b=2&a=3');
// forEach
params.forEach((value, key) => {
console.log(key, value);
});
// 依次输出: a 1 / b 2 / a 3
// for...of 配合 entries
for (const [key, value] of params) {
console.log(key, value);
}
// 输出同上
这里有个细节:URLSearchParams 本身是可迭代对象,直接 for...of 遍历它,等价于遍历 entries(),每次循环拿到的就是 [key, value] 结构。
还有一个很实用的技巧:转成普通对象。
javascript复制const params = new URLSearchParams('a=1&b=2&c=3');
const obj = Object.fromEntries(params);
// { a: '1', b: '2', c: '3' }
Object.fromEntries 能直接把这个迭代器转成对象,代码非常简洁。但要注意如果存在重复键,后面的值会覆盖前面的值。比如 a=1&a=2 转出来的对象只有 { a: '2' }。如果你的参数有重复键,老老实实用 getAll 处理,别想着靠 fromEntries 一锅端。
3. 改参数、造参数、删参数:URL 操作的完整闭环
3.1 append、set、delete 的区别和适用场景
读取只是第一步,URLSearchParams 真正的价值在于它允许你“改”查询字符串。
append(name, value) 是追加,不关心这个键是否已经存在,直接往末尾添加一组键值对。set(name, value) 是覆盖,如果这个键存在,会先删除所有旧值,再插入一个新的;如果不存在,效果跟 append 一样。delete(name) 则会把所有同名键值对都删掉。
javascript复制const params = new URLSearchParams('tag=js&tag=css');
params.append('tag', 'html');
// tag=js&tag=css&tag=html
params.set('tag', 'css');
// 先删掉全部的 tag,再写入新的
// tag=css
params.delete('tag');
// 结果为 空字符串
重点说一下 set 的行为:它针对的是“键名”,不是“第一个匹配的”。所以同名参数无论有几个,set 一次就全部清掉再重建。这在某些场景下是好事,比如你想把某个筛选条件整体重置再设新值;但在另一些场景下可能是坑,比如你想只改第二个同名参数的值,那就得手动循环处理了。
append、set 添加的值同样会自动编码,不需要自己先调一次 encodeURIComponent。
3.2 修改当前页面 URL 参数而不刷新页面
这是 URLSearchParams 在 SPA 项目里最常见的应用:更新地址栏参数,但页面不刷新。
标准流程是:先取当前 URL 的参数,然后用 set 或 delete 修改,最后用 history.replaceState 或 history.pushState 更新地址。
javascript复制function updateQueryParam(key, value) {
const url = new URL(window.location.href);
if (value === undefined || value === null || value === '') {
url.searchParams.delete(key);
} else {
url.searchParams.set(key, value);
}
history.replaceState({}, '', url.toString());
}
这个方法我封装过很多次,核心就两行:改 url.searchParams,然后回写地址。history.replaceState 不会刷新页面,但地址栏会变化,这在做筛选器、分页、搜索条件持久化的时候特别顺手。
用 replaceState 还是 pushState,取决于你的需求:replaceState 替换当前历史记录,用户点浏览器返回不会回到修改之前的地址;pushState 会新增一条历史记录,用户按返回键可以回退到上一个参数状态。我的习惯是:如果参数变化代表的是“页面状态的自发更新”,用 replaceState;如果是用户主动触发的导航行为,用 pushState。
顺手提一个细节:url.toString() 生成的 URL,哈希部分(# 后面的内容)是保留在末尾的,不会被参数操作影响。但如果原来的 URL 没有哈希,也不用担心多出什么奇怪的字符。
3.3 序列化与反序列化:自动编码的边界在哪里
URLSearchParams 的自动编码看起来省心,但它的编码规则有自己的一套,不完全是 encodeURIComponent。
调用 params.toString() 时,空格会编码成 +,不是 %20。这个行为跟 HTML 表单的提交规则一致,服务端解析表单格式时也会把 + 还原成空格,所以大多数场景没问题。但如果你要把参数拼接进 path 片段,或者对接个严格按 RFC 3986 编码的服务端,那 %20 和 + 的差异就可能出错。这种情况下可以手动把 + 替换回来:
javascript复制const safeQuery = params.toString().replace(/\+/g, '%20');
另外,URLSearchParams 会自动处理中文、特殊字符的编码,但不会帮你编码 !、'、(、)、* 这几个字符,encodeURIComponent 也不编码它们,所以这块倒是一致的。
解码方向,get 等方法会自动解码 %XX 形式的字符,前面已经说过,+ 号也会被还原成空格。这意味着如果你想传递一个真正的 + 号,需要写成 %2B,否则服务端拿到的就是空格。反过来,如果参数值本身带英文 & 或 =,直接传就行了,toString() 会自动编码成 %26、%3D,不会破坏整个查询字符串的结构。
4. 真实项目中的高级用法与组合技巧
4.1 与 fetch/axios 配合时的一个隐藏细节
URLSearchParams 不只是拿来处理 GET 请求的 URL,它还可以直接作为 POST 请求的请求体。
用 fetch 的时候,你不需要手动设置 Content-Type 头,直接把这个实例塞进 body 就行:
javascript复制const params = new URLSearchParams({ name: '张三', age: 25 });
const response = await fetch('/api/user', {
method: 'POST',
body: params
});
浏览器会自动设置 Content-Type: application/x-www-form-urlencoded;charset=UTF-8,请求体就是 name=%E5%BC%A0%E4%B8%89&age=25 这种标准表单格式。这个用法在做传统表单提交、OAuth 授权码换取 token 这些场景下特别顺手,因为那些接口基本上只吃 form-urlencoded 格式。
在 axios 里同样能直接用:
javascript复制axios.post('/api/user', new URLSearchParams({ name: '张三', age: 25 }))
但这里有个隐藏小坑:axios 默认会把普通对象序列化成 JSON,设置 Content-Type: application/json。如果你传的是 URLSearchParams 实例,它就会走表单序列化这条路径。不过 axios 不同版本对于“什么时候该用表单”,判断逻辑略有差异,保险的做法是手动加一下请求头:
javascript复制axios.post('/api/user', params, {
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }
})
自己动手把 Content-Type 写清楚,省得被框架的默认逻辑坑到。
4.2 数组参数与复杂结构的序列化方案
查询字符串本身没有“数组”这个概念,但很多后端接口里确实需要传数组参数。常见做法有两种,看你后端怎么解析。
第一种:重复键。
javascript复制const params = new URLSearchParams();
params.append('tags', 'js');
params.append('tags', 'css');
params.append('tags', 'html');
// 输出 tags=js&tags=css&tags=html
这种格式在 Spring、Express(配合 qs)等框架里能直接解析成数组 ['js', 'css', 'html']。
第二种:带下标或方括号的键名。
javascript复制const params = new URLSearchParams();
params.append('tags[]', 'js');
// 输出 tags%5B%5D=js
用 tags[] 这种写法其实也能解析数组,而且也是后端常见的约定。但问题在于 URLSearchParams 本身不懂数组语义,它只是把这当成一个普通键名来处理,所以上面的代码输出是 tags%5B%5D=js。
实际项目中我更倾向第一种“重复键”方案,因为复用同一个键名,后端解析成本低,前端写起来也自然。
如果是更复杂的嵌套对象,比如 { user: { name: '张三', age: 20 } } 或者数组套对象,URLSearchParams 就真不擅长了。我一般直接用 qs 库来序列化,它能生成 user[name]=张三&user[age]=20 这种结构,后端配合对应解析就能拿到嵌套对象。对于这种场景,别硬用原生 API,工具库该用就用。
4.3 配合 history 路由实现可共享的筛选状态
写过后台管理系统的都知道,列表页的搜索条件、分页信息,最好都能反映在 URL 上。这样用户刷新页面不会丢状态,还能直接把 URL 发给别人打开看到同样的列表。
实现思路很清晰:筛选条件一变化,就更新 URL 参数。
javascript复制function applyFilters(filters) {
const url = new URL(window.location.href);
for (const [key, value] of Object.entries(filters)) {
if (Array.isArray(value)) {
url.searchParams.delete(key);
value.forEach((item) => url.searchParams.append(key, item));
} else if (value !== '' && value !== undefined && value !== null) {
url.searchParams.set(key, value);
} else {
url.searchParams.delete(key);
}
}
history.replaceState({}, '', url.toString());
// 然后你再去请求列表数据
fetchList();
}
页面初始加载时,再把这几个参数读出来回填到搜索框里:
javascript复制const params = new URLSearchParams(window.location.search);
const filters = {
keyword: params.get('keyword') ?? '',
page: Number(params.get('page')) || 1,
tags: params.getAll('tags')
};
这一套下来,筛选状态就完全“外置”到 URL 上了。我再补充几个项目里验证过的小点:
- 空值、默认值一律不写入 URL,避免地址栏越来越长。
- 分页参数只在
page > 1时才写进去,否则删掉,这样首页 URL 非常干净。 - 如果某个筛选条件特别多,比如几十个 checkbox,URL 可能超长,这时候可以考虑存到
localStorage只留一个state=xxx参数。不过一般场景下,几百个字符的 URL 完全没问题,不用过度设计。
5. 常见问题与踩坑实录
5.1 中文乱码与 + 号变空格
我在接手老项目时见过中文参数乱码,排查到最后基本都是老代码里用了 decodeURIComponent 去解含 + 号的字符串。前面已经提过,URLSearchParams 的解码规则里 + 会变成空格,这是符合规范的,但如果你跟老代码混用,就会出现对不上的情况。
典型例子是这么一组参数:?keyword=hello+world%2Btest。
- 用
URLSearchParams.get('keyword')得到hello world+test。因为+被当成空格,%2B被当成字面意义的+。 - 如果你用
decodeURIComponent('hello+world%2Btest'),得到的是hello+world+test,+没有被转换。
所以我的建议是:同一个项目里,URL 参数解析只认一套规则,要么全用 URLSearchParams,要么全用老方案,千万别混。“混着用导致 + 号显示不对”这个问题,我印象里被同事问过不下三次。
5.2 重复键真的防不胜防
URLSearchParams 对重复键支持得很好,但它不会主动告诉你“这个键有多个值”。如果你只用 get 取数据,拿到了第一个值之后也不会报错,问题就是:你不知道后面还有值被丢了。
比如接口返回了 ?tag=js&tag=css,你用 get('tag') 拿到了 js,页面只显示了一个标签,排查半天还以为后端只给了一个值。这种问题很隐蔽,我建议你在封装 getParams 方法的时候,对数组类型的参数统一用 getAll 取值,别管返回几个。
5.3 容易被忽略的 hash 与 search 边界
URLSearchParams 只处理 ? 后面、# 前面的部分。如果你的 URL 是 https://example.com/page?keyword=js#section,window.location.search 拿到的只有 ?keyword=js,不会带 #section。这通常是好事,但如果你自己写正则去解析 location.href,一定要先按 # 切分,不然 hash 里的参数会被混进查询字符串。
还有一个容易懵的边界:URL 里只有 # 后面的参数,没有 ?,比如 https://example.com/page#/detail?id=3。这种其实是前端路由(hash 路由)自己在 hash 里带的参数,URLSearchParams 完全拿不到 id。因为对浏览器来说,#/detail?id=3 都是 hash,不是 search。这个场景下你需要先 window.location.hash 截出来再解析,或者直接用 Vue Router / React Router 提供的 query API。
5.4 兼容性与 polyfill 方案
支持情况大概是这样的:
| 环境 | 支持情况 |
|---|---|
| Chrome | 49+ 完整支持 |
| Firefox | 29+ 完整支持 |
| Safari | 10.1+ 完整支持 |
| Edge | 17+ 完整支持 |
| IE | 不支持 |
| Node.js | 10.0.0+ 内置全局可用 |
如果项目还需要兼容 IE,推荐用一个轻量 polyfill:url-search-params-polyfill,直接在入口文件 import 一下就行。
bash复制npm install url-search-params-polyfill
javascript复制import 'url-search-params-polyfill';
这个 polyfill 很薄,只补缺失的 API,不会影响原生行为,而且多年没更新也说明已经很稳定了。如果项目有构建工具,也可以考虑让 Babel 不转换它,因为 polyfill 本身就是为了在旧环境补齐原生 API,转不转都不影响功能。
5.5 常见问题速查表
| 症状 | 原因 | 解决方式 |
|---|---|---|
中文参数显示为 %E5%BC%A0%E4%B8%89 |
这是编码后的正常格式 | 用 get 方法,自动解码 |
参数里的 + 丢了 |
URLSearchParams 把 + 解码为空格 |
值里的加号要传 %2B |
| 同名参数只取到第一个 | get 只返回首个匹配值 |
改用 getAll(name) |
Object.fromEntries(params) 参数丢失 |
重复键被后值覆盖 | 数组型参数用 getAll 单独处理 |
IE 不识别 URLSearchParams |
浏览器不支持 | 引入 polyfill |
| hash 路由里的参数取不到 | 参数在 # 后面,不属于 search |
先取 location.hash 再解析 |
做前端这些年,我的体会是:越是基础的工具方法,越值得花时间彻底吃透。URLSearchParams 这个 API 看起来简单,但用好了能省掉不少低级 bug,也能让代码干净很多。当你下次再想从 URL 里“抠”参数的时候,先停下来想想,是不是直接交给原生 API 就够了。
