1. 为什么一个“取参数”的小功能值得单独拎出来讲
前端项目里有一类代码,写的时候觉得三行搞定,出问题的时候能查一下午。GET 参数解析就属于这种典型。页面地址栏里挂着一串?id=123&name=abc&tags=a,b,c,后端说参数传过去了,前端说取出来是undefined,两边对着日志互相看,最后发现是+号被当成了空格,或者中文被编码了两次。这类问题不复杂,但极其消耗时间,而且几乎每个项目都会重新踩一遍。
我做过不少带分享链接、带筛选条件、带埋点透传的页面,参数解析这块的代码前后重构过好几轮。早期直接用字符串split('&')再split('='),简单粗暴,遇到?a=1&a=2这种同名参数就抓瞎;后来换成URLSearchParams,大部分场景够用,但老环境兼容和特殊字符处理又得单独兜底;再后来干脆封装了一个统一的解析函数,把编码、数组、空值、哈希路由这些情况一次性收口。这篇就把这套东西完整拆开讲清楚,从原理到实现到排错,争取让你看完之后,项目里这块代码不用再改第二遍。
GET 参数解析这件事,表面上是“把问号后面的字符串拆成键值对”,实际上牵扯到编码规则、数据结构设计、运行环境差异、边界条件处理四个层面。适合谁看?刚入门前端、经常写活动页和分享页的同学,以及那些项目里已经有一坨解析代码但总觉得不踏实的开发者。哪怕你只用过URLSearchParams,看完也能知道它在哪些地方会悄悄坑你。
2. 先想清楚:参数解析到底要解决哪些问题
2.1 从一段真实地址说起
假设页面地址是这样的:
https://example.com/list?keyword=%E6%89%8B%E6%9C%BA&page=2&tags=red,blue&tags=green&empty=&flag这一串里藏着至少五个需要处理的信息点。keyword的值是经过encodeURIComponent编码的中文,直接取出来是乱码,必须解码。page是普通数字,但取出来是字符串,业务里要转成数字。tags出现了两次,是保留最后一个、保留第一个,还是聚合成数组,得有个明确策略。empty=是空字符串还是null,不同业务要求不一样。flag后面没有等号,它算true、空字符串还是直接忽略,也得定规矩。
很多解析函数出问题,不是因为不会写split,而是因为没有提前定义清楚这些边界行为。写的时候随手一写,用的时候各处理各的,最后同一个参数在不同页面表现不一致,排查成本直接翻倍。所以我的习惯是,动手写代码之前先把规则列成表,后面所有实现都围绕这张表来。
| 场景 | 常见处理方式 | 建议策略 |
|---|---|---|
| 同名参数多次出现 | 覆盖 / 取首个 / 聚合成数组 | 默认聚合成数组,单值时降级为字符串 |
值为空a= | 空字符串 / null / 忽略 | 保留为空字符串,语义最明确 |
只有键没有值a | true / 空字符串 / 忽略 | 保留为空字符串,避免布尔歧义 |
编码字符%E6%89%8B | 解码 / 原样保留 | 解码,且要处理解码失败的情况 |
+号 | 空格 / 加号 | 按表单编码规则转为空格 |
哈希路由#/path?a=1 | 忽略 / 解析 | 需要单独处理,问号可能在哈希内部 |
这张表看着朴素,但它决定了后面代码的每一个分支。我见过太多项目,解析函数写了二十行,结果tags传数组的时候直接拿到"green",因为后面的覆盖了前面的,筛选功能莫名其妙失效,查了半天以为是后端问题。
2.2 三种主流方案的取舍逻辑
前端解析 GET 参数,常见路子就三条,各有各的适用面。
第一条是手写字符串处理。location.search.slice(1).split('&')起手,循环里再split('=')。优点是零依赖、逻辑完全可控、任何环境都能跑。缺点是编码、数组、空值这些全得自己兜,代码量不小,而且容易漏边界。适合对体积敏感、或者需要深度定制的场景。
第二条是**URLSearchParams**。现代浏览器和 Node 都内置,API 语义清晰,get、getAll、has、entries一应俱全,编码解码也帮你做了。缺点是老环境需要 polyfill,而且它对+号的处理、对重复键的默认行为,跟很多人直觉不完全一致,不熟悉的话容易踩坑。适合现代浏览器为主、逻辑不复杂的项目。
第三条是第三方库,比如查询字符串解析类的工具库。功能全、边界处理成熟,但为了一个参数解析引入一个包,在轻量项目里不太划算,而且库的行为你未必完全清楚,出问题时还得去翻源码。
我的实际选择是:以手写函数为主,内部逻辑参考URLSearchParams的语义,同时保留一个基于原生 API 的快速路径。这样既可控,又不重复造轮子。下面第三节就把这个函数的完整实现拆开讲。
2.3 一个容易被忽略的前提:参数从哪来
解析函数本身不关心参数来源,但调用它的地方必须想清楚。参数可能来自location.search,也可能来自哈希路由里问号后面的部分,还可能是别人传给你的一整条 URL 字符串,甚至是后端返回的一个查询串。如果函数只认location.search,那复用性就很差。
所以设计上,解析函数应该接收一个字符串入参,而不是直接读全局的location。读地址栏这件事交给调用方,函数只负责“字符串进、对象出”。这个解耦看着小,实际收益很大:单测好写、服务端渲染不报错、拿历史 URL 做对比分析时也能直接复用。这一点在后面实现里会体现得很明显。
3. 核心实现拆解:一个能扛住边界情况的解析函数
3.1 整体结构:先剥壳,再切分,最后归并
函数的主干分三步。第一步剥壳,把传入字符串里问号之前的部分去掉,同时处理哈希路由的情况。第二步切分,按&拆成一个个键值片段,再按第一个=拆成键和值。第三步归并,把重复的键聚合成数组,把编码解开,把空值按约定处理。
这个顺序很重要。很多人习惯边切边解码,结果值里本身含有编码过的&(也就是%26),切分的时候没问题,但如果先整体解码再切分,%26变成&,一个参数就被劈成两半了。所以必须先按原始字符串切分,再对键和值分别解码。这是编码处理里最经典的一个坑,我在活动页埋点参数上栽过一次,一个带&的分享文案把整个参数结构搞乱了。
function parseQuery(input) { if (typeof input !== 'string' || input.length === 0) return {}; // 剥壳:去掉问号之前的内容,兼容哈希路由 let str = input; const hashIndex = str.indexOf('#'); if (hashIndex !== -1) { const afterHash = str.slice(hashIndex + 1); const qIndex = afterHash.indexOf('?'); str = qIndex !== -1 ? afterHash.slice(qIndex + 1) : ''; } else { const qIndex = str.indexOf('?'); str = qIndex !== -1 ? str.slice(qIndex + 1) : str; } if (!str) return {}; const result = {}; const pairs = str.split('&'); for (let i = 0; i < pairs.length; i++) { const pair = pairs[i]; if (!pair) continue; const eqIndex = pair.indexOf('='); let key, value; if (eqIndex === -1) { key = pair; value = ''; } else { key = pair.slice(0, eqIndex); value = pair.slice(eqIndex + 1); } key = safeDecode(key); value = safeDecode(value); if (!key) continue; if (Object.prototype.hasOwnProperty.call(result, key)) { if (Array.isArray(result[key])) { result[key].push(value); } else { result[key] = [result[key], value]; } } else { result[key] = value; } } return result; }这段代码不长,但每个分支都有讲究。eqIndex === -1处理的是只有键没有值的情况,按前面表格的约定给空字符串。if (!key) continue是防御性的,避免&&连续出现时产生空键。重复键的判断用hasOwnProperty而不是in,是为了防止toString、constructor这类原型链上的键被误判。这些细节单看都不起眼,凑在一起就是“稳”和“能用”的区别。
3.2 解码函数:为什么不能直接decodeURIComponent
safeDecode是整个函数里最需要小心的地方。直接调decodeURIComponent有两个风险。一是遇到非法编码序列会抛异常,比如%E6%89这种被截断的编码,一个异常就能让整个页面白屏。二是+号在查询字符串里按表单编码约定应该转成空格,但decodeURIComponent不会处理它。
function safeDecode(str) { if (typeof str !== 'string') return ''; const replaced = str.replace(/\+/g, ' '); try { return decodeURIComponent(replaced); } catch (e) { return replaced; } }先替换+再解码,顺序不能反。如果先解码,值里本来编码成%2B的加号会变成+,再被替换成空格,语义就错了。这个顺序问题我在处理搜索关键词时遇到过,用户搜“C++”,传过来是C%2B%2B,先解码再替换的话就变成“C ”,搜索直接失效。
try/catch兜底返回替换后的原字符串,是一种“降级但不崩溃”的策略。有人会问,为什么不返回空字符串?因为非法编码往往意味着数据在传输过程中被截断或二次编码了,原样返回至少保留了排查线索,返回空反而把问题藏起来了。这个取舍看团队习惯,但我的经验是保留原始信息比静默丢弃更有价值。
3.3 数组聚合策略:单值和多值怎么平滑切换
重复键聚合成数组,这个策略前面表格里定了。但实际用的时候有个体验问题:如果某个参数只出现一次,返回的是字符串,出现两次返回数组,调用方就得写Array.isArray判断,很啰嗦。
有两种处理思路。一种是永远返回数组,调用方统一按数组处理,简单但单值场景要多写[0]。另一种是单值降级为字符串,就是我上面代码的做法,调用方大多数时候直接当字符串用,需要多值的时候再判断。我倾向后者,因为实际业务里单值参数占绝大多数,为了少数多值场景让所有调用方都写数组取值,不划算。
如果确实需要统一数组语义,可以再加一个包装函数:
function getArrayParam(params, key) { if (!Object.prototype.hasOwnProperty.call(params, key)) return []; const val = params[key]; return Array.isArray(val) ? val : [val]; }这个函数很小,但把“要不要数组”的决定权交给了调用方,解析层保持简单。分层清晰,后面维护的人一看就懂。
3.4 和URLSearchParams的行为对齐
既然手写了函数,就得知道它和原生 API 的差异在哪,避免团队里两套代码行为不一致。我整理了一张对照表,实际项目里对着这张表统一约定,省了很多扯皮。
| 行为 | 手写函数(本文策略) | URLSearchParams默认 |
|---|---|---|
| 重复键 | 聚合成数组 | get取首个,getAll取全部 |
空值a= | 空字符串 | 空字符串 |
无等号a | 空字符串 | 空字符串 |
+号 | 转空格 | 转空格 |
| 非法编码 | 降级返回原文 | 可能抛错或替换 |
| 哈希内参数 | 支持解析 | 需手动截取 |
可以看到,主要差异在重复键和非法编码上。如果项目里同时用了两种方式,务必在团队规范里写清楚用哪个,别一个页面用原生、一个页面用手写,最后数据对不上。
4. 实操落地:从接入到验证的完整流程
4.1 接入项目的三种典型位置
解析函数写好了,放哪、怎么调,决定了它能不能真正被复用。我一般会把它放在项目的工具层,按三种场景接入。
第一种是页面初始化读取参数。在路由进入或组件挂载时调用一次,把结果存到状态里,后续所有逻辑都从这个状态取,而不是反复读地址栏。反复读地址栏的问题在于,用户如果手动改了地址但没触发路由更新,读到的值可能和页面状态不一致。
const params = parseQuery(window.location.href); const keyword = params.keyword || ''; const page = Number(params.page) || 1;第二种是哈希路由场景。很多单页应用用#/path?x=1这种形式,问号在哈希内部,location.search是空的。这时候直接把location.hash传给解析函数就行,函数内部的剥壳逻辑已经兼容了。
第三种是处理外部传入的 URL。比如分享链接生成、跳转前校验、历史记录分析,拿到的是一整条字符串,直接丢给parseQuery即可,不依赖任何全局对象。这也是前面强调“函数只接收字符串”的收益所在。
4.2 参数类型转换:解析层不做,业务层统一做
解析函数返回的值全是字符串,这是刻意的设计。如果解析层自作主张把"123"转成数字、把"true"转成布尔,会带来两个问题:一是前导零丢失,"007"变成7;二是语义歧义,"1"到底该是数字还是字符串,解析层无从判断。
所以类型转换放在业务层,用一个小的取值工具统一处理:
function getNumber(params, key, defaultValue = 0) { const raw = params[key]; if (raw === undefined || raw === '') return defaultValue; const num = Number(raw); return Number.isNaN(num) ? defaultValue : num; } function getBoolean(params, key, defaultValue = false) { const raw = params[key]; if (raw === undefined) return defaultValue; return raw === 'true' || raw === '1'; }这样职责清晰:解析层只管结构,业务层管语义。新人接手时也不会疑惑“为什么这个参数有时候是数字有时候是字符串”。
4.3 验证清单:上线前必须跑一遍的用例
函数写完不等于能用,我习惯用一组固定用例过一遍,覆盖前面表格里的所有边界。这套用例可以直接写成单测,也可以手动在控制台跑。
const cases = [ ['?a=1&b=2', { a: '1', b: '2' }], ['?a=1&a=2', { a: ['1', '2'] }], ['?a=', { a: '' }], ['?a', { a: '' }], ['?a=%E6%89%8B%E6%9C%BA', { a: '手机' }], ['?a=C%2B%2B', { a: 'C++' }], ['?a=x+y', { a: 'x y' }], ['?a=%E6%89', { a: '%E6%89' }], ['#/path?a=1', { a: '1' }], ['?&&a=1&&', { a: '1' }], ['', {}], ];重点看三个用例。C%2B%2B验证加号解码顺序,%E6%89验证非法编码不崩溃,#/path?a=1验证哈希路由。这三个是我在实际项目里真实踩过的坑,每次重构解析逻辑都会先跑一遍。
提示:验证时不要只看返回值对不对,还要看函数有没有抛异常。非法编码那条用例,如果函数直接抛错,说明
try/catch没生效,后面所有参数都会解析失败。
4.4 性能考量:什么时候需要缓存
参数解析本身很快,一次解析通常在一毫秒以内,绝大多数场景不需要优化。但有两种情况值得注意。一是高频调用,比如滚动监听里每次都读参数,这时候应该在初始化时解析一次存起来,而不是每次读地址栏。二是超长参数串,某些埋点场景会把大量数据塞进 URL,字符串切分的开销会上升,这时候可以考虑惰性解析,用到哪个键再解哪个。
不过说实话,这两种情况在普通业务里都不常见。我的建议是先保证正确性,性能问题等真出现了再优化,别为了一个可能不存在的瓶颈把代码写复杂。
5. 常见问题与排查实录
5.1 中文变乱码:编码层级没对齐
最常见的现象是取出来的中文是%E6%89%8B%E6%9C%BA这种原样字符串,或者是一堆问号。原因通常有两个:一是解析时没解码,二是编码被做了两次。前者检查safeDecode有没有被调用,后者检查生成链接的地方是不是对已经编码过的值又调了一次encodeURIComponent。
排查方法很直接:在浏览器地址栏复制完整 URL,手动把参数值拿出来,用decodeURIComponent在控制台跑一下,看能不能还原。能还原说明是解析层没解码,不能还原说明是生成端编码有问题。定位到是哪一端的问题,比盲目改代码快得多。
5.2 参数丢失:&被提前吃掉了
现象是本来三个参数,只解析出两个,或者某个参数的值被截断。根因几乎都是值里含有未编码的&或=。比如分享文案是“A&B”,生成链接时没编码,拼出来就是?text=A&B,解析时B被当成了独立参数。
解决办法是在生成端对每个值做encodeURIComponent,解析端坚持“先切分后解码”。两端配合,这个问题就绝迹了。我在代码评审时会把这条列为必查项,尤其是涉及用户输入拼进 URL 的地方。
5.3 哈希路由下取不到参数
单页应用用哈希路由时,location.search是空的,参数全在location.hash里。有人直接把location.search传给解析函数,结果永远是空对象。这时候要么传location.hash,要么传完整的location.href,让函数内部的剥壳逻辑去处理。
还有一种更隐蔽的情况:哈希里既有路径又有参数,比如#/detail/123?id=456。如果剥壳逻辑只按第一个?切,路径里的内容可能被误伤。稳妥的做法是先定位#,再在#之后找?,这也是我前面代码里的处理顺序。
5.4 同名参数只拿到一个
前面提过,URLSearchParams.get只返回第一个,手写函数如果用了对象直接赋值,后面的会覆盖前面的。表现就是多选筛选传了三个值,页面只认最后一个。排查时打印原始字符串,确认参数确实传了多次,然后检查解析函数有没有做数组聚合。
| 现象 | 可能原因 | 排查动作 | 解决方式 |
|---|---|---|---|
| 中文乱码 | 未解码或重复编码 | 控制台手动解码验证 | 统一在解析层解码一次 |
| 参数被截断 | 值含未编码& | 打印原始 URL 检查 | 生成端编码,解析端先切后解 |
| 哈希路由取不到 | 传了location.search | 检查传入的字符串 | 传hash或完整href |
| 多值只剩一个 | 对象赋值覆盖 | 打印解析结果 | 聚合成数组 |
| 函数抛异常 | 非法编码未兜底 | 看控制台报错位置 | try/catch降级返回 |
5.5 几个我踩过的坑和对应心得
第一个坑是空值语义。早期我把a=解析成null,结果业务里判断if (params.a)全部为假,筛选条件被当成没传。后来统一成空字符串,判断逻辑改成params.a !== undefined,语义清晰多了。这件事让我意识到,空字符串和undefined的区别必须在团队里说清楚,否则每个人判断方式不一样,bug 防不胜防。
第二个坑是原型链污染。如果参数里出现?__proto__=x,用普通对象接收并直接赋值,可能影响对象原型。虽然现代浏览器对__proto__赋值有保护,但稳妥起见,接收对象可以用Object.create(null),或者赋值前过滤掉危险键名。这个属于安全习惯,平时不显眼,出事就是大事。
第三个坑是解码顺序。前面反复提过,这里再强调一次:先按原始字符串切分,再对键值分别解码。任何“先整体解码再切分”的写法都是错的,值里只要有一个编码过的&就会翻车。
注意:解析函数不要直接读
location,也不要修改传入的字符串。纯函数特性带来的可测试性和可复用性,远比省一行代码重要。
6. 把这套东西沉淀成团队规范
6.1 一个函数加一份约定,比一堆工具函数管用
很多团队的做法是遇到问题就加一个工具函数,最后工具目录里躺着五六个功能重叠的解析方法,新人根本不知道该用哪个。我的建议是只保留一个解析入口,所有边界行为写进注释和文档,类型转换、数组取值这些用薄封装函数补充,而不是另起炉灶再写一个解析器。
规范里至少要写清楚四件事:重复键怎么处理、空值怎么表示、编码在哪一层做、哈希路由怎么传参。这四条定下来,团队里就不会出现“同一个参数在不同页面表现不同”的情况。
6.2 后续可以扩展的方向
这套解析逻辑稳定之后,可以往上叠一些实用能力。比如参数白名单校验,只取业务关心的键,避免把无关参数带进后续逻辑;比如参数序列化,把对象转回查询字符串,和解析函数配对使用;再比如参数变更监听,配合路由事件在参数变化时触发回调。这些都属于锦上添花,核心解析稳了,加什么都顺手。
我个人在实际操作中的体会是,参数解析这种基础功能,投入产出比最高的做法不是写得多么花哨,而是把边界情况一次性想全、写死、测到。代码短一点没关系,行为可预期才是关键。项目里这块代码一旦稳定,后面所有涉及 URL 传参的需求都能直接复用,省下来的排查时间远超当初多花的那半小时。