1.场景
在进行开发过程中,直接使用了Vue-Router来进行页面跳转,但是出现了一些奇奇怪怪的bug,特花时间来进行相关调研并记录,如有不严谨或不正确的地方,欢迎指正探讨。
问题
使用Vue-Router来进行页面跳转
使用this.$router.push() 地址栏的链接不变,Iframe的src不变,但是Iframe的内容发生变化。
使用this.$router.go(-1) 来进行跳转,地址栏链接改变,Iframe的src改变,Iframe的内容也发生变化。
使用this.$router.href()可以进行跳转,且地址栏发生改变
2.路由处理
说到路由跳转就不得不提Window.history 系列的Api了,常见的Vue-router等路由处理其本质也都是在通过该系列Api来进行页面切换操作。
本次我们讨论的就主要涉及 到Window.history.pushState
和Window.history.go
。
Window.history(下文将直接简称为history)指向一个History对象,表示当前窗口的浏览历史,History对象保存了当前窗口访问过的所有页面网址。
2.1History常见属性与方法
go() 接受一个整数为参数,移动到该整数指定的页面,比如history.go(1)相当于history.forward(),history.go(-1)相当于history.back(),history.go(0)相当于刷新当前页面
back() 移动到上一个访问页面,等同于浏览器的后退键,常见的返回上一页就可以用back(),是从浏览器缓存中加载,而不是重新要求服务器发送新的网页
forward() 移动到下一个访问页面,等同于浏览器的前进键
pushState() pushState()
需要三个参数:一个状态对象(state),一个标题(title)和一个URL。
*注意:pushState会改变url,但是并不会刷新页面,也就是说地址栏的url会被改变,但是页面仍保持当前。
总之,pushState()
方法不会触发页面刷新,只是导致 History 对象发生变化,地址栏会有反应。
history.pushState({a:1},'page 2','2.html')
popState事件
每当同一个文档的浏览历史(即history对象)出现变化时,就会触发popstate事件。简单可以理解为,每次我们需要修改url 那么必定是先出发了popState事件,浏览器的地址栏随后才会发生改变。
注意,仅仅调用pushState()方法或replaceState()方法 ,并不会触发该事件,**只有用户点击浏览器倒退按钮和前进按钮,或者使用 JavaScript 调用History.back()、History.forward()、History.go()方法时才会触发。**另外,该事件只针对同一个文档,如果浏览历史的切换,导致加载不同的文档,该事件也不会触发。
2.2Vue-Router的实现
mode
#push src/history/html5.js
push (location: RawLocation, onComplete?: Function, onAbort?: Function) {
const { current: fromRoute } = this
this.transitionTo(location, route => {
pushState(cleanPath(this.base + route.fullPath))
handleScroll(this.router, route, fromRoute, false)
onComplete && onComplete(route)
}, onAbort)
}
function pushState (url, replace) {
saveScrollPosition();
// try...catch the pushState call to get around Safari
// DOM Exception 18 where it limits to 100 pushState calls
var history = window.history;
try {
if (replace) {
// preserve existing history state as it could be overriden by the user
var stateCopy = extend({}, history.state);
stateCopy.key = getStateKey();
history.replaceState(stateCopy, '', url);
} else {
history.pushState({ key: setStateKey(genStateKey()) }, '', url);
}
} catch (e) {
window.location[replace ? 'replace' : 'assign'](url);
}
}
#go src/history/html5.js
go (n: number) {
window.history.go(n)
}
以上是Vue-router再history模式下push和go的源码,可见其主要的实现是通过History Api来实现跳转的。
2.3Vue-Router是如何实现单页应用的呢?
vue-router 主要用来做单页面,即更改 url 无需刷新能够渲染部分组件达到渲染不同页面的效果,其中 history 模式监听 url 的变化的也是由 popstate 实现的,然后监听浏览器返回的方法也是大同小异。
原理是,A url-> B url,此时用户点击返回时,url 先回退到 A url,此时触发 popstate 回调,vuerouter 根据 next 回调传参是 false 判断需要修成 A url 成 B url,此时需要将进行 pushstate(B url),则此时就实现了阻止浏览器回退的效果
Ps:篇幅原因,源码在文章底部附上。
那么在进行了Iframe嵌套后会有什么不一样呢?
3.IFrame嵌套情况下问题解决
The sequence of
Document
s in a browsing context is its session history. Each browsing context, including child browsing contexts, has a distinct session history. A browsing context's session history consists of a flat list of session history entries.Each
Document
object in a browsing context's session history is associated with a uniqueHistory
object which must all model the same underlying session history.The
history
getter steps are to return this's associatedDocument
'sHistory
instance.-https://html.spec.whatwg.org/multipage/history.html#joint-session-history
简单来说不同的documents在创建的时候都有自己的history ,同时内部的document在进行初始化时候具有相同的基础HIstory。
如上,当我们从页面A进行跳转以后,Top层,和内嵌Iframe层初始时是具有相同的history,因此,当我们进入页面后,无论是在页面B 还是页面C中使用window.history.go(-1)均可以实现相同的效果,即返回页面A,且浏览器的URl栏也会随之发生改变。
当我们从hybrid页面跳向hybrid的时候
如下,此时如果在新的页面内使用go(-1),则可能会出现问题【当页面A和页面B的History不一致时】,但是除了我们手动去pushState改变,大部分情况页面A和页面B的history是完全一致的因此也就不会出现History不一致的问题了。
那么来看一下我们一开始遇到的问题:
注意:以下仅仅针对Chrome浏览器,不同浏览器对于Iframe中的HIstory Api处理方式可能会存在不一样。
1.使用this.$router.push() 地址栏的链接不变,Iframe的src不变,但是Iframe的内容发生变化。
2.使用this.$router.go(-1) 来进行跳转,地址栏链接改变,Iframe的src改变,Iframe的内容也发生变化。
3.使用this.$router.href()可以进行跳转,且地址栏发生改变
1.直接调用Router.push 相当于我们在Iframe中调用了pushState,但是由于pushState是不会主动触发popstate的,所以外层的popstate是没有被触发,因此外层的url并无改变,但是内层由于VueRouter通过对pushState的callBack事件来进行的后续操作,因此可以实现对popState事件的触发,从而实现了在将新的url push到history中以后,并进行了页面的跳转。
2.使用this.$router(-1) 可以实现跳转的原因在于,在我们进入一个hybrid页面的时候,iframe的history会被初始化和window完全相同,也就是说,这个时候我们在Iframe中执行window.go(-1)取到的url 是和直接在Top执行Window。所以这个时候执行Router.go(-1)是可以正常运行且返回上一个页面的。
3.本质还是对remote方法进行封装 。
关于页面IFrame中history Api的应用还是存在着一些争议和问题,在W3C的TPAC会议上也都有在进行相关的讨论
虽然最后有了一些共识,但是对于各个浏览器来说,兼容性还是不太一致。因此,建议大家在Iframe中使用history系列api时,务必小心并加强测试。
从上来看,是非常不科学的,iframe中可以影响到Window的history,Chorme也承认这是一个漏洞。
4.实际开发中的应用
1.返回检测
1.实际开发需求:
用户填写表单时,需要监听浏览器返回按钮,当用户点击浏览器返回时需要提醒用户是否离开。如果不需要,则需要阻止浏览器回退
2.实现原理:监听 popstate 事件
popstate,MDN 的解释是:当浏览器的活动历史记录条目更改时,将触发 popstate 事件。
触发条件:当用户点击浏览器回退或者前进按钮时、当 js 调用 history.back,history.go, history.forward 时
但要特别注意:当 js 中 pushState, replaceState 并不会触发 popstate 事件
window.addEventListener('popstate', function(state) {
console.log(state) // history.back()调用后会触发这一行
})
history.back()
原理是进入页面时,手动 pushState 一次,此时浏览器记录条目会自动生成一个记录,history 的 length 加 1。接着,监听 popstate 事件,被触发时,出弹窗给用户确认,点取消,则需要再次 pushState 一次以恢复成没有点击前的状态,点确定,则可以手动调用 history.back 即可实现效果
20200607233903
window.onload = (event) => {
window.count = 0;
window.addEventListener('popstate', (state) => {
console.log('onpopState invoke');
console.log(state);
console.log(`location is ${location}`);
var isConfirm = confirm('确认要返回吗?');
if (isConfirm) {
console.log('I am going back');
history.back();
} else {
console.log('push one');
window.count++;
const state = {
foo: 'bar',
count: window.count,
};
history.pushState(
state,
'test'
// `index.html?count=${
// window.count
// }&timeStamp=${new Date().getTime()}`
);
console.log(history.state);
}
});
console.log(`first location is ${location}`);
// setTimeout(function () {
window.count++;
const state = {
foo: 'bar',
count: window.count,
};
history.pushState(
state,
'test'
// `index.html?count=${window.count}&timeStamp=${new Date().getTime()}`
);
console.log(`after push state locaiton is ${location}`);
// }, 0);
};
2.Ajax请求后可以后退
在Ajax请求虽然不会造成页面的刷新,但是是没有后退功能的,即点击左上角是无法进行后退的
如果需要进行后退的话 就需要结合PushState了
当执行Ajax操作的时候,往浏览器history中塞入一个地址(使用pushState)(这是无刷新的,只改变URL);于是,返回的时候,通过URL或其他传参,我们就可以还原到Ajax之前的模样。
demo参考链接https://www.zhangxinxu.top/wordpress/2013/06/html5-history-api-pushstate-replacestate-ajax/
5.参考资料
HIstory APi 学习 :
https://developer.mozilla.org/en-US/docs/Web/API/Window/popstate_event
https://wangdoc.com/javascript/bom/history.html
https://www.cnblogs.com/jehorn/p/8119062.html
Vue-Router源码
https://liyucang-git.github.io/2019/08/15/vue-router%E6%BA%90%E7%A0%81%E5%88%86%E6%9E%90/
https://zhuanlan.zhihu.com/p/27588422
Iframe相关问题学习:
https://github.com/WICG/webcomponents/issues/184
https://www.cnblogs.com/ranran/p/iframe_history.html
https://www.coder.work/article/6694188
http://www.yuanmacha.com/12211080140.html
开发应用:
https://www.codenong.com/cs106610163/
Vue-Router实现源码:
#src/history/html5.js
beforeRouteLeave (to, from, next) { // url离开时调用的钩子函数
if (
this.saved ||
window.confirm('Not saved, are you sure you want to navigate away?')
) {
next()
} else {
next(false) // 调用next(false) 就实现了阻止浏览器返回,请看下面
}
}
setupListeners () {
// 为简略,省略部分源码
const handleRoutingEvent = () => {
const current = this.current
// Avoiding first `popstate` event dispatched in some browsers but first
// history route not updated since async guard at the same time.
const location = getLocation(this.base)
if (this.current === START && location === this._startLocation) {
return
}
this.transitionTo(location, route => { // 这里调用自定义的transitionTo方法,其实就是去执行一些队列,包括各种钩子函数
if (supportsScroll) {
handleScroll(router, route, current, true)
}
})
}
window.addEventListener('popstate', handleRoutingEvent) // 在这里添加popstate监听函数
this.listeners.push(() => {
window.removeEventListener('popstate', handleRoutingEvent)
})
}
#下面看 transitionTo 的定义,参见 src/history/base.js
transitionTo (
location: RawLocation,
onComplete?: Function,
onAbort?: Function
) {
const route = this.router.match(location, this.current)
this.confirmTransition( // 调用自身的confirmTransition方法
route,
// 为简略,省略部分源码
)
}
confirmTransition (route: Route, onComplete: Function, onAbort?: Function) {
const current = this.current
const abort = err => {
// changed after adding errors with
// https://github.com/vuejs/vue-router/pull/3047 before that change,
// redirect and aborted navigation would produce an err == null
if (!isRouterError(err) && isError(err)) {
if (this.errorCbs.length) {
this.errorCbs.forEach(cb => {
cb(err)
})
} else {
warn(false, 'uncaught error during route navigation:')
console.error(err)
}
}
onAbort && onAbort(err)
}
if (
isSameRoute(route, current) &&
// in the case the route map has been dynamically appended to
route.matched.length === current.matched.length
) {
this.ensureURL()
return abort(createNavigationDuplicatedError(current, route))
}
const { updated, deactivated, activated } = resolveQueue(
this.current.matched,
route.matched
)
const queue: Array<?NavigationGuard> = [].concat( // 定义队列
// in-component leave guards
extractLeaveGuards(deactivated), // 先执行当前页面的beforeRouteLeave
// global before hooks
this.router.beforeHooks, // 执行新页面的beforeRouteUpdate
// in-component update hooks
extractUpdateHooks(updated),
// in-config enter guards
activated.map(m => m.beforeEnter),
// async components
resolveAsyncComponents(activated)
)
this.pending = route
const iterator = (hook: NavigationGuard, next) => { // iterator将会在queue队列中一次被执行,参见src/utils/async
if (this.pending !== route) {
return abort(createNavigationCancelledError(current, route))
}
try {
hook(route, current, (to: any) => {
if (to === false) { // next(false) 执行的是这里
// next(false) -> abort navigation, ensure current URL
this.ensureURL(true) // 关键看这里:请看下面ensureURL的定义,传true则是pushstate
abort(createNavigationAbortedError(current, route))
} else if (isError(to)) {
this.ensureURL(true)
abort(to)
} else if (
typeof to === 'string' ||
(typeof to === 'object' &&
(typeof to.path === 'string' || typeof to.name === 'string'))
) {
// next('/') or next({ path: '/' }) -> redirect
abort(createNavigationRedirectedError(current, route))
if (typeof to === 'object' && to.replace) {
this.replace(to)
} else {
this.push(to)
}
} else {
// confirm transition and pass on the value
next(to)
}
})
} catch (e) {
abort(e)
}
}
// 为简略,省略部分源码
}
#eusureURL 的定义,参见 src/history/html5.js
ensureURL (push?: boolean) {
if (getLocation(this.base) !== this.current.fullPath) {
const current = cleanPath(this.base + this.current.fullPath)
push ? pushState(current) : replaceState(current) // 执行一次pushstate
}
}