现在很多网站都已经变成单页应用(Single Page Application),例如YouTube、虾米音乐。当时发现的时候也是挺惊奇的,“为什么可以不重新加载页面地址栏的URL就可以改变?”
这让我想起BackBone.js,它的行为就是这样的,只不过它是使用location.hash
来实现的,每次“页面”切换都会在浏览器的地址栏看到#...
的路径在切换。
后来才知道,W3C将这种行为和编程方式纳入到History API中了、已经被规范化了,添加了多个方法与属性。
早年多多少少都用过JS的History API,但都仅限于十分简单的场景,例如前进后退那样。现在的确是时候恶补一下了。
浏览器历史栈及其状态state
浏览器历史栈不是什么新概念,一直都有,这里重温一下:
- 打开浏览器空白页面时,此时历史栈为长度为0;
- 然后在浏览器输入
http://a.cn/
后,此时在历史栈的末端塞入这个地址,栈记录着的地址及其指针为:['http://a.cn/'] (history.length === 1) ↑ location.href
- 然后在浏览器输入或者点击链接
http://b.cn/
后,此时在历史栈的末端塞入这个地址,栈记录着的地址及其指针为:['http://a.cn/', 'http://b.cn/'] (history.length === 2) ↑ location.href
- 然后页面上的脚本执行
history.back()
或history.go(-1)
,栈的内容不会变,但是指针往前跳一格:['http://a.cn/', 'http://b.cn/'] (history.length === 2) ↑ location.href
- 然后页面上的脚本执行
history.forward()
或history.go(1)
,栈的内容依然不会变,但是指针往后跳一格:['http://a.cn/', 'http://b.cn/'] (history.length === 2) ↑ location.href
- 然后再次执行
history.back()
、接着再打开链接http://a.cn/
,即便栈内已经有这个地址,但是栈记录的是打开链接的过程、而不是只保留唯一的地址记录,所以http://b.cn/
会被覆盖,栈会变成这样:['http://a.cn/', 'http://a.cn/'] (history.length === 2) ↑ location.href
- 即便
history
有length
属性,但是它不是一个仿数组,所以不能使用history[0]
访问历史记录、更不能使用for..of
迭代它。
但这样的API有一个缺点,就像上面例子的最后一步,需要繁琐的操作才可以覆盖http://b.cn/
这个历史。而且由于history
不是一个仿数组,没法通过历史索引下标获取状态,而且每次切换页面之后状态就会被冲走,这样便使得History API在原本的W3C没什么编程价值,只能用它前前后后走一两步。
于是history
便引入了pushState()
和replaceState()
方法,这便变得有编程价值了,因为使用它们的时候JS的上下文状态不会被冲走,而且还给栈中的每个历史记录都引入了状态state
属性。
为了兼容原有的API,在没有使用pushState()
和replaceState()
方法的情况下history.state
一直为null
。
history.push/repalceState()方法修改历史的状态
假设现在要设计一个Web音乐播放器,需要在页面切换的时候音乐播放不会中断,且所有操作不能打开新页签/新页面。这种需求就是典型的单页面应用,没有这对方法的帮助下是无法完成任务的。在执行下面例子的时候可以同时观察一下浏览器的地址栏。
- 打开Web音乐播放器的域名地址:
URL: ['http://a.cn/'] (history.length === 1) ↑ location.href state: [ null ] ↑ history.state
- 打开播放列表:
URL: ['http://a.cn/', 'http://a.cn/pl'] (history.length === 2) ↑ location.href state: [ null , [{mp3:'1'}...]] ↑ history.state
'use strict'; globalThis.__playlist__ = [{ mp3: '1' }, { mp3: '2' }]; globalThis.__playlistURL__ = location.href + 'pl'; history.pushState(__playlist__, '我的播放列表', // 页面标题,多数浏览器都会忽略这个参数 __playlistURL__); alert('playlist: ' + JSON.stringify(history.state));
- 从头开始播放播放列表:
URL: ['http://a.cn/', 'http://a.cn/pl', 'http://a.cn/pl/0'] (history.length === 3) ↑ location.href state: [ null , [{mp3:'1'}...], {mp3:'1'} ] ↑ history.state
'use strict'; alert('playlist: ' + JSON.stringify(history.state)); const idx = 0, mp3 = __playlist__[idx]; history.pushState(mp3, '正在播放MP3 - ' + idx, __playlistURL__ + '/' + idx); alert('curr mp3: ' + JSON.stringify(history.state));
- 播放至下一首歌:
URL: ['http://a.cn/', 'http://a.cn/pl', 'http://a.cn/pl/1'] (history.length === 3) ↑ location.href state: [ null , [{mp3:'1'}...], {mp3:'2'} ] ↑ history.state
'use strict'; alert('prev mp3: ' + JSON.stringify(history.state)); const idx = 1, mp3 = __playlist__[idx]; history.replaceState(mp3, '正在播放MP3 - ' + idx, __playlistURL__ + '/' + idx); alert('curr mp3: ' + JSON.stringify(history.state));
通过例子可以清楚看到,页面不会重新加载,地址栏却在一直变化。pushState()
的语义就是可以不断往历史栈的末端添加URL和state,而replaceState()
的语义就是可以直接替换历史栈的最末端的URL和state。
window.onpopstate监听状态改变
继续前面的例子。假设播放列表不是一个全局变量,但是如果希望从每首单曲的页面后退到播放列表页面的时候获得这个列表对象,将应该如何处理?答案就是这个window.onpopstate
回调:
'use strict';
window.addEventListener('popstate', (event) => { // 防止覆盖别人的回调
alert('onpopstate: ' + JSON.stringify(event.state));
}, { once: true });
history.back();
setTimeout(() => {
history.back();
}, 1000);
注意,这个事件会在history.back()
、history.forward()
、history.go(n)
的时候触发,而history.pushState()
、history.repalceState()
都不会触发。
对滚动的支持
在执行前面的例子的时候可以看到,在调用history.back()
之后浏览器也滚动回对应的位置。这是因为浏览器不单在记录历史栈的URL和state,实际上每个历史记录的滚动位置也被记录下来了,在弹出记录的同时会把滚动位置信息也一并恢复。
当然,这种行为会对单页应用带来困扰。于是便提供了一个全新的属性history.scrollRestoration
来控制这种问题,它的值默认就是auto
,也就是会自动恢复滚动位置,如果将它设置为manual
,再执行一边前面的例子,就可以发现页面没有乱跳了:
'use strict';
history.scrollRestoration = 'manual';
window.scrollTo(0, 0);
打赏作者