I'm trying to understand arrow functions in JavaScript and have a few questions regarding how they interact with ExecutionContext/environment and closures.
How I understand the model:
To the best of my understanding, the "specification" model in JS is that as code gets executed, a stack of ExecutionContexts is maintained (1, 6). I.e. at the beginning there's a ExecutionContext for global, when a function is called a new ExecutionContext is added for the time of its execution, and when it finishes, it's popped. I.e. it matches frames on callstack.
Assuming a little bit of simplification (ignore diff between global/function/eval & no let and const (i.e. variable environment), ExecutionContext consists of LexicalEnvironemnt, which in turn is made of three components:
Environment record: mapping between variable/func symbols and objects they represent.
Reference to outer environment: Ref to lexically outer ExecutionContext
This binding: what this variable references. For unbound functions, this is set based on how the method is called (2)
When a function is called a new ExecutionContext is created for the duration of its execution (to track its variables as they change in Environment record, ...).
Normal functions
Normal function, within lexical scope:
For normal function, s.a. b() in example bellow, the creation of new ExecutionContext is relatively simple.
function a() {
var myVar = 42;
function b() {
console.log(myVar)
console.log(this)
}
b()
}
a()
Environment record: It's always simple for all types, just scan the method, note all symbols, init to default.
Reference to outer environment: We're running the method within its lexical outer scope, i.e. we can simply reference the EnvironmentContext that's currently (i.e. of a()) on execution stack (3). This gives us access to the outer lexical scope variable myVar.
It's called normally, so we'd go with global binding for this, i.e. in browser a window.
Normal function, outside lexical scope:
function a() {
let myVar = 42;
function b() {
console.log(myVar) // from closure
console.log(myCVar) // will not be accessible, even if it will have lived in above frame (from c)
console.log(this)
}
return b
}
function c(f) {
let myVar = 48;
let myCVar = 49;
f()
}
returnedFun = a()
c(returnedFun)
In this case, when we run the method b (as f() within method c, after being returned from a), it is not so simple. 1) and 3) portions of the new ExecutionContext are still populated the same, but 2) has to be different.
At the point where b is returned from its lexical scope, i.e. from the function a, a closure must be created out of current ExecutionContext (the one for a() being executed, with myVar: 42 in environment record) and added to the returned function object b.
When the function object is executed in function c (f()), instead of wiring the newly created ExecutionContext's Reference to outer environment to the one on top of execution stack (i.e. the one for the currently executing c()), the closure of the function object f (returned function b) must be used instead.
I.e. the Reference to outer environment for the just being created ExecutionContext of just executed f() doesn't point to ExecutionContext of the function that's currently running (i.e. runtime outer scope; would be of c()) but to a captured closure of a no-longer-running lexically-outer-environment (a()).
This captured closure is visible as ?pseudo? property when console.dir of the returnedFun object (.[[Scopes]][0].myVar == 42).
Normal function, bound
let myObj = {asdf: 42}
function a() { console.write("tst");}
console.dir(a.bind(myObj))
Similarly, when bind is used explicitely - the args/this is added to the function object, visible as ?pseudo? property [[BoundThis]]. And it's used, when the function object is invoked and the corresponding ExecutionContext is created to populate its This binding.
Arrow functions
But what about arrow functions? To the best of my googling, a common way to explain them is that they don't get their own ExecutionContext (4, 5) and instead re-use the one of their lexical outer-scope; but how does that work, really?
Arrow functions, within lexical scope:
function a() {
let myVar = 42;
b = () => {
var myBVar = 48;
}
b()
console.log(myBVar) // not accessible -> run of b() must use copy of a's EC
}
a()
When the arrow function is executed in its lexical scope, it's - again - relatively straightforward. When function b() is executed, the current ExecutionContext (for a, which is b's lexical outer scope) is duplicated (needs to be to allow having just its own variables, otherwise during a() you could access myBVar) and used; including this binding (demonstated by explicit binding example bellow).
function a() {
console.log(this)
arrF = () => {
console.log(this.myMyObjVar)
}
arrF() // when called duplicates current ExecutionContext (LexicalEnvironment + thisBinding), runs in it.
}
var myObj = {myMyObjVar: 42}
a.bind(myObj)()
Arrow functions, outside lexical scope
But what if the arrow function escapes its lexical scope? I.e. it needs to have closure created?
function a() {
console.log(this)
var asdf = 48;
arrF = () => {
console.log(this.myMyObjVar)
console.log(asdf)
}
return arrF
}
var myObj = {myMyObjVar: 42}
aBound = a.bind(myObj)
returnedArrF = aBound()
returnedArrF()
console.dir(returnedArrF)
In this case, returnedArrF's closure needs to not only contain the Environment record of a()'s ExecutionContext (to provide normal closure access to variables from outer lexical scope (asdf)), i.e. what Chromium Devtools show us as [[Scopes]], but also to its This binding. I.e needs to save pretty much the whole ExecutionContext, to allow the excaped arrow function - when executed - to not need to have its own and reuse its outer lexical scope's one.
Curiously, the stored This binding doesn't seem to be surfaced as ?pseudo? property visible with console.dir, the same way as either bind'ed this or normal closure is.
What are my questions?
Are the references to outer lexical context's ExecutionContext, specifically this binding for arrow functions, stored using similar mechanism (under similar model) as closure (think [[scopes]] as chrome dev tools show them) is?
If that's the case, why are both thisBinding created by bind(...) and normal closures visible in via Chrome devtools/console.dir, but arrow function's this binding isn't? Is it just implementation detail or is there some higher level reason?
Why are there differences in how explicitely bind'ed functions and arrow functions look when being inspected (or is it just implementation detail and not something JS model mandates?)?
Do I have the model right?
What is not my question / notes?
I understand that ExecutionContext etc. is just a specification "model" and not how individual VMs (V8, ...) implement JS. I also understand that Chromium devtools might show "pseudo" properties that don't really exist/are accessible on the objects (s.a. [[Scopes]]).
I'm also not interested in how arrow functions manifest, how to work with them (I think I have decent grasp; but if you think I missed something based on my examples - feel free to tell me).
Instead, I'm curious how the specification "model" maps to actual implementation. I hope it's clear from the questions ?.
Notes:
Things I tried to read to make sense of this:
https://betterprogramming.pub/javascript-internals-execution-context-bdeee6986b3b#:~:text=There%20are%20three%20types%20of,in%20which%20code%20is%20executed
https://blog.bitsrc.io/understanding-execution-context-and-execution-stack-in-javascript-1c9ea8642dd0
https://betterprogramming.pub/execution-context-lexical-environment-and-closures-in-javascript-b57c979341a5
https://medium.com/front-end-weekly/the-strange-case-of-arrow-functions-and-mr-3087a0d7b71f
https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Functions/Arrow_functions