Vue + Vite + SSG – selbstgeschrieben

Ich habe eine Webseite, die in Vue 3 + Vite geschrieben ist. Die Seite enthält nur statischen Content sowie via vue-router diverse Unterseiten. Und von den Seiten ohne großen Aufwand automatisch HTML zu erstellen, das pregerendert ist (Static Site Generation), scheint spontan nervig. Zwar findet man viel zu nuxt, vite-ssg, etc – allerdings erfordert mir das alles zu viel Aufwand für eine bereits fertig geschriebene Seite, die „nur“ prerendering nachgerüstet bekommen soll.

Also – selbstgeschriebene Lösung. Wir nehmen puppeteer, eine Liste an Routen, generieren Seiten und eine .htaccess-Datei, die das deployment später vereinfacht.

Ruft ein Client eine Seite ab, bekommt er das prerendered – bis Vue reinkickt und die Kontrolle über den DOM übernimmt. Sobald Vue geladen ist, merkt man das prerendering schon nicht mehr.

Die Anpassungen im Projekt

Zuerst installieren wir uns puppeteer:

npm install --save-dev puppeteer

Dann neuen Unterordner „build“, in dem wir zwei Dateien anlegen; eine für die routen, eine für die Logik an sich.

In der build/routes.js wird eine asynchrone Funktion zurückgegeben, die die Routen auflistet. In diesem Fall habe ich mich für diesen „komplizierteren“ Weg entschieden, damit man hier ggf. noch Daten nachladen kann. In diesem Beispiel wird das also nicht genutzt.

export default async function() {
    return [
        {
            // Ruft "/" ab und schreibt es in "start.html"
            "file": "start",
            "path": "/"
        },
        {
            // Ruft "/about" ab und schreibt es in "about.html"
            "file": "about",
            "path": "/about"
        },
        {
            // Ruft "/products/overview" ab und 
            // schreibt es in "products-overview.html"
            "file": "products-overview",
            "path": "/products/overview"
        },
        // [...]
    ];
}

in die build/index.js kommt dann die eigentliche Logik; wir laden die Routen, rufen die nach und nach ab, und schreiben die HTML-Dateien. Ebenfalls passen wir die .htaccess-Datei in /src/public-Ordner an und fügen die Routen da ein, wo der Kommentar ### RULE_LOCATION ### erscheint.

import puppeteer from 'puppeteer';
import fs from 'fs';
import path from 'path';

/**
 * Wir importieren die Routen.
 * Die routes.js liefert eine asynchrone Funktion zurück, die dann eine Liste mit 
 * URLs und Dateinamen resolved
 */
import Routes from './routes.js';

/**
 * Hier geben wir die Base-URL an, die von `npm run preview` angezeigt wird
 */
const BaseURL = "http://localhost:4173";


// Puppeteer Browser
const browser = await puppeteer.launch();

/**
 * Ruft eine Seite ab. gibt den kompletten Inhalt als HTML wieder.
 * 
 * @param {string} URL - die abzurufende URL, als absoluter Pfad (also z.B. "/")
 * @returns {Promise<string>}
 */
async function download(URL) {
    const page = await browser.newPage();
    await page.setUserAgent('prerender');
    // Abmessungen lassen sich frei anpassen,
    // habe das via Dev Tools von meinem Chrome genommen
    await page.setViewport({ width: 1920, height: 963}); 
    await page.goto(BaseURL + URL);

    var source = await page.content({"waitUntil": "networkidle0"});
    source = source.split(BaseURL).join('');
    page.close();

    return source;
}

(async function() {
    // Routen laden
    var requestRoutes = await Routes();

    // Alle durchgehen und in das dist-Verzeichnis die HTML-Dateien schreiben
    for(var i = 0; i < requestRoutes.length; i++) {
        console.log("Requesting "+ requestRoutes[i].path);
        var fileContent = await download(requestRoutes[i].path);
        fs.writeFileSync(path.join("dist", 
            requestRoutes[i].file +".html"), 
            fileContent 
            +"\n<!-- @pregenerared "+ (new Date().toUTCString()) +" -->"
        );
    }

    // Die htaccess aus dem dist-Verzeichnis lesen und die Routen hinzufügen
    var htaccess = fs.readFileSync(path.join("dist", ".htaccess")) +"";
    var replace = [];
    for(var i = 0; i < requestRoutes.length; i++) {
        replace.push("RewriteRule ^"
          + requestRoutes[i].path.substr(1) +"$ "
          + requestRoutes[i].file +".html [NC,L]"
        );
    }
    replace = replace.join("\n");
    htaccess = htaccess.replace("### RULE_LOCATION ###", replace);
    fs.writeFileSync(path.join("dist", ".htaccess"), htaccess);

    process.exit(0);
})();

In der /src/public/.htaccess steht folgendes:

Order Allow,Deny
Allow From All

RewriteEngine ON
Options +FollowSymLinks

# Beispiel: alles mit /api am Anfang zur api.php umleiten
RewriteRule ^api/(.*)$ api.php?path=$1 [NC,L]

# Hier werden die Regeln eingefügt
### RULE_LOCATION ###
# Ende der automatisch generierten Regeln

# Fallback: unbekannte Requests an die index.html weiterleiten
RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^(.*)/?$ index.html [NC,L]

Und damit steht das grobe Konstrukt nun. Puppeteer ruft die Daten ab, rendert den Dom, er wird in eine HTML-Datei geschrieben und die htaccess sogar automatisch so gepatcht, dass unsere eigenen Regeln da drin landen.

Prebuild

Nun, wenn wir ein neues Release haben, compilen wir das wie gewohnt:

npm run build

Vite leert unseren dist-Ordner und schreibt das fertige Vue-Projekt da rein. Anschließend starten wir den preview-Mode von vite:

npm run preview

Vite startet nun einen Webserver, der die Dateien (wie in einem Live-System) ausgibt. Anfragen, die nicht als reine Datei existieren, werden an die index.html geroutet – so, wie man es aus einem Live-System kennt.

Während dieser Preview-Server läuft, starten wir unser gerade geschriebenes Script:

node ./build/index.js

Der läuft nun alle routen durch, und schreibt deren Ergebnis in die entsprechende HTML-Datei im dist-Ordner; passt die htaccess an und schreibt die Regeln da rein. Alles ist ready, und wir könnten die Site so deployen.

Die Sache mit dem Prerendering

Ein paar Sachen muss man hierbei allerdings bedenken.

(1) der Inhalt ist statisch, bis Vue übernimmt. Wenn beim Prerendering Abfragen genutzt werden, um Daten zu visualisieren, nimmt puppeteer die mit – und schreibt sie statisch rein. Also – nur für fixe Webseiten gedacht

(2) alle Module, zum Beispiel aos.js, werden auch mit geprerendert. Im Falle von aos bekamen einige Elemente, die durch puppeteer nicht gerendert wurden, also ein display: none. In Vue müssen also ggf. ein paar Weichen gebaut werden, wenn man möchte, dass die Seite auch ohne Javascript funktioniert und Elemente nicht ausgeblendet werden.

Für Problem 1 gibt es keine Lösung. Wer das bedenken muss, ist mit dem serverseitigem Rendern besser aufgehoben.

Bei Problem 2 – wer den Quellcode oben inspiziert hat, sieht bestimmt auch, dass wir den User-Agent auf „prerender“ setzen. In meinem Projekt checke ich diesen Useragent, und baue meine Weichen drum herum.

Nehmen wir aos.js als Beispiel; in der main.js exklusidere ich explizit die CSS-Datei von diesem, wenn der User-Agent „prerender“ ist:

if(window.navigator.userAgent != 'prerender') {
    import('~/aos/dist/aos.css');
}

Auf Seiten, auf denen aos genutzt wird, rufe ich ebenfalls nur AOS.init() auf, wenn der User-Agent nicht „prerender“ ist:

mounted() {
  if(window.navigator.userAgent != "prerender") AOS.init();
}

Auf diesem Weg haben Besucher ohne Javascript eine langweilige, allerdings funktionierende Seite, die sauber angezeigt wird. Bei Besuchern mit Javascript wird der DOM sowieso ausgetauscht, sobald Vue kickt, von daher passt das.

Kategorie: Vue.JS

tino-ruge.de wird tino-kuptz.de

Im Laufe des Jahres 2024 wird dieser Blog umziehen. Alle Inhalte werden 1:1 weitergeleitet, nix geht verloren. Neue Domain, alter Autor, alter Inhalt.