Fragen zu HTML5 und Co beantwortet 27 - native Tabs, Conditional Types, HTML-Imports, Top-Level Async

Veröffentlicht am 4. August 2020

Auch in Zeiten der Seuche herrscht an Webentwicklungs-Fragen kein Mangel und aufgrund von fortgesetzter coronabedingter Arbeitslosigkeit habe ich beschlossen, mein beachtliches Backlog in Angriff zu nehmen! Wenn auch ihr Fragen zu Frontend-Themen aller Art habt, stellt sie mir per E-Mail oder Twitter und ich verspreche zeitnahe Antworten!

Warum gibt es kein HTML-Element für Tab-Widgets?

Warum gibt es eigene HTML-Elemente für Fortschrittsbalken und das Details-Element für Klapp-Dialoge, aber kein eingebautes Tab-Widget-Element?

Es gibt einen fundamentalen Unterschied zwischen einem Tab-Widget und dem <details>-Element und dieser Unterschied ist die vermutlich beste Erklärung dafür, warum ersteres nicht im HTML-Standard ist (und meiner Prognose nach dort auch nie landen wird): ein Tab-Widget ist ein sehr konkretes und komplexes UI-Konzept, während <details> extrem abstrakt bzw. allgemein spezifiziert ist. Um die Spezifikationen zu zitieren:

The details element represents a disclosure widget from which the user can obtain additional information or controls.

Das <details>-Element ist mitnichten ein „Element für Klapp-Dialoge“, sondern definiert einen Container für ein- und ausblendbare Informationen und damit ein extrem allgemeines Konzept. Eine konkrete Umsetzung der damit verbundenen User Experience ist nicht vorgeschrieben und es ist nur mehr oder minder zufällig so, dass alle bekannten Browser das Element als Klapp-Dialog umsetzen. Die Definition von <details> lässt aber auch viele andere Umsetzungen zu, was für Screenreader, Terminal-Anwendungen und vor allem für zukünftige UI-Konzepte, die es 2020 noch gar nicht gibt, sehr wichtig ist. Die Webplattform konnte nur über 30 Jahre relevant bleiben, indem von der <h1> bis hin zu <details> alles so flexibel und zukunftssicher (man könnte auch sagen: unkonkret) gehalten wurde, dass alle neuen Entwicklungen wie z.B. Smartphones mitgegangen werden konnten. Ein weiteres Beispiel für solch allgemeine Spezifizierungen sind die HTML5-Formularelemente. So ist z.B. für das <input type="date"> nicht vorgeschrieben, wie ein dazugehöriger Datumspicker aussehen soll. Die Browser können selbst entscheiden und daher für Desktop wie Mobiltelefon jeweils passende Interfaces auswählen.

Ein hypothetisches Tab-HTML-Element ist im Prinzip auch nur ein weiteres „disclosure widget“ wie <details>-Element, aber konzentriert sich mit dem Fokus auf ein Tab-Interface schon sehr auf eine konkrete User Experience. Diese so zu spezifizieren, dass sie zum einen so allgemein und anpassungsfähig bleibt, wie es sich bei HTML gehört und dabei trotzdem (auf den heutigen Geräten) so etwas wie ein Tab-Widget festzuschreiben, dürfte der Quadratur des Kreises gleichkommen. Entweder es ist ein flexibles „disclosure widget“ oder es ist ein konkretes Tab-Element – beides auf einmal ist, wenn überhaupt, nur sehr schwer unter einen Hut zu bekommen.

Ein etwas zynischeres Argument gegen das HTML-Tab-Widget habe ich aber auch noch: egal wie es am Ende spezifiziert wird, es wird für 95% der Use Cases nicht reichen. Das <input type="date"> zeigt sehr schön, was passiert, wenn HTML versucht, komplexe UI-Elemente zu definieren: aus den genannten Gründen muss die Spezifikation sehr offen bleiben, was nicht nur in verschiedene Browsern zu unterschiedlichen UIs führt, sondern auch diese UIs dazu verdammt, je nach Projekt entweder zu komplex oder zu simpel zu sein. Eine Reisebuchungs-Webseite kann Datepicker gebrauchen, die zwei Monate auf einmal anzeigen können, eine Behörden-Webapp, der man sein Geburtsdatum mitteilen möchte, braucht das nicht. Beide benötigen aber sehr wohl sofortigen Cross-Browser-Support, weswegen der Griff zu einem JS-Datepicker nahe liegt. Ähnlich wird es sich beim Tab-Widget verhalten: die fette Enterprise-App braucht eine scrollbare Tab-Leiste, die zu 99% aus Whitespace bestehende Startup-Landingpage sicher nicht. All diese Details zu spezifizieren wäre zum einen eine nie dagewesene (und kaum zu bewältigende) Mammutaufgabe und würde zum anderen den Zwang zur Flexibilität unterlaufen.

Tab-Widgets sind allgegenwärtig, aber das bedeutet nicht, dass sie einfach unter einer vereinheitlichten Definition zu fassen sind. Ich glaube, dass es nicht möglich ist, ein allgemeines natives HTML-Tab-Widget vernünftig zu spezifizieren. Und sollte das möglich sein, ist immer noch fraglich, ob dieses native Widget am Ende auch genutzt wird, wenn als Alternative die in das JS-Framework der Wahl integrierte, konfigurierbare, in allen Browsern funktionierende NPM-Modul winkt.

Reinhard fragt: Conditional TypeScript-Types für Methoden-Signaturen verwenden?

Ich habe eine Frage zu Conditional Types in TypeScript. Folgendes Szenario:

class Node {…}

class Factory {
  sendMessage(type: "create" | "remove") {}
}

Die Factory soll bei sendMessage() mit "create" eine Node zurückliefern und bei "remove" eine number. D.h. ich will folgendes schreiben können:

const node = factory.sendMessage("create");
// hier soll node jetzt direkt vom typ "Node" sein

Geht das überhaupt? Ich vermute, hier wären beim Rückgabetyp von sendMessage() Conditional Types hilfreich …

Dein Ziel kannst du mit Overloads besser als mit Conditional Types erreichen. Immer wenn in der Signatur einer Funktion (oder Methode) unterschiedliche Parameter-Typen unterschiedliche Rückgabetypen produzieren sollen, ist ein Overload das Mittel der Wahl. Anders formuliert: wenn die Beziehung zwischen Input- und Output-Typen in eine Tabelle passt, sind Overloads optimal. In diesem Fall ist die Tabelle:

Parameter-Typ Return-Typ
"create" Node
"remove" number
"create" | "remove" Node | number

Der letzte Fall in der Tabelle umschreibt die eigentliche Implementierung der Funktion, während die beiden ersten Fälle die jeweiligen Spezialisierungen festlegen. In Code formuliert sieht die Tabelle wie folgt aus:

class Factory {
  sendMessage(type: "create"): Node;
  sendMessage(type: "remove"): number;
  sendMessage(type: "create" | "remove"): Node | number {
    let x: any;
    return x;
  }
}

Gegenüber Conditional Types hat Überladen einen großen Vorteil: Es ist sowohl für die TypeScript-Typinferenz als auch für Menschen leichter verständlich. Für TS ist die 1:1-Beziehung zwischen Input- und Output-Typen von Vorteil und die meisten TS-Autoren dürften mit Overloads eher vertraut sein als mit den vergleichsweise esoterischen Coditional Types.

Robert fragt: Was ist aus HTML-Imports geworden?

Was ist denn aus HTML-Imports geworden? Damit könnte man ziemlich viele Probleme erschlagen, für die man sonst zu JavaScript und Bundlern greifen muss.

Von Anfang an hat Mozilla HTML-Imports eine Absage erteilt und seither sind HTML-Imports weitgehend in der Versenkung verschwunden. ECMAScript-Module können (u.U. über Umwege) auch HTML importieren und da ES-Module definitiv existieren und HTML-Imports eigentlich nur ein anderes UI für die gleiche Funktionalität (Datei-Request durchführen und anschließend verarbeiten) darstellen, ist es durchaus vertretbar, ES-Module den Vorzug zu geben.

Außerdem unterstelle ich, dass HTML-Imports und vergleichbare Tools viel weniger nützlich sind, als manche annehmen. Vor langer Zeit habe ich zum clientseitigen Zusammenstückeln von Präsentationen eine eigene Variante von HTML-Imports gebaut, die ich zu diesem Zweck bis heute verwende. Aber auch nur zu diesem Zweck. Kein einziges anderes Projekt verlangte je nach HTML-Imports oder ähnlichem, und wenn doch, war ich stets innerhalb von JavaScript unterwegs und konnte mir mit einem Modul-Import behelfen.

Markus fragt: Top-Level-Await außerhalb von Async Functions?

Hattest du auf den JavaScript-Days nicht gesagt, dass fetch() async ist und mit await verwendet werden kann? Bei mir schlägt await fetch("/playlists") fehl und die Fehlermeldung ist: await is only valid in async functions. Heißt das, ich muss fetch() selber asynchron wrappen?

Genau richtig: await funktioniert heutzutage eigentlich nur innerhalb von asynchronen Funktionen. Und der Grund dafür ist eigentlich recht interessant. Async/Await ist eigentlich nur syntaktischer Zucker für Generator Functions bzw. das yield-Statement, das seinerseits prinzipbedingt nur in Generator Functions vorkommen kann. Dieses Video erklärt, wie mit Generators und einer kleinen Runtime die gleiche Funktionalität wie Async/Await umgesetzt werden kann und das reale Async/Await funktioniert ziemlich genau so.

Du hast also zwei Möglichkeiten das Problem anzugehen:

  1. das gute alte .then() statt await benutzen, zumindest im Top-Level außerhalb von anderen asynchronen Funktionen
  2. den ganzen Code in einen Async-Wrapper verpacken, z.B. (async () => { /* dein Code */ })()

Top-Level Await ist als offizielles JS-Feature in Arbeit, aber funktioniert weder im Browser (außerhalb der Chrome-Devtools-Konsole) noch via Babel. Es gibt experimentellen Support in Webpack und Rollup, aber von flächendeckender Einsatzbereitschaft sind wir noch etwas entfernt. Das macht aber nichts: Das Top-Level-Async-Problem besteht fast ausschließlich in Index-Modulen und CLIs-Scripts und deren Menge (und daher die Anzahl der einzubauenden Workarounds) ist in jedem Projekt endlich.

Weitere Fragen?

Habt ihr auch dringende Fragen zu Frontend-Technologien? Nur her damit! Alle Fragen in diesem Post wurden mir per E-Mail oder Twitter gestellt und ihr könnt das genau so machen! Einfach über einen der genannten Kanäle anschreiben oder für die Zeit nach Corona schon mal das komplette Erklärbären-Paket reservieren.

10 Jahre HTML5-Buch: Zeit für ein HTML5-Fazit

Veröffentlicht am 27. Mai 2020

HTML5. Webseiten innovativ und zukunftssicher

Durch groben Leichtsinn kam ich vor etwas mehr als 10 Jahren zu der Aufgabe, das erste deutschsprachige Buch zu HTML5 zu schreiben, dessen erste Auflage vor genau 10 Jahren erschien. Ein paar Exemplare der ersten Auflage lagern sogar noch im Schrank meiner Eltern, aber dort verbietet sich aufgrund von Corona zurzeit jedweder Besuch. Doch auch die mir vorliegende zweite Auflage von 2011 kann als Leitfaden durch HTML5 und vor allem die Erwartungen an HTML5 von damals mit denen von heute zu vergleichen. Hat HTML5 wie geplant die Welt erobert? Haben sich die im Buch beschriebenen Technologien durchgesetzt? Schauen wir uns doch mal an, wie sich die im Inhaltsverzeichnis geäußerten oder zumindest angedeuteten Versprechen mit Realität decken.

Das HTML von HTML5

Vernünftiges HTML hat sich derart durchgesetzt, dass kaum jemand mehr darüber spricht. Der eigentlich größte Verdienst von HTML5 war die Formalisierung des zuvor uneinheitlichen SGML-Dialekts, der sich durch Browser-Konkurrenzkampf gebildet hatte. Heutzutage sind sich (die wenigen) verbliebenen Browser-Engines über die Verarbeitung von HTML-Syntax vollkommen einig und es gibt zumindest hier keine mir bekannten Inkompatibilitäten mehr. Das ist eine große Leistung, für die sich aber niemand interessiert – HTML ist einfach Infrastruktur. Spannend ist aber, wie sehr Web Components mit HTML5 zu kämpfen haben. Custom Elements müssen so gestrickt sein, dass sie das Verhalten vorhandener HTML-Elemente und des HTML-Parsers erklären können, doch beides sind Schlangengruben voller Inkosistenzen. Diese haben sich ergeben, weil im Zuge der HTML5-Formalisierung zwischen allen inkompatiblen Browsern der kleinste gemeinsame Nenner gesucht wurde und dabei die Bedürfnisse von nutzerdefinierten Elementen keine Rolle spielte. Ob aus Web Components angesichts dieser und anderer Herausforderungen nochmal was wird?

Semantisches HTML5

Die semantischen Elemente scheinen sich im Alltags-HTML der meisten Frontendler festgesetzt zu haben und versehen die immer noch allgegenwärtigen Div-Suppen mit einer gewissen Würze. Insbesondere vor dem Hintergrund der eingebauten ARIA-Features ist das ein größerer Pluspunkt. Der Outline-Algorithmus hingegen (Gegenstand der letzten Folge von Working Draft) scheint genau so moribund wie HTML5-Microdata und alle andere Formen von Semantic-Web-Lüftschlössern. In einer Welt der Tech-Monopole finden semantische Informationen nur noch in Form des Frondienstes für den Monopol-Lord (vulgo SEO) eine ökologische Nische, sind aber weit von dem entfernt, was die diversen Vordenker (und HTML5-Buch-Autoren, die darüber viel zu viele Seiten geschrieben haben) des Semantic Web sich einst ausgemalt haben.

HTML5-Formulare

Formulare sind und bleiben ein kompliziertes Thema. An manchen Stellen finden HTML5-Neuerungen wie Date-Picker und Formular-Validierung Anwendung, doch die handgeschriebene Re-Implementierung solcher Standards ist noch sehr verbreitet. Das mag daran liegen, dass Formulare ein inhärent so ausufernd-facettenreiches Thema ist, dass kein Standard der Welt jeden Use Case bedienen kann. Und obwohl zumindest die Validierungs-API erweiterbar ist, ist sie das wohl nicht in ausreichendem Umfang. Ich würde von einem ganz klaren Teilerfolg sprechen, aus dem man die Lehre ziehen kann, dass das Extensible Web Manifesto schon den richtigeren Weg ausleuchtet, als ein Default-Datumspicker. Letzteres ist wohl eine Zu-High-Level-API, die sich nie als der Standard durchsetzen wird, denn was die Webentwickler wirklich brauchen, ist das Inputmode-Attribut, um damit eigene Formularelemente zu bauen.

Offline-Webapps

Während uns Local Storage bis zum heutigen Tag begleitet (und dank Cookie-Bannern und GDPR prominenter ist als je zuvor), müssen wir den Application Cache von HTML5 ist als kompletten Griff ins Klo betrachten. Zum Glück ist er bereits durch Progressive Web Apps zu 100% abgelöst und stellt kein Problem mehr dar. Die API des Application Cache ist/war extrem verwirrend und bei meinen HTML5-Workshops damals war nur die Drag&Drop-API von HTML5 noch schwerer zu vermitteln. Und da wir gerade beim Thema sind …

Drag & Drop

Dateien per Drag & Drop hochzuladen ist auf dem Desktop mittlerweile auch bei Web Apps gängige Praxis, was 2010 noch lange nicht der Fall war. Dieser Teil der API ist auch nicht besonders schlimm missraten, der für Drag-Operationen zwischen Webseiten sowie Webseiten und anderen Programmen umso mehr. Allerdings scheint das auch keinen Entwickler zu stören und Nutzer scheinen dieses Feature nicht zu vermissen. Ich denke, dass wir uns an dieser Stelle bei der Smartphone-Revolution bedanken dürfen. In erster Näherung sitzt kein Mensch mehr an Desktop-Computern und hantiert mit Mäusen herum, und die wenigen solchen Power-User sind offenbar damit zufrieden, Drag & Drop für ihren E-Mail-Anhang zu verwenden. Passt schon.

Video und Audio

Großes Trara um Flash-Killer und Video-Codecs, und hinterher hostet sowieso jeder seinen Content beim Plattform-Monopolisten du jour. Was kümmern die Details der Video-Player-Implementierung, wenn die tatsächliche Einbettung (sofern man sowas überhaupt noch macht) per Youtube-Iframe erfolgt? Das Web von heute ist wahrlich etwas ganz anderes als vor 10 Jahren und niemand hostet heutzutage mehr seine eigenen Videos. Podcasts werden noch individuell gehostet und per Audio-Element in Webseiten eingebunden, aber bis sich das auch erledigt hat, wird es nur eine Frage der Zeit sein.

Web Workers

Auch wenn einige wenige mit zunehmender Lautstärke für die Vorzüge von Web Workers agitieren, so haben sie sich nicht wirklich im Webentwicklungs-Alltag festgefressen. Ich schätze das liegt daran, dass der Tradeoff zwischen Einsatz und Ertrag nicht besonders attraktiv ist. Frontend-Entwickler müssen langsame, lang laufende Funktionen mühsam vom DOM und anderen Browser-APIs entkoppeln – und das setzt voraus, dass Frontend-Entwickler überhaupt mit solchen Funktionen befasst sind und nicht etwa ein wie auch immer geartetes Backend. Häufig scheint es das nicht zu geben.

Fazit und Ausblick

Was kann man über HTML5 10 Jahre später sagen und welche Lehren lassen sich ziehen? Ich würde es mal so zusammenfassen:

  • HTML ist wie geplant eine vernünftig definierte und implementierte Auszeichnungssprache geworden. Auch ohne die Konsolidierung am Browser-Markt hätten/haben wir heute eine Situation, in der jeder Browser jedes HTML exakt gleich verarbeitet.
  • Diverse Features wie Formulare und Offline-Support sind (zumindest in Teilen) an ihrer High-Level-Fehlkonzeption gescheitert. Low-Level-APIs sind das, was Webentwickler wollen, denn nur so können sie ihren Job machen und damit auch die Web-Plattform als ganzes relevant halten.
  • Semantik und Drag & Drop sind Opfer des Zeitgeists, der sich nicht mehr für Open Web interessiert und viel mehr sein Smartphone als seinen großen Desktop-Rechner nutzt.

Als die dem Ganzen zugrundeliegenden Trends würde ich zum einen Marktkonsolidierung (bei Browsern wie Plattformen) und auf der anderen Seite des Webentwicklers berechtigte Vorliebe für Low-Level-APIs ausmachen. Wenn wir mal annehmen, das sich daran nichts weiter ändert, würde ich mich auf folgende Vorhersagen für 2030 einlassen:

  • Web Assemby wird die Web-Welt auf dem Kopf stellen. Es ist schließlich die ultimative Low-Level-API, die das Web zu einer komplett technologieunabhängigen Content-Delivery-Plattform macht. Es fehlt vermutlich noch der eine oder andere Katalysator (eine CRUD-Webapp mit Rust zu schreiben erscheint mir nicht sonderlich sinnvoll), aber der wird sich schon noch einfinden. JavaScript wird noch als allgemeine Scriptsprache eingesetzt werden (ein bisschen wie Perl) und TypeScript wird einen Status wie CoffeeScript genießen.
  • Progressive Web Apps werden es schwer haben. Sie sind weniger Zu-High-Level als der Application Cache, aber sehr auf die Mobile-Plattform der Gegenwart fokussiert. Sollte sich die Plattform von ihrem heutigen Ist-Zustand wegbewegen, wird es für Webstandards schwer, sich dem anzupassen – das cross-kompilierte Web-Assemby-Produkt könnte einen Wettbewerbsvorteil haben. Außerdem mag es daran liegen, das ich seit Beginn der Corona-Pandemie keinen ICE mehr von innen erleben musste, aber könnte es nicht vielleicht doch möglich sein, das Land in endlicher Zeit so weit mit Mobilfunkmasten zuzupflastern, dass kaum jemand mehr Offline-Webapps braucht? Wäre doch ein denkbares Post-Corona-Konjunkturprogramm, nur nicht für PWA-Entwickler.
  • Unabhängiges Podcasting und damit das letzte Refugium von RSS-Feeds und Audio-Elementen ist dem Untergang durch Monopolisierung geweiht und wird in 10 Jahren keine Rolle mehr spielen. Bisher hat es noch kein Spotify o.Ä. geschafft, das Youtube für gesprochenes Audio zu werden, aber Risikokapitalgeber haben tiefe Taschen und scheinbar nichts Besseres vor.

Das mutet alles etwas apokalyptisch an, aber andererseits sind Vorhersagen nicht meine Stärke. Immerhin habe ich mal ein ganzes Buch über revolutionäre neue Web-Zukunftstechnologien geschrieben und es für sinnvoll erachtet, Seite um Seite über Microdata und Video-Codecs zu referieren. Vielleicht liege ich ja wieder komplett falsch! Wir lesen uns (spätestens) in 10 Jahren wieder und vergleichen.

Welche HTML-Elemente sind immer unsichtbar?

Veröffentlicht am 28. April 2020

Bei Warhol testen wir die Styles von Elementen direkt im Browser und Styles auslesen ist ein Performance-Albtraum sondergleichen. Ein Aufruf von getComputedStyle() löst Style-Neuberechnungen, möglicherweise gar Reflows aus und nicht bei jedem Element kommen wir mit einem einzigen getComputedStyle() aus – Pseudo-Elementen, :hover-States und vielen anderen Besonderheiten sei Dank. Warhol ist zwar dank allerlei Tricks und Optimierungen bei seinen Tests extrem flott unterwegs, aber noch schneller möchten wir natürlich trotzdem sein. Daher trieb mich zuletzt die Frage um, welche HTML-Elemente eigentlich immer und unter allen nur denkbaren Umständen unsichtbar bleiben. Könnten wir einem Element an seinem Tag und/oder seinen Attributen ansehen, dass es nichts rendern kann (und auch nicht durch z.B. Margins andere optische Auswirkungen haben kann), bräuchten wir seine Styles nicht abzufragen und hätten eine Menge Style-auslese-Operationen eingespart, womit ein großer Performance-Gewinn realisiert wäre – und je häufiger das Element, umso größer der Gewinn!

Sehr viele normalerweise unsichtbare Elemente sind lediglich per Browser-Standard-Styles auf display:none gesetzt. Elemente wie <style>, <script> sowie alle Elemente mit hidden-Attribut können ohne weiteres mittels display:block sichtbar gemacht werden, bei einem sichtbaren <title> ist zu beachten, dass auch der umgebende <head> sichtbar sein muss. Hier ist also kein Performance-Blumentopf zu gewinnen, denn wir können nicht einfach davon ausgehen, dass diese Elemente keine optischen Auswirkungen haben. Unwissenschaftlichen Twitter-Umfragen zufolge verwendet fast niemand sichtbaren <head>- oder <script>-Inhalt, aber fast niemand ist nun mal nicht niemand.

Elemente mit Inhalt, und seien es Scripts und Styles können also sichtbar werden. Wie ist es aber mit Elementen ohne Inhalt, wie z.B. <meta> oder <col>? Leider nicht wirklich anders! Zwar gibt es in diesen Elementen keinen Inhalt, der sichtbar gemacht werden könnte, aber mit einer Border werden auch leere Elemente sichtbar. Unter diese Definition von leer fällt auch das <template>-Element, dessen „Inhalt“ keine Auswirkungen auf das Rendering hat, für das sich aber neben Borders und Margins auch Pseudo-Elemente angeben lassen. Unsichtbar sieht definitiv anders aus.

Die nächste naheliegenden Kandidaten sind <br> und <wbr>. Die beiden Zeilenumbruch-Elemente widersetzen sich mit etwas mehr Vehemenz der Sichtbarmachung, haben aber eine Schwachstelle: die CSS-Eigenschaft content. Diese kennen wir vor allem aus der Arbeit mit den Pseudo-Elementen ::before und ::after, doch content kann noch mehr. Mit Text-Inhalt auf ein <br> angewendet verhilft die content-Eigenschaft in Chrome zumindest Margins zur Wirkung, in Firefox klappt das gleiche auch bei <wbr>. Damit haben die Elemente schon mal wahrnehmbare Auswirkungen, wenn sie selbst nicht sichtbar sind. Aber auch ihre Unsichtbarkeit lässt sich abschalten.

Text-Werte sind bei der Content-Eigenschaft nur für die Pseudo-Elemente ::before und ::after vorgesehen. Neben Texten akzeptiert die Eigenschaft aber auch url()-Werte und diese haben auch bei Nicht-Pseudo-Elementen einen Effekt: sie ersetzen den vorhandenen Inhalt durch etwas anderes und machen das Element quasi nachträglich zu einem Replaced Element. Replaced Elements sind Elemente, deren Inhalt nicht den Regeln von CSS unterliegt, da statt des Inhalts etwas anderes gerendert wird. In diese Kategorie fallen z.B. <video> und <img>, deren ganzer Zweck darin besteht, Platzhalter für Content aus externen Quellen (Videos und Bilder) zu spielen. So gesehen ist ein <span>-Element mit dem Text-Inhalt Foo, der per content: url(x.png) ersetzt wird, fast das gleiche wie ein <img alt="Foo" src="x.png">. Es versteht sich hoffentlich von selbst, dass solche Hacks unter keinen Umständen etwas in echten Web-Projekten zu suchen haben, denn sie widersprechen neben den Regeln von HTML-Semantik und Barrierefreiheit auch dem gesunden Entwicklerverstand. Ein Tool wie Warhol kann solche HTML/CSS-Fouls nicht ignorieren, aber jeder vernünftige Frontendler sollte das tun.

Unvernünftige Frontendler können zumindest in Chrome den (eigentlich nicht vorhandenen) Inhalt von zumindest <br>-Elementen per content ersetzen. Selbst Textumbrüche sind also bei entsprechendem CSS-Einsatz, der sie zu Replaced Elements macht, auch sichtbare Elemente.

Gibt es dann gar nichts, das wirklich per Definition unsichtbar ist? Doch, und zwar die folgenden Elemente:

  1. <audio>-Elemente ohne controls-Attribut §
  2. <input type="hidden"> §
  3. <noscript> wenn Scripting aktiviert ist §
  4. <form>-Elemente, die direkte Kinder von <table>, <thead>, <tbody>, <tfoot> oder <tr> sind (ohnehin kein gültiges HTML, aber technisch möglich) §
  5. Inhalte von Replaced Elements, d.h. <source> und <track> in <video> bzw. <audio>, Fallback-Inhalt in unterstützen <video>-, <audio>- und <canvas>-Elementen

Die ersten drei Punkte auf dieser Liste enthalten die einzigen Elemente, deren Browser-Standard-Styles die Deklaration display: none !important; enthalten (bzw. bei denen die Spezifikations-Prosa darauf schließen lässt) und nach den Regeln der Kaskade können diese Deklarationen durch nichts getoppt werden. Der letzte Punkt ist einerseits logisch (ersetzte Replaced Elements können nicht sichtbar sein), muss aber andererseits auch mit etwas Vorsicht genossen werden. Der Inhalt der genannten Elemente ist nur dann unsichtbar, wenn sie korrekt unterstützt werden, ansonsten dient der Inhalt als Fallback-Content für fossile Browser.

Außerdem erwähnenswert: das <picture>-Element ist kein Replaced Element, sondern dient nur als Steuerungs-Wrapper für das enthaltene <img>-Element. Entsprechend sind die <source>-Elemente in einem <picture> ohne größeren Aufwand sichtbar zu machen.

Wir halten fest: es gibt nur sehr wenige Elemente, bei denen Unsichtbarkeit sicher gegeben ist. Zu Beginn der Recherche hatte ich gehofft, Warhol könnte sich die Betrachtung von z.B. <br> grundsätzlich sparen, aber davon dürfen wir uns wohl verabschieden. Von den ewig unsichtbaren sind nur das <audio>-Element ohne controls und das <input type="hidden"> nicht an komplizierte Bedingungen geknüpft und von diesen beiden ist nur das letzte wirklich halbwegs häufig. Solange wir nur moderne Browser unterstützen mag es auch noch sinnvoll sein, den Inhalt von <audio>, <video>, <canvas> und <picture> zu ignorieren. Den großen Performance-Gewinn dürfte all das nicht einbringen, aber dann müssen wir uns den Gewinn eben anderswo holen.

JavaScript-Generators durch die Array-Linse betrachtet

Veröffentlicht am 30. März 2020

JavaScript-Arrays in wenigen Worten abschließend zu beschreiben ist fast unmöglich, denn sie sind so vielseitig! Je nach Einsatzszenario fungieren sie als Stack (Methoden push() und pop()), als Tuple (mit fester Länge und gemischtem Inhalt) oder als das, was in anderen Programmiersprachen als „Array“ oder „List“ bezeichnet wird. Außerdem implementieren sie das Iterationsprotokoll, sind also kompatibel zu for-of-Schleifen und vielen anderen nativen Sprachkonstrukten. Wenn wir nicht zu genau hinschauen und keine unangenehmen Fragen stellen, können wir uns vielleicht mit der Umschreibung „iterierbare Liste von Werten“ zufriedengeben.

Genau das Gleiche lässt sich über JavaScript-Generators sagen. Sie sind vergleichsweise neu, vergleichsweise selten und, genau wie Arrays, extrem vielseitig. Wenn wir nicht zu genau hinschauen und keine unangenehmen Fragen stellen, können wir uns auch für Generators mit der Umschreibung „iterierbare Liste von Werten“ zufriedengeben. Sobald wir uns auf diese Sichtweise einlassen, werden Generators zu einem schönen Werkzeug für den Alltag!

Generators sind Objekte, die Generator Functions entspringen, fast normalen function-Functions mit einem Sternchen dahinter. In Generator Functions ist das yield-Keyword verfügbar. Dieses fungiert ein wenig wie ein return-Statement und gibt den Wert auf seiner rechten Seite heraus. Damit lässt sich eine Sequenz bzw. eine Liste formulieren, die in der Benutzung einem Array nicht unähnlich ist:

// Generator Function
function * genFn () {
  yield 0;
  yield 1;
  yield 2;
}

// Generator
const numberGenerator = genFn();

// Generator wie ein Array verwenden
for (const number of numberGenerator) {
  console.log(number); // 0, 1, 2
}

Ein Generator Function ist im Prinzip ein Template für eine Sequenz bzw. Liste. Die Schritte der Sequenz sind die Ausdrücke auf der rechten Seite der yield-Statements, und die Liste ergibt sich, wenn die Schleife die Sequenz durchgeht und der Reihe nach 0, 1 und 2 generiert werden.

Das sieht auf den ersten Blick etwas exotisch aus, aber vergleichen wir das Ganze doch mal mit dem folgenden Code:

// Fast eine Generator Function
function genFn () {
  const values = [];
  values.push(0);
  values.push(1);
  values.push(2);
  return values;
}

// Quasi ein Generator
const numbers = genFn();

// Array wie einen Generator verwenden
for (const number of numbers) {
  console.log(number); // 0, 1, 2
}

Unter der JavaScript-Haube geht hier zwar etwas ganz anderes ab, als mit Generators, aber nicht vergessen: heute schauen wir nicht allzu genau hin und stellen keine unangenehmen Fragen. Wir erreichen mit beiden Codeschnipseln das gleiche Ziel einer Liste von Zahlen, wobei der Ansatz mit Generators einige Vorteile (oder zumindest interessante Tradeoffs) bietet.

Ein erster Vorteil ist, dass ein Generator immer eine Liste von Werten abbildet, zumindest bei unserer vereinfachten Betrachtungsweise. Das bedeutet, dass eine leere Liste besonders einfach zu bauen ist: wir machen einfach gar kein yield:

// Nur Werte liefern, wenn "someCondition" true ist

function createArray () {
  const values = [];
  if (someCondition) {
    values.push(0);
    values.push(1);
    values.push(2);
  }
  return values;
}

function * createGenerator () {
  if (someCondition) {
    yield 0;
    yield 1;
    yield 2;
  }
}

Die Vereinfachung ist offensichtlich und selbsterklärend: wenn ein Generator implizit immer eine Liste liefert, brauchen wir, anders als bei der Array-Funktion, keine zu erstellen und zurückzugeben. Ein weiterer Bonus liegt darin, dass es, ausgehend von der Beschreibung des Problems im Kommentar, nur einen Weg gibt, createGenerator() zu schreiben: wenn Bedingung, dann yield Werte. Über die Formulierung von createArray() könnte man hingegen streiten. Vielleicht wäre ein Early Return besser? Oder undefined statt eines leeren Arrays? Bei Generators stellt sich diese Frage praktischerweise gar nicht.

Sollte ein Early Return gewünscht sein, geht der Vorteil des Generators dahin:

// Wenn "someCondition" true ist, Early Return durchführen

function createArray () {
  if (someCondition) {
    return [ 42 ]; // Ausgabe + Ende der Funktion
  }
  const values = [];
  values.push(0);
  values.push(1);
  values.push(2);
  return values;
}

function * createGenerator () {
  if (someCondition) {
    yield 42; // Ausgabe
    return;   // Ende der Funktion
  }
  yield 0;
  yield 1;
  yield 2;
}

Einerseits ist es bei createArray() nötig, die zurückgegebene 42 händisch in ein Array zu verpacken, was beim Generator entfällt. Dieser benötigt dafür ein yield und ein return-Statement; return 42 ist zwar im Prinzip erlaubt, liefert aber die 42 nicht in der erzeugten Liste, sondern irgendwo im Nirvana ab.

Wiederum für Generators spricht, dass es neben dem normalen yield noch eine Art Super-Yield gibt, das den Inhalt anderer Generators herausgibt:

function * createStrings () {
  yield "A";
  yield "B";
  yield "C";
}

function * createValues () {
  yield 0;
  yield 1;
  yield 2;
  yield* createStrings();
}

for (const value of createValues()) {
  console.log(value)
}
// > 0, 1, 2, "A", "B", "C"

Das yield* in der Zeile yield* createStrings() gibt anders als das normale yield nicht einfach den Wert auf seiner rechten Seite (den Generator) aus, sondern das, was der Generator erzeugt! Und das klappt auch mit anderen iterierbaren Objekten, nicht nur mit Generatoren:

function * createValues () {
  yield 0;
  yield 1;
  yield 2;
  yield* [ "A", "B", "C" ]; // klappt auch mit Arrays!
}

for (const value of createValues()) {
  console.log(value)
}
// > 0, 1, 2, "A", "B", "C"

yield* fungiert also eine Art implizites flatMap() für alles mögliche an listen-artigen Datenstrukturen, nicht nur für Generators. Das ist ein ziemlich mächtiges Feature! Die Bedingung hierfür ist allerdings, dass yield* auch verwendet werden kann, was ausschließlich direkt in Generator Functions möglich ist. Callbacks sind also ein syntaktisches Problem:

function * createValues () {
  yield 0;
  setTimeout( () => {
    yield 1; // > SyntaxError
  }, 1000);
  yield 2;
}

Mit Arrays würde der Code zwar nicht mit einem Error abbrechen, sondern „funktionieren“ …

function createValues () {
  const values = [];
  values.push(0);
  setTimeout( () => {
    values.push(1);
  }, 1000);
  values.push(2);
  return values;
}

…aber trotzdem ein großes Problem darstellen! Immerhin wird hier ein Array aus der Funktion ausgegeben, in das nach seiner Ausgabe noch zusätzliche Werte hinterlegt werden – ein sicheres Rezept für Bugs und Verwirrung. An sich ist aber das Konzept einer Liste, in der sich erst nach und nach Werte einfinden, durchaus sinnvoll und wird z.B. durch die Observables in RxJS umgesetzt. Mit Arrays lässt sich dieses Konzept, wie wir gesehen haben, nicht wirklich abbilden und ein Timeout-Callback in einem Generator ist, wie wir ebenfalls gesehen haben, syntaktisch nicht mit yield kombinierbar. Das bedeutet aber nicht, dass Generators und setTimeout() nicht wunderbar zusammenpassen würden – der Timeout darf sich nur nicht im Generator befinden, sondern muss nach außen verfrachtet werden.

Wir können Generators unter gewissen Bedingungen mit Arrays vergleichen, aber es gibt einen wichtigen Unterschied: Generators sind lazy und liefern nur dann Werte, wenn Werte angefordert werden. Dies geschieht mit der Generator-Methode next():

function * createGenerator () {
  yield 0; // landet in a
  yield 1; // landet in b
  yield 2; // wird nie angefordert
}

const generator = createGenerator();
const a = generator.next(); // { value: 0, done: false }
const b = generator.next(); // { value: 1, done: false }

Jeder Aufruf von next() liefert ein Objekt, in dem das Feld value den Wert rechts des letzten yield-Statements enthält und in dem done nur true ist, wenn der Generator abgearbeitet wurde bzw. ein return-Statement erreicht wurde. In obigen Beispiel finden nur zwei Aufrufe von next() statt, was bedeutet, dass das yield 2 in der Generator Function nie erreicht wird und nie eine 2 in die Welt gesetzt wird. Bei einer Funktion, die ein Array liefert, sähe das anders aus:

function createArray () {
  const values = [];
  values.push(0); // landet in a
  values.push(1); // landet in b
  values.push(2); // braucht niemand, ist trotzdem im Array
  return values;
}

const list = createArray();
const a = list[0]; // 0
const b = list[1]; // 1

Auch wenn der dritte Wert nie verwendet wird, so landet er doch im Array, unmittelbar beim Aufruf der Funktion. Es ist erwähnenswert, dass Arrays zwar ganz anders funktionieren als Generators, aber auch eine Generators entsprechende API anbieten:

const gen = function * () {
  yield 23;
  yield 42;
}();

gen.next(); // > { value: 23, done: false }
gen.next(); // > { value: 42, done: false }

const arr = [ 23, 42 ];

const arrIter = arr[Symbol.iterator]();
arrIter.next(); // > { value: 23, done: false }
arrIter.next(); // > { value: 42, done: false }

Im speziellen Feld Symbol.iterator verstecken Arrays eine Funktion, die einen Iterator für das betroffene Array bereitstellt. Dieser Iterator unterstützt ebenso wie ein Generator die next()-Methode, weswegen for-of-Schleifen beide Objekt-Typen gleich unterstützen. Der Unterschied bleibt aber bestehen: während der Generator Werte erst generiert, sind sie beim Array von Anfang an vorhanden und binden entsprechend Ressourcen.

Wir halten fest: sowohl Arrays als auch Generators bilden Listen von Werten ab und beide unterstützen das Iterator-Protokoll mit der next()-Methode. Dank des Iterator-Protokolls können Arrays wie Generators von for-of-Schleifen konsumiert werden, von Array.from() in (neue) Arrays überführt werden oder manuell via next() iteriert werden. Der große Unterschied ist zwischen Arrays und Generator ist, dass letztere lazy sind und daher immer nur den Code bis zum nächsten yield ausführen. Der Unterschied zwischen for-of bzw. Array.from() und der next()-Methode ist, dass letztere (da sie ja nur eine Funktion ist) in Callbacks gesteckt und damit asynchron verarbeitet werden kann. Daraus folgt: setTimeout() und Generators können sehr wohl zusammen verwendet werden, der Timeout muss nur außerhalb der Funktion stattfinden:

function * createGenerator () {
  yield 0;
  yield 1;
  yield 2;
}

const generator = createGenerator();

setTimeout( function consumeValue () {
  const next = generator.next();
  if (!next.done) {
    console.log(next.value);
    setTimeout(consumeValue, 1000);
  }
}, 1000);

Dieser Aufruf schreibt nicht nur mit einer Sekunde Abstand Abstand 0, 1 und 2 in die Konsole, sondern erzeugt die Werte auch jeweils erst nach einer Sekunde. Dadurch haben wir die eigentliche Erzeugung der Werte vom Timing für die Erzeugung der Werte entkoppelt und Interleaving etabliert. Zwischen der Erzeugung der einzelnen Werte erhält die JS-Engine dank setTimeout() eine Atempause, um andere Aufgaben abzuwickeln, Frames zu rendern und sonstige dringende Angelegenheiten zu erledigen. Da wir unsere Werte in einem Generator erzeugen, brauchen wir die Generator Function selbst hierfür nicht zu verbiegen, sondern überlassem die Timing-Frage denen, die den Generator nutzen. Jeder Konsument eines Generators kann selbst über das genehme Timing entscheiden:

function * createGenerator () {
  yield 0;
  yield 1;
  yield 2;
}

// Zeitgesteuert?
const gen1 = createGenerator();
setTimeout( function consumeValue () {
  const next = gen1.next();
  if (!next.done) {
    console.log(next.value);
    setTimeout(consumeValue, 1000);
  }
}, 1000); // 0, 1 und 2 nach je einer Sekunde


// Abhängig von der Framerate?
const gen2 = createGenerator();
requestAnimationFrame( function consumeValue () {
  const next = gen1.next();
  if (!next.done) {
    console.log(next.value);
    requestAnimationFrame(consumeValue);
  }
}); // 0, 1 und 2 nach je einem Frame

// Alles auf einmal, ohne Pausen?
for (const value of createGenerator()) {
  console.log(value);
}

// Oder noch einfacher
const allValues = Array.from(createGenerator());

Es gibt mehrere Gründe dafür, Timing-Fragen von der eigentlichen Logik zu entkoppeln. Zum einen ist es oft wünschenswert, lang laufende und komplexe JS-Funktionen durch Timeout-Atempausen für Browser besser verdaulich zu machen. Wer (aus welchen Gründen auch immer) 1000 Reflows zu triggern gedenkt, ist gut damit beraten, diese nicht auf einmal durchzuführen, sondern zeitgesteuert zu batchen. Somit hat der Browser zwischen den Rechenaufgaben Zeit die Webseite zu rendern und die Framerate bleibt im grünen Bereich. Allerdings ist es bei ohnehin schon komplizierten Funktionen aus Entwicklersicht wünschenswert, wenn diese Funktionen nicht auch noch durch Timeout-Callbacks weiter verkompliziert würden. Generator Functions sind hier die beste Lösung: einfach zunächst die (Generator-) Funktion schreiben, als gäbe es die Timeout-Überlegungen gar nicht und hinterher ein setTimeout() anflanschen – so bleibt sowohl die Erzeugung der Werte als auch das Timing der Erzeugung übersichtlich und beherrschbar, bei gleichzeitig flotter Framerate.

Aber brauchen wir wirklich immer eine flotte Framerate? Für einen Unit Test ist das eher unwichtig, denn hier kommt es nur auf die Ergebnisse an. Zum Glück ist es den Konsumenten von Generators selbst überlassen, wie sie einen Generator zu konsumieren gedenken. Für einen Test könnte das synchron erfolgen, für Production mit framerate-schonenden Timeout-Päuschen:

function * createGenerator () {
  yield 0;
  yield 1;
  yield 2;
}

// Für Production: mit Timeout-Interleaving und Callbacks
consume(createGenerator(), (values) => {
  // values = 0, 1, 2
});


// Für den Test: alles auf einmal bitte!
expect(Array.from(createGenerator())).toBe([ 0, 1, 2 ]);

Ob lang laufende und komplexe JS-Funktionen wirklich Timeout-Atempausen brauchen, kommt oft mehr auf den Kontext des Aufrufs an als auf die Funktion selbst.

Ein drittes schönes Feature von Generators ist der eingebaute Abbruch-Mechanismus. Normale JavaScript-Funktionen laufen nach ihrem Aufruf bis zum Ende durch, falls nicht Exceptions dazwischenfunken. Ein Abbrechen einer laufenden Operation von Außen gibt es eigentlich gar nicht … abgesehen von Generator Functions! Die Operationen in einem Generator finden nur statt, solange Werte angefordert werden. Wird nichts mehr angefordert, passiert nichts mehr; die Funktion ist effektiv gestoppt.

const createGenerator = function * () {
  let i = 0;
  while (true) {
    yield i++;
  }
};

let stop = false;

const gen = createGenerator();
setTimeout( function consumeValue () {
  const next = gen.next();
  if (!stop) {
    console.log(next.value);
    setTimeout(consumeValue, 1000);
  }
}, 1000); // jede Sekunde eine Zahl bis "stop" true ist

stopButton.addEventListener("click", () =>{
  stop = true;
}, { once: true });

Durch das hin- und herschalten zwischen verschiedenen Generators lassen sich auch Konzepte wie Restarts recht bequem umsetzen.

Ich schraube gerade an der Tester-Library von Warhol, die die Aufgabe hat, Designfehler auf Websites zu finden. Diese Library wird in unserer Browser-Extension verwendet, aber auch in unseren automatisierten Services, die mit ferngesteuerten Browsern automatisch ganze Domains nach Fehlern abgrasen. Dank der Vielfalt der Einsatzgebiete findet sich die Library mit zahlreichen konkurrierenden Ansprüchen konfrontiert:

  1. Die Tests müssen funktionieren und weder falsch positive noch falsch negative Ergebnisse sind akzeptabel. Also haben wir hunderte von automatisierten Tests und es werden stetig mehr. Diese müssen so schnell laufen wie möglich, denn Entwickler-Zeit ist kostbar.
  2. Innerhalb der Browser-Extension müssen die Tests vor den Augen eines ungeduldigen Menschen durchgeführt werden. „Dank“ moderner Webentwicklungs-Praktiken wir es hierbei oft mit absurd großen DOM-Bäumen zu tun, die rekursiv nach Fehlern abgegrast werden müssen. Ein Fortschrittsbaken könnte die wahrgenommene Performance stark verbessern und ein Stopp-Button, den die Nutzer nach den ersten 9000 gefundenen Fehlern drücken können, trägt auch zum Erhalt der Laune bei.
  3. Die gleichen Tests in automatisierten Services brauchen keine ungeduldigen Nutzer zu bedenken, sondern müssen einfach nur so schnell sein wie möglich. Jedes bisschen Cloud-Zeit kostet schließlich!
  4. Die Tester-Library ist ein ausgesprochen kompliziertes Stück TypeScript-Hexerei und darf nicht unübersichtlicher werden als absolut nötig.

Ursprünglich war die Tester-Library ungefähr wie folgt aufgebaut:

// Alte Vesion (extrem vereinfachte Darstellung)
const test = (element: Element): TestResult[] => {
  return [
    testElement(element),
    ...element.children.map( (child) => test(child) ),
  ];
};

Der ganze DOM-Tree wurde durchgetestet und am Ende gibt es ein Array mit Ergebnissen. Es gab kein Interleaving, keine Abbruch-Möglichkeit und in der Realität stellte sich das Handling der Ergebnis-Arrays oft weniger einfach dar wie hier gezeigt. Oft gibt es Early Returns, im Voraus bekannte Ergebnisse und viele weitere Komplikationen. Mit Warhols zunehmenden Fähigkeiten und den immer komplexeren Enterprise-Webseiten, auf denen Warhol meist zum Einsatz kommt, wurde die Performance-Frage akut. Und was haben wir getan?

// Neue Version (extrem vereinfachte Darstellung)
const test = (element: Element): Generator<TestResult, void, void> => {
  yield testElement(element);
  for (const child of element.children) {
    yield * test(child);
  }
};

Wir haben eigentlich fast gar nichts getan! Die array-produzierenden Funktionen mussten zu Generator Functions umgemodelt werden (was bei 90% der Funktionen komplett ohne Nachdenken ging) und schon sind alle Anforderungen bedient:

  1. Automatische Tests konsumieren die Generators synchron und sind damit so schnell wie eh und je.
  2. In der Browser-Extension macht Interleaving mit requestAnimationFrame() der Rendern einer Lade-Animation möglich und ein Abbruch-Button war trivial zu implementieren.
  3. In der Cloud verzichten wir auf Interleaving und blockieren einfach mit den Tests das UI komplett, denn es gibt ja keinen Menschen, der sich daran stört.
  4. Der Code für die Tester-Library ist praktisch gleich geblieben, Arrays wurde fast überall 1:1 mit Generators ersetzt.

Generators in JavaScript sind sehr seltsame, sehr vielseitige Objekte. Arrays und normale JS-Objekte sind auch sehr vielseitig, aber so konkret, dass man als Entwickler recht schnell etwas damit anzufangen weiß. Die Vielseitigkeit von Generators ist anderer Natur: sie sind so vielseitig, weil sie unglaublich allgemein sind, aber das macht auch schwer, sie überhaupt als etwas zu erkennen, das eine Lösung für ein konkretes Problem sein kann. In solchen Fällen ist es wichtig, Dinge durch bestimmte Linsen zu betrachten – in meinem Fall mit dem Warhol-Tester war es die Listen-Linse, aber durch andere Linsen sind Generators genau das richtige Mittel um Message Passing oder Async/Await zu implementieren. Für das initiale Verständnis ist es dabei immer gut, nicht zu genau hinzuschauen, keine unangenehmen Fragen zu stellen und sich nicht zu sehr im Abstrakten zu verlieren.