有个需求几乎每个前端都写过:从 URL 上读一个一次性参数------登录 token、邀请 code、渠道来源 ref------用完之后,把它从地址栏抹掉,再刷新一下页面让新状态生效。
听起来两行代码的事。但我最近排查到一个线上问题:页面进来之后地址栏开始疯狂横跳,在 ?token=xxx 和 ?[object Object]= 之间无限刷新,CPU 直接拉满。
代码看起来人畜无害,但它同时踩中了两个独立的坑。结论先放前面:
这类「无限重定向」的根因,是把异步的客户端软导航 和同步的整页刷新 当成同一个东西混用;再叠加一个看起来是 URLSearchParams、其实是普通对象 的伪装。理解
router.replace/history.replaceState/location.replace各自的「同步性」和「作用域」,是绕开这类坑的关键。
下面从复现讲起,拆开两个坑,再讲清前端路由的客户端导航到底怎么工作,最后给出正确姿势和一张 API 速查表。
一、复现:一段「看起来没问题」的代码
把业务剥掉,问题代码可以抽象成这样:
ts
// 期望:删掉 URL 上的一次性参数,然后刷新页面让新状态生效
function refreshWithoutParams(keys: string[]) {
keys.forEach((key) => {
delete searchParams[key];
});
router.replace(`?${searchParams.toString()}`);
router.reload();
}
其中 searchParams 来自框架/封装层提供的 router 对象,router.reload() 在很多封装里实际就是 window.location.reload()。
直觉上这段逻辑是:删掉参数 → 用剩下的参数重写 URL → 刷新。但实际跑起来,地址栏在这两个 URL 之间无限横跳:
https://example.com/?token=abchttps://example.com/?[object Object]=
两个反常信号已经摆在脸上了:一个是诡异的 ?[object Object]=,一个是「刷新根本停不下来」。它们对应两个独立的坑。
二、第一个坑:你的 searchParams 真的是 URLSearchParams 吗
?[object Object]= 这个字符串,是 JS 里一个非常经典的味道:你对一个普通对象 调用了 .toString()。
浏览器原生的 URLSearchParams 是有正确 toString() 的:
ts
const sp = new URLSearchParams('a=1&b=2');
sp.toString(); // 'a=1&b=2' ✅
但很多框架的封装层,为了让你能用 obj.key 这种顺手的方式读参数,会把它转成普通对象:
ts
// 封装层常见做法:把 URLSearchParams 摊平成普通对象
const searchParams = Object.fromEntries(new URLSearchParams(location.search).entries());
// { token: 'abc' }
searchParams['token']; // 'abc',读起来很爽
searchParams.toString(); // '[object Object]' ❌ 普通对象没有自定义 toString
Object.fromEntries 出来的是个朴素对象 ,它的 toString() 来自 Object.prototype,永远返回 '[object Object]'。于是 router.replace(`?${searchParams.toString()}`) 拼出来的就是 ?[object Object]=。
更隐蔽的是 delete 的语义也悄悄变了:
ts
// 普通对象:delete 能删掉属性
delete searchParams['token']; // ✅ 生效
// URLSearchParams:delete 操作符对它无效,必须用方法
delete urlSearchParams['token']; // ❌ 静默失败,参数还在
urlSearchParams.delete('token'); // ✅ 正确做法
这就是抽象层最容易咬人的地方:类型换了,但方法名没换,看起来一切正常。 searchParams.toString()、delete searchParams[key] 这种「读起来天经地义」的调用,恰恰是 bug 高发区------它在 URLSearchParams 上是一种行为,在普通对象上是另一种,编译器还不会报错。
经验法则:凡是从框架手里拿到的
searchParams,先确认它是原生URLSearchParams还是被摊平的普通对象,再决定用.delete()还是delete,用.toString()还是手动拼。
但即使把 toString 这个坑修好,让 URL 能正确拼成 ?(参数全删光),无限刷新依然存在。因为真正致命的是第二个坑。
三、第二个坑:软导航是异步的,整页刷新是同步的
要讲清楚这个坑,得先理解前端路由的客户端导航到底怎么工作。
客户端软导航的本质:History API + 框架调度
传统多页应用里,「换地址」意味着浏览器向服务器请求一个新文档,整页卸载重建。为了省掉这个开销,现代前端路由------无论是 React Router 这类纯客户端 SPA,还是 Next.js App Router 这类 SSR 框架的客户端导航------都用浏览器的 History API (history.pushState / history.replaceState)在不重载页面 的前提下改写地址栏,再由框架在内存里换掉要渲染的组件。这就是所谓的软导航(soft navigation)。
一次 router.push / router.replace 大致做了这几件事:
- 调用
history.pushState/replaceState改写地址栏 URL; - 通知框架的路由系统「路径变了」;
- 框架据此重新匹配路由、加载数据、重渲染对应组件。
关键在于第 3 步:现代框架(React 系尤甚)会把这种更新放进可中断的异步调度 里(比如 React 的 transition),不会在你调用 router.replace() 的那一刻同步完成。也就是说:
router.replace(url)返回时,导航通常还没真正发生------它只是被排进了框架的更新队列。地址栏什么时候变、组件什么时候重渲染,是稍后的事。
而 window.location.reload()(以及 location.href = ...)完全是另一个世界的东西:它是浏览器层面的硬导航 ,同步触发,调用即开始卸载当前文档、重新请求。
两个世界相撞
现在把问题代码的最后两行放慢看:
ts
router.replace(`?${searchParams.toString()}`); // 异步软导航:排队,还没改地址栏
router.reload(); // = location.reload():同步硬刷新,立即执行
router.replace 把「改成新 URL」排进了异步队列,还没生效 ;紧接着 location.reload() 同步触发,浏览器立刻重载当前这一刻的 URL------而当前 URL 还是带着原参数的那个。软导航根本没机会落地就被整页刷新打断了。
于是流程变成一个闭环:
每次重载都会重新挂载组件、重新读到那个参数、再次执行清理、再次被自己的 reload 打断。参数永远去不掉,页面永远在刷新。叠加第一个坑,软导航即使侥幸生效,目标也是错的 ?[object Object],照样回不到干净 URL。
这个 bug 的迷惑性在于:两行代码单独看都对 。router.replace 是去参数的标准写法,reload 是刷新的标准写法,错在把一个异步操作和一个同步操作串在一起,还指望前者先于后者生效。
四、正确姿势:在重载之前,用同步手段把 URL 改干净
既然根因是「异步改 URL 撞上同步刷新」,解法就清晰了------别用异步的软导航去改 URL,改用同步的浏览器 API,在重载真正发生之前就把 URL 改到位。
有两种等价写法。
方案 A:history.replaceState 同步改 + reload
ts
function refreshWithoutParams(keys: string[]) {
const url = new URL(window.location.href);
keys.forEach((key) => url.searchParams.delete(key)); // 在真实 URL 副本上删
// state 传 history.state 而非 null:有的框架(如 Vue Router)把滚动位置等元数据
// 存在 history.state 里,直接覆盖会破坏它并触发警告
window.history.replaceState(window.history.state, '', url.toString());
window.location.reload(); // 此时重载的已经是干净 URL
}
这里的关键是 history.replaceState,很多人对它的参数和行为不太熟,先说清楚它的签名:
ts
history.replaceState(state, unused, url)
state:与这条历史记录关联的状态对象,前进/后退时能从popstate事件或history.state读回。⚠️ 别习惯性传null------有的框架(如 Vue Router)把滚动位置等元数据存在这里,直接覆盖会破坏它并触发警告,所以上面代码传的是现有的window.history.state,把框架的元数据原样带上。unused:第二个参数曾经是页面标题,但现代浏览器基本都忽略它,按惯例传空字符串''即可(别指望它改标签页标题)。url:要替换成的新地址,必须同源,否则抛错。
它和 pushState 只差一点:pushState 往历史栈里新增 一条记录,replaceState 是替换 当前这条。抹一次性参数时,我们不想在历史里留下那条带参数的旧 URL(否则用户一点「后退」又回去了),所以用 replace 而不是 push。
但它有个最容易被忽略的边界:replaceState 只是同步地改写地址栏和历史栈 ,它不触发导航、不重载页面、也不通知框架的 router ------光调它,页面内容一点都不会变。所以方案 A 必须靠紧跟的 window.location.reload() 把整页重载一次,去掉参数的新 URL 才真正生效。这也是为什么方案 A 是「replaceState 改 URL + reload 刷新」两步:前者负责同步把地址栏改干净,后者负责让它落地。重载之后组件重新读取,参数已经没了,闭环被打破。
方案 B:location.replace 一步到位(更推荐)
ts
function refreshWithoutParams(keys: string[]) {
const url = new URL(window.location.href);
keys.forEach((key) => url.searchParams.delete(key));
window.location.replace(url.toString()); // 同步导航到新 URL + 整页重载 + 不留历史
}
location.replace(newUrl) 一个 API 就把「导航到去参数后的 URL」「整页重载」「替换当前历史记录」三件事一次性同步做完。没有异步队列,没有时序竞争。
会不会报错?它的抛错边界其实很窄:只有传入非法 URL 、或极短时间内反复调用 (触发浏览器导航节流)才可能出问题。而我们传的是 new URL(location.href) 删参数后的结果------一定是合法、同源的 URL ,又是一次性消费、只调一次 ,这两种边界都碰不到,可以放心用。反过来说,真正会触发节流告警的,恰恰是像本文开头那样陷入无限循环、反复 replace------只要保证它是「一次性消费、用完即走」、不塞进会反复触发的渲染逻辑里,就碰不到。
两种写法的共同点,也是它们能修好的根本原因:
- 用
new URL(location.href)拿到真实当前 URL 的独立副本 ,在它身上调url.searchParams.delete()------这是货真价实的URLSearchParams,delete和toString都正确,彻底绕开第一个坑; - 用同步 API 改 URL ,绕开
router.replace的异步性,彻底绕开第二个坑。
为什么我更偏向方案 B?因为它不依赖 router.reload() 的实现细节 。方案 A 的正确性押在「reload 必须是整页硬刷新」上------一旦某天有人把封装层的 reload 改成框架的软刷新(比如某些框架的 router.refresh(),只重取数据不重新挂载),方案 A 又会出现 replaceState 改了地址栏但框架 router 状态不同步的新问题。location.replace 是纯浏览器语义,不和任何框架契约耦合,更稳。
五、顺手厘清:一堆「改地址」API 的区别
这次踩坑暴露出一个事实:很多人对「改地址」的几个 API 其实是混着用的。一张表说清楚它们的差异:
| API | 来源 | 整页重载 | 留历史 | 同步 | 通知框架 router |
|---|---|---|---|---|---|
location.href = url / location.assign(url) |
浏览器原生 | 是 | 是(后退能回) | 是 | 否(整页重启) |
location.replace(url) |
浏览器原生 | 是 | 否(替换当前条目) | 是 | 否(整页重启) |
history.pushState(...) / replaceState(...) |
浏览器原生 | 否 | push 新增 / replace 替换 | 是 | 否(只改地址栏) |
router.push(...) / router.replace(...) |
框架软导航 | 否 | push 新增 / replace 替换 | 否(异步) | 是 |
router.reload() |
Next.js Pages Router | 是(= location.reload(),丢 state) |
------ | 是 | ------(整页重启) |
router.refresh() |
Next.js App Router | 否(软刷新,留 state) | ------ | 否(异步) | 是(重取 Server Component) |
revalidate() |
React Router(useRevalidator) |
否 | ------ | 否(异步) | 是(重拉路由数据) |
表里几个最容易混的点,配合矩阵再点一句:
location三兄弟 :href = url≈assign(url)(都硬跳转、留历史),replace(url)唯一的不同是替换当前历史条目、后退回不去------抹一次性参数(token、code)正是要这个「不留历史」,否则用户一点后退又触发一次。- 同叫「刷新」差别巨大 (表里第 5、6 行):Next.js Pages Router 的
reload()是整页硬刷新 、丢掉客户端 state,App Router 的refresh()是软刷新 、重取 Server Component 数据并保留 state,官方迁移指南就是让你把前者换成后者。这正是方案 B「不依赖reload实现」的顾虑:封装层一旦把统一的reload()从硬刷新换成软刷新,依赖「整页重载把 URL 重新走一遍」的方案 A 就可能失效,而location.replace(方案 B)不碰框架这套、更稳。 - 兼容性无忧 :
location.assign/location.replace是 DOM Level 0 时代的古董 API(早于 W3C 标准化、连老 IE 都支持),history.replaceState是 HTML5 起的稳定特性,都早已 Baseline 广泛可用。
几个由此推出的实战结论:
- 抹一次性参数,优先
location.replace而不是location.href。 一次性参数(token、code)不该留在历史里,否则用户一点「后退」又回到带参数的 URL,可能再次触发处理逻辑。replace替换当前历史条目,后退回不去,正合需求。 history.replaceState不触发popstate,框架当下不会同步路由状态。 React Router、Vue Router 都靠popstate感知前进后退,而pushState/replaceState按规范不触发它------React Router 官方就明说「无法检测pushState调用」。所以「replaceState+ 整页reload」必须靠 reload 重启整个应用来「对齐」;只replaceState不 reload,会出现「地址栏新了、页面内容还是旧的」的割裂。但别以为框架完全不在意 :有的框架(如 Vue Router)把滚动位置等元数据存在history.state里,手动replaceState时务必保留它(history.replaceState(history.state, '', url)),直接传null/{}覆盖会破坏框架状态并触发警告------这也是location.replace(方案 B)更省心的又一个原因:整页导航不碰history.state这套。- 别直接修改框架给你的
searchParams对象。 它通常是useMemo缓存的只读快照或派生对象,delete searchParams[key]是在改一个不该改的东西。这次能「侥幸没出问题」只是因为后面紧跟整页 reload 把它抹平了------换个时序就是隐藏 bug。要改就改new URL(location.href)这种自己的副本。
六、排查与心智模型
下次遇到「页面无限刷新 / 无限重定向」,可以按这个顺序快速定位:
- 看地址栏字符串。 出现
?[object Object]=、?undefined=这类,基本就是把普通对象当URLSearchParams用了,或者把对象直接塞进了字符串拼接。 - 看是不是「软导航 + 整页刷新」混用。 搜一下出问题路径附近有没有
router.replace/push紧跟location.reload/location.href的写法------这是时序竞争的高发组合。 - 确认每个「改地址」调用的同步性。 异步的(框架软导航)和同步的(浏览器硬导航)不能想当然地串在一起按书写顺序生效。
更通用的心智模型其实就一句话:
前端有两套「改地址」的世界------框架的软导航 (异步、内存内换组件、归 router 管)和浏览器的硬导航 (同步、整页重载、归 location/history 管)。跨这两个世界写代码时,认准每个 API 的同步性 和作用域,不要假设它们能按你写的顺序乖乖排队。
这次的 bug,本质就是在这两个世界的接缝处栽了跟头。两行代码、两个坑,但拆开看每一个都很基础------前端的很多「灵异问题」,往往就藏在这种「看起来理所当然」的基础 API 语义里。
参考与数据源
- MDN · History API(pushState / replaceState) 与 popstate 事件(明确:pushState / replaceState 不触发它)
- MDN · Location.replace() 与 Location.assign()
- MDN · URL 与 URLSearchParams
- React · useTransition 与可中断更新
- Next.js · App Router useRouter(refresh / push / replace) 与 Pages Router useRouter(reload)
- Next.js · Linking and Navigating(软导航)
- React Router · Navigating(navigate / replace) 与 useRevalidator(重新校验数据)
- Vue Router · 迁移指南:history.state 的用法(手动 replaceState 须保留 history.state)