最近在写油猴脚本的时候,偶尔遇到一些单页面应用模型的网站。
单页面应用(Single Page Applications,SPA),顾名思义,是只有一个页面的web应用,与传统的web应用不同,它只需要首次从服务器加载资源(静态),在不刷新的情况下,后续的任何内容更新仅发生于客户端浏览器而不会请求服务器,这种特性在提升了客户端性能的利用率&提升用户体验的同时,也减轻了服务器压力。
但是,该特性也间接导致了基于url匹配规则的脚本无法正常运行。
默认情况下,油猴插件会根据url的匹配结果,在页面(或文档)加载前后注入脚本,即是说,脚本的执行依赖于“加载页面”这个过程,只有加载页面时,才会执行脚本。
而由于SPA只会从服务器加载一次页面,后续看到的“切换页面”或“跳转页面”,实际上都只是在当前页面上动态生成并渲染出来的,url虽然发生变化但未真正加载页面,因此脚本不会执行。
在得知了这一原因之后,我们就应该明白,想要脚本能在单页面应用下正常执行,就需要监听网站的url(或路由),当变为某个url时,执行某些逻辑。
简而言之,相当于从油猴插件手中接管了部分匹配路径的工作。
不过,比较遗憾的是,js并未提供任何现成可用的监听url的接口或方法,因此,通常情况下无法对url实现监听。
请注意,这里说的是“通常情况”,而不是说不可实现。
众所周知,单页面应用一般是在html5的history api的基础上实现的,它提供了一套操作浏览器会话历史(browser session history)的方法,使得修改浏览器会话历史而不触发页面刷新成为可能。
主要涉及到两个方法:
- pushState()
- replaceState()
以vue为例,查看其路由vue-router框架源码可知,无论是Router.push()还是Router.replace(),在底层都调用了以上两个方法:
这里指history和hash模式
// 位置:./packages/router/src/history/html5.ts
// 版本:v4.5.0
function push(to: HistoryLocation, data?: HistoryState) {
// ...
changeLocation(currentState.current, currentState, true)
// ...
changeLocation(to, state, false)
currentLocation.value = to
}
function replace(to: HistoryLocation, data?: HistoryState) {
// ...
changeLocation(to, state, true)
currentLocation.value = to
}
function changeLocation(to: HistoryLocation, state: StateEntry, replace: boolean): void {
// ...
try {
// BROWSER QUIRK
// NOTE: Safari throws a SecurityError when calling this function 100 times in 30 seconds
history[replace ? 'replaceState' : 'pushState'](state, '', url)
historyState.value = state
} catch (err) {
// ...
}
}
此外,还有go()、back()、forward()三个方法,以及一个与其密切相关的PopStateEvent接口:当浏览器历史记录改变时,该接口对应popstate事件将会被触发。
在了解了以上的信息之后,应该不难联想到,我们或许可以通过监听popstate事件来得知单页面应用的url变化,当进行路由跳转时,底层调用history api,继而触发popstate事件...
addEventListener("popstate", (event) => {
// to do something
});
然而,现实并非如想象般美好。
我们会发现,写完代码后,无论进行怎样的跳转,监听器都没有任何反应。
原因在于pushState()和replaceState()方法不会触发popstate事件,The history stack文档中已经明确表示。
Note that just calling
history.pushState()
orhistory.replaceState()
won't trigger apopstate
event. Thepopstate
event will be triggered by doing a browser action such as a click on the back or forward button (or callinghistory.back()
orhistory.forward()
in JavaScript).
这也是前面提到js未提供任何可监听url变化接口或方法的原因。
与之形成对比的是,处于同一层级的history.back()和history.forward()却不受这一限制,或者是浏览器的前进、后退按钮,都能正常触发popstate事件,从视觉效果上来看,它们的作用和路由跳转别无二致,所以感觉调用history api未触发事件有点奇怪,不然没什么理由,即使它不叫popstate,也应该有一个别的事件。
不过,前面也说了,办法还是有的。
既然它们没有触发,那就让它们触发不就好了...
目前已经了解到,单页面应用的路由跳转底层会调用pushState()或replaceState(),因此,只需要对这它们动一动手脚:
const methods = ['replaceState', 'pushState'];
for (const item of methods) {
const fn = history[item];
history[`_${item}`] = fn;
history[item] = function (state, unused, url) {
history[`_${item}`](state, unused, url);
const ev = new Event('urlchange');
ev.args = { state, unused, url };
window.dispatchEvent(ev);
}
}
window.addEventListener('urlchange', ev => console.log(ev));
以上代码对两个方法进行了重写,使得它们在被调用时触发自定义的“urlchange”事件,该事件对象携带了将要跳转的url信息,同时还为urlchange事件添加了监听器,以某个单页面应用网站为例:
图1 监听url实现-1
点击菜单,从当前页面/#/article跳转到目标页面/#/category。
图1 监听url实现-2
可以看到,在切换路由的时,预期的数据回传到事件处理器中,即是说,目前已经成功实现了对单页面应用url的监听效果。(有够简单的吧。。)
事实上,油猴插件其实也已经预料到这种情况,并对之做了处理:
If a script runs on a single-page application, then it can use
window.onurlchange
to listen for URL changes.
只需在脚本的元信息处处启用,效果是一样的,只是说,即使不用油猴插件的接口,或在油猴脚本以外的地方,也能很简单地实现对单页面应用路由的监听功能。
// ==UserScript==
...
// @grant window.onurlchange
// ==/UserScript==
if (window.onurlchange === null) {
// feature is supported
window.addEventListener('urlchange', (info) => ...);
}
现在可以愉快地编写脚本了。
本文完。