Flickern zwischen Seitenwechsel verhindern

Post cover image

In diesem Artikel zeige ich, wie du Client-Side-Routing in eine Website integrieren kannst.

Update: Unterstützung für Hash-Links

Ich bin froh, dass ich mit 11ty aus meiner Website eine statische Website gemacht habe. Etwas, was ich aber vermisse, als meine Website noch eine Single Page Application (SPA) war, sind die sanften Übergänge, wenn man von einer Seite auf die nächste navigiert. SPAs laden dabei keine tatsächlich neue Seite, es wird lediglich der Inhalt per JavaScript ausgetauscht. Es werden noch weitere Aktionen durchgeführt, damit sich der Wechsel wie ein echter Seiten-Wechsel anfühlt. Es wird auch der Browser-Verlauf geändert und die URL ausgetauscht.

Das wollte ich bei meiner Website auch umsetzen, ohne die Vorteile von einer statischen Website zu verlieren. Und das ist das Ergebnis:

Vorher

Nachher

Schritt 1: Alle internen Links manipulieren

Ich muss alle a abfangen, die auf eine interne Seite verlinken. Externe Links sollen nicht angefasst werden. Anschließend fange ich ab, wenn auf eins dieser geklickt wird:

const links = document.querySelectorAll('a[href^="/"]')

links.forEach((link) => {
    link.addEventListener("click", async (e) => {
        e.preventDefault()
    }
})

Bei mir erkenne ich interne Links daran, dass sie mit / beginnen. Je nachdem, wie du deine Seite hostest, könnte sich das bei dir unterscheiden. Aktuell würde gar nichts passieren, wenn man auf einen internen Link klickt.

Jetzt müssen wir die Ziel-URL raus suchen und per fetch() das HTML abrufen. Dann können wir den Inhalt der aktuellen Seite einfach austauschen:

// Der Parser wird benötigt, um den HTML-String zu interpretieren.
const parser = new DOMParser()
const links = document.querySelectorAll('a[href^="/"]')

links.forEach((link) => {
    link.addEventListener("click", async (e) => {
        e.preventDefault()
        const href = link.getAttribute("href")

        const html = await fetch(href).then((r) => r.text())
        const doc = parser.parseFromString(html, "text/html")
        document.body.innerHTML = doc.body.innerHTML
    }
})

Es wird einfach der Inhalt vom <body></body> mit dem der neuen Seite ausgetauscht. Der Inhalt wird getauscht, aber die URL in der Adresszeile und auch die Titelzeile im Browser bleibt noch unverändert. Dafür müssen wir noch das hinzufügen:

window.history.pushState({ page: href }, doc.title, href)
document.title = doc.title
window.scroll({ top: 0, behavior: "instant" })

Mit window.scroll({ top: 0, behavior: "instant" }) wird außerdem wieder an den Anfang gescrollt. Man würde sonst auf der neuen Seite an der gleichen Scroll-Position stehen bleiben wie auf der bisherigen.

Der gesamte Code ist jetzt etwa so:

// Der Parser wird benötigt, um den HTML-String zu interpretieren.
const parser = new DOMParser()
const links = document.querySelectorAll('a[href^="/"]')

links.forEach((link) => {
    link.addEventListener("click", async (e) => {
        e.preventDefault()
        const href = link.getAttribute("href")

        const html = await fetch(href).then((r) => r.text())
        const doc = parser.parseFromString(html, "text/html")

        // Wichtig ist, dass `pushState` vor dem Austausch des
        // HTML passiert, da sonst relative Pfade z.B. zu
        // Bildern nicht mehr funktionieren könnten.
        window.history.pushState({ page: href }, doc.title, href)
        document.title = doc.title
        window.scroll({ top: 0, behavior: "instant" })

        document.body.innerHTML = doc.body.innerHTML
    }
})

Schritt 2: Nicht den gesamten Body austauschen

Den gesamten <body></body> auszutauschen könnte zu Problemen führen. Wenn sich dort wie hier JavaScript-Einbindungen befinden, würden diese anschließend nicht mehr funktionieren:

<body>
    <header>My header</header>
    <main>My content</main>

    <!-- Das wird anschließend nicht mehr funktionieren: -->
    <script src="./my-script.js"></script>
</body>

Der Browser wird Script-Einbindungen nicht ausführen, die durch ein anderes Script ins DOM geladen werden. Das hat Sicherheitsgründe, weil sonst möglicherweise schädliche Scripts ausgeführt werden könnten. Deswegen müssen wir die Script-Einbindungen davon ausschließen, ausgetauscht zu werden. Wir tauschen also nicht mehr den gesamten Body aus, sondern nur einen abgesteckten Bereich. Ich kennzeichne diesen mit der ID contents:

<body>
    <div id="contents" style="display: contents">
        <header>My header</header>
        <main>My content</main>
    </div>

    <script src="./my-script.js"></script>
</body>

display: contents sorgt dafür, dass wir uns nicht weiter per CSS mit #contents herumschlagen müssen. Der Browser wird dessen Schale quasi ignorieren, so als seien dessen Children direkt innerhalb von <body></body>.

Jetzt muss noch das Script angepasst werden:

// Der Parser wird benötigt, um den HTML-String zu interpretieren.
const parser = new DOMParser()
const links = document.querySelectorAll('a[href^="/"]')

links.forEach((link) => {
    link.addEventListener("click", async (e) => {
        e.preventDefault()
        const href = link.getAttribute("href")

        const html = await fetch(href).then((r) => r.text())
        const doc = parser.parseFromString(html, "text/html")

        window.history.pushState({ page: href }, doc.title, href)
        document.title = doc.title
        window.scroll({ top: 0, behavior: "instant" })

        document.getElementById("contents").innerHTML =
            doc.getElementById("contents").innerHTML

        // Füge das hinzu, um Screenreadern zu signalisieren, dass sich 
        // der Inhalt geändert hat:
        document.getElementById("contents").focus()
    }
})

Schritt 3: Scripts nach Seiten-Wechsel reinitialisieren

Da wir beim Seiten-Wechsel viel HTML austauschen, gehen DOM-Bindungen, die manches vom JavaScript hatte verloren. Auch das Script zum Initialisieren für das Browser-Routing. Deswegen müssen wir alles einmal in eine Funktion verstauen, damit dieses erneut ausgeführt werden kann:

const parser = new DOMParser()
let links

function browserRouting() {
    links = document.querySelectorAll('a[href^="/"]')

    links.forEach((link) => {
        link.addEventListener("click", async (e) => {
            e.preventDefault()
            const href = link.getAttribute("href")

            const html = await fetch(href).then((r) => r.text())
            const doc = parser.parseFromString(html, "text/html")

            window.history.pushState({ page: href }, doc.title, href)
            document.title = doc.title
            window.scroll({ top: 0, behavior: "instant" })

            document.getElementById("contents").innerHTML =
                doc.getElementById("contents").innerHTML

            document.getElementById("contents").focus()

            // Browser-Routing nach Seiten-Wechsel neu initialisieren
            browserRouting()
        }
    })
}

Du hast auf deiner Website wahrscheinlich noch weiteres, das nach einem Seiten-Wechsel erneut ausgeführt werden muss. Das kannst du dort auch mit hinzufügen:

const parser = new DOMParser()
let links

function browserRouting() {
    links = document.querySelectorAll('a[href^="/"]')

    links.forEach((link) => {
        link.addEventListener("click", async (e) => {
            e.preventDefault()
            const href = link.getAttribute("href")

            const html = await fetch(href).then((r) => r.text())
            const doc = parser.parseFromString(html, "text/html")

            window.history.pushState({ page: href }, doc.title, href)
            document.title = doc.title
            window.scroll({ top: 0, behavior: "instant" })

            document.getElementById("contents").innerHTML =
                doc.getElementById("contents").innerHTML

            document.getElementById("contents").focus()

            browserRouting()
            initLanguage()
            initThemeMode()

            // Reinit HTMX
            const searchToggle = document.querySelector(".search-toggle")
            if (searchToggle) {
                htmx.process(searchToggle)
            }
        }
    })
}

Damit hast du im Grunde ein gutes simples Browser-Routing-System für statische Websites. Je nachdem kann es aber noch Probleme bei einzelnen Seiten geben, bei denen es nicht so einfach ist, alles nach einem Seiten-Wechsel zu initialisieren.

Schritt 4: Einzelne Seiten aus Routing-System ausschließen

Manchmal ist es sinnvoller, dass für einzelne Seiten nicht das Routing-System verwendet werden soll, sondern wie bisher die komplette Seite neu zu laden. Ich habe bei mir eine Blacklist mit URLs von internen Seiten, die ausgeschlossen werden sollen:

// Liste mit Href-Werten, die ausgeschlossen werden sollen.
const browserRoutingBlacklist = ["/tags/", "/tags", "/tags/index.html"]

const parser = new DOMParser()
let links

function browserRouting() {
    links = document.querySelectorAll('a[href^="/"]')

    links.forEach((link) => {
        link.addEventListener("click", async (e) => {
            e.preventDefault()
            const href = link.getAttribute("href")

            // Ist Href in Blacklist, wird die komplette Seite gewechselt.
            if (browserRoutingBlacklist.includes(href)) {
                location.href = href
                return
            }

            const html = await fetch(href).then((r) => r.text())
            const doc = parser.parseFromString(html, "text/html")

            window.history.pushState({ page: href }, doc.title, href)
            document.title = doc.title
            window.scroll({ top: 0, behavior: "instant" })

            document.getElementById("contents").innerHTML =
                doc.getElementById("contents").innerHTML

            document.getElementById("contents").focus()

            browserRouting()
            // Mehr Initialisierer
        }
    })
}

Schritt 5: Auf Browser-Verlauf horchen

Es sieht jetzt alles gut aus! Ein Problem gibt es aktuell leider noch, wenn du über den Browser vor- und zurück navigierst. Die URL wird dann ausgetauscht, die Inhalte aber nicht. Um das zu beheben, muss der Code noch etwas geändert werden.

Zuerst lagere ich den Code zum Austauschen des Inhalts in eine eigene Funktion aus:

async function goTo(href, pushState = false) {
    if (browserRoutingBlacklist.includes(href)) {
        location.href = href
        return
    }

    const html = await fetch(href).then((r) => r.text())
    const doc = parser.parseFromString(html, "text/html")

    if (pushState) {
        window.history.pushState({ page: href }, doc.title, href)
    }

    document.title = doc.title
    window.scroll({ top: 0, behavior: "instant" })

    document.getElementById("contents").innerHTML =
        doc.getElementById("contents").innerHTML

    document.getElementById("contents").focus()

    browserRouting()
    // Mehr Initialisierer
}

browserRouting() muss entsprechend angepasst werden:

function browserRouting() {
    links = document.querySelectorAll('a[href^="/"]')

    links.forEach((link) => {
        link.addEventListener("click", async (e) => {
            e.preventDefault()
            const href = link.getAttribute("href")
            await goTo(href, true)
        })
    })
}

Und anschließend ein Listener beim Ändern des Browser-Verlaufs:

window.addEventListener("popstate", async (event) => {
    await goTo(event.target.location.pathname, false)
})

Mit Hash-Links oder Anchor-Links (Beispiel: <a href="#ziel">Springe zum Ziel</a>) kann man auf eine bestimmte Stelle innerhalb einer Seite verlinken. Der Browser scrollt dann automatisch an diese Stelle. Dadurch dass ich in meinem Script immer nach Ändern des Browser-Verlaufs am Anfang der Seite scrolle, funktionieren Hash-Links nicht mehr.

Hier ist mein Fix für den popstate-Event-Listener:

window.addEventListener("popstate", async (event) => {
-    await goTo(event.target.location.pathname, false)
+    await goTo(
+        event.target.location.pathname + event.target.location.hash,
+        false
+    )
})

Der Hash aus der URL wird mit an den href-Parameter der goto-Funktion übergeben. In der Funktion habe ich dann das geändert:

async function goTo(href, pushState = false) {
    // ...
    document.title = doc.title
-    window.scroll({ top: 0, behavior: "instant" })
+
    document.getElementById("contents").innerHTML =
        doc.getElementById("contents").innerHTML

    const hashTarget = href.includes("#")
        ? document.getElementById(href.split("#")[1])
        : null
+
+    if (hashTarget) {
+        hashTarget.scrollIntoView()
+        hashTarget.focus()
+    } else {
+        window.scroll({ top: 0, behavior: "instant" })
        document.getElementById("contents").focus()
+    }
+
    browserRouting()
    // Mehr Initialisierer
}

Wenn href einen Hash (#) enthält, wird nach einem Element mit diesem als ID gesucht und dort hin gescrollt und das Element wird auch fokussiert. Andernfalls wird wie bisher an den Anfang der Seite gescrollt und der Haupt-Inhalt fokussiert. Die Scroll-Aktion musste verschoben werden, sodass das DOM bereits mit dem neuen Inhalt ausgetauscht wurde.

Hier nochmal die gesamte goto-Funktion:

async function goTo(href, pushState = false) {
    if (browserRoutingBlacklist.includes(href)) {
        location.href = href
        return
    }

    const html = await fetch(href).then((r) => r.text())
    const doc = parser.parseFromString(html, "text/html")

    if (pushState) {
        window.history.pushState({ page: href }, doc.title, href)
    }

    document.title = doc.title

    document.getElementById("contents").innerHTML =
        doc.getElementById("contents").innerHTML

    const hashTarget = href.includes("#")
        ? document.getElementById(href.split("#")[1])
        : null

    if (hashTarget) {
        hashTarget.scrollIntoView()
        hashTarget.focus()
    } else {
        window.scroll({ top: 0, behavior: "instant" })
        document.getElementById("contents").focus()
    }

    browserRouting()
    // Mehr Initialisierer
}