1. 什么是「返回」按钮?
这里不是浏览器的「返回」按钮,我们没办法修改它的行为。
而是网页代码中的「返回」按钮,我们可以定义它的行为。
举个例子
比如我的
五子棋小游戏
:
点开链接,会出现文章开头图片的的页面——游戏主页,「进入房间」后,左上角有个「离开房间」按钮,点击后,会返回主页。
这种需要返回上层页面的按钮
,在本文中,称之为「返回」按钮。
image.png
2. 什么是 push、back、replace?
|
|
|
|
|
|
|
页面会发生跳转,并在当前浏览记录新增一条记录(之后你可以按浏览器「返回」,回到跳转前的页面)。
|
页面返回上一条浏览记录(之后你可以按浏览器「前进」,重新回到返回前的页面)。若浏览器没有上一条记录,则什么都不会发生。
|
页面会发生跳转,覆盖当前的浏览记录。(你按浏览器「返回」,无法回到跳转前的页面)
|
|
|
|
|
|
|
|
|
|
|
|
|
navigate(url, { state, replace: false })
|
|
navigate(url, { state, replace: true })
|
这3种,都可以实现页面跳转,对于用户体验也是有差异的。
3. 「返回」按钮的难题
「返回」按钮,做好用户体验,挺难的。这里罗列一些容易想到的、但不完美的方案。
3.1 方案一:用back实现「返回」
存在的问题:
-
如果用户直接从URL进入该页面,点「返回」无效。
-
同一个页面,如果来源不同,点「返回」,回到的页面也不同,会让用户困惑。
其实,如果用back实现「返回」按钮,这个按钮元素会有点多余,因为它与浏览器原生的「返回」能力一样。
3.2 方案二:用push实现「返回」
这种方式解决了back导致的2个问题,但并不完美。
存在的问题:
-
页面浏览记录栈膨胀迅速,剥夺了用户使用原生「返回」按钮的权利。
我解释一下。比如有个
初始页面H
,用户从
初始页面H
跳转到了
列表页A
,用户通过点击
列表页A
里面的
详情Ax链接
(x代表一个正整数,列表页通常有多个详情链接),可以进入
详情页Ax
。在
详情页Ax
中,可以点
网页「返回」按钮
,回到
列表页A
。
当用户在
列表页A
和
详情页Ax
之间多次通过
详情Ax链接
和
网页「返回」按钮
来回切换时,页面浏览记录已经累积很多了,用户若想通过浏览器
原生「返回」按钮
,再返回
初始页面H
,是需要按很多次返回的。
但用户没有这个耐心。
所以你不得不在
列表页A
增加一个
网页「返回」按钮
,用于跳转
初始页面H
。这就诞生了新的问题:
-
如果一个
列表页A
的来源,不止
初始页面H
,还有多个页面可以跳转
列表页A
,那么
列表页A
的
网页「返回」按钮
,应该返回到哪里呢?
除此之外,我想强调一句:
剥夺用户使用原生「返回」按钮的权利,不是一件好事。
尤其是对于安卓端用户,重度依赖原生「返回」操作(在屏幕边缘左滑或右滑)。网页打破了他们的操作习惯,只能表明网页用户体验做的不够好。
4. 网页「返回」按钮,什么效果才是符合用户认知的?
这里,我想先提出「
页面层级
」的概念。
4.1 页面层级
假设网站有这样的结构:
image.png
它是一个树状结构,每个页面、模块划分非常清晰。
什么是页面层级?
同一层子结点,称之为同一个「页面层级」。
(例如图中模块A、B、C就是同一层级)
4.2 基于此定义,我们可以提出这样的产品原则:
-
页面跳转(push)或前进(forward),只允许
相邻页面层级
,
从左往右跳转
。
-
网页里的「返回」按钮(back),只允许
相邻页面层级
,
从右往左返回
。
-
对于
同一页面层级
的跳转:可以限制,必须先返回某结点的父结点,再进入该结点的兄弟结点。如果确实有快速跳转的诉求,只能用replace实现。
-
不允许
跨模块的跳转(如模块A某页面跳模块B某页面)。如果一定需要这种跳转,只能在新标签页打开。
-
不允许
跨层级的跳转(如第2层级直接跳转第4层级、或第4层级跳到第2层级)。如果一定需要这种跳转,只能在新标签页打开。
这样,页面整体跳转逻辑,是非常清晰的,对于用户而言,也容易理解你的逻辑。
4.3 为什么这样定义产品原则?
产品原则的目标:
让浏览器的历史记录栈与网页结构保持一致
:
-
用户进入更深的页面层级,浏览器的历史记录栈就增1。
-
用户返回更浅的页面层级,浏览器的历史记录栈就减1。
而浏览器原生的「返回」,正是使浏览器的历史记录栈回退1个。这样两种「返回」就归一了。
这件就解决了「3.2 方案二」中的问题,达到这样的效果:
-
保留用户使用原生「返回」的权利。
-
使网页「返回」按钮具有唯一目的地。
但网页「返回」按钮还有个问题必须解决:
若浏览器当前历史记录栈为空,或历史记录栈的上个页面并非该网页的页面,点「返回」,应该也能返回它的父页面。
现在我告诉你,这个技术难点,是有解的!
4.4 实现方案
「返回」按钮,逻辑如下
-
判断历史记录栈的上个页面,是不是我的父页面。
-
如果是我的父页面,我就用
history.back()
,使用浏览器原生返回行为。
-
如果不是我的父页面,我就用
history.replace()
,使当前页面替换为我的父页面。(不能用push,否则在父页面返回,回到了子页面,是反直觉的)
难点:
如何判断历史记录栈的上个页面,是不是我的父页面。
问题:浏览器基于安全性,不允许你读取历史记录栈。
解决方案
只要父页面跳转到子页面时,携带个「标识」,告知子页面,跳转来源。子页面就知道了。
跳转时的「标识」,刚好可以用
history.pushState()
中的
state
来实现。
只要是内部跳转,都封装一个统一的组件。该组件允许定义跳转目的地,而且会在
state
中携带「标识」(如果你的网页有带自定义
state
的诉求,则还需要在该组件中组装一下参数中的
state
和「标识」,变成新的
state
)。
获取当前页面的
state
,如果包含了「标识」,则直接
history.back()
;否则,用
history.replaceState
(注意replace时不用带「标识」)。
其它问题
实际使用中,发现一个问题,我直接举真实案例。
我的五子棋,联机对战模式,页面分为3个层级:首页、对战房间、单机演练。按照如下流程操作:
-
用户直接输入网址进入第2层级(对战房间),此时没「标识」。
-
用户点「单机演练」,携带「标识」,进入第3层级。
-
用户点「返回房间」,发现此页面
state
有「标识」,触发浏览器原生返回,返回第2层级。
-
用户点「离开房间」(此页面
state
没「标识」,会通过replace进入第1层级)。
-
用户点「前进」,会直接到第3层级。不符合预期。
为了解决这个情况,我做了兼容处理:
如果当前页面
state
没「标识」,如果当前浏览器历史记录栈长度为1,直接replace是没问题的,不会出现上述问题;但如果当前浏览器历史记录栈长度大于1,我调用replace后,需要连续调用一次push和一次back,目的是清空浏览器「前进」的历史记录栈。
打开网址
https://game.hullqin.cn/wzq/bgzyyds
,会直接进入第2层级。你可以按上述流程操作下。你不会遇到问题,因为这个问题已经被解决了,体验好很多。
代码片段参考
这是
LinkButton
逻辑,其中
back
参数,
true
表示是返回按钮,
false
表示是跳转按钮。我的
state
中「标识」叫做
keepSession
。
if (back) {
return (
<BackLink to={to}>
{children}
</BackLink>
return (
<Link to={to} state={{ ...state, keepSession: true }} onClick={handleClick}>
{children}
</Link>
);
这是
BackLink
核心逻辑(注:
navigate
是
React Router@6
提供的函数)
const handleClick = (event) => {
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} onClick={handleClick}>
{children}
</Link>
);
如果你好奇
event.xxxKey
、
event.preventDefault()
那3行代码,请一定要看下这篇文章:
《你的 Link Button 能让用户选择新页面打开吗?》
5. 一些想法
只要你的页面里,没有「返回」按钮,那啥事都没有 😁
如果你的页面,不追求移动端的极致用户体验,那也没啥事,PC端用户对原生「返回」的依赖没那么重,你想剥夺就剥夺吧 😁
而我要做移动端页面,有些情况下,原生「返回」是无法返回上一层级的(例如用户直接从url进入了第2层级,原生返回只能关闭页面,不能返回第1层级),所以我在网页加了「返回」按钮。与此同时,我还没剥夺用户使用原生「返回」的权利。总算是完成了令我满意的「返回」😎
如果你想体验我的游戏,看看「返回」的交互,欢迎访问
game.hullqin.cn
写在最后
我是HullQin,公众号
线下聚会游戏
的作者(欢迎关注我,交个朋友)。转发本文前需获得作者HullQin授权。我独立开发了《联机桌游合集》,是个网页,可以很方便的跟朋友联机玩UNO、斗地主、五子棋、飞行棋、一夜狼、象棋、德国心脏病等游戏,不收费无广告。还开发了
《Dice Crush》
参加Game Jam 2022。喜欢可以关注我噢~我有空了会分享做游戏的相关技术,会在这个专栏里分享:
《教你做小游戏》
。