1. Pure Web
The web and app development landscape has been heavily influenced by a bandwagon effect, leading to the dominance of React, Angular and Vue over the past decade. This has fostered a prevailing belief that using a framework is essential for building any professional-grade application.
2. Warum Pure Web
Pros | Cons |
---|---|
Unabhängigkeit von Frameworks und deren Bibliotheken |
Mehr Entwicklungsaufwand |
Generelle gute Performance |
Performance kann schlechter sein |
Kleine Bundle Größe |
Größere Bundle Größe |
3. Technologien
3.1. Vite
vite
ist ein Build-Tool, welches esbuild
und rollup
kombiniert.
Im Development wird esbuild
verwendet aufgrund der Performance.
In der Produktion wird rollup
verwendet und esbuild
nur für das Transpilieren. rollup
ist zwar langsamer als esbuild
, jedoch wird von vite
argumentiert, dass die Plugin API von rollup
den Tradeoff rechtfertigt.
Die Migration von rollup
zu rolldown
(rollup
rust port) ist Stand 11.04.2025 noch WIP.
3.2. Web Components
Mithilfe von Web Components kann man eigene HTML Elemente erstellen und mithilfe des Shadow DOMs Funktionalitäten privat halten ohne Kollisionen.
export class Nav extends HTMLElement { (1)
constructor() {
super();
this.attachShadow({ mode: "open" }); (2)
}
connectedCallback() {
this.render(); (3)
}
render() {
const template = document.createElement("template");
template.innerHTML = `
<div>
<a href="/">Home</a>
<a href="/admin">Admin</a>
<a href="/user">User</a>
</div>
`;
this.shadowRoot?.append(template.content.cloneNode(true)); (4)
}
}
customElements.define("app-nav", Nav); (5)
1 | Custom Elements sind Klassen welche von HTMLElement erben.Die Elemente können auch von spezifischeren Elementen erben z.B. HTMLParagraphElement , dann muss bei der Definition customElements.define("selector", Class, { extends: "p" }); extra angegeben werden. |
2 | Erstellt einen Shadow DOM Tree am Element{ mode: "open" } bedeutet, dass Elemente im Shadow DOM außerhalb mit JavaScript zugänglich sind. |
3 | connectedCallback wird jedes mal aufgerufen, wenn das Element im DOM hinzugefügt wird. |
4 | Erstellt eine Deepcopy des Inhaltes des erstellten Templateelements und fügt es im Shadow DOM hinzu. |
5 | Registriert ein neues Element in der Custom Element Registry |
3.2.1. ShadowRoot
Der Shadow DOM ist ein Teil der Web Components und erlaubt es, HTML-Elemente mit einem gekapselten, eigenen DOM-Baum auszustatten. Dieser Baum beginnt beim Shadow Root.
3.3. Custom Events
Mit Custom Events kann man zusätzliche Daten zu Events hinzufügen.
export class Nav extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: "open" });
}
connectedCallback() {
this.render();
}
render() {
const template = document.createElement("template");
template.innerHTML = `
<div id="container">
<a id="home" href="/"></a>
</div>
`;
this.shadowRoot?.append(template.content.cloneNode(true));
element(this.shadowRoot, "container").addEventListener(
"custom",
(e) => {
if (e instanceof CustomEvent) console.log(e.detail.time);
},
);
setInterval(() => {
element(this.shadowRoot, "home").dispatchEvent(
new CustomEvent("custom", {
bubbles: true,
detail: { time: Date.now() }, (1)
}),
);
}, 10_000);
}
}
customElements.define("app-nav", Nav);
1 | Zusätzliche Daten |
3.4. Proxy Objects
Proxy Objekte in JavaScript sind Stellvertreter für das Originale Objekt.
Mit diesen können fundamentale Operationen, wie get
und set
überschrieben werden.
const proxy = new Proxy(
{
value: ""
},
{
get: (target, p) => {
if (!(p in target)) {
return undefined;
}
let value = target[p as keyof typeof target];
return value;
},
set: (target, prop, value) => {
if (!(prop in target)) {
return false;
}
target[prop as keyof typeof target] = value;
console.log(`${target} has changed`)
return true;
},
}
);
proxy.value = "T.W.S"
proxy.value = "W.W"
In diesem Codebeispiel wird bei jedem Update value has changed
in der Konsole ausgegeben.
In diesem Template wird diese Funktionalität für die Erkennung von Änderungen verwendet.
4. Setup
npm create vite@latest frontend -- --template vanilla-ts
cd frontend
npm i
npm i keycloak-js (1)
1 | keycloak-js ist der offizielle Adapter für Keycloak |
Das Setup des Backends ist in diesem Tutorial näher beschrieben Quarkus Backend mit Keycloak Policies
4.2. Template
frontend
├── src
│ ├── components/
│ ├── services/
│ ├── types/
│ ├── utils/
│ ├── views/
│ ├── main.ts
│ └── vite-env.d.ts
├── public (1)
│ └── vite.svg
├── index.html
├── package.json
├── package-lock.json
├── tsconfig.json
└── vite.config.ts
1 | Statische Assets |
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Pure Web Keycloak</title>
</head>
<body>
<app-nav></app-nav> (1)
<app-outlet></app-outlet> (2)
<script type="module" defer src="/src/main.ts"></script> (3)
</body>
</html>
1 | Navbar Komponente |
2 | Vergleichbar mit dem Routeroutlet von Angular |
3 | Mit defer wird das Skript während das HTML geparsed wird heruntergeladen und erst ausgeführt sobald die Seite fertig geparsed wurde. mdn |
import "./css";
import "./components";
import "./views";
import { keycloakService, storeService } from "./services";
const model = storeService.model;
model.navigation.pane = new URL(document.location.href).pathname; (1)
history.replaceState({ pane: model.navigation.pane }, ""); (2)
window.addEventListener("popstate", (event: PopStateEvent) => {
model.navigation.pane = event.state.pane; (3)
});
window.onload = async () => {
await keycloakService.keycloak.init({
onLoad: "login-required",
});
model.profile = await keycloakService.keycloak.loadUserProfile();
model.realmAccess = keycloakService.keycloak.realmAccess;
};
1 | Initialisierung des Navigationszustandes |
2 | Die History API kann das Proxy Objekt des Models nicht klonen |
3 | Wenn im Browser der Zurückknopf gedrückt wird wird der Zustand ebenfalls geändert |
4.2.1. Services
import { Model } from "../types";
export class StoreService {
private readonly _model: Model = {
navigation: {
pane: "/",
},
vehicles: undefined,
vehicle: undefined,
profile: undefined,
realmAccess: undefined,
};
private subscribers: ((model: Model) => void)[] = [];
private notify() {
this.subscribers.forEach((s) => s(this.model));
}
// doesnt work with arrays (but with some hacks it does) (1)
private proxyObject(object: Object) {
return new Proxy(object, {
get: (target, p) => {
if (!(p in target)) {
return undefined;
}
let value = target[p as keyof typeof target];
if (typeof value === "object") {
return this.proxyObject(value); (2)
}
return value;
},
set: (target, prop, value) => {
if (!(prop in target)) {
return false;
}
target[prop as keyof typeof target] = value;
this.notify();
return true;
},
});
}
get model() {
return this.proxyObject(this._model) as Model;
}
public subscribe(fn: (model: Model) => void) {
this.subscribers.push(fn);
}
public unsubscribe(fn: (model: Model) => void) {
this.subscribers = this.subscribers.filter((s) => s !== fn);
}
}
export const storeService = new StoreService();
1 | push ⇒ model.array = […model.array, value] splice ⇒ model.array = model.array.filter(x ⇒ …) |
2 | Erstellt ein Rekursives Proxy Objekt |
Durch die Verwendung von Proxy Objekten werden alle Subscriber benachrichtigt bei jeder Änderung am Model.
import Keycloak from "keycloak-js";
export class KeycloakService {
private readonly _keycloak = new Keycloak({
url: "http://localhost:8000",
realm: "demo",
clientId: "frontend",
});
public async isReady() { (1)
while (!keycloakService.keycloak.token) {
await new Promise((f) => setTimeout(f, 100));
}
}
public get keycloak() {
return this._keycloak;
}
}
export const keycloakService = new KeycloakService();
1 | Wartet bis der KeycloakService fertig initialisiert wurde. |
import { Vehicle } from "../types";
import { keycloakService } from "./keycloak-service";
export class VehicleService {
private readonly url = "http://localhost:8080";
// requires admin permissions
public async getAll() {
await keycloakService.isReady();
const response = await fetch(`${this.url}/vehicle/`, {
headers: {
accept: "application/json",
authorization: `Bearer ${keycloakService.keycloak.token}`,
},
});
if (!response.ok) return;
return (await response.json()) as Vehicle[];
}
// requires user permissions
public async getById(id: number) {
await keycloakService.isReady();
const response = await fetch(`${this.url}/vehicle/${id}`, {
headers: {
accept: "application/json",
authorization: `Bearer ${keycloakService.keycloak}`,
},
});
if (!response.ok) return undefined;
return (await response.json()) as Vehicle;
}
}
export const vehicleService = new VehicleService();
4.2.2. Utils
export function html(content: string): HTMLTemplateElement {
const template = document.createElement("template");
template.innerHTML = content;
return template;
}
export function element<T extends HTMLElement>(
shadowRoot: ShadowRoot | null,
id: string,
): T {
const element = shadowRoot?.getElementById(id);
if (!element) throw Error;
return element as T;
}
HTMLTemplateElement
enthält HTML Fragmente, welche nicht gerendert werden.
Diese Fragmente kann man später mithilfe von JavaScript zum DOM hinzufügen.
4.2.3. Web Components
<div>
<a href="/">Home</a>
<a href="/admin" data-roles='["admin"]'> Admin </a>
<a href="/user" data-roles='["admin", "user"]'> User </a>
</div>
import { html, Store } from "../../utils";
import template from "./nav-template.html?raw"; (1)
export class Nav extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: "open" }); (2)
}
connectedCallback() { (3)
this.render();
const links = Array.from(this.shadowRoot?.querySelectorAll("a") ?? []);
const model = Store.store.model;
links.forEach((a) => {
a.onclick = (e: MouseEvent) => {
e.preventDefault(); (4)
const pane = new URL(a.href).pathname;
if (model.navigation.pane === pane) {
return;
}
if (a.hasAttribute("data-roles")) {
const roles = JSON.parse(
a.getAttribute("data-roles")!,
) as string[];
if (
!roles.some((r) => model.realmAccess?.roles.includes(r)) (5)
) {
return;
}
}
model.navigation.pane = pane;
history.pushState(
{ pane: model.navigation.pane },
"",
model.navigation.pane,
);
};
});
}
render() {
const nav = html(template).content.cloneNode(true); (6)
this.shadowRoot?.append(nav);
}
}
customElements.define("app-nav", Nav); (7)
1 | Importiert das Template als string anstatt als Pfad |
2 | Die Elemente im ShadowRoot sind von JavaScript außerhalb zugänglich mdn |
3 | Wird jedes mal aufgerufen, wenn das Element zum Dokument hinzugefügt wurde |
4 | Verhindert den Page Reload |
5 | Verhindet die Navigation auf Routen, auf welche man keinen Zugriff hat |
6 | Erstellt eine Deepcopy des Inhaltes des erstellten Templateelements (Konvertiert HTML zu DOM Nodes) |
7 | Registriert ein neues Element app-nav |
4.2.4. Routing
<div>
<div hidden data-pane="/admin" data-roles='["admin"]'>
<app-admin-view></app-admin-view>
</div>
<div hidden data-pane="/user" data-roles='["admin", "user"]'>
<app-user-view></app-user-view>
</div>
</div>
import { html, Store } from "../../utils";
import template from "./app-template.html?raw";
export class App extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: "open" });
}
connectedCallback() {
this.render();
Store.store.subscribe((model) => (1)
this.renderPane(model.navigation.pane),
);
}
render() {
const app = html(template).content.cloneNode(true);
this.shadowRoot?.append(app);
}
renderPane(pane: string) {
const panes = this.shadowRoot?.querySelectorAll("[data-pane]");
const model = Store.store.model;
panes?.forEach((p) => {
p.getAttribute("data-pane") === pane &&
(p.hasAttribute("data-roles")
? (JSON.parse(p.getAttribute("data-roles")!) as string[]).some(
(r) => model.realmAccess?.roles.includes(r),
)
: true)
? p.removeAttribute("hidden")
: p.setAttribute("hidden", ""); (2)
});
}
}
customElements.define("app-outlet", App);
1 | Registriert eine Callback Funktion die nach jeder Änderung aufgerufen wird. |
2 | Setzt je nach ausgewähltem Pfad die richtige Komponente sichtbar. Ebenfalls wird geprüft ob der Nutzer die Seite laden darf oder nicht. |
4.2.5. Views
<p>Admin</p>
<pre id="vehicles"></pre>
import { element, html } from "../../utils";
import template from "./admin-template.html?raw";
import { vehicleService, storeService } from "../../services";
export class AdminView extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: "open" });
}
async connectedCallback() {
this.render();
storeService.model.vehicles = await vehicleService.getAll();
}
render() {
const admin = html(template).content.cloneNode(true);
this.shadowRoot?.append(admin);
const vehicles = element(this.shadowRoot, "vehicles");
storeService.subscribe((model) => {
vehicles.innerHTML = JSON.stringify(model.vehicles, null, "\t");
});
}
}
customElements.define("app-admin-view", AdminView);
<p>User</p>
<button id="logout">Logout</button>
<pre id="vehicle"></pre>
<pre id="profile"></pre>
<pre id="roles"></pre>
import { keycloakService, vehicleService, storeService } from "../../services";
import { element, html } from "../../utils";
import template from "./user-template.html?raw";
export class UserView extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: "open" });
}
async connectedCallback() {
this.render();
storeService.model.vehicle = await vehicleService.getById(1);
}
render() {
const user = html(template).content.cloneNode(true);
this.shadowRoot?.append(user);
const logoutBtn = element(this.shadowRoot, "logout");
const vehicle = element(this.shadowRoot, "vehicle");
const profile = element(this.shadowRoot, "profile");
const roles = element(this.shadowRoot, "roles");
logoutBtn.onclick = () => keycloakService.keycloak.logout();
storeService.subscribe((model) => {
profile.innerText = JSON.stringify(model.profile, null, "\t");
roles.innerText = JSON.stringify(model.realmAccess, null, "\t");
vehicle.innerHTML = JSON.stringify(model.vehicle, null, "\t");
});
}
}
customElements.define("app-user-view", UserView);
5. Fazit
Pure Web ist ein durchaus interesanter Weg Web Anwendungen zu entwickeln.
Man lernet die Grundlagen von Web Standards, JavaScript Tricks, usw.
Im Endeffekt läuft es aus, dass man sein eigenes Framework entwickelt, in welchem man aber die volle Kontrolle besitzt.
Besonders Web Components sind etwas umständlich. Eine Abhilfe dafür bietet lit. vite
besitzt dafür auch ein Template vite-lit-ts