MPA Navigation — Giữ UX mượt như SPA mà không cần framework
Blog dùng static HTML là lựa chọn đúng: SEO tốt, deploy trên CDN, không cần server. Nhưng mặc định thì navigate giữa các trang sẽ reload toàn bộ — CSS, JS, font đều tải lại từ đầu dù chúng giống nhau hoàn toàn.
Vấn đề không phải là MPA hay SPA. Vấn đề là browser không giữ state giữa các HTML page.
Thực tế khi navigate bình thường
/blog/a.html → /blog/b.html
DOM → reset
JS memory → mất
CSS/JS → tải lại từ đầu
chỉ còn → HTTP cache (nếu server set header đúng)Người dùng nhìn thấy: flash trắng, layout shift, cảm giác chậm dù bandwidth tốt.
Giải pháp: PJAX
Ý tưởng đơn giản — chặn browser reload, tự fetch HTML và swap phần content:
async function navigate(url) {
const res = await fetch(url);
const html = await res.text();
const doc = new DOMParser().parseFromString(html, 'text/html');
document.querySelector('main').replaceWith(doc.querySelector('main'));
document.title = doc.title;
history.pushState(null, '', url);
window.scrollTo(0, 0);
}
document.addEventListener('click', (e) => {
const link = e.target.closest('a');
if (!link || link.origin !== location.origin) return;
if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
e.preventDefault();
navigate(link.href);
});
window.addEventListener('popstate', () => navigate(location.href));Kết quả: CSS/JS shared chỉ load một lần trong cả session. Navigation chỉ fetch và swap phần <main> — giống SPA.
Footgun 1: Script re-execution
Khi swap <main> qua innerHTML hoặc replaceWith, scripts bên trong không tự chạy:
<!-- Trang mới có script này trong <main> -->
<script>initSyntaxHighlight()</script>// ❌ script không chạy
oldMain.replaceWith(newMain);Phải clone và append thủ công:
oldMain.replaceWith(newMain);
newMain.querySelectorAll('script').forEach(old => {
const el = document.createElement('script');
if (old.src) el.src = old.src;
else el.textContent = old.textContent;
document.body.appendChild(el);
});Footgun 2: Head diffing cho page-specific libs
Mỗi bài blog tối ưu <head> riêng:
<!-- /post/toan-hoc.html -->
<head>
<script src="https://cdn.mathjax.org/mathjax.js"></script>
<script src="/js/sandbox-demo.js"></script>
</head>
<!-- /post/bao-la-cai.html -->
<head>
<!-- không có gì thêm -->
</head>PJAX chỉ swap <main>, không đụng <head>. Nên khi vào bài báo lá cải trước rồi navigate sang bài toán học:
MathJax chưa load → công thức hiển thị raw: $\int_0^\infty$
Sandbox lib chưa load → demo crashFix: sau khi swap content, diff <head> và load script còn thiếu:
const loaded = new Set(
[...document.querySelectorAll('script[src]')].map(s => s.src)
);
doc.querySelectorAll('script[src]').forEach(s => {
if (!loaded.has(s.src)) {
const el = document.createElement('script');
el.src = s.src;
document.head.appendChild(el);
}
});Chiều ngược lại (toán học → báo lá cải): không sao — libs thừa vẫn trong memory nhưng không được gọi.
Service Worker: Cache nhưng vẫn reload
Service Worker cache HTML để load nhanh hơn, nhưng không giải quyết vấn đề reload:
self.addEventListener('fetch', (event) => {
if (event.request.mode === 'navigate') {
event.respondWith(
caches.match(event.request).then(res => res || fetch(event.request))
);
}
});Tốt cho offline support, không tốt cho giữ state. Dùng kết hợp với PJAX thì được cả hai.
Preload khi hover
Kết hợp với PJAX, preload biến navigation thành instant:
const prefetched = new Set();
document.querySelectorAll('a').forEach(a => {
a.addEventListener('mouseenter', () => {
if (prefetched.has(a.href)) return;
prefetched.add(a.href);
fetch(a.href);
});
});Set để tránh fetch nhiều lần khi hover liên tục.
Hoặc dùng <link rel="prefetch"> — không cần JS
Browser hỗ trợ native prefetch, tự quản lý priority:
<link rel="prefetch" href="/post/bai-tiep-theo.html">Hoặc inject động sau khi trang load xong:
const link = document.createElement('link');
link.rel = 'prefetch';
link.href = '/post/bai-tiep-theo.html';
document.head.appendChild(link);Kết hợp với View Transitions API
PJAX giải quyết reload, View Transitions API giải quyết animation:
async function navigate(url) {
const res = await fetch(url);
const html = await res.text();
const doc = new DOMParser().parseFromString(html, 'text/html');
if (!document.startViewTransition) {
// fallback cho browser chưa hỗ trợ
swap(doc);
return;
}
document.startViewTransition(() => swap(doc));
}
function swap(doc) {
document.querySelector('main').replaceWith(doc.querySelector('main'));
document.title = doc.title;
}Khi nào dùng library (Swup/Turbo)?
Tự viết PJAX ổn cho blog đơn giản. Nhưng nếu cần:
- Transition animation phức tạp
- Nhiều loại page với lifecycle khác nhau
- Không muốn maintain edge cases
→ Swup hoặc Turbo xử lý hết head diffing, script dedup, scroll management, accessibility (focus management).
Kiến trúc thực tế
Static HTML (SEO tốt, deploy CDN)
+
PJAX với head diffing (navigation không reload)
+
Service Worker (cache, offline)
+
Prefetch khi hover (instant feel)
+
View Transitions API (animation)| Cách | Reload | Giữ CSS/JS | Giữ JS state | Độ phức tạp |
|---|---|---|---|---|
| MPA thuần | ✅ | ❌ | ❌ | thấp |
| Service Worker | ✅ | cache | ❌ | trung bình |
| PJAX cơ bản | ❌ | ✅ | ✅ | trung bình |
| PJAX + head diff | ❌ | ✅ | ✅ | trung bình |
| Hybrid đầy đủ | ❌ | ✅ | ✅ | cao hơn |
MPA và SPA không còn là lựa chọn 1 trong 2. Bạn có thể lấy điểm mạnh của cả hai.