天天看點

[極緻使用者體驗] 多頁面應用裡,「網頁内傳回」按鈕,何時用 history.back 何時用 replaceState?

攜手創作,共同成長!

背景

上篇文章​​《網頁裡的「傳回」應該用 history.back 還是 push》​​論證了單頁面應用(Single-Page Application,簡稱SPA)如何實作網頁内的「傳回」按鈕,本篇文章将會論證多頁面應用(Multi-Page Application,簡稱MPA)如何實作網頁内的「傳回」按鈕。

何謂「極緻使用者體驗」

上文我提到,網站應該是有頁面層級的:

[極緻使用者體驗] 多頁面應用裡,「網頁内傳回」按鈕,何時用 history.back 何時用 replaceState?

它是一個樹狀結構,每個頁面、子產品劃分非常清晰。

如果要追求極緻使用者體驗,使用者在浏覽器點選「前進」或「傳回」時,應該遵循這樣的規則:

  • 點浏覽器的「前進」按鈕(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()​

    ​。
[極緻使用者體驗] 多頁面應用裡,「網頁内傳回」按鈕,何時用 history.back 何時用 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​

​這個名字。

子頁面核心代碼

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();
});      

寫在最後

繼續閱讀