Renderen Zonder De Main Thread Te Blokkeren
Wat ik leerde toen ik het genereren van pagina-thumbnails in de browser naar een web worker en een offscreen canvas verplaatste: waarom je je scene beschrijft in plaats van de DOM te screenshotten, en waarom geheugen ineens jouw probleem wordt.
Elke pagina in een document heeft een klein voorbeeldplaatje nodig, een thumbnail die je bijvoorbeeld in een overzicht of zijbalk toont. Klinkt simpel, maar dat is het niet. Mijn eerste aanpak was de meest voor de hand liggende: gewoon een screenshot maken van de live pagina in de DOM en die verkleinen. Dat werkte prima in een demo, maar zodra de pagina echt druk werd en er continu opnieuw vastgelegd moest worden, liep de hele interface vast. Ik neem je mee in hoe ik stopte met vechten tegen de main thread en het renderwerk ernaast ging doen, met web workers en een offscreen canvas.
Waarom de main thread zo kostbaar is
In een browser draait één thread je JavaScript, je layout, je paint én je event handling. Alles. Daarom kan één zware loop letterlijk alles tegelijk blokkeren. Toen ik previews van pagina's genereerde, kon het vastleggen van één drukke pagina de interface honderden milliseconden op slot zetten.
Als een taak langer duurt dan een frame en het paint niet direct wat de gebruiker op dit moment ziet, hoort het waarschijnlijk niet op de main thread.
Wat een web worker je echt oplevert
Een web worker is een aparte thread met zijn eigen JavaScript-context. Hij kan niet bij de DOM, en eerlijk gezegd is die beperking het hele punt. Het dwingt je om platte data door te geven en platte data terug te krijgen. Het zware werk gebeurt op een plek waar de gebruiker het nooit voelt.
De worker is geen plek om je componenten naartoe te verhuizen. Het is een plek om je berekening naartoe te verhuizen. Je stuurt een beschrijving van wat er gerenderd moet worden, de worker maakt de pixels, en stuurt een resultaat terug.
OffscreenCanvas: pixels zonder zichtbaar canvas
Het onderdeel dat alles aan elkaar knoopt is OffscreenCanvas. Dat is een canvas dat niet aan de pagina hangt, zodat een worker erop kan tekenen. Je rasteriseert je scene daar, encodeert het naar een compacte afbeelding, en geeft iets terug dat de main thread zo in een <img> kan zetten.
Beschrijf de scene, screenshot de DOM niet
Mijn grootste fout was proberen de live DOM te screenshotten. Loop door de nodes, inline de styles, serialiseer, teken. Het werkt in een demo en valt in de praktijk uit elkaar. Fonts zijn nog niet geladen, media komt niet mee, cross-origin content is getaint, en de resultaten verschillen per platform.
Wat uiteindelijk wél werkte, was elk ding op de pagina beschrijven als kleine, serialiseerbare data. Een afbeelding wordt een verwijzing plus een kader. Tekst wordt een string plus layout-info. Zodra alles platte data is, kun je het goedkoop naar de worker overdragen en deterministisch renderen. Je bent niet langer overgeleverd aan hoe de DOM er toevallig in die milliseconde uitziet.
Het proces optimaliseren
Het plaatje tekenen is misschien twintig procent van het werk. De andere tachtig procent is beslissen wanneer je het juist níet tekent.
- Zet het werk in een queue. Tien zichtbare previews horen geen tien parallelle renders te starten. Doe ze één voor één, zodat de browser niet gaat thrashen.
- Cache op versie. Gebruik het item plus zijn laatste wijziging als cachesleutel. Zo komt ongewijzigde content meteen uit de cache en wordt alleen aangepaste content opnieuw gerenderd.
- Annuleer verouderd werk. Bewerk iets drie keer snel achter elkaar en de oudere renders zijn waardeloos. Gooi ze weg en houd de nieuwste.
- Debounce invoer. Render niet bij elke aanpassing. Wacht op een pauze en lees dan de laatste staat.
Geheugen is nu jouw taak
Zodra een render klaar is, geef ik de thumbnail terug als een object-URL in plaats van een base64-string, zodat de afbeeldingsbytes nooit in de JavaScript-heap terechtkomen. Dat houdt de main thread licht, maar er zit een addertje onder het gras: dat geheugen moet je nu zelf beheren. De browser (bijvoorbeeld Chrome) bewaart de onderliggende blob native, buiten je JS-geheugen, dus de garbage collector ruimt het niet voor je op. Bij een herlaad of navigatie van de pagina maakt de browser die URL's vanzelf ongeldig, maar zolang de pagina open blijft ruimt hij niets voor je op. De oplossing is saai en essentieel: elke URL die je aanmaakt heeft een bijpassende release nodig wanneer zijn eigenaar verdwijnt. Behandel het als handmatige allocatie, want dat is het.
Tot slot
- Verplaats zwaar werk vroeg van de main thread af.
- Beschrijf je scene als data, maar alleen bij zware visuele load. Voor een normale webpagina is dit natuurlijk niet nodig. Wanneer het wél nodig is, is het sneller, voorspelbaarder en overdraagbaar naar een worker.