相关文章推荐
睿智的荒野  ·  Qt ...·  1 年前    · 
备案 控制台
学习
实践
活动
专区
工具
TVP
写文章
专栏首页 教你做小游戏 网页里的「返回」应该用 history.back 还是 push ?
6 1

海报分享

原创

网页里的「返回」应该用 history.back 还是 push ?

1. 什么是「返回」按钮?

这里不是浏览器的「返回」按钮,我们没办法修改它的行为。

而是网页代码中的「返回」按钮,我们可以定义它的行为。

举个例子

比如我的 五子棋小游戏

点开链接,会出现文章开头图片的的页面——游戏主页,「进入房间」后,左上角有个「离开房间」按钮,点击后,会返回主页。

这种需要返回上层页面的按钮 ,在本文中,称之为「返回」按钮。

image.png

2. 什么是 push、back、replace?

push

back

replace

浏览器行为

页面会发生跳转,并在当前浏览记录新增一条记录(之后你可以按浏览器「返回」,回到跳转前的页面)。

页面返回上一条浏览记录(之后你可以按浏览器「前进」,重新回到返回前的页面)。若浏览器没有上一条记录,则什么都不会发生。

页面会发生跳转,覆盖当前的浏览记录。(你按浏览器「返回」,无法回到跳转前的页面)

HTML DOM API: History

History.pushState()

History.back()

History.replaceState()

history.push()

history.goBack()

history.replace()

navigate(url, { state, replace: false })

navigate(-1)

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 实现方案

「返回」按钮,逻辑如下

  1. 判断历史记录栈的上个页面,是不是我的父页面。
  2. 如果是我的父页面,我就用 history.back() ,使用浏览器原生返回行为。
  3. 如果不是我的父页面,我就用 history.replace() ,使当前页面替换为我的父页面。(不能用push,否则在父页面返回,回到了子页面,是反直觉的)

难点: 如何判断历史记录栈的上个页面,是不是我的父页面。

问题:浏览器基于安全性,不允许你读取历史记录栈。

解决方案

只要父页面跳转到子页面时,携带个「标识」,告知子页面,跳转来源。子页面就知道了。

跳转时的「标识」,刚好可以用 history.pushState() 中的 state 来实现。

只要是内部跳转,都封装一个统一的组件。该组件允许定义跳转目的地,而且会在 state 中携带「标识」(如果你的网页有带自定义 state 的诉求,则还需要在该组件中组装一下参数中的 state 和「标识」,变成新的 state )。

实现返回链接(比如叫BackLinkButton)

获取当前页面的 state ,如果包含了「标识」,则直接 history.back() ;否则,用 history.replaceState (注意replace时不用带「标识」)。

其它问题

实际使用中,发现一个问题,我直接举真实案例。

我的五子棋,联机对战模式,页面分为3个层级:首页、对战房间、单机演练。按照如下流程操作:

  1. 用户直接输入网址进入第2层级(对战房间),此时没「标识」。
  2. 用户点「单机演练」,携带「标识」,进入第3层级。
  3. 用户点「返回房间」,发现此页面 state 有「标识」,触发浏览器原生返回,返回第2层级。
  4. 用户点「离开房间」(此页面 state 没「标识」,会通过replace进入第1层级)。
  5. 用户点「前进」,会直接到第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。喜欢可以关注我噢~我有空了会分享做游戏的相关技术,会在这个专栏里分享: 《教你做小游戏》

原创声明,本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。