Ein Blick auf moderne Webbrowser (Teil 3)

Mariko Kosaka

Funktionsweise eines Renderer-Prozesses

Dies ist Teil 3 einer vierteiligen Blogreihe zur Funktionsweise von Browsern. Zuvor haben wir die mehrstufige Architektur und den Navigationsfluss behandelt. In diesem Beitrag sehen wir uns an, was im Rendering-Prozess passiert.

Der Rendererprozess wirkt sich auf viele Aspekte der Webleistung aus. Da beim Rendering-Prozess viel passiert, ist dieser Beitrag nur ein allgemeiner Überblick. Wenn Sie mehr darüber erfahren möchten, finden Sie im Abschnitt zur Leistung der Webgrundlagen viele weitere Ressourcen.

Renderer-Prozesse verarbeiten Webinhalte

Der Renderer-Prozess ist für alles verantwortlich, was auf einem Tab passiert. In einem Renderer-Prozess verarbeitet der Haupt-Thread den Großteil des Codes, den Sie an den Nutzer senden. Wenn Sie einen Webworker oder Service Worker verwenden, werden Teile Ihres JavaScript manchmal von Worker-Threads verarbeitet. Compositor- und Raster-Threads werden ebenfalls in einem Renderer-Prozess ausgeführt, um eine Seite effizient und reibungslos zu rendern.

Die Hauptaufgabe des Rendering-Prozesses besteht darin, HTML, CSS und JavaScript in eine Webseite umzuwandeln, mit der Nutzer interagieren können.

Renderer-Prozess
Abbildung 1: Renderer-Prozess mit einem Haupt-, einem Worker-, einem Compositor- und einem Raster-Thread

Parsen

Aufbau eines DOM

Wenn der Renderer-Prozess eine Commit-Nachricht für eine Navigation empfängt und HTML-Daten erhält, beginnt der Haupt-Thread, den Textstring (HTML) zu parsen und in ein Document Object Model (DOM) umzuwandeln.

Das DOM ist die interne Darstellung der Seite in einem Browser sowie die Datenstruktur und API, mit der Webentwickler über JavaScript interagieren können.

Das Parsen eines HTML-Dokuments in ein DOM wird durch den HTML-Standard definiert. Möglicherweise haben Sie bemerkt, dass beim Eingeben von HTML in einen Browser nie ein Fehler auftritt. Ein fehlendes schließendes </p>-Tag ist beispielsweise gültiges HTML. Falsche Markierungen wie Hi! <b>I'm <i>Chrome</b>!</i> (b-Tag wird vor dem i-Tag geschlossen) werden so behandelt, als hätten Sie Hi! <b>I'm <i>Chrome</i></b><i>!</i> geschrieben. Das liegt daran, dass die HTML-Spezifikation so konzipiert ist, dass diese Fehler möglichst reibungslos verarbeitet werden. Wenn Sie wissen möchten, wie das funktioniert, lesen Sie den Abschnitt Einführung in die Fehlerbehandlung und ungewöhnliche Fälle im Parser in der HTML-Spezifikation.

Laden von Unterressourcen

Eine Website verwendet in der Regel externe Ressourcen wie Bilder, CSS und JavaScript. Diese Dateien müssen aus dem Netzwerk oder Cache geladen werden. Der Haupt-Thread könnte sie einzeln anfordern, wenn er sie beim Parsen zum Erstellen eines DOM findet. Zur Beschleunigung wird der „Preload-Scanner“ jedoch gleichzeitig ausgeführt. Wenn sich im HTML-Dokument Elemente wie <img> oder <link> befinden, sieht sich der Preloader-Scanner die vom HTML-Parser generierten Tokens an und sendet Anfragen an den Netzwerk-Thread im Browserprozess.

DOM
Abbildung 2: Der Haupt-Thread, der HTML analysiert und einen DOM-Baum erstellt

JavaScript kann das Parsen blockieren

Wenn der HTML-Parser ein <script>-Tag findet, wird das Parsen des HTML-Dokuments pausiert und der JavaScript-Code muss geladen, geparst und ausgeführt werden. Warum? Weil JavaScript die Form des Dokuments mithilfe von Elementen wie document.write() ändern kann, wodurch sich die gesamte DOM-Struktur ändert. In der Übersicht über das Parsemodell in der HTML-Spezifikation finden Sie ein schönes Diagramm. Deshalb muss der HTML-Parser warten, bis JavaScript ausgeführt wurde, bevor er mit dem Parsen des HTML-Dokuments fortfahren kann. Wenn Sie wissen möchten, was bei der JavaScript-Ausführung passiert, hat das V8-Team dazu Vorträge und Blogbeiträge veröffentlicht.

Browser angeben, wie Ressourcen geladen werden sollen

Es gibt viele Möglichkeiten, wie Webentwickler dem Browser Hinweise senden können, um Ressourcen effizient zu laden. Wenn in Ihrem JavaScript kein document.write() verwendet wird, können Sie dem <script>-Tag das Attribut async oder defer hinzufügen. Der Browser lädt und führt den JavaScript-Code dann asynchron aus und blockiert das Parsen nicht. Sie können auch das JavaScript-Modul verwenden, wenn dies geeignet ist. Mit <link rel="preload"> können Sie den Browser darüber informieren, dass die Ressource für die aktuelle Navigation unbedingt erforderlich ist und Sie sie so schnell wie möglich herunterladen möchten. Weitere Informationen finden Sie unter Ressourcenpriorisierung – den Browser als Helfer nutzen.

Stilberechnung

Ein DOM reicht nicht aus, um zu wissen, wie die Seite aussehen würde, da wir Seitenelemente in CSS stylen können. Der Haupt-Thread analysiert CSS und ermittelt den berechneten Stil für jeden DOM-Knoten. Hier erfahren Sie, welche Art von Stil auf jedes Element basierend auf CSS-Selektoren angewendet wird. Sie finden diese Informationen in den DevTools im Bereich computed.

Berechneter Stil
Abbildung 3: Der Hauptthread, der CSS parset, um den berechneten Stil hinzuzufügen

Auch wenn Sie kein CSS angeben, hat jeder DOM-Knoten einen berechneten Stil. Das <h1>-Tag wird größer als das <h2>-Tag angezeigt und für jedes Element werden Ränder definiert. Das liegt daran, dass der Browser ein Standard-Stylesheet hat. Wenn Sie wissen möchten, wie das Standard-CSS von Chrome aussieht, finden Sie hier den Quellcode.

Layout

Der Rendering-Prozess kennt jetzt die Struktur eines Dokuments und die Stile für die einzelnen Knoten. Das reicht jedoch nicht aus, um eine Seite zu rendern. Stellen Sie sich vor, Sie versuchen, einem Freund ein Gemälde am Telefon zu beschreiben. „Es gibt einen großen roten Kreis und ein kleines blaues Quadrat“ sind nicht genügend Informationen, damit Ihr Freund weiß, wie genau das Gemälde aussehen würde.

Spiel als menschliches Faxgerät
Abbildung 4: Eine Person steht vor einem Gemälde, Telefonleitung mit der anderen Person verbunden

Das Layout ist ein Prozess, um die Geometrie von Elementen zu finden. Der Haupt-Thread durchläuft das DOM und berechnete Stile und erstellt den Layout-Baum mit Informationen wie X‑ und Y‑Koordinaten und Begrenzungsboxgrößen. Das Layout-Bäumchen kann eine ähnliche Struktur wie das DOM-Bäumchen haben, enthält aber nur Informationen zu den Elementen, die auf der Seite sichtbar sind. Wenn display: none angewendet wird, ist dieses Element nicht Teil des Layoutbaums. Ein Element mit visibility: hidden ist jedoch im Layoutbaum enthalten. Wenn ein Pseudo-Element mit Inhalten wie p::before{content:"Hi!"} angewendet wird, wird es ebenfalls in den Layoutbaum aufgenommen, auch wenn es nicht im DOM enthalten ist.

Layout
Abbildung 5: Der Haupt-Thread durchläuft den DOM-Baum mit berechneten Stilen und erstellt ein Layout-Baum.
Abbildung 6: Box-Layout für einen Absatz, der sich aufgrund einer Änderung des Zeilenendes verschiebt.

Das Layout einer Seite zu bestimmen, ist eine anspruchsvolle Aufgabe. Selbst beim einfachsten Seitenlayout wie einem Blockfluss von oben nach unten muss berücksichtigt werden, wie groß die Schrift ist und wo Zeilenumbrüche eingefügt werden, da sich dies auf die Größe und Form eines Absatzes auswirkt, was wiederum Auswirkungen darauf hat, wo der nächste Absatz platziert werden muss.

Mit CSS können Sie Elemente an eine Seite floaten, Überlaufelemente maskieren und die Schreibrichtung ändern. Wie Sie sich vorstellen können, ist diese Layoutphase eine große Herausforderung. Bei Chrome arbeitet ein ganzes Team von Entwicklern am Layout. Wenn Sie mehr über ihre Arbeit erfahren möchten, können Sie sich einige Vorträge von der BlinkOn-Konferenz ansehen.

Farben

Zeichenspiel
Abbildung 7: Eine Person vor einer Leinwand mit Pinsel, die sich fragt, ob sie zuerst einen Kreis oder ein Quadrat zeichnen soll

Ein DOM, ein Stil und ein Layout reichen nicht aus, um eine Seite zu rendern. Angenommen, Sie möchten ein Gemälde reproduzieren. Sie kennen die Größe, Form und Position der Elemente, müssen aber noch entscheiden, in welcher Reihenfolge Sie sie malen.

Beispielsweise kann z-index für bestimmte Elemente festgelegt sein. In diesem Fall führt das Malen in der Reihenfolge der in HTML geschriebenen Elemente zu einem falschen Rendering.

Z-Index-Fehler
Abbildung 8: Seitenelemente werden in der Reihenfolge eines HTML-Markups angezeigt, was zu einem falsch gerenderten Bild führt, da der Z-Index nicht berücksichtigt wurde

In diesem Schritt der Paint-Phase durchläuft der Hauptthread den Layoutbaum, um Paint-Einträg zu erstellen. Ein Paint-Eintrag ist eine Notiz zum Malvorgang, z. B. „Zuerst Hintergrund, dann Text, dann Rechteck“. Wenn Sie mit JavaScript bereits auf das Element <canvas> gezeichnet haben, ist dieser Vorgang möglicherweise bereits bekannt.

Paint-Datensätze
Abbildung 9: Der Haupt-Thread durchläuft den Layout-Baum und erstellt Paint-Einträge

Aktualisierung der Rendering-Pipeline ist kostspielig

Abbildung 10: DOM- und Stil-, Layout- und Paint-Bäume in der Reihenfolge, in der sie generiert werden

Das Wichtigste bei der Rendering-Pipeline ist, dass in jedem Schritt das Ergebnis des vorherigen Vorgangs verwendet wird, um neue Daten zu erstellen. Wenn sich beispielsweise etwas im Layoutbaum ändert, muss die Paint-Reihenfolge für die betroffenen Teile des Dokuments neu generiert werden.

Wenn Sie Elemente animieren, muss der Browser diese Vorgänge zwischen jedem Frame ausführen. Die meisten Displays aktualisieren das Display 60 Mal pro Sekunde (60 fps). Die Animation erscheint für das menschliche Auge flüssig, wenn Sie Objekte in jedem Frame über den Bildschirm bewegen. Wenn die Animation jedoch die Frames dazwischen überspringt, wirkt die Seite unruhig.

Ruckeln durch fehlende Frames
Abbildung 11: Animationsframes auf einer Zeitleiste

Selbst wenn Ihre Rendering-Vorgänge mit der Bildschirmaktualisierung Schritt halten, werden diese Berechnungen im Hauptthread ausgeführt. Das bedeutet, dass er blockiert werden kann, wenn in Ihrer Anwendung JavaScript ausgeführt wird.

jage jank von JavaScript
Abbildung 12: Animationsframes auf einer Zeitachse, aber ein Frame wird durch JavaScript blockiert

Sie können JavaScript-Vorgänge in kleine Teile aufteilen und mit requestAnimationFrame() festlegen, dass sie in jedem Frame ausgeführt werden. Weitere Informationen zu diesem Thema finden Sie unter JavaScript-Ausführung optimieren. Sie können Ihr JavaScript auch in Webworkern ausführen, um das Blockieren des Hauptthreads zu vermeiden.

Animationsframe anfordern
Abbildung 13: Kleinere JavaScript-Chunks, die auf einer Zeitachse mit Animationsframe ausgeführt werden

Compositing

Wie würden Sie eine Seite zeichnen?

Abbildung 14: Rasterung mit naivem Algorithmus

Nachdem der Browser die Struktur des Dokuments, den Stil der einzelnen Elemente, die Geometrie der Seite und die Malreihenfolge kennt, wie wird eine Seite gezeichnet? Diese Informationen in Pixel auf dem Bildschirm umzuwandeln, wird als Rasterung bezeichnet.

Eine naive Lösung wäre, Teile innerhalb des Darstellungsbereichs zu rastern. Wenn ein Nutzer auf der Seite scrollt, verschieben Sie den gerasterten Frame und füllen Sie die fehlenden Teile durch weiteres Rastern aus. So wurde die Rasterung in Chrome bei der Erstveröffentlichung durchgeführt. Moderne Browser führen jedoch einen ausgefeilteren Prozess namens Rendering aus.

Was ist Compositing?

Abbildung 15: Animation des Compositing-Prozesses

Beim Compositing werden Teile einer Seite in Ebenen unterteilt, separat gerastert und in einem separaten Thread (Compositor-Thread) als Seite zusammengesetzt. Wenn der Nutzer scrollt, muss nur ein neuer Frame zusammengesetzt werden, da die Ebenen bereits gerastert sind. Auf die gleiche Weise können Sie Animationen erstellen, indem Sie Ebenen verschieben und einen neuen Frame zusammensetzen.

Im Bereich Ebenen in den DevTools sehen Sie, wie Ihre Website in Ebenen unterteilt ist.

In Ebenen unterteilen

Um herauszufinden, welche Elemente in welchen Ebenen sein müssen, durchläuft der Haupt-Thread den Layout-Baum, um den Ebenen-Baum zu erstellen. Dieser Teil wird im DevTools-Steuerfeld „Leistung“ als „Ebenen-Baum aktualisieren“ bezeichnet. Wenn bestimmte Bereiche einer Seite, die eine separate Ebene sein sollten (z. B. ein ausziehbares Seitenmenü), keine erhalten, können Sie dem Browser mit dem Attribut will-change in CSS einen Hinweis geben.

Baumansicht „Ebenen“
Abbildung 16: Der Haupt-Thread, der den Layout-Baum durchläuft und den Ebenen-Baum erstellt

Sie könnten versucht sein, jedem Element Ebenen zuzuweisen. Das Zusammenführen über eine zu große Anzahl von Ebenen kann jedoch zu einer langsameren Ausführung führen als das Rastern kleiner Teile einer Seite pro Frame. Daher ist es wichtig, die Renderingleistung Ihrer Anwendung zu messen. Weitere Informationen zu diesem Thema finden Sie unter Nur für den Compositor bestimmte Properties verwenden und die Anzahl der Ebenen verwalten.

Raster und Komposition außerhalb des Hauptthreads

Sobald der Ebenenstrukturbaum erstellt und die Malanordnungen festgelegt wurden, übergibt der Hauptthread diese Informationen an den Kompositionsleiter. Der Kompositions-Thread rastert dann jede Ebene. Eine Ebene kann so groß sein wie die gesamte Länge einer Seite. Daher teilt der Compositor-Thread sie in Kacheln auf und sendet jede Kachel an Raster-Threads. Raster-Threads rastern jede Kachel und speichern sie im GPU-Speicher.

Raster
Abbildung 17: Raster-Threads, die die Bitmap der Kacheln erstellen und an die GPU senden

Der Compositor-Thread kann verschiedene Raster-Threads priorisieren, damit Elemente innerhalb des Viewports (oder in der Nähe) zuerst gerastert werden. Eine Ebene hat auch mehrere Kachelungen für verschiedene Auflösungen, um z. B. Zoomaktionen zu verarbeiten.

Sobald die Kacheln gerastert sind, sammelt der Compositor-Thread Kachelninformationen, die als Draw Quads bezeichnet werden, um einen Compositor-Frame zu erstellen.

Quadrate zeichnen Enthält Informationen wie den Speicherort der Kachel und die Position auf der Seite, an der die Kachel unter Berücksichtigung der Seitenkomposition gezeichnet werden soll.
Compositor-Frame Eine Sammlung von Draw Quads, die einen Frame einer Seite darstellt.

Ein Compose-Frame wird dann über IPC an den Browserprozess gesendet. An dieser Stelle kann über den UI-Thread ein weiterer Compose-Frame für die Änderung der Browser-Benutzeroberfläche oder über andere Renderer-Prozesse für Erweiterungen hinzugefügt werden. Diese Frames werden an die GPU gesendet, um sie auf einem Bildschirm anzuzeigen. Wenn ein Scroll-Ereignis eintritt, erstellt der Compositor-Thread einen weiteren Compositor-Frame, der an die GPU gesendet wird.

composit
Abbildung 18: Compositor-Thread, der einen Compositing-Frame erstellt. Frame wird an den Browserprozess und dann an die GPU gesendet

Der Vorteil des Compositings besteht darin, dass der Hauptthread nicht beteiligt ist. Der Compositor-Thread muss nicht auf die Stilberechnung oder die JavaScript-Ausführung warten. Deshalb gilt nur das Zusammenführen von Animationen als die beste Lösung für eine flüssige Leistung. Wenn das Layout oder die Darstellung neu berechnet werden muss, muss der Hauptthread beteiligt sein.

Zusammenfassung

In diesem Beitrag haben wir uns die Rendering-Pipeline vom Parsen bis zum Compositing angesehen. Ich hoffe, dass Sie jetzt mehr über die Leistungsoptimierung einer Website erfahren haben.

Im nächsten und letzten Beitrag dieser Reihe sehen wir uns den Compositor-Thread genauer an und erfahren, was passiert, wenn Nutzereingaben wie mouse move und click eingehen.

Hat Ihnen der Beitrag gefallen? Wenn du Fragen oder Vorschläge für den nächsten Beitrag hast, kannst du sie mir unten in den Kommentaren oder auf Twitter unter @kosamari senden.

Nächster Schritt: Eingabe wird an den Renderer gesendet