Welkom op julesgraus.nl

Google Podcasts

30-09-2019

Javascript - Execution context

Vraag jij je ook wel eens af waarom de waarde van "this" in javascript niet is wat je dacht? Waarom dat je in een scope, wel toegang hebt tot variabelen buiten die scope? En heeft dit iets te maken met closures? Lees dan verder, want dit kan wel eens de heilige graal zijn van kennis die je zoekt.

Wat is het?

Eerst even wat theorie! Later een concreet voorbeeld. De execution context kun je zien als een omgeving waar verschillende variabelen in zitten, daar waar Javascript word uitgevoerd op een gegeven moment. Het helpt om het in je hoofd voor te stellen als een object literal. In deze context zitten een aantal dingen:

Een execution context is niet hetzelfde als een scope. Zie het als een object met verschillende variabelen en referenties naar andere scopes.

Hoe werkt het?

Als je een javascript programma start, wordt er altijd een Global Execution Context (GEC) gemaakt (Creation Phase). En die word daarna uitgevoerd in een zogenoemde Execution Phase. De creation phase word ook wel de compilation phase genoemd. JA, javascript is in tegenstelling wat de meeste denken geen geïnterpreteerde taal, maar een gecompileerde taal! Wanneer de javascript engine een functie call tegen komt, maakt hij voor die function call een eigen execution context. Dit noemen ze dan Function execution context (FEC).

Creation phase

Het maken van een execution context word gedaan een zogenoemde "Creation phase". Deze creation phase bestaat uit 3 stappen:

Er word een variabel object gemaakt:

Er wordt een scope chain gemaakt:

De scope chain is een aaneenschakeling van scopes die leiden van de huidige uitgevoerde scope, tot de global scope. Dit noemen ze ook wel lexicaal. Een voorbeeld zal verduidelijking bieden over wat een scope chain is:

function a()
{
    let aVar = 'I am A var :)';
    function b()
    {
        let bVar = 'I am B var :)';
        function c() {
            let cVar = 'I am C var :)';
            console.log(aVar,bVar,cVar);
        }
        c();
    }
    b()
}

a();

De functie a is globaal. De scope chain van verwijst simpelweg naar de scope waar a in zit. We omschrijven de scope chain als volgt: a=a. De functie b zit in de functie a dus de scope chain van functie b bestaat uit de scope van zichzelf plus die van a. Omschreven als volgt: b=b+a. En c zit in b. Dus dat kan je omschrijven als: c=c+b+a. Omdat functie c bestaat uit de scopes, c, b, en a kun je in functie c de aVar loggen / manipuleren. Maar ook bVar! Behalve als je aVar of bVar nogmaals declareert in functie c zelf. In dat geval word de lokale variabele gebruikt. Je ziet dus dat "zoeken" ook wel resolven van een te gebruiken variabele van de binnenste (inner) functie naar de buitenste (outer) functie gaat.

De waarde van this word bepaald:

Execution phase + Call stack

Als de execution context de creation phase heeft doorlopen, kan die de code gaan uitvoeren. Dit doet hij in de "execution phase". Regel voor regel. Beginnend bij de GEC. Deze komt op de zogenoemde call stack terecht. Telkens als dan een functie gecalled word, word de FEC van die functie bovenop die call stack geplaatst. En al Telkens als de code uit een functie retourneert door middel van een impliciete of expliciete return, word de FEC van die functie verwijderd. Tezamen met heel zijn inhoud. Dit word dan rommel voor de garbage collector van Javascript, die uiteindelijk alles uit het geheugen verwijderd. In sommige gevallen word de FEC niet verwijderd. Je hebt in dat geval te maken met een closure.

Een voorbeeld.

Laten we bovenstaande kennis eens kijken in een praktisch voorbeeld. Bekijk deze code:

let appName = 'Execution context demo';

function a()
{
  let aVar = 'Dit';
  b();

  function b() {
    let bVar = 'is';
    c();

    function c() {
      let cVar = 'een';
      console.log(aVar+' '+bVar+' '+cVar+' '+appName);
    }
  }
}

a();

Dit is wat er stap voor stap gebeurt: Eerst wordt de Global execution context gemaakt in de Creation Phase. Die kun je visualiseren zoals hieronder. Het variabel object hebben we voor de eenvoud samengevoegd met de root:

{
    window: global object,
    this: window,
    appName: undefined,
    a: fn(),
    scope_chain: [],
}

Dan wordt de Global Execution Context uitgevoerd in de Execution Phase. Daardoor word appName "ingevuld".

{
    window: global object,
    this: window,
    appName: 'Execution context demo',
    a: fn(),
    scope_chain: [],
}

En wordt de context bovenop op de call stack gezet, die we als een array visualiseren:

[
    anonymous()
]

Vervolgens wordt functie a aangeroepen. Waardoor voor functie a Function Execution Context gemaakt. Merk op dat er een arguments object gemaakt wordt met daarin alle argumenten voor functie a (geen dus). De scope chain krijgt tevens een verwijzing naar de global execution context. In de creation phase zal deze er zo uit zien:

{
    arguments: { length: 0 },
    this: window,
    aVar: undefined,
    b: fn(),
    scope_chain: [anonymous],
}

In de Execution phase ziet de Function Execution Context voor functie a er zo uit:

{
    arguments: { length: 0 },
    this: window,
    aVar: "Dit",
    b: fn(),
    scope_chain: [anonymous],
}

Daarna word ook de function execution context voor functie a bovenop de call stack gezet:

[
    a(),
    anonymous(),
]

Vervolgens wordt functie b aangeroepen. Waardoor voor functie b een Function Execution Context gemaakt wordt. Ook hier weer een leeg arguments object. De scope chain krijgt hier een verwijzing naar de function execution context van functie a, die weer verwijst naar de global execution context. In de creation phase zal de context zo uit zien:

{
    arguments: { length: 0 },
    this: window,
    bVar: undefined,
    c: fn(),
    scope_chain: [a, anonymous],
}

En in de execution phase ziet de Function Execution Context voor functie b er zo uit:

{
    arguments: { length: 0 },
    this: window,
    bVar: "is",
    c: fn(),
    scope_chain: [a, anonymous],
}

Ook hier word dan de function execution context voor functie b bovenop de call stack gezet:

[
    b(),
    a(),
    anonymous(),
]

Vervolgens wordt functie c aangeroepen. Waardoor voor functie c een Function Execution Context gemaakt word. Ook hier weer een leeg arguments object. De scope chain krijgt hier een verwijzing naar de function execution context van functie b. In de creation phase zal de context zo uit zien:

{
    arguments: { length: 0 },
    this: window,
    cVar: undefined,
    scope_chain: [b, a, anonymous],
}

En in de execution phase ziet de Function Execution Context voor functie c er zo uit:

{
    arguments: { length: 0 },
    this: window,
    cVar: "een",
    scope_chain: [b, a, anonymous],
}

Ook hier word dan de function execution context voor functie c bovenop de call stack gezet:

[
    c(),
    b(),
    a(),
    anonymous(),
]

Als de code van functie c wordt uitgevoerd gebeurt het volgende nadat cVar is gezet en de console.log wordt aangeroepen:

Closures

Nu je eerder opgedane kennis je eigen hebt gemaakt, kunnen we kijken naar closures. Laten we een closure "by example" leren kennen. Onderstaand stukje code logt telkens iets naar de console. En hoe die dat doet, is zeer bijzonder en mogelijk dankzij een closure:

function makePrefixedLogger(firstName, lastName)
{
  let name = firstName+' '+lastName;
  return function(message) {
    let date = new Date();
    let log = '['+date.getHours()+':'+date.getMinutes()+':'+date.getSeconds()+' '+name+'] '+message;
    console.log(log);
  }
}

let log = makePrefixedLogger('Jules', 'Graus');

log('Dit is gaaf!'); //Console.log output: [15:38:50 Jules Graus] Dit is gaaf!
log('En dit ook!'); //Console.log output: [15:38:50 Jules Graus] En dit ook!

Op een bepaald moment word de makePrefixedLogger functie aangeroepen met een voor en een achternaam. Op dat moment wordt in de creation phase van de functie makePrefixedLogger, een FEC gemaakt. Deze ziet er in de execution phase zo uit:

{
    arguments: { "Jules", "Graus" },
    this: window,
    firstName: "Jules",
    lastName: "Gaus"
    name: "Jules Graus"
    scope_chain: [anonymous],
}

En heeft dus een local scope waarin we de variabelen, firstName en lastName kunnen gebruiken. Je ziet dat de (outer) functie makePrefixedLogger, een (inner) functie retourneert. Die functie word geplaatst in de log variabele. En is dus ineens beschikbaar buiten makePrefixedLogger functie. Dit punt is een cruciaal punt in het begrijpen van closures. Want:

Wanneer een inner function beschikbaar wordt gesteld buiten de functie die de inner function maakte, word er een closure gemaakt!

Meestal is dat wanneer een outer function een inner function retourneert. Een closure is de combinatie van de inner function en de scope tussen de inner en outer scope. Die scope heeft via de scope chain toegang tot omliggende scopes (dit noemen ze lexical / static scoping). Wat hierin ook essentieel is om te begrijpen, is dat wanneer de inner function geretourneerd word, de scope van de outer function, behouden wordt. Dit omdat de inner function daar variabelen of functies uit gebruikt. Wat dit betekent, is:

Wanneer de geretourneerde inner function word aangeroepen, kan deze nog altijd aan de variabelen en functies van de outer function, ook al is de outer function al klaar met uitvoeren.

Sterker nog, al roep je de geretourneerde inner function 1000 keer aan, dan nog heeft deze toegang tot de variabelen van de outer function.

Dus in dit geval, kun je je dus zo vaak als je wil de log functie aanroepen. Deze draagt altijd de name, firstName en lastName variabelen met zich mee. Handig toch?