swup.js SPA 網站開發教學
前一篇文章整理了 SPA-like 的套件,特色就是全都是 AJAX DOM swup,這裡以 swup.js 為例簡單說明注意事項。前一篇文章是全中文圈第一篇深入分析比較 SPA-like 套件的文章,本文則是全中文圈第一篇直白的統整 SPA-like (swup.js) 開發的文章。
所有內容都源自於 swup 的原理:「swup 不會讓瀏覽器載入下一頁,而是攔截連結點擊,在背景載入新頁面,並在舊內容和新內容之間平滑地進行動畫切換。」
Head 標籤
AJAX 換頁後 head 標籤內容不會自己更新,如果你是多語言網站就需要更新 lang 屬性、meta 標籤、conditional 載入的 CSS 也需要額外修改,這時應該使用 swup head-plugin。
CSS
Head 標籤內的 CSS 由 head-plugin 處理,而 body 內部的 CSS 又分成兩種情況:
- 屬於 swup 換頁的更新區域
- 不屬於 swup 換頁的更新區域
第一種情況 CSS 會自動套用,再次換頁也不會污染下一頁的 CSS;而第二種完全不會套用,因為他從頭到尾就不存在在 DOM 裡面。
JS
JS 需改為手動初始化和清理,進入頁面判斷的 DocumentLoaded 因為 AJAX 已經無法使用,換頁清理 JS 的原生機制也已經不會觸發,需要改成手動清理。
本段內容基本上就和 common issues Scripts on the next page are not executed 裡面說的一樣。
基礎範例
因此,舉例來說,如果要綁定事件監聽器,並且在頁面之間同步 UI,應該要這樣做
// swup.js
const swup = new Swup()
function initToggle(swup) {
swup.delegateEvent('#toggle-btn', 'click', (event) => {
const container = document.querySelector('.post-container');
if (!container) return;
const next = !container.classList.contains('is-compact');
applyCompactState(container, event.delegateTarget, next);
sessionStorage.setItem(COMPACT_STORAGE_KEY, next ? '1' : '0');
});
}
function syncUI() {
const toggle = document.getElementById('toggle-btn');
const container = document.querySelector('.post-container');
if (!toggle || !container) return;
const isCompact = sessionStorage.getItem(COMPACT_STORAGE_KEY) === '1';
applyCompactState(container, toggle, isCompact);
}
initToggle(swup);
syncUI();
swup.hooks.on('content:replace', syncUI);
關鍵是使用事件委派避免使用元素綁定,這樣就不用在每次換頁都重複設定 EventListener;首次進入網站的初始化不由 swup 管理,因此獨立呼叫 initToggle 和 syncUI;swup 在 HTML 是使用 type=module 載入的,因此直接載入不需要監聽 DocumentedLoaded;最後設定 hook 在換頁後、頁面渲染前就執行 syncUI。
Inline Script
如果 DOM 裡面包含 optional 的 inline script,比如 Hugo 的 mermaid codeblock render hook 就是 optional script 避免無關頁面也載入肥大的 mermaid。在瀏覽器的標準規範中,這些 script 被 AJAX 替換之後不會執行,因此除了 inline script 以外,swup 也要設定手動觸發:
async function renderMermaid() {
if (!document.querySelector('pre.mermaid')) return;
const { default: mermaid } = await import('https://cdn.jsdelivr.net/npm/mermaid/dist/mermaid.esm.min.mjs');
mermaid.initialize({ startOnLoad: false });
await mermaid.run();
}
swup.hooks.on('page:view', renderMermaid);
基本上就是找到元素,然後手動執行一次那個 script 的初始化。
記憶體洩漏 Memory Leak
因為使用 AJAX 換頁所以沒有瀏覽器換頁的重置 JS 環境的功能,已經執行過的 JS 就會不斷疊加,如果沒有正確清理就會造成記憶體洩漏問題。
比如說 A 頁面有很重的圖表 JS,現在換頁到 B 頁面沒有這些 JS 了,但是那些圖表 JS 沒有被正確清理就會造成記憶體持續佔用,如果程式寫的不好,甚至不只佔用記憶體,可能會連 CPU 都持續佔用,解決方式是在 hook 也要註冊 JS 的清理程序,比如 echarts.dispose()。
另外要注意的還有 observer/timeout 這些類型的變數,他們會持續存活因此總是應該要手動清理。
垃圾回收 Garbage Collection
總結來說就是「瀏覽器比你想的更聰明」。
前一個記憶體洩漏的段落明顯遺漏掉最常見的 EventListener 問題,要不要手動 remove listener 呢?實際上不必要。
如果不是迴圈一次綁定幾千個,懶惰一點不清理也沒問題,這了不起也就幾 KB 的佔用,更重要的是在 AJAX 更換之後,這些 listener 監聽的 DOM 節點不存在了,因此 JS 的垃圾回收 (garbage collection) 之後會自動清理掉這些監聽器,要確保的只有不要讓這些東西 reachable,比如閉包、外部奇怪的東西還拿著這個 listener 的參照造成 gc 認為這個 listener 還有用,從而造成記憶體洩漏。另一種情況是一次綁定幾千個監聽器,這時手動清理確實更乾淨,不過綁幾千個的情況本來就不該發生,應該用事件委派而不是綁一堆監聽器。
正確做法如下:
function setup() {
const el = document.querySelector('.foo');
el.addEventListener('click', () => {...});
}
setup();
// setup() 執行完就結束了,el 這個區域變數消失
// 之後 AJAX 把 .foo 從 DOM 移除,listener 在之後的自動 gc 也會被清除
錯誤做法如下:
const el = document.querySelector('.foo');
el.addEventListener('click', () => {...});
// 之後 AJAX 把 .foo 從 DOM 移除
// el 這個變數還活著、還握著強參照。
// 節點雖然從 DOM 樹上被拔掉了,但 el 這條參照鏈沒斷。
// 結果:這個節點物件(連同掛在它身上的 listener)繼續佔用記憶體,永遠不會被 GC,直到 el 也被設成 null、離開作用域,或整個頁面關閉。
相關參考文章可看
-
4 Types of Memory Leaks in JavaScript and How to Get Rid Of Them 2016 的老文章就已經明確指出 Nowadays, modern browsers (including Internet Explorer and Microsoft Edge) use modern garbage collection algorithms that can detect these cycles and deal with them correctly. In other words, it is not strictly necessary to call removeEventListener before making a node unreachable.
-
Event listeners and garbage collection 前端大神,FireFox 開發者,這篇文章的意思就是瀏覽器很聰明,並且舉例 The event listener added to signal, and everything that listener can access, needs to stay in memory as long as the 'abort' event can fire.
-
How to Avoid Memory Leaks in JavaScript Event Listeners 10 年 SWE 在 2025 年還在傳遞錯誤資訊,別人老早在 2016 就已經說不清除 EventListener 不會有問題了。WeakRef 也用錯,設定了一個弱引用目的是讓 GC 可自動回收,但是 WeakRef 完全沒有發揮他想做的效果,還是要手動把強引用移除
// 錯誤的 WeakRef 範例const elementRef = new WeakRef(element);const listener = () => { /* ... */ };elementRef.deref().addEventListener("click", listener);// 他自己在下面寫了註解「element 移除後就可以被 GC」,那這個 WeakRef 有跟沒有效果都一樣 -
WeakRef 和 FinalizationRegistry 的範例(英文原文版)和 10 年 SWE 的範例非常相似,但是這才是正確的用法:
SWE 範例 —
element這個 DOM 元素被誰參照?- 外部有一個變數
element(強參照)。 elementRef裡面又包了一份弱參照。- 這個元素同時被「一個強參照 + 一個弱參照」指著。
- 只要那個外部的
element變數還活著(還在作用域內、沒被設成 null),這個元素就不可能被回收,跟elementRef這個 WeakRef 完全無關,WeakRef 在這裡是多餘的裝飾品。
正確範例 — DOM 元素被誰參照?
.window__body這個元素,唯一的參照就是 windowElementRef 裡的弱參照。- 沒有任何其他變數強抓著它。
- 所以只要它從 DOM 移除,它就真的沒人抓著了,可以被回收。
- 外部有一個變數
-
絕大多數情況下無需使用 WeakRef
-
如果想要清理,AbortController + signal 方式可以統一清理
觸發鉤子
Swup 提供非常多的鉤子讓你可控制任務的執行時機,重點是 content, page, visit,其他 hook 適用的時機比較屬於高級動畫客製化的部分。
文檔:
無障礙網站
做了也沒人知道,但是用的到的人會很感謝你的內容。大部分人是閱讀正常不需要使用螢幕輔助器等工具,但是需要的人也不算少:大概有 5% 左右的人群有程度不一的功能障礙,用 AJAX 換頁會讓他們難以瀏覽網站,我們可以直接瀏覽是幸運的,但也應該幫他們設計好可訪問性,設身處地著想,如果哪天我們也有某種障礙,是要加上幾行設定、幾行 aria 標記幫助閱讀,還是就讓網站難以被存取。
swup 對此也帶有 a11y 插件。