In meinem vorherigen Beitrag haben wir uns Shoelace angesehen, eine Komponentenbibliothek mit einer vollständigen Suite von UX-Komponenten, die schön, zugänglich und – vielleicht unerwartet – mit Web Components erstellt wurden. Das bedeutet, dass sie mit jedem JavaScript-Framework verwendet werden können. Während die Interoperabilität von React mit Web Components derzeit nicht ideal ist, gibt es Workarounds.
Ein schwerwiegender Mangel an Web Components ist jedoch ihre derzeitige mangelnde Unterstützung für serverseitiges Rendering (SSR). Es gibt etwas namens Declarative Shadow DOM (DSD) in Arbeit, aber die aktuelle Unterstützung dafür ist ziemlich minimal, und es erfordert tatsächlich die Zustimmung Ihres Webservers, um spezielle Markups für DSD auszugeben. Derzeit wird für Next.js daran gearbeitet, was ich gerne sehen würde. Aber für diesen Beitrag werden wir uns ansehen, wie Web Components mit jedem SSR-Framework, wie Next.js, heute verwaltet werden.
Wir werden eine nicht unerhebliche Menge an manueller Arbeit leisten und dabei die Startperformance unserer Seite leicht beeinträchtigen. Anschließend werden wir uns ansehen, wie wir diese Performancekosten minimieren können. Aber machen Sie sich keine Illusionen: Diese Lösung ist nicht ohne Kompromisse, erwarten Sie also nichts anderes. Messen und profilieren Sie immer.
Das Problem
Bevor wir eintauchen, nehmen wir uns einen Moment Zeit, um das Problem tatsächlich zu erklären. Warum funktionieren Web Components nicht gut mit serverseitigem Rendering?
Anwendungsframeworks wie Next.js nehmen React-Code und führen ihn durch eine API, um ihn im Wesentlichen zu "stringifizieren", d. h. sie verwandeln Ihre Komponenten in reinen HTML. Der React-Komponentenbaum wird also auf dem Server, der die Web-App hostet, gerendert, und dieser HTML wird zusammen mit dem Rest des HTML-Dokuments der Web-App an den Browser Ihres Benutzers gesendet. Zusammen mit diesem HTML werden einige <script>-Tags geladen, die React sowie den Code für alle Ihre React-Komponenten laden. Wenn ein Browser diese <script>-Tags verarbeitet, rendert React den Komponentenbaum neu und gleicht ihn mit dem heruntergesendeten SSR-HTML ab. An diesem Punkt beginnen alle Effekte zu laufen, die Event-Handler werden verdrahtet und der Zustand enthält tatsächlich... Zustand. An diesem Punkt wird die Web-App interaktiv. Der Prozess der erneuten Verarbeitung Ihres Komponentenbaums auf dem Client und der Verdrahtung von allem wird als Hydration bezeichnet.
Was hat das also mit Web Components zu tun? Nun, wenn Sie etwas rendern, sagen wir, die gleiche Shoelace <sl-tab-group>-Komponente, die wir letztes Mal besucht haben
<sl-tab-group ref="{tabsRef}">
<sl-tab slot="nav" panel="general"> General </sl-tab>
<sl-tab slot="nav" panel="custom"> Custom </sl-tab>
<sl-tab slot="nav" panel="advanced"> Advanced </sl-tab>
<sl-tab slot="nav" panel="disabled" disabled> Disabled </sl-tab>
<sl-tab-panel name="general">This is the general tab panel.</sl-tab-panel>
<sl-tab-panel name="custom">This is the custom tab panel.</sl-tab-panel>
<sl-tab-panel name="advanced">This is the advanced tab panel.</sl-tab-panel>
<sl-tab-panel name="disabled">This is a disabled tab panel.</sl-tab-panel>
</sl-tab-group>
…React (oder ehrlich gesagt jedes JavaScript-Framework) wird diese Tags sehen und sie einfach weitergeben. React (oder Svelte, oder Solid) sind nicht dafür verantwortlich, diese Tags in schön formatierte Tabs zu verwandeln. Der Code dafür ist im Code versteckt, der diese Web Components definiert. In unserem Fall befindet sich dieser Code in der Shoelace-Bibliothek, aber der Code kann überall sein. Wichtig ist, wann der Code ausgeführt wird.
Normalerweise wird der Code, der diese Web Components registriert, über einen JavaScript-import in den normalen Code Ihrer Anwendung geladen. Das bedeutet, dass dieser Code in Ihrem JavaScript-Bundle landet und während der Hydration ausgeführt wird, was bedeutet, dass zwischen dem ersten Erscheinen des SSR-HTML für Ihren Benutzer und der Hydration diese Tabs (oder jede Web Component) den korrekten Inhalt nicht rendern werden. Wenn dann die Hydration erfolgt, wird der richtige Inhalt angezeigt, was wahrscheinlich dazu führt, dass sich der Inhalt um diese Web Components herum verschiebt und dem korrekt formatierten Inhalt entspricht. Dies wird als Flash of Unstyled Content oder FOUC bezeichnet. Theoretisch könnten Sie Markup zwischen all diesen <sl-tab-xyz>-Tags einfügen, um die endgültige Ausgabe anzupassen, aber das ist in der Praxis praktisch unmöglich, insbesondere für eine Drittanbieter-Komponentenbibliothek wie Shoelace.
Verschieben unseres Web Component-Registrierungscodes
Das Problem ist also, dass der Code, der Web Components zum Laufen bringt, erst während der Hydration ausgeführt wird. Für diesen Beitrag werden wir diesen Code früher ausführen; tatsächlich sofort. Wir werden unseren Web Component-Code benutzerdefiniert bündeln und manuell ein Skript direkt in den <head> unseres Dokuments einfügen, damit es sofort ausgeführt wird und den Rest des Dokuments blockiert, bis es fertig ist. Das ist normalerweise eine schreckliche Sache zu tun. Der Sinn von serverseitigem Rendering ist es, unsere Seite nicht zu blockieren, bis unser JavaScript verarbeitet wurde. Aber wenn es getan ist, bedeutet dies, dass die Web Components registriert werden, während das Dokument unseren HTML vom Server initial rendert, und sofort und synchron die richtigen Inhalte ausgeben.
In unserem Fall wollen wir nur unseren Web Component-Registrierungscode in einem blockierenden Skript ausführen. Dieser Code ist nicht riesig, und wir werden versuchen, die Performance-Einbuße durch Hinzufügen einiger Cache-Header für nachfolgende Besuche erheblich zu verringern. Dies ist keine perfekte Lösung. Der erste Besuch einer Seite durch einen Benutzer wird immer blockieren, während diese Skriptdatei geladen wird. Nachfolgende Besuche werden gut gecacht, aber dieser Kompromiss ist für Sie möglicherweise nicht praktikabel – E-Commerce, irgendjemand? Messen Sie auf jeden Fall, profilieren Sie und treffen Sie die richtige Entscheidung für Ihre App. Außerdem ist es in Zukunft durchaus möglich, dass Next.js DSD und Web Components vollständig unterstützt.
Erste Schritte
Der gesamte Code, den wir betrachten werden, befindet sich in diesem GitHub-Repo und hier mit Vercel bereitgestellt. Die Web-App rendert einige Shoelace-Komponenten zusammen mit Text, der sich nach der Hydration in Farbe und Inhalt ändert. Sie sollten sehen, wie sich der Text in "Hydrated" ändert und die Shoelace-Komponenten bereits ordnungsgemäß rendern.
Benutzerdefiniertes Bündeln von Web Component-Code
Unser erster Schritt ist die Erstellung eines einzigen JavaScript-Moduls, das alle unsere Web Component-Definitionen importiert. Für die von mir verwendeten Shoelace-Komponenten sieht mein Code wie folgt aus:
import { setDefaultAnimation } from "@shoelace-style/shoelace/dist/utilities/animation-registry";
import "@shoelace-style/shoelace/dist/components/tab/tab.js";
import "@shoelace-style/shoelace/dist/components/tab-panel/tab-panel.js";
import "@shoelace-style/shoelace/dist/components/tab-group/tab-group.js";
import "@shoelace-style/shoelace/dist/components/dialog/dialog.js";
setDefaultAnimation("dialog.show", {
keyframes: [
{ opacity: 0, transform: "translate3d(0px, -20px, 0px)" },
{ opacity: 1, transform: "translate3d(0px, 0px, 0px)" },
],
options: { duration: 250, easing: "cubic-bezier(0.785, 0.135, 0.150, 0.860)" },
});
setDefaultAnimation("dialog.hide", {
keyframes: [
{ opacity: 1, transform: "translate3d(0px, 0px, 0px)" },
{ opacity: 0, transform: "translate3d(0px, 20px, 0px)" },
],
options: { duration: 250, easing: "cubic-bezier(0.785, 0.135, 0.150, 0.860)" },
});
Es lädt die Definitionen für die Komponenten <sl-tab-group> und <sl-dialog> und überschreibt einige Standardanimationen für den Dialog. Einfach genug. Aber das Interessante daran ist, diesen Code in unsere Anwendung zu bekommen. Wir können dieses Modul nicht einfach importen. Wenn wir das tun würden, würde es in unsere normalen JavaScript-Bundles gebündelt und während der Hydration ausgeführt werden. Das würde den FOUC verursachen, den wir vermeiden wollen.
Während Next.js eine Reihe von Webpack-Hooks zur benutzerdefinierten Bündelung bietet, verwende ich stattdessen Vite. Installieren Sie es zunächst mit npm i vite und erstellen Sie dann eine Datei vite.config.js. Meine sieht so aus:
import { defineConfig } from "vite";
import path from "path";
export default defineConfig({
build: {
outDir: path.join(__dirname, "./shoelace-dir"),
lib: {
name: "shoelace",
entry: "./src/shoelace-bundle.js",
formats: ["umd"],
fileName: () => "shoelace-bundle.js",
},
rollupOptions: {
output: {
entryFileNames: `[name]-[hash].js`,
},
},
},
});
Dies erstellt eine Bundle-Datei mit unseren Web Component-Definitionen im Ordner shoelace-dir. Verschieben wir sie in den public-Ordner, damit Next.js sie bedient. Und wir sollten auch den genauen Namen der Datei mit dem Hash am Ende behalten. Hier ist ein Node-Skript, das die Datei verschiebt und ein JavaScript-Modul schreibt, das eine einfache Konstante mit dem Namen der Bundle-Datei exportiert (das wird bald nützlich sein).
const fs = require("fs");
const path = require("path");
const shoelaceOutputPath = path.join(process.cwd(), "shoelace-dir");
const publicShoelacePath = path.join(process.cwd(), "public", "shoelace");
const files = fs.readdirSync(shoelaceOutputPath);
const shoelaceBundleFile = files.find(name => /^shoelace-bundle/.test(name));
fs.rmSync(publicShoelacePath, { force: true, recursive: true });
fs.mkdirSync(publicShoelacePath, { recursive: true });
fs.renameSync(path.join(shoelaceOutputPath, shoelaceBundleFile), path.join(publicShoelacePath, shoelaceBundleFile));
fs.rmSync(shoelaceOutputPath, { force: true, recursive: true });
fs.writeFileSync(path.join(process.cwd(), "util", "shoelace-bundle-info.js"), `export const shoelacePath = "/shoelace/${shoelaceBundleFile}";`);
Hier ist ein begleitendes npm-Skript:
"bundle-shoelace": "vite build && node util/process-shoelace-bundle",
Das sollte funktionieren. Bei mir existiert util/shoelace-bundle-info.js nun und sieht wie folgt aus:
export const shoelacePath = "/shoelace/shoelace-bundle-a6f19317.js";
Laden des Skripts
Lassen Sie uns in die Next.js-Datei _document.js gehen und den Namen unserer Web Component-Bundle-Datei abrufen:
import { shoelacePath } from "../util/shoelace-bundle-info";
Dann rendern wir manuell ein <script>-Tag im <head>. Hier ist meine gesamte _document.js-Datei:
import { Html, Head, Main, NextScript } from "next/document";
import { shoelacePath } from "../util/shoelace-bundle-info";
export default function Document() {
return (
<Html>
<Head>
<script src={shoelacePath}></script>
</Head>
<body>
<Main />
<NextScript />
</body>
</Html>
);
}
Und das sollte funktionieren! Unsere Shoelace-Registrierung wird in einem blockierenden Skript geladen und ist sofort verfügbar, während unsere Seite den anfänglichen HTML verarbeitet.
Performance verbessern
Wir könnten die Dinge so belassen, wie sie sind, aber lassen Sie uns Caching für unser Shoelace-Bundle hinzufügen. Wir werden Next.js anweisen, diese Shoelace-Bundles cachtauglich zu machen, indem wir den folgenden Eintrag zu unserer Next.js-Konfigurationsdatei hinzufügen:
async headers() {
return [
{
source: "/shoelace/shoelace-bundle-:hash.js",
headers: [
{
key: "Cache-Control",
value: "public,max-age=31536000,immutable",
},
],
},
];
}
Nun sehen wir bei nachfolgenden Besuchen unserer Website, dass das Shoelace-Bundle gut gecacht wird!

Wenn sich unser Shoelace-Bundle jemals ändert, ändert sich der Dateiname (über den :hash-Teil aus der obigen Source-Eigenschaft), der Browser stellt fest, dass er diese Datei nicht gecacht hat, und fordert sie einfach neu vom Netzwerk an.
Zusammenfassung
Das mag wie viel manuelle Arbeit erschienen sein; und das war es auch. Es ist bedauerlich, dass Web Components keine bessere Out-of-the-Box-Unterstützung für serverseitiges Rendering bieten.
Aber wir sollten die Vorteile, die sie bieten, nicht vergessen: Es ist schön, qualitativ hochwertige UX-Komponenten verwenden zu können, die nicht an ein bestimmtes Framework gebunden sind. Es ist auch schön, mit brandneuen Frameworks experimentieren zu können, wie z. B. Solid, ohne eine Tab-, Modal-, Autocomplete- oder beliebige andere Komponente finden (oder zusammenbasteln) zu müssen.