AJAX 以單頁面 (SPA) 非同步互動取代傳統「請求 → 整頁回應」模式。瀏覽器歷史堆疊僅記錄完整頁面導航 (超連結、表單提交、location.href 變更),不會自動記錄 AJAX 狀態變更。導致:
// 新增歷史記錄 (不觸發跳轉)
history.pushState(stateObj, title, url);
// 替換當前記錄
history.replaceState(stateObj, title, url);
// 監聽瀏覽器前進/後退
window.addEventListener('popstate', e => {
const state = e.state; // 取回 pushState 存入的 stateObj
restoreUI(state);
});
stateObj:任意可序列化物件 (JSON),儲存還原 UI 所需資料 (頁碼、篩選條件、滾動位置…)。title:目前多數瀏覽器忽略,傳 '' 即可。url:新 URL (同源),可為 ?page=3&sort=desc 形式,支援書籤分享。// 統一狀態結構
interface AppState {
page: number;
filters: Record;
scrollY: number;
modalId?: string;
}
// 更新 UI 並同步歷史
function navigate(newState: Partial, replace = false) {
const merged = { ...currentState, ...newState };
currentState = merged;
render(merged); // 依狀態渲染
const url = buildUrl(merged); // 如 /list?page=3&status=active
const method = replace ? 'replaceState' : 'pushState';
history[method](merged, '', url);
}
// 監聽 popstate 還原
window.addEventListener('popstate', e => {
if (e.state) {
currentState = e.state;
render(currentState);
}
});
document.body.addEventListener('click', e => {
const a = e.target.closest('a[data-ajax]');
if (a) {
e.preventDefault();
fetch(a.href)
.then(r => r.text())
.then(html => {
document.getElementById('content').innerHTML = html;
navigate({ page: getPageFromUrl(a.href) });
});
}
});
// 離開前記錄
window.addEventListener('beforeunload', () => {
currentState.scrollY = window.scrollY;
history.replaceState(currentState, '', location.href);
});
// 還原時恢復
function restoreUI(state) {
render(state);
requestAnimationFrame(() => window.scrollTo(0, state.scrollY || 0));
}
| 模式/庫 | 特點 |
|---|---|
Hash Routing (#/page/3) | 相容 IE 8+、無需伺服器配置、SEO 弱 |
| History API + URL Rewriting | 乾淨 URL、需伺服器 fallback 導向 index.html (SPA 標準) |
| React Router / Vue Router / TanStack Router | 框架級路由,內建巢狀路由、代碼分割、資料預取 |
| htmx / Hotwire (Turbo) | HTML-over-the-wire,後端渲染片段,前端極薄,自然支援歷史 |
所有非靜態資源請求皆回傳 index.html,由前端路由接管:
# Nginx
location / {
try_files $uri $uri/ /index.html;
}
# Apache (.htaccess)
RewriteEngine On
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^ index.html [QSA,L]
# Spring Boot (WebMvcConfigurer)
registry.addViewController("/**").setViewName("forward:/index.html");
| 坑 | 對策 |
|---|---|
| pushState 重複相同 URL 不生效 | 先 replaceState 再 pushState,或在 state 加時間戳區分 |
| popstate 首次載入也觸發 | 忽略 e.state === null 或用旗標標記首次 |
| 巢狀路由 / Tab 切換狀態遺失 | State 結構化:{ tab: 'detail', detailId: 123, listPage: 2 } |
| 滾動位置在動態高度容器失效 | 記錄容器 scrollTop 而非 window.scrollY |
IE 11 不支援 history.scrollRestoration | 手動管理 scrollY,或用 Polyfill (history.scrollRestoration = 'manual') |
「返回鍵」問題本質是應用狀態與瀏覽器歷史不同步。現代解法核心三步:
pushState/replaceState;popstate → State。善用框架路由 (React Router, Vue Router) 可自動處理 90% 細節;若輕量專案,手寫 HistoryManager 亦非難事。關鍵在於將「狀態管理」提升為架構核心,而非事後補丁。