紀錄工作經驗、相關知識,解決技術相關問題。

Javascript, 工作雜談, 新手 Jamp Holes, 網站相關

JavaScript 變更網址不刷新頁面與 history API 用法

JavaScript 變更網址不刷新頁面與 history API 用法 特色圖片

在進行前端單頁應用程式(SPA)開發或動態非同步載入內容時,開發者經常會遇到需要「JS 變更網址不重新整理」頁面的需求。透過瀏覽器原生的 History API,我們可以在不觸發頁面重新載入(Reload)的情況下,自由操作瀏覽器的網址與歷史紀錄堆疊。這篇文章將完整解析 history.pushState 與 history.replaceState 的核心差異、程式碼範例,以及如何利用 window.onpopstate 完美捕捉使用者的「上一頁」與「下一頁」導覽行為。


常見問題與 History API 核心觀念快速跳轉


一、 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 例外錯誤。

參考資料與延伸閱讀

發佈留言