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 crash

Fix: 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.