Advertisement

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

阅读量:

HullQin

我是HullQin,《线下聚会游戏》公众号创始人(一文由作者HullQin授权)。未经授权禁止转载或引用本文内容。我独立开发并维护了《联机桌游合集》,这是一个方便联机斗地主、五子棋等游戏的网页;同时参与了第22届Game Jam竞赛创作了《Dice Crush》。如需了解更多信息,请关注我的个人公众号【HullQin

背景

之前的文章《网页中的返回功能应采用history.back还是push?》深入探讨了单页面应用(SPA)如何实现返回按钮这一技术问题。本篇文章将深入分析多页面应用(MPA)中实现网页返回按钮的技术方案。

何谓「极致用户体验」

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

1.png

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

为实现最佳使用体验,在浏览器中点击「前进」或「返回」时需遵守以下规定:

  • 点击浏览器的 «导航» 按钮(forward, 右箭头),只允许 连续的页面层次 ,并 依次向右切换
    • 点击浏览器的 «后退» 按钮(back, 左箭头),只允许 连续的页面层次 ,并 依次向左切换

实现方案

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

  • 当用户访问更深层次的页面时,** 浏览器的历史记录栈会增加**。
  • 当用户的浏览深度降至最顶层时,** 在正常情况下历史记录栈会减少**;但如果已经处于最顶层状态,则历史记录栈数量保持不变。

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

当执行history.pushState()操作时,在执行history.pushState()操作时,在执行history.pushState()操作时,在执行history.pushState()操作的时候

在什么情况下会使历史记录栈减少一次?当调用history.back()进行操作时。实际上,并非如此;页面仅返回到上一条记录。这类似于用户点击了「浏览器返回」按钮。

有时候点击「网页返回」按钮时会遇到无法直接调用history.back()的情况(或者用户可能仅访问了我们网站的一个单独页面),这时就不能简单地使用该方法来实现导航功能?因为按照导航逻辑设计的原则(即我们希望实现的是单向层级关系),如果出现这种情况则应该采用另一种方法:即通过调用history.replaceState()来完成上层导航功能(这种情况下不应该使用history.push操作符)。这是因为如果我们误用了该操作符(如使用了push操作),将会破坏原本的设计原则:当再次点击「网页返回」按钮时将无法按预期进行上下文导航(从左到右)而只能进行垂直层级关系的导航操作

回顾单页面应用方案

当父页面导航至子页面时, 会传递一个标识符, 告诉子页面, 跳转来源来自你的亲爸爸。随后, 子页面明白了, 由该网页的「返回按钮」触发history.back()功能。

如果某个子页面未能找到对应的「标识」, 这表明该子页面并非来自父方跳转。因此无法通过调用history.back()来返回父方主页。必须使用history.replaceState()方法来切换回父方主页,并且在切换过程中不应携带任何「标识」信息, 因为该子页面并非来自父方的孩子节点。

在跳跃过程中使用的标识可以通过调用history.pushState()函数获取当前的状态变量,并将其赋值给相关变量完成数据更新操作。绝对无法通过 URL 中的 URL 参数来进行数据传输与存储。由于 URLs 容易被仿制,请确保所有涉及到敏感信息的数据存储均采用更加安全的方式进行处理以防止潜在的安全漏洞风险。不过state$变量本身却具有极高的安全性

多页面应用方案

问题描述

我的父网站 game.hullqin.cn 包含两个子网站 game.hullqin.cn/wzq,它们都安装了两套前端代码库。这些应用采用微服务架构(MPA)进行开发。

在子版块里有一个「游戏菜单」按钮。相当于我的「返回主界面」按钮。我希望这两个页面遵循网站页面层级规范:

  • 建议优先选用\texttt{hittory.back()}来实现首页返回。
  • 则采用\texttt{history.replaceState()}来进行状态替换。
2.png

难点1

执行history.pushState(state)的方法将允许向服务器发送请求。然而,这只会更改URL路径,并不会导致浏览器重载。网页仍保留在父页面。

调用window.location.href = 'game.hullqin.cn/wzq'会导致浏览器重定向(referer),但无法传输state标识符(State)。在这种情况下,默认情况下无法通过简单的状态管理来实现这一功能。然而,在这种情况下,默认情况下无法通过简单的状态管理来实现这一功能(即默认情况下无法通过简单的状态管理来实现这一功能)。然而,在这种情况下,默认情况下无法通过简单的状态管理来实现这一功能(即默认情况下无法通过简单的状态管理来实现这一功能)。然而,在这种情况下,默认状态下无法传输state标识符(State),因此在使用 sessionStorage 方案时需要额外存储一些路由信息以确保能够正确地传输该标识符(State)

解决难点1

在执行过程中,在发送该特定的状态标识符后,在触发刷新之前,在执行以下操作:首先,“history.pushState()”,接着,“window.location.reload()”。这将确保状态信息被正确传递到下一个页面。

难点2

当我们使用history.pushState()函数来扩展浏览器的历史记录栈时,在回退至历史记录的过程中(即调用history.back()),页面不会刷新,并且仅修改URL字段。

或许你说:如同之前所述,在这种情况下,请先调用history.back()之后再次执行window.location.reload操作以触发页面刷新。

但这里还需补充一点:当用户点击「浏览器返回」按钮时,在完成操作后仅会更改当前页面的URL地址,并不会导致整个页面重新加载(即刷新)。尽管该链接指向的是首页位置,并且该网页依旧停留在game.hullqin.cn/wzq这个子页面上。

在操作过程中,在父页面点击「浏览器前进」按钮准备访问子页面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();
    });

你是否对这两个条件判断代码感到好奇?想了解的话,请阅读文章:《你的 Link Button 能让用户选择新页面打开吗?》。

写在最后

我是HullQin,《桌游活动》的作者(快来关注公众号线下聚会游戏 ,添加微信好友交个朋友),未经授权请勿转发本文内容。我自己开发了一个网页版桌游工具《联机桌游合集》,支持跟朋友们一起联机体验斗地主、五子棋等经典桌游,并没有广告也没有额外费用。我还参与了一个2022年Game Jam竞赛的作品——《Dice Crush》。如果喜欢这个项目的朋友当然可以关注我 HullQin 呢?有空了我会分享一些做游戏的技术经验和心得。

全部评论 (0)

还没有任何评论哟~