写点什么

[极致用户体验] 多页面应用里,「网页内返回」按钮,何时用 history.back 何时用 replaceState?

作者:HullQin
  • 2022 年 9 月 21 日
    广东
  • 本文字数:3615 字

    阅读完需:约 12 分钟

[极致用户体验] 多页面应用里,「网页内返回」按钮,何时用 history.back 何时用 replaceState?

我是 HullQin,公众号线下聚会游戏的作者(欢迎关注公众号,发送加微信,交个朋友),转发本文前需获得作者 HullQin 授权。我独立开发了《联机桌游合集》,是个网页,可以很方便的跟朋友联机玩斗地主、五子棋等游戏,不收费没广告。还独立开发了《合成大西瓜重制版》。还开发了《Dice Crush》参加 Game Jam 2022。喜欢可以关注我 HullQin 噢~我有空了会分享做游戏的相关技术。

背景

上篇文章《网页里的「返回」应该用 history.back 还是 push ?》论证了单页面应用(Single-Page Application,简称 SPA)如何实现网页内的「返回」按钮,本篇文章将会论证多页面应用(Multi-Page Application,简称 MPA)如何实现网页内的「返回」按钮。

何谓「极致用户体验」

上文我提到,网站应该是有页面层级的:



它是一个树状结构,每个页面、模块划分非常清晰。


如果要追求极致用户体验,用户在浏览器点击「前进」或「返回」时,应该遵循这样的规则:


  • 点浏览器的「前进」按钮(forward,右箭头),只允许相邻页面层级从左往右跳转

  • 点浏览器的「返回」按钮(back,左箭头),只允许相邻页面层级从右往左返回

实现方案

要实现这样的规则,开发者必须控制好浏览器的历史记录栈:


  • 用户进入更深的页面层级,浏览器的历史记录栈就增 1。

  • 用户返回更浅的页面层级,浏览器的历史记录栈就减 1。但历史记录栈无法减 1 时,可以让历史记录栈数量保持不变。


我解释一下,开发者怎么控制历史记录栈?


什么时候历史记录栈增一? 当我们调用history.pushState()时,浏览器历史记录栈就会新增一个历史记录,主要存了 URL 等信息。此时,用户点击「浏览器返回」和「浏览器前进」,就可以在「上一个页面」和「当前页面」反复横跳。


什么时候历史记录栈减一? 当我们调用history.back()时,就可以让浏览器历史记录栈减一。其实严格来说不算减一,只是页面回退到了上一条记录,这相当于用户点了「浏览器返回」按钮。


有时候点「网页返回」按钮,不能直接调用 history.back,为什么? 如果调用history.back()会返回其它界面(或者用户是直接打开了我们的某个页面,没有上一条历史记录了,「浏览器返回」按钮也是灰色),即调用history.back()无法返回我们自己网站的上一页面层级,就应该调用history.replaceState(),跳到上一页面层级。注意不能用history.push,如果用了 push 会打破我们的原则,那时候再点「浏览器返回」就从左往右导航了,违背了我们的网站页面层级。

回顾单页面应用方案

只要父页面跳转到子页面时,携带个「标识」,告知子页面,跳转来源是你亲爸爸。子页面就知道了,自页面的「网页返回」按钮,可以直接触发history.back()返回。


如果子页面发现没有「标识」,说明不是亲爸爸跳转到该子页面的,通过history.back()无法返回亲爸爸页面。不得不通过history.replaceState()前往亲爸爸页面,并且去的时候,不能带「标识」,因为子页面不是父页面的亲爸爸。


跳转时的「标识」,可以用history.pushState()中的state来实现。绝不能用 URL 中的参数来实现。因为 URL 太容易伪造了,可能用户点个收藏、复制个网址,就把标识给带上了。但是state绝对足够隐蔽。

多页面应用方案

问题描述

我的父页面 game.hullqin.cn 和 子页面 game.hullqin.cn/wzq 是部署了两套前端代码,他们是 MPA。


在子页面有个「游戏列表」按钮,相当于我的「网页返回」按钮。我期望这两个页面符合网站页面层级标准:


  • 如果可以通过hittory.back()返回首页就用它。

  • 如果hittory.back()无法返回首页,就用history.replaceState()


难点 1

直接调用history.pushState(),可以传递state标识,但这只会修改 URL,并不会触发浏览器刷新,网页依然停留在父页面。


直接调用window.location.href = 'game.hullqin.cn/wzq'会使浏览器刷新,但是不能传递state标识。可能还需要借助sessionStorage方案来保存、传递「标识」,但这又引入了更高的复杂度,因为它是跟历史记录栈无关的,我们不得不在sessionStorage中存一些路由信息,才能正确传递「标识」。

解决难点 1

先调用history.pushState(),传递state标识,再调用window.location.reload()触发刷新。这会保持state给下一个页面。

难点 2

如果我们是通过调用history.pushState()来增加浏览器历史记录栈的,那么我们调用history.back()时,页面不会刷新,只改变 URL。


也许你就说:像刚才一样,调用history.back()后再调用window.location.reload()触发刷新,不就解决了吗?


但这里还有一点:用户点击「浏览器返回」按钮时,只会改 URL,页面不会刷新。虽然网址已经是首页了,但是界面依然是在game.hullqin.cn/wzq这个子页面。


类似的,用户在父页面点「浏览器前进」按钮准备进入game.hullqin.cn/wzq这个子页面时,也会只改 URL,页面不刷新。

解决难点 2

监听window.onpopstate事件,这个事件会在用户点「浏览器返回」按钮或「浏览器前进」按钮时触发。我们监听该事件,判断当前页面 URL 是否符合当前页面的路由规则。如果有差异,就调用window.location.reload()触发刷新。

代码

父页面核心代码

你可以参考 game.hullqin.cn 的网页源码,这是一个非常简洁的门户页面。


<div style="flex-grow:1;display:flex;flex-direction:column;justify-content:center">  <a class="game" href="/uno"><img alt="" class="logo" src="https://fe-1255520126.file.myqcloud.com/uno/logo.svg"/><span>UNO</span></a>  <a class="game" href="/ddz"><img alt="" class="logo" src="https://fe-1255520126.file.myqcloud.com/ddz/logo.svg"/><span>斗地主</span></a>  <a class="game" href="/wzq"><img alt="" class="logo" src="https://fe-1255520126.file.myqcloud.com/wzq/logo.svg"/><span>五子棋</span></a></div>
<script>Array.from(document.getElementsByClassName('game')).forEach(game => { game.addEventListener('click', (event) => { if (event.button !== 0) return; if (event.ctrlKey || event.metaKey || event.shiftKey || event.altKey) return; event.preventDefault(); window.history.pushState({key: Math.random().toString(36).substring(2, 10), usr: {keepSession: true}}, '', game.getAttribute('href')); window.location.reload(); });});window.addEventListener('popstate', () => { if (window.location.pathname !== '/') window.location.reload();});</script>
复制代码


注意点:调用history.pushState()时,传递的state标识是:


{  key: Math.random().toString(36).substring(2, 10),  usr: { keepSession: true },}
复制代码


这是为了符合子页面 React-Router state的规范,需要包含一个随机字符串key,标记一次会话,用usr存储开发者自定义数据。


正如我上篇文章提到的,我为了标识子页面来自亲爸爸,是用了keepSession这个名字。

子页面核心代码

子页面我使用了 React 框架和 React-Router。「网页返回」按钮核心逻辑如下:


import { Link, useLocation, useNavigate } from 'react-router-dom';
function BackLink(props: BackLinkProps) { const { to, children, className, } = props; const navigate = useNavigate(); const { state } = useLocation(); const keepSession = state.keepSession; const handleClick = (event: MouseEvent<HTMLAnchorElement | HTMLButtonElement>) => { if (event.button !== 0) return; if (event.ctrlKey || event.metaKey || event.shiftKey || event.altKey) return; event.preventDefault(); if (keepSession) { navigate(-1); } else if (window.history.length === 1) { navigate(to, { replace: true }); } else { navigate(to, { replace: true }); // 通过下面方式刷新浏览器"前进"记录,以免通过"前进"进入不符预期的页面 navigate(to); navigate(-1); } }; return ( <Link to={to} className={className} onClick={handleClick}> {children} </Link> );}// 另外还有以下逻辑,可以写在另一个JS文件或直接写在html中:window.addEventListener('popstate', () => { if (!window.location.pathname.startsWith("/wzq")) window.location.reload();});
复制代码


也许你会好奇if (event.button !== 0) return;if (event.ctrlKey || event.metaKey || event.shiftKey || event.altKey) return;这两行代码,欢迎阅读文章:《你的 Link Button 能让用户选择新页面打开吗?》。

写在最后

我是 HullQin,公众号线下聚会游戏的作者(欢迎关注公众号,发送加微信,交个朋友),转发本文前需获得作者 HullQin 授权。我独立开发了《联机桌游合集》,是个网页,可以很方便的跟朋友联机玩斗地主、五子棋等游戏,不收费没广告。还独立开发了《合成大西瓜重制版》。还开发了《Dice Crush》参加 Game Jam 2022。喜欢可以关注我 HullQin 噢~我有空了会分享做游戏的相关技术。

发布于: 刚刚阅读数: 4
用户头像

HullQin

关注

公众号【线下聚会游戏】 2020.10.07 加入

game.hullqin.cn 我做了一些联机桌游网页:支持2-10人联机的UNO、2-4人联机的斗地主、2人联机的五子棋。无需下载,点开即玩!叫上朋友,即刻开局!不看广告,不做任务,享受「纯粹」的游戏!

评论

发布
暂无评论
[极致用户体验] 多页面应用里,「网页内返回」按钮,何时用 history.back 何时用 replaceState?_CSS_HullQin_InfoQ写作社区