在進行前端單頁應用程式(SPA)開發或動態非同步載入內容時,開發者經常會遇到需要「JS 變更網址不重新整理」頁面的需求。透過瀏覽器原生的 History API,我們可以在不觸發頁面重新載入(Reload)的情況下,自由操作瀏覽器的網址與歷史紀錄堆疊。這篇文章將完整解析 history.pushState 與 history.replaceState 的核心差異、程式碼範例,以及如何利用 window.onpopstate 完美捕捉使用者的「上一頁」與「下一頁」導覽行為。
常見問題與 History API 核心觀念快速跳轉
- 1.1
history.pushState與history.replaceState的本質差異 - 1.2 核心機制與參數說明
- 1.3
pushState與replaceState規格對照表 - 2.1 使用
pushState新增歷史紀錄 - 2.2 使用
replaceState替換當前網址 - 2.3 使用
window.onpopstate監聽上一頁與下一頁 - Q1:使用 History API 變更網址後,重新整理 (F5) 為什麼會出現 404 錯誤?
- Q2:
pushState與直接修改window.location.href有什麼不同? - Q3:為什麼在某些情況下觸發
pushState後,popstate事件沒有如預期被呼叫? - Q4:使用 History API 更改網址時,跨網域(Cross-Origin)會發生什麼事?
一、 History API 核心方法與差異比較
在現代瀏覽器中,window.history 物件提供了安全的介面來操作瀏覽器的會話歷史紀錄(Session History)。其中最常使用的兩個方法為 pushState() 與 replaceState()。兩者最大的區別在於:前者會在歷史紀錄堆疊中新增一筆紀錄,而後者則是替換當前堆疊中的最新一筆紀錄。
1.1 核心機制與參數說明
這兩個方法均接收三個相同的參數,語法結構如下:
history.pushState(state, unused, url);
history.replaceState(state, unused, url);
state(狀態物件):一個 JavaScript 物件,當使用者導覽至該歷史紀錄時,會觸發popstate事件並將此物件傳入事件的state屬性中。若不需要可傳入null。unused(標題):目前多數主流瀏覽器皆已忽略此參數,通常直接傳入空字串""即可。url(新網址):要顯示在瀏覽器網址列的新網址。必須注意,此網址必須與當前網頁維持同源政策(Same-Origin),否則會直接拋出安全性錯誤。
1.2 pushState 與 replaceState 規格對照表
| 比較維度 | history.pushState() | history.replaceState() |
|---|---|---|
| 歷史紀錄堆疊 | 在現有堆疊頂端推入(Push)一筆新紀錄 | 取代(Replace)目前堆疊頂端的最新紀錄 |
| 瀏覽器「上一頁」行為 | 按下上一頁會返回修改網址前的原始頁面 | 按下上一頁會略過被取代的網址,返回更早之前的頁面 |
| 典型應用場景 | 使用者點擊分頁、分頁切換、無窮滾動載入新內容 | 搜尋條件篩選、分頁排序變更、表單暫存狀態更新 |
1.3 歷史紀錄堆疊(History Stack)視覺化圖解
為了更直覺理解 pushState 與 replaceState 在瀏覽器堆疊指標(Pointer)上的運作差異,可參考以下 ASCII 堆疊結構示意圖:
【初始狀態】
[ 頁面 A: / ] <--- 堆疊指標 (Current Pointer)
【執行 history.pushState(null, '', '/page-b') 之後】
[ 頁面 A: / ]
[ 頁面 B: /page-b ] <--- 堆疊指標移動至新推入的紀錄 (總長度增加)
【若改用 history.replaceState(null, '', '/page-c') 取代當前頁面】
[ 頁面 A: / ]
[ 頁面 C: /page-c ] <--- 堆疊指標維持在頂端,但原本的 /page-b 直接被 /page-c 覆蓋
二、 實作範例:如何變更網址與監聽歷史行為
2.1 使用 pushState 新增歷史紀錄
當使用者在單頁應用程式中點擊不同導覽列選項,且我們希望使用者能透過瀏覽器的「上一頁」按鈕回到前一個檢視狀態時,應使用 pushState:
// 假設目前網址為 https://example.com/
const stateObj = { page: 'profile', userId: 42 };
const newTitle = '';
const newUrl = '/user/profile';
// 在不刷新頁面的情況下將網址變更,並在歷史堆疊新增紀錄
history.pushState(stateObj, newTitle, newUrl);
console.log(window.location.href); // 輸出 https://example.com/user/profile
//此時頁面不會重新載入,瀏覽器的「上一頁」按鈕會被點亮
2.2 使用 replaceState 替換當前網址
若使用者在頁面中調整了篩選條件(例如排序方式改為依價格排序),我們通常不希望每一次微調都在歷史紀錄中堆疊一筆垃圾紀錄,此時就應使用 replaceState:
// 假設目前網址為 https://example.com/products?sort=date
const nextState = { filter: 'price' };
const targetUrl = '/products?sort=price';
// 直接替換當前網址,不增加歷史紀錄數量
history.replaceState(nextState, '', targetUrl);
console.log(window.location.href); // 輸出 https://example.com/products?sort=price
// 使用者點擊「上一頁」時,將直接返回進入 /products 之前的頁面
2.3 使用 window.onpopstate 監聽上一頁與下一頁
光是改變網址還不夠,當使用者按下瀏覽器的「上一頁」或「下一頁」時,網址雖然變回去了,但畫面的資料並不會自動恢復。我們必須透過 window.onpopstate 來監聽歷史紀錄的變動,並重新渲染對應的畫面內容:
// 監聽瀏覽器上一頁與下一頁行為
window.onpopstate = function(event) {
console.log("觸發 popstate 事件,當前 state 資料:", event.state);
// 根據當前網址或 event.state 重新載入對應內容
const currentPath = window.location.pathname;
if (currentPath === '/user/profile') {
renderProfilePage(event.state);
} else if (currentPath === '/') {
renderHomePage();
}
};
function renderProfilePage(state) {
console.log("重新渲染使用者專區,使用者 ID:", state ? state.userId : '預設');
}
function renderHomePage() {
console.log("重新渲染首頁畫面");
}
三、 開發者高頻 Q&A 與注意事項
🚨 前端工程師實作 History API 的 3 大高頻踩坑點與除錯實作
在軟體開發團隊中(特別是維護 SPA、電商篩選頁面或企業內部的動態表單系統),工程師在使用 History API 時,最常因忽略瀏覽器機制的細節而踩入以下陷阱:
坑點一:誤觸 pushState 同網址防呆機制(Redundant State 效應)
💥 踩坑情境
新手工程師在實作「點擊分頁按鈕」時,常常沒有先檢查當前網址,就直接呼叫 history.pushState。當使用者瘋狂連點同一個分頁(例如連續點擊第 2 頁按鈕好幾次),瀏覽器堆疊內就會被塞滿一整串長度超長、且網址與 state 完全一模一樣的重複歷史紀錄。
這會導致一個非常糟糕的 UX(使用者體驗):使用者好不容易瀏覽完網站的其他單元,想按「上一頁」離開時,卻必須連續按好幾次上一頁,才能真正退回上一層頁面(因為卡在同一個網址的重複歷史堆疊裡)。
🛠️ 解決方法與除錯實作
在呼叫 pushState 或發動非同步請求前,務必加入防呆條件,檢查當前網址或目標參數是否與現狀相同:
// 點擊分頁按鈕的處理函式
function handlePagination(targetPage) {
const currentUrl = new URL(window.location.href);
const currentPage = currentUrl.searchParams.get('page') || '1';
// 如果點擊的是目前已經在看的頁面,直接中斷,不重複 push 歷史紀錄
if (currentPage === String(targetPage)) {
console.log("當前已在該頁面,略過歷史紀錄寫入。");
return;
}
// 更新網址參數
currentUrl.searchParams.set('page', targetPage);
// 安全寫入歷史紀錄
history.pushState({ page: targetPage }, '', currentUrl.toString());
// 觸發畫面非同步渲染
fetchPageData(targetPage);
}
坑點二:忘記「初始頁面(Initial Page Load)」的 event.state 陷阱
💥 踩坑情境
許多工程師在寫完 window.onpopstate 監聽函式後,測試點擊「上一頁」、「下一頁」運作完美,就直接部署上線。然而,當使用者透過外部連結直接貼上網址(Deep Linking)進入 /user/profile 時,瀏覽器載入頁面的瞬間並不會觸發 popstate 事件!
此時,event.state 會是 null。如果你的 onpopstate 邏輯寫得太粗糙(例如直接讀取 event.state.userId 而沒有加上防禦性檢查),頁面就會直接噴出 TypeError: Cannot read properties of null (reading 'userId') 的紅色錯誤,導致畫面直接白屏。
🛠️ 解決方法與除錯實作
在頁面初始化(Initial Load)時,必須同時處理「從網址列直接進來(帶有參數或需要透過 API 補抓資料)」與「透過 popstate 觸發」這兩種情境:
// 1. 頁面初次載入時的初始化邏輯(處理直接輸入網址或重新整理)
function initPageFromUrl() {
const currentPath = window.location.pathname;
const urlParams = new URLSearchParams(window.location.search);
if (currentPath === '/user/profile') {
const userId = urlParams.get('id') || 'default_user';
// 直接向後端抓取資料渲染,不依賴 history.state
fetchUserData(userId);
}
}
// 2. 監聽上一頁 / 下一步行為
window.onpopstate = function(event) {
console.log("觸發 popstate,歷史 state 物件:", event.state);
// 如果使用者按上一頁回到初次進入點,event.state 可能為 null
if (!event.state) {
initPageFromUrl();
return;
}
// 有 state 時直接使用歷史狀態渲染
renderProfilePage(event.state);
};
// 網頁載入執行初始化
document.addEventListener('DOMContentLoaded', initPageFromUrl);
坑點三:非同步請求(AJAX / Fetch)發生 Race Condition(競態條件)
💥 踩坑情境
當使用者在電商網站快速狂按「上一頁」與「下一頁」時,瀏覽器網址會瞬間連續變更,並觸發多次 popstate。這時前端會發出多個非同步的 fetch 請求去跟後端拿資料。
由於網路傳輸秒數不固定(例如第 1 次請求比較慢回來,第 2 次請求比較快回來),畫面最後渲染出來的內容,竟然是較舊的那一筆資料,導致「網址跟畫面的資料兜不起來」,形成嚴重的資料錯亂 Bug。
🛠️ 解決方法與除錯實作
在處理 popstate 觸發的非同步請求時,建議引入 AbortController 來取消前一個尚未完成的請求,確保永遠只有最後一次操作能生效:
let currentAbortController = null;
window.onpopstate = async function(event) {
// 如果上一個請求還沒回來,強制將它中斷取消
if (currentAbortController) {
currentAbortController.abort();
}
// 建立新的 AbortController
currentAbortController = new AbortController();
const { signal } = currentAbortController;
const targetUrl = window.location.pathname;
try {
console.log(`正在載入路由:${targetUrl}`);
const response = await fetch(`/api/content?path=${encodeURIComponent(targetUrl)}`, { signal });
if (!response.ok) {
throw new Error('網路回應異常');
}
const data = await response.json();
renderContent(data);
} catch (error) {
if (error.name === 'AbortError') {
console.log("前一個過期的非同步請求已被成功中止 (Abort)。");
} else {
console.error("載入頁面失敗:", error);
}
}
};
Q1:使用 History API 變更網址後,重新整理 (F5) 為什麼會出現 404 錯誤?
解法:這是因為前端透過 pushState 改變網址後,瀏覽器會向伺服器請求該網址的實體資源。如果伺服器端(如 Nginx、Apache 或 Node.js 伺服器)沒有設定「所有路由統一指向 index.html」的重新導向規則,伺服器就會找不到對應的檔案而回傳 404。你必須在伺服器端配置 Fallback 路由來解決此問題。
Q2:pushState 與直接修改 window.location.href 有什麼不同?
解法:直接指派 window.location.href = '/new-path' 或使用 window.location.assign() 會強制瀏覽器向伺服器發送完整請求並重新整理整頁。而 history.pushState() 則是在客戶端完成網址更新,完全不觸發頁面重新整理,能帶來流暢的單頁應用程式(SPA)體驗。
Q3:為什麼在某些情況下觸發 pushState 後,popstate 事件沒有如預期被呼叫?
解法:呼叫 history.pushState() 或 history.replaceState() 本身不會直接觸發 popstate 事件。popstate 僅會在使用者主動操作瀏覽器導覽行為(如點擊上一頁、下一頁按鈕,或在程式碼中呼叫 history.back()、history.forward()、history.go())時才會被觸發。
Q4:使用 History API 更改網址時,跨網域(Cross-Origin)會發生什麼事?
解法:為了資安考量,瀏覽器嚴格限制 pushState 與 replaceState 所指定的 URL 必須與當前頁面保持相同的網域、通訊協定與連接埠。若嘗試寫入不同網域的網址(例如從 https://site.com 更改為 https://other.com),瀏覽器會直接拋出 SecurityError 例外錯誤。
參考資料與延伸閱讀
- MDN Web Docs:History API 介面與操作規範
- MDN Web Docs:history.pushState() 方法說明
- MDN Web Docs:history.replaceState() 方法說明
- MDN Web Docs:Window: popstate 事件說明
- WHATUG HTML Spec:Session history and navigation 規範
- 延伸閱讀:JS 上一頁與下一頁語法:history.back、forward 與 go 用法教學
- 延伸閱讀:[JS] Javascript 取得網址後面 #(錨點)名稱:location.hash 用法、去井字號與 hashchange 監聽
發佈留言