攜手創作,共同成長!
背景
上篇文章《網頁裡的「傳回」應該用 history.back 還是 push》論證了單頁面應用(Single-Page Application,簡稱SPA)如何實作網頁内的「傳回」按鈕,本篇文章将會論證多頁面應用(Multi-Page Application,簡稱MPA)如何實作網頁内的「傳回」按鈕。
何謂「極緻使用者體驗」
上文我提到,網站應該是有頁面層級的:
![](https://img.laitimes.com/img/_0nNw4CM6IyYiwiM6ICdiwiI0gTMx81dsQWZ4lmZf1GLlpXazVmcvwFciV2dsQXYtJ3bm9CX9s2RkBnVHFmb1clWvB3MaVnRtp1XlBXe0xCMy81dvRWYoNHLwEzX5xCMx8FesU2cfdGLwMzX0xiRGZkRGZ0Xy9GbvNGLpZTY1EmMZVDUSFTU4VFRR9Fd4VGdsYTMfVmepNHLrJXYtJXZ0F2dvwVZnFWbp1zczV2YvJHctM3cv1Ce-AnYldnL4QTOxYjYlFzMiVDZxMjZyYzXxEjM1ATMwEzLchDMyIDMy8CXn9Gbi9CXzV2Zh1WavwVbvNmLvR3YxUjLyM3Lc9CX6MHc0RHaiojIsJye.webp)
它是一個樹狀結構,每個頁面、子產品劃分非常清晰。
如果要追求極緻使用者體驗,使用者在浏覽器點選「前進」或「傳回」時,應該遵循這樣的規則:
- 點浏覽器的「前進」按鈕(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
這個名字。
子頁面核心代碼
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();
});