点击一个链接,页面还没加载完,你又点了另一个。按直觉,第二次点击应该赢——普通链接加整页刷新就是这么干的。但在很多单页应用里,赢的往往是第一个。
实际发生的事是:第一次导航开始拉数据,第二次导航也开始了。不管你先点了哪个、后点了哪个,哪个请求最后返回,哪个就显示在页面上。有那么一瞬间,地址栏显示的是A页面,渲染出来的内容却是B页面。网络慢的时候,这一瞬间足够长,长到有人能截个图,提个bug,标题写"应用显示了错误的页面"。
![]()
这里让很多人意外的地方在于:这不是你路由器的bug,是工具本身的形态决定的。
pushState路由到底是怎么工作的
每个客户端路由器——不管是手写的还是框架自带的——大致都在做这件事:监听点击事件,找到最近的标签,判断是不是站内链接,然后阻止默认行为,调用history.pushState改URL,再调用renderRoute去拉数据、换视图。同时监听popstate事件,处理浏览器前进后退。
再读一遍这段逻辑,注意缺了什么:没有任何地方知道上一次renderRoute调用还在进行中。当新的renderRoute开始时,pushState已经把URL改掉了——就浏览器历史记录而言,导航已经完成了。你的路由器不是在拦截导航,它是在对一个已经提交的操作做出反应,然后让自己的异步任务跟上一个反应的残留任务赛跑。
双击bug用一句话说就是:两次renderRoute调用,彼此之间没有任何关联,谁最后完成谁赢。加上一个取消的fetch和AbortController,这个问题大部分时候能消掉——成熟的路由库就是这么干的。但你是在从外部修补一个由事件本身引发的竞态条件,因为点击处理和URL变更从来就不是一个原子操作。
专为URL变更前设计的API
Navigation API存在的目的就是补上这个缺口。它不再监听变更后的popstate,而是监听window.navigation上的navigate事件——这个事件在浏览器提交新URL之前触发,覆盖所有导航类型:链接点击、前进后退、history.pushState,甚至表单提交。
核心是intercept()方法,这是popstate从来没有过的东西:它把renderRoute返回的promise交给浏览器,浏览器会等这个promise完成,才把导航视为结束。不需要你伪造加载状态,不需要手动维护"当前路由是否还有效"的标记。而且每个navigate事件自带event.destination,你不需要从全局location去猜当前URL是什么。
换句话说,Navigation API把"导航"从"事后反应"变成了"事前拦截"——浏览器在URL变更前给你一个机会,让你决定这次导航怎么处理、什么时候算完成。这跟pushState时代"先改URL再赛跑"的模型,是两种完全不同的心智模式。
对开发者来说,这意味着单页应用的导航竞态问题终于有了一个原生解法,而不是靠每个团队自己写一套取消逻辑。对用户来说,这意味着"点了B却看到A"这种诡异体验,理论上可以从根上消失。
特别声明:以上内容(如有图片或视频亦包括在内)为自媒体平台“网易号”用户上传并发布,本平台仅提供信息存储服务。
Notice: The content above (including the pictures and videos if any) is uploaded and posted by a user of NetEase Hao, which is a social media platform and only provides information storage services.