Ein Blick auf moderne Webbrowser (Teil 3)

Mariko Kosaka

Innere Funktionsweise eines Renderer-Prozesses

Dies ist Teil 3 von 4 Teile der Blog-Reihe zur Funktionsweise von Browsern. Zuvor haben wir uns mit der Multi-Prozess-Architektur und dem Navigationsablauf beschäftigt. In diesem Beitrag sehen wir uns an, was im Renderer-Prozess passiert.

Der Renderer-Prozess wirkt sich auf viele Aspekte der Webleistung aus. Da im Renderer-Prozess viel los ist, bietet dieser Post nur einen allgemeinen Überblick. Wenn du mehr erfahren möchtest, findest du im Abschnitt „Leistung“ in den Web Fundamentals viele weitere Ressourcen.

Renderer-Prozesse verarbeiten Webinhalte

Der Renderer-Prozess ist für alle Vorgänge innerhalb eines Tabs verantwortlich. In einem Renderer-Prozess verarbeitet der Hauptthread den Großteil des Codes, den Sie an den Nutzer senden. Manchmal werden Teile Ihres JavaScript von Worker-Threads verarbeitet, wenn Sie einen Web Worker oder einen Service Worker verwenden. Compositor- und Raster-Threads werden ebenfalls innerhalb von Renderer-Prozessen ausgeführt, um eine Seite effizient und reibungslos zu rendern.

Die Hauptaufgabe des Rendererprozesses besteht darin, HTML-, CSS- und JavaScript-Code in eine Webseite umzuwandeln, mit der der Nutzer interagieren kann.

Renderer-Prozess
Abbildung 1: Renderer-Prozess mit einem Hauptthread, Worker-Threads, einem Compositor-Thread und einem Rasterthread darin

Parsen

DOM-Erstellung

Wenn der Rendererprozess eine Commit-Nachricht für eine Navigation empfängt und HTML-Daten empfängt, beginnt der Hauptthread mit dem Parsen des Textstrings (HTML) und der Umwandlung in ein Dokument-Object Model (DOM).

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

Das Parsen eines HTML-Dokuments in ein DOM wird durch den HTML-Standard definiert. Sie haben vielleicht bemerkt, dass beim Einspeisen von HTML-Code in einen Browser nie ein Fehler ausgegeben wird. Wenn beispielsweise das schließende </p>-Tag fehlt, ist es ein gültiger HTML-Code. Fehlerhaftes Markup wie Hi! <b>I'm <i>Chrome</b>!</i> (b-Tag wird vor i-Tag geschlossen) wird 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 ordnungsgemäß behandelt werden. Wenn Sie wissen möchten, wie diese Vorgänge funktionieren, lesen Sie den Abschnitt Eine Einführung in Fehlerbehandlung und ungewöhnliche Fälle im Parser der HTML-Spezifikation.

Laden von Unterressourcen

Websites verwenden in der Regel externe Ressourcen wie Bilder, CSS und JavaScript. Diese Dateien müssen aus dem Netzwerk oder dem Cache geladen werden. Der Hauptthread könnte sie beim Parsen nacheinander anfordern, um ein DOM zu erstellen. Zur Beschleunigung wird jedoch gleichzeitig „Preload Scanner“ ausgeführt. Wenn das HTML-Dokument Dinge wie <img> oder <link> enthält, prüft der Scanner beim Vorabladen Tokens, die vom HTML-Parser generiert wurden, und sendet Anfragen an den Netzwerkthread im Browserprozess.

DOM
Abbildung 2: Hauptthread zum Parsen von HTML und Erstellen eines DOM-Baums

JavaScript kann das Parsing blockieren

Wenn der HTML-Parser ein <script>-Tag findet, pausiert er das Parsen des HTML-Dokuments und muss den JavaScript-Code laden, parsen und ausführen. Der Grund dafür ist, dass JavaScript die Form des Dokuments ändern kann, z. B. mit document.write(), wodurch die gesamte DOM-Struktur geändert wird. In der Übersicht über das Parsing-Modell in der HTML-Spezifikation finden Sie ein schönes Diagramm. Aus diesem Grund muss der HTML-Parser warten, bis JavaScript ausgeführt wurde, bevor er mit dem Parsen des HTML-Dokuments fortfahren kann. Wenn du neugierig bist, was bei der JavaScript-Ausführung passiert, findest du hier entsprechende Vorträge und Blogposts des V8-Teams.

Hinweis an den Browser, wie Ressourcen geladen werden sollen

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

Stilberechnung

Ein DOM reicht nicht aus, um zu wissen, wie die Seite aussehen würde, da wir Seitenelemente in CSS gestalten können. Der Hauptthread parst CSS und bestimmt den berechneten Stil für jeden DOM-Knoten. Hier erfahren Sie, welche Art von Stil basierend auf CSS-Selektoren auf jedes Element angewendet wird. Diese Informationen finden Sie im Abschnitt computed der Entwicklertools.

Berechneter Stil
Abbildung 3: Hauptthread zum Parsen von CSS, um den berechneten Stil hinzuzufügen

Auch wenn Sie kein CSS angeben, verfügt jeder DOM-Knoten über 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 verwendet. Wenn Sie wissen möchten, wie der Standard-CSS-Code von Chrome aussieht, können Sie sich den Quellcode hier ansehen.

Layout

Jetzt kennt der Renderer-Prozess 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 möchten einem Freund ein Gemälde per Telefon beschreiben. „Es gibt einen großen roten Kreis und ein kleines blaues Quadrat“ reicht nicht aus, um zu wissen, wie das Gemälde genau aussehen soll.

Spiel eines menschlichen Faxgeräts
Abbildung 4: Eine Person vor einem Gemälde; Telefonleitung mit der anderen Person

Das Layout ist ein Prozess, um die Geometrie der Elemente zu finden. Der Hauptthread durchläuft das DOM und berechnete Stile und erstellt die Layoutstruktur, die Informationen wie x-y-Koordinaten und Begrenzungsrahmengrößen enthält. Der Layoutbaum hat eine ähnliche Struktur wie der DOM-Baum, enthält aber nur Informationen zu den auf der Seite sichtbaren Elementen. Wird display: none angewendet, ist dieses Element nicht Teil der Layoutstruktur. Ein Element mit visibility: hidden befindet sich jedoch darin. Wenn eine Pseudoklasse mit Inhalten wie p::before{content:"Hi!"} angewendet wird, ist sie in der Layoutstruktur enthalten, obwohl sie nicht im DOM enthalten ist.

Layout
Abbildung 5: Hauptthread über den DOM-Baum mit berechneten Stilen und Erstellung des Layoutbaums
Abbildung 6: Feldlayout für einen Absatz, der sich aufgrund eines Zeilenumbruchs bewegt

Das Festlegen des Layouts einer Seite ist eine anspruchsvolle Aufgabe. Auch beim einfachsten Seitenlayout wie einem Blockfluss von oben nach unten müssen Sie berücksichtigen, wie groß die Schrift ist und an welcher Stelle der Zeilenumbruch erfolgen soll, da diese sich auf die Größe und Form eines Absatzes auswirken und sich dann darauf auswirken, wo der folgende Absatz platziert werden muss.

Mit CSS kann das Element auf einer Seite schweben, Überlaufelemente maskiert und die Schreibrichtung geändert werden. Sie können sich vorstellen, dass diese Layoutphase eine große Aufgabe hat. In Chrome arbeitet ein ganzes Team von Ingenieuren an dem Layout. Wenn Sie sich Details zu ihrer Arbeit ansehen möchten, werden einige Vorträge der BlinkOn Conference aufgezeichnet und sind ziemlich interessant.

Farben

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

DOM, Stil und Layout reichen immer noch nicht aus, um eine Seite zu rendern. Angenommen, Sie versuchen, ein Gemälde zu reproduzieren. Sie kennen die Größe, Form und Position von Elementen, müssen aber trotzdem beurteilen, in welcher Reihenfolge Sie sie zeichnen.

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

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

In diesem Zeichenschritt läuft der Hauptthread durch den Layoutbaum, um Paint-Datensätze zu erstellen. Paint Record ist ein Hinweis zum Painting-Prozess, z. B. „Hintergrund zuerst, dann Text und dann Rechteck“. Wenn Sie <canvas>-Elemente mit JavaScript gezeichnet haben, ist Ihnen dieser Vorgang möglicherweise bekannt.

Paint-Datensätze
Abbildung 9: Hauptthread, der durch den Layoutbaum läuft und Paint-Datensätze erzeugt

Das Aktualisieren der Rendering-Pipeline ist kostspielig

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

Das Wichtigste in der Rendering-Pipeline ist, dass bei jedem Schritt das Ergebnis des vorherigen Vorgangs verwendet wird, um neue Daten zu erstellen. Wenn sich beispielsweise etwas in der Layoutstruktur ä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. Bei den meisten Displays wird der Bildschirm 60-mal pro Sekunde (60 fps) aktualisiert. Animationen erscheinen für das menschliche Auge flüssig, wenn Sie in jedem Frame etwas über den Bildschirm bewegen. Wenn bei der Animation jedoch die dazwischen liegenden Frames fehlen, erscheint die Seite „auffällig“.

Jage verzögert sich wegen fehlender Frames
Abbildung 11: Animationsframes auf einer Zeitachse

Auch wenn Ihre Rendering-Vorgänge mit der Bildschirmaktualisierung Schritt halten, werden diese Berechnungen im Hauptthread ausgeführt. Dies bedeutet, dass dieser möglicherweise blockiert wird, wenn Ihre Anwendung JavaScript ausführt.

Jage Jank von JavaScript
Abbildung 12: Animationsframes auf einer Zeitachse, aber ein Frame wird von JavaScript blockiert

Der JavaScript-Vorgang lässt sich in kleine Blöcke unterteilen und mit requestAnimationFrame() so planen, dass sie bei jedem Frame ausgeführt werden. Weitere Informationen zu diesem Thema finden Sie unter JavaScript-Ausführung optimieren. Sie können Ihren JavaScript-Code auch in Web Workers ausführen, um zu verhindern, dass der Hauptthread blockiert wird.

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

Aufbau

Wie würden Sie eine Seite zeichnen?

Abbildung 14: Animation eines naiven Rasterprozesses

Der Browser kennt nun die Struktur des Dokuments, den Stil der einzelnen Elemente, die Geometrie der Seite und die Farbreihenfolge. Wie wird eine Seite gezeichnet? Die Umwandlung dieser Informationen in Pixel auf dem Bildschirm wird Rasterung genannt.

Eine einfache Möglichkeit wäre das Rastern von Teilen innerhalb des Darstellungsbereichs. Wenn ein Nutzer auf der Seite scrollt, dann den Rasterframe verschieben und die fehlenden Teile durch weitere Raster ergänzen kann. So hat Chrome das Rastern bei der Erstveröffentlichung behandelt. In modernen Browsern wird jedoch ein komplexerer Prozess ausgeführt, der als "compositing" bezeichnet wird.

Was ist Compositing?

Abbildung 15: Animation des Compositing-Prozesses

Das Compositing ist eine Technik, um Teile einer Seite in Ebenen zu unterteilen, sie separat zu rastern und als Seite in einem separaten Thread, den sogenannten Compositor-Thread, zusammenzusetzen. Da die Ebenen bereits gerastert sind, muss beim Scrollen nur ein neuer Frame zusammengesetzt werden. Animationen können auf die gleiche Weise erreicht werden, indem Ebenen verschoben und ein neuer Frame zusammengesetzt wird.

Wie Ihre Website in Ebenen unterteilt ist, können Sie in den Entwicklertools im Bereich „Ebenen“ sehen.

In Ebenen unterteilen

Um herauszufinden, welche Elemente in welchen Ebenen enthalten sein müssen, läuft der Hauptthread durch den Layoutbaum, um den Ebenenbaum zu erstellen. Dieser Teil wird im Leistungssteuerfeld der Entwicklertools „Update Layer Tree“ genannt. Wenn bestimmte Bereiche einer Seite, die ein separater Layer sein sollen (z. B. das seitliche eingeschobene Menü), keine Ebene erhalten, können Sie dem Browser mithilfe des will-change-Attributs in CSS einen Hinweis senden.

Ebenenstruktur
Abbildung 16: Der Hauptthread durchläuft den Ebenenbaum, der den Layoutbaum erzeugt

Sie könnten versucht sein, jedem Element Ebenen zuzuweisen, aber das Zusammensetzen über eine übermäßige Anzahl von Ebenen könnte den Vorgang verlangsamen als das Rastern kleiner Teile einer Seite für jeden Frame. Daher ist es wichtig, dass Sie die Rendering-Leistung Ihrer Anwendung messen. Weitere Informationen finden Sie unter Eigenschaften nur für zusammengesetzte Objekte beibehalten und Anzahl der Ebenen verwalten.

Raster und Zusammensetzung des Hauptthreads

Nachdem die Ebenenstruktur erstellt und die Paint-Reihenfolgen festgelegt sind, überträgt der Hauptthread diese Informationen an den Compositor-Thread. Der Compositor-Thread rastert dann jede Ebene. Eine Ebene kann wie die gesamte Länge einer Seite groß sein. Der Compositor-Thread teilt sie daher in Kacheln auf und sendet jede Kachel an Rasterthreads. Rasterthreads rastern jede Kachel und speichern sie im GPU-Arbeitsspeicher.

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

Der Compositor-Thread kann verschiedene Rasterthreads priorisieren, sodass Dinge innerhalb des Darstellungsbereichs (oder in der Nähe) zuerst gerastert werden können. Eine Ebene hat außerdem mehrere Kacheln für verschiedene Auflösungen, um z. B. Aktionen wie das Heranzoomen zu verarbeiten.

Nach dem Rastern der Kacheln erfasst der Compositor-Thread Kachelinformationen, die sogenannten Draws, um einen Compositor-Frame zu erstellen.

Quadrate zeichnen Enthält Informationen wie die Position der Kachel im Speicher und die Stelle auf der Seite, an der die Kachel unter Berücksichtigung der Seitenzusammensetzung gezeichnet werden soll.
Compositor-Frame Eine Sammlung von Zeichenquadraten, die einen Frame einer Seite darstellen.

Ein Compositor-Frame wird dann über IPC an den Browserprozess gesendet. An dieser Stelle könnte ein weiterer Compositor-Frame aus dem UI-Thread für die Änderung der Browser-UI oder aus anderen Rendererprozessen für Erweiterungen hinzugefügt werden. Diese Compositor-Frames werden an die GPU gesendet, um sie auf einem Bildschirm anzuzeigen. Wenn ein Scroll-Ereignis eingeht, erstellt der Compositor-Thread einen weiteren Compositor-Frame, der an die GPU gesendet wird.

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

Der Vorteil der Zusammensetzung besteht darin, dass der Hauptthread nicht einbezogen wird. Der Compositor-Thread muss nicht auf die Stilberechnung oder JavaScript-Ausführung warten. Aus diesem Grund gelten Animationen, die sich nur zusammensetzen, als am besten für eine reibungslose Leistung. Wenn Layout oder Paint neu berechnet werden muss, muss der Hauptthread einbezogen werden.

Zusammenfassung

In diesem Beitrag haben wir die Rendering-Pipeline vom Parsing bis zur Erstellung behandelt. Hoffentlich haben Sie jetzt die nötigen Kenntnisse, um mehr über die Leistungsoptimierung einer Website zu erfahren.

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

Hat Ihnen der Beitrag gefallen? Wenn ihr Fragen oder Vorschläge für den zukünftigen Beitrag habt, könnt ihr gerne unten im Kommentarbereich oder unter @kosamari auf Twitter Beiträge von euch hören.

Nächster Schritt: Eingabe kommt an den Compositor