Als developers zijn we gewend om veel info te vergaren, zodat we, wat we voor ogen hebben, voor elkaar kunnen krijgen. Door de jaren heen hebben we op deze manier veel kennis opgedaan en vaak denk ik dan ook te snappen hoe processen werken, die de JavaScript Engine doorloopt. Om vervolgens in de verre uithoeken van de internet-oceaan een juweeltje van kennisdeling te vinden, die me laat inzien dat ik het alleen nog oppervlakkig heb begrepen. Zo ontdekte ik dit voorjaar een parel van een artikel over Execution Context, die ik wel vier keer gelezen heb. In dit artikel wil ik het hebben over de JavaScript Engine versie van Execution Context. En daarmee ook over Closures, Hoisting en Scopes. Duikbril mee, we gaan de diepte in.

Auteur: Sylvia Poelgeest is Software Engineer bij Ordina en al zo’n 6 jaar verliefd op JavaScript, AngularJS en sinds kort op Angular. In haar vorige leven was ze zangeres, zangdocent en componist. Ze is getrouwd en ja, nog verliefder op Guido dan op JavaScript. Ze is moeder van 3 kinderen en een kat genaamd “Bloem” (naar het stinkdiertje in Bambi) en ze is verslaafd aan coderen.

Het proces begrijpen van Execution Context, die de JavaScript Engine opstart wanneer je je code laat runnen, zorgt voor zoveel meer inzichten over hoe scope afhandeling gebeurt en op welke manier closures ontstaan. Inzichten, die belangrijk zijn voor cruciale beslissingen, die je als developer maakt. Ook hoisting binnen JavaScript wordt in 1 klap duidelijk, terwijl dit vaak als complexere materie wordt gezien. Vooral doordat bijvoorbeeld je gevoel bij scopes in eerste instantie intuïtief ontstaat, terwijl deze zal verdiepen bij cognitieve kennis over hoe de computer omgaat met scopes.

Zoals het codeerproces voor developers overzichtelijk(er) gemaakt wordt door code onder te verdelen in modules, directives en services, zo zorgt de Execution Context ervoor dat voor de JavaScript Engine het verwerken van deze code overzichtelijk en in de juiste volgorde gebeurt. Het zorgt ervoor dat de ‘natuurwetten’ van JavaScript voorspelbaar zijn. Als je weet hoe de wereld van jouw code zich zal gedragen, kun je ook betere code schrijven.

Grofweg kun je stellen dat tijdens de Execution Context het landschap, waarbinnen jouw code zich bevindt, tot leven komt. De Execution Context heeft twee states: Global Execution Context en Function Execution Context plus een soort van substate, genaamd Closure Scope.

De Global Execution Context is de eerste context, die gemaakt wordt. Het is de context waarin de wereld van jouw code ontstaat. Je kunt het vergelijken met een canvas, die je nodig hebt om je code door de JavaScript Engine te laten verwerken. Dit gebeurt in twee fases: de creation fase en execution fase. Tijdens de creation fase worden 4 processen afgewerkt:

  1. Het Global object wordt gecreëerd (‘window’ in een browser en ‘global’ in een Node omgeving).
  2. Een ‘this’ object wordt gecreëerd, die verwijst naar het Global object.
  3. Er wordt memory space gereserveerd voor variabelen en functies.
  4. Variabelen krijgen een default waarde van ‘undefined’ (dit heet Hoisting, helemaal niet zo moeilijk toch? :)) en functies worden in memory gezet.

Na de creation fase gaan we over naar de execution fase. Tijdens deze fase wordt de code regel voor regel uitgevoerd en worden alle echte waarden (indien bekend) toegekend aan variabelen. Is er (nog) geen waarde bekend, dan zullen deze variabelen de waarde ‘undefined’ houden. Dit is ook waarom in JavaScript een variabele de default waarde ‘undefined’ heeft en er geen reference error ontstaat op moment dat de waarde nog niet bekend is. Alleen wanneer er binnen de scope van een code block (ook binnen de scope van bijvoorbeeld een controller) een variabele niet geïnstantieerd is, en door de creation fase gehoist is, zal er in de context van die code block een reference error optreden.

Wanneer een functie aangeroepen wordt, ontstaat de tweede staat: Function Execution Context. Waar bij de Global Execution Context de Global scope ontstaat ( ‘this’ wijst naar het Global object), daar ontstaat nu de function scope. Function Execution Context kent ook de twee fases creation en execution. Wat er gebeurt tijdens de creation fase van Function Execution Context is bijna hetzelfde als bij de Global Execution Context’s creation fase. Ontdek de verschillen:

  1. Een ‘arguments’ object wordt gecreëerd.
  2. Een ‘this’ object wordt gecreëerd, die wijst naar het Global object.
  3. Er wordt memory space gereserveerd voor variabelen en functies.
  4. Variabelen krijgen een default waarde van ‘undefined’ (yep, weer Hoisting) en functies worden in memory gezet.

Heb je de verschillen ontdekt? Alleen de eerste stap is anders, doordat een arguments object gecreëerd wordt. Dit arguments object is van groot belang voor de Closure Context, aangezien deze de arguments object overneemt in zijn scope. Hier later meer over.

Tijdens de execution fase wordt ook hier de code regel voor regel uitgevoerd en waarden die bekend zijn, worden toegeschreven naar de variabelen. Het is precies in deze context, de Function Execution Context, waar het complexer wordt. Hier ontstaan namelijk Execution Stacks, Closure Scopes en hier wordt het best zichtbaar hoe de scopes door de JavaScript Engine verwerkt worden; als opstijgende bubbeltjes (we zitten al behoorlijk diep, maar we gaan nog iets dieper).

De Execution Contexts maken gebruik van de Execution Stack. Deze Execution Stack ontstaat op het moment dat er binnen een functie nog een functie staat. Op moment dat een functie aangeroepen wordt, wordt die functie op de Execution Stack gezet en wordt deze als ‘actief’ gezien, net zolang tot deze klaar is. Komt de Engine echter binnen die functie nog een functie tegen, dan wordt die tweede functie bovenop de eerste functie op de Execution Stack gezet. Vervolgens wordt de status van de eerste functie als ‘pending’ gezien en wordt de tweede functie (laatst aangeroepen functie) als ‘actief’ gezien. Staan er nu 7 functies binnen een functie, zoals Matroeshka-poppen, dan zal elke laatste functie boven op de voorgaande functie gezet worden. Op een gegeven moment zou je dan op de Execution Stack 7 functies hebben, die als ‘pending’ gezien worden en de bovenste functie op de Execution Stack zou dan als enige als ‘actief’ gezien worden. De functie, die bovenop de Stack zit en als ‘actief’ gezien wordt (want als laatst aangeroepen en nog niet opgelost), zal als eerst opgelost worden. Vervolgens de een na laatste en zo verder. Dit is wat we het LIFO principe noemen: Last In First Out. In het volgende voorbeeld wordt dat wellicht iets duidelijker:

function parent() {
    return function child() {
        return function grandChild() {
            console.log(“Hello from within function grandChild”);
        }
    }
};

Oké, dit is inderdaad een nutteloze functie, maar voor dit artikel illustreert het wel hoe de JavaScript Engine deze functies zal gaan afhandelen. De stack wordt gevuld met eerst de parent(), dan child() en vervolgens als laatste grandchild(). Als eerste zal nu grandchild() afgehandeld en gesloten worden, dan pas child() en als laatste parent(). Een soort van estafette, waarbij de laatste functie de sleutel naar de vorige functie die op de stack staat doorgeeft, net zolang de stack weer helemaal leeg is en de JavaScript Engine z’n sleutel weer terug heeft.

Aangezien JavaScript single threaded is, maken we gebruik van Closures. Single threaded betekent dat er maar, net zoals bij de computer memory, één functie tegelijk actief kan zijn, want er is tenslotte maar één sleutel. Bij Closures blijf je toegang houden tot variabelen en hun waarden van een reeds afgehandelde en afgesloten functie. En zoals ik al eerder zei, ik dacht te begrijpen wat Closures waren, totdat ik eerder genoemde kennis ergens in een uithoek van het internet tegen kwam. Om voor zo’n Closure de diepte in te kunnen gaan, moeten we nog even terug gaan naar de Function Execution Context. Ik ga aan de hand van een voorbeeld uitleggen hoe vanuit de Function Execution Context de Closure Scope ontstaat. Stel je hebt de volgende code:

var result = 0;


function add(x) {
    return function inner(y) {
        return x + y;
    }
} ;

/* hier ontstaat de Closure, aangezien in addNumber het 
resultaat van add(5) wordt opgeslagen. */
var addNumber = add(5); 
result += addNumber(2);

Stel je nu eens voor dat je in de execution fase zit van de Function Execution Context. De JavaScript Engine is add(5); aan het interpreteren en komt bij het stuk return x + y;. Er zal gezocht moeten worden naar de waarde van x. Dit zal eerst binnen de scope van inner(y) gezocht worden, aangezien dat de scope is waar de Engine bezig is. Binnen die scope is x niet aanwezig, dus verplaatst de Engine de zoektocht één scope level omhoog, naar binnen add(x). Daar is uiteraard x wel aanwezig en zal de waarde van x worden veranderd naar 5. De uitkomst van add(5) wordt opgeslagen in addNumber, waardoor een Closure Scope ontstaat. add(5 is wel afgesloten en we hebben nog steeds toegang tot x, want inner(y) is nog niet opgelost. Er is namelijk nog geen return waarde. Op de volgende regel wordt addNumber(2) aangeroepen. In feite kun je de functie return nu zo lezen: return 5 + y. Aangezien bij addNumber als argument 2 wordt meegegeven, wordt die waarde toegekend aan y. Hierdoor kan de return wel opgelost worden en zal de Closure Scope sluiten.

Het nut van Scopes binnen JavaScript is enerzijds het tot een zekere mate verhogen van de security, anderzijds het zuinig omspringen met memory, het voorkomen dat gelijk genaamde variabelen overschreven worden wanneer dit niet wenselijk is en als laatste: het debug proces vereenvoudigen, doordat je beter kunt pinpointen waar een error vandaan komt. Het verhoogt de efficiëntie van je code.

De JavaScript Engine zal altijd naar de waarde van een variabele zoeken binnen de scope van het code block waar de executie zich bevindt. Als op die plek niet gevonden kan worden waar naar gezocht wordt, zal de Engine pas gaan zoeken naar de waarde bij de parent. En zo steeds een level hoger, totdat de Engine zich in de Global Scope bevindt. Net zoals je je sleutels eerst binnenshuis zoekt en pas buiten gaat zoeken als je ze binnen niet gevonden hebt, zo werkt dat ook voor het oplossen van het zoeken naar de waarde van variabelen binnen de scope van zijn leefgebied door de JavaScript Engine.

Het proces van JavaScripts Execution Contexts lijkt een beetje op duiken. Als het proces start, duik je vanaf de wateroppervlakte de diepte in. Op de bodem ben je in de binnenste functie aangekomen. De scope afhandeling bubbelt vanaf die bodem als luchtbelletjes uit de uitrusting van de duiker omhoog tot aan het wateroppervlak. En als de duiker weer langzaam omhoog komt, sluit de Execution Stack op de LIFO manier. Net zolang tot de duiker weer aangekomen is bij het wateroppervlak. De duiker stapt uit het water zijn boot in en de Execution Context is in zijn geheel weer afgesloten.

Het begrijpen van de Execution Context en z’n kornuiten, heeft er bij mij voor gezorgd dat ik makkelijker bugs, gerelateerd aan het wel of niet ‘geset’ zijn van variabelen, op kan lossen. Snappen welke fasen er afgewerkt worden door de JavaScript Engine bleken onmisbaar in die gevallen. En wat ik kicken vond om te ontdekken, was dat het principe functie afhandeling op het level van de JavaScript Engine erg lijkt op hoe je computer memory dit doet.

Mocht je dit alles in actie willen zien, Tyler McGinnis heeft de JavaScript Visualizer ontwikkeld. Deze tool en veel andere info die ik heb gebruikt voor dit artikel kun je vinden op: https://tylermcginnis.com/javascript-visualizer/.