Javascript Scope
This article will attempt to deep dive in how scope works in javascript with practical examples and references to the official ECMAScript documentation.
Before we begin, there is a very important article you should read first detailing javascript execution context and how it works. This article will give you the perfect base to start understanding everything else in javascript.
So what is scope? There are so many technical terms that get loosely thrown around and oftentimes wrongly. Scope is not complicated, and if you read the previous artcle on execution context, you will get through this article without problem.
Execution Context
Everytime a function is invoked in javascript, a new execution context is created and appended to the top of the execution context stack or callstack. The execution context that is at the very top of the stack is referred to as the running execution context. Each execution context in the execution context stack is what is often referred to as a stack frame.
Let's quickly review how the process works:
The execution context has two phases, the creation phase
and the execution phase
. In the creation phase the LexicalEnvironment
and VariableEnvironment
state components get created.
Anytime any code is evaluated there is always at least the global execution context. If a function is invoked then a new execution context is created and pushed onto the top of the callstack. Each execution context has a LexicalEnvironment
and VariableEnvironment
state component.
The LexicalEnvironment
component is a pointer to an Environment Record
. The Environment Record
defines the association of identifiers to specific variables and functions based upon the lexical nesting structure of your code. The Environment Record
also contains a property called [[OuterEnv]]
which references it's outer LexicalEnvironment
.
function greet(name) {
console.log(`hi ${name}`)
}
greet('david')
The below code represents how this might look, notice that the topmost execution context is the function execution context, it is ordered just as it would be if it were in the callstack.
GreetFunctionExecutionContext = {
LexicalEnvironment: {
arguments: {
length: 1,
0: 'david'
},
[[OuterEnv]]: <ref. to GlobalExecutionContext Environment Record>,
[[ThisValue]]: window
}
}
GlobalExecutionContext = {
LexicalEnvironment: {
foo: <ref. to greet function object>,
[[OuterEnv]]: null
}
}
This is a basic example, but it starts to allow you to visualize how the javascript engine works internally.
Execution Context and Scope
Execution contexts occur anytime a function is invoked. If there is no function, for example in the case of a for
loop that is in global scope there is still a global execution context at the minimum. No matter what there is always at least the global execution context. A for
loop will not create a new execution context, it only modifies the environment record of the running execution context. Another example to drive this point home, if you have some global code and a block is evaluated with let
or const
declarations, then a new Environment Record
is created, and the running execution context's lexical environment is pointed to the new environment record.
How Scope Gets Updated
Let's try to solidify the above concepts before we get deeper into this.
function foo() {
const x = 1
if (x > 0) {
const x = 2
console.log(x) // 2
}
console.log(x) // 1
}
foo()
Functions in javascript are objects but special ones, they are special because they have an additional internal method, [[Call]]
. For this reason you will often see functions in javascript referred to as callable objects. If we look at what the spec says about the [[Call]]
internal method it says:
10.2.1 [[Call]] ( thisArgument, argumentsList )
It performs the following steps when called, pay attention to steps 2 and 3. On step 2 an abstract operation, PrepareForOrdinaryCall runs. In step 3, this new calleeContext returned from step 2 is set to the running execution context.
- Let callerContext be the running execution context.
- Let calleeContext be PrepareForOrdinaryCall(F, undefined).
- Assert: calleeContext is now the running execution context.
- If F.[[IsClassConstructor]] is true, then
- Let error be a newly created TypeError object.
- NOTE: error is created in calleeContext with F's associated Realm Record.
- Remove calleeContext from the execution context stack and restore callerContext as the running execution context.
- Return ThrowCompletion(error).
- Perform OrdinaryCallBindThis(F, calleeContext, thisArgument).
- Let result be Completion(OrdinaryCallEvaluateBody(F, argumentsList)).
- Remove calleeContext from the execution context stack and restore callerContext as the running execution context.
- If result.[[Type]] is return, return result.[[Value]].
- ReturnIfAbrupt(result).
- Return undefined.
Now let's take a look at the abstract operation, PrepareForOrdinaryCall which returns calleeContext. Pay attention to points 2, 7 and 8. Step 1 says that the callerContext
is the running execution context, for this example let's assume that the running execution context is the global execution context.
10.2.1.1 PrepareForOrdinaryCall ( F, newTarget )
The abstract operation PrepareForOrdinaryCall takes arguments F (a function object) and newTarget (an Object or undefined) and returns an execution context.
On line 7 something happens, a new scope is created. We said before that scopes are Environment Records
and on line 7 we get a new Function Environment Record
and on line 8, the LexicalEnvironment
of calleeContext is set to the scope created on line 7.
- Let callerContext be the running execution context.
- Let calleeContext be a new ECMAScript code execution context.
- Set the Function of calleeContext to F.
- Let calleeRealm be F.[[Realm]].
- Set the Realm of calleeContext to calleeRealm.
- Set the ScriptOrModule of calleeContext to F.[[ScriptOrModule]].
- Let localEnv be NewFunctionEnvironment(F, newTarget).
- Set the LexicalEnvironment of calleeContext to localEnv.
- Set the VariableEnvironment of calleeContext to localEnv.
- Set the PrivateEnvironment of calleeContext to F.[[PrivateEnvironment]].
- If callerContext is not already suspended, suspend callerContext.
- Push calleeContext onto the execution context stack; calleeContext is now the running execution context.
- NOTE: Any exception objects produced after this point are associated with calleeRealm.
- Return calleeContext.
When our example function is invoked, the above steps would take place. Now we need to figure out what happens on line 5 of the example code block when a const
declaration inside of a block is encountered. When line 5 is encountered, the Environment Record
will be modified for the current execution context. Here you can read the steps that occur when a block is evaluated or just read below.
14.2.2 Runtime Semantics: Evaluation
- Let
oldEnv
be the running execution context'sLexicalEnvironment
. - Let
blockEnv
beNewDeclarativeEnvironment(oldEnv)
. - Perform
BlockDeclarationInstantiation(StatementList, blockEnv)
. - Set the running execution context's LexicalEnvironment to blockEnv.
- Let blockValue be Completion(Evaluation of StatementList).
- Set the running execution context's LexicalEnvironment to oldEnv.
- Return ? blockValue.
Pay attention to what is happening here, when a function is invoked and a block is evaluated, a new execution context is not created, rather a new Envrionment Record
is created and the running execution context's LexicalEnvironment
is pointed to the new EnvironmentRecord
. On step 2, a new block scope is created. When the block is exited the running execution contexts LexicalEnvironment
is pointed back to the previous EnvironmentRecord
before the block was entered.
function foo() {
const x = 1
if (x > 0) {
// step 2 occurs and a new environment record (scope) is created
const x = 2
console.log(x) // 2
}
console.log(x) // 1
}
foo()
Scope Chain
Each time a variable or function is needed, the javascript compiler looks for it in it's current scope and if it can't find it there is checks the [[OuterEnv]]
field of the Environment Record
to see if the variable or function exists in the enclosing scope (outer Environment Record
), it will continue doing this all the way up to the global scope and if it still can't find it there then the compiler will complain and throw an error.
Therefore, scope chain is a chain of Environment Records
linked with the [[OuterEnv]]
property. Scope lookup is another common term describing how the javascript compiler will continue to look for a variable in the outer Environment Record
.
const name = 'david'
function greet() {
console.log(`hi ${name}`)
}
greet()
Here is how the above code block is evaluated, note how name: 'david'
exists in the GlobalExecutionContext
but not in GreetFunctionExecutionContext
.
When this code is executed the javascript compiler will see that there is no name
variable in the current Lexical Environment
so it will check in the [[OuterEnv]]
field of the running execution context's environment record to see if it can find it there, if not it continues to move up the scope chain until the variable is found otherwise the compiler will throw an error complaining that the variable could not be found.
GreetFunctionExecutionContext = {
LexicalEnvironment: {
[[ThisValue]]: window,
[[OuterEnv]]: <ref. to GlobalExecutionContext Environment Record>
}
}
GlobalExecutionContext = {
LexicalEnvironment: {
foo: <ref. to greet function object>,
name: 'david',
[[OuterEnv]]: null
}
}
Lexical Scope
Lexical scope refers to how scope is determined at lexing time which is the first stage in compilation. In simpler terms, lexical scope is determined by the physical placement of your code. Lexical scope is also referred to as static scope.
Looking at this code it's easy to see lexical scope, that is we can see which functions have access to certain variables due to the physical placement of the code.
function foo() {
const bar = 'bar'
function baz() {
console.log(bar)
}
}
Types of Scope
When reading about scope you will often see that there are four types of scope: global scope, function scope (local scope), module scope, and block scope.
Function Scope (local scope)
Functions also create a scope for variables declared with var
, let
and const
. As we seen above, it is a bit more complex than this and when a function is invoked a new function scope or more specifically, a new Function Environment Record is created with the NewFunctionEnvironment
operation.
function foo() {
const x = 1
}
console.log(x) // ReferenceError: x is not defined
Module Scope
Module scope is basically like a function and that's what modules essentially are. They are javascript files that have type="module"
and the file itself acts as one big function.
Anytime <script type="module"></script
is evaluated, a Module Environment Record
(module scope) is created by the NewModuleEnvironment
operation.
If you want to read about the exact steps that take place, you can read this section of the spec detailing how a module environment record is created.
If you read the spec in the above link, pay attention to step 5.
16.2.1.6.4 InitializeEnvironment ()
- Let env be NewModuleEnvironment(realm.[[GlobalEnv]]).
This line is what creates the Module Environment Record
otherwise known as module scope.
<script src="app1.js" type="module"></script>
<script src="app2.js" type="module"></script>
Global Scope
Anytime code is evaluated, there is always at least the global execution context. You can add a debugger
statement inside any function and you will see (anonymous)
inside of the call stack pane in chrome dev tools, this is simply the global execution context.
To expand on this a bit more, according to the spec:
Before it is evaluated, all ECMAScript code must be associated with a realm. Conceptually, a realm consists of a set of intrinsic objects, an ECMAScript global environment, all of the ECMAScript code that is loaded within the scope of that global environment, and other associated state and resources.
The abstract operation CreateRealm
creates a new Realm Record and a Realm Record has a few fields, but we will only look at [[GlobalObject]]
and [[GlobalEnv]]
. The [[GlobalObject]]
is an object or undefined and the [[GlobalEnv]]
is a Global Environment Record
or global scope. The [[OuterEnv]]
of the Global Environment Record
is null.
You can imagine the realm as a global execution context, since any time code is evaluated there must always be at least a global execution context.
The Global Environment Record
or global scope is the default scope if none of the other rules apply. Here you can find the global object, which is window in the case of browsers, bindings for built in globals, properties of the global object ect. It is a single enclosing environment record that wraps an Object Environment Record
and a Declarative Environment Record
. The bindings for all the declarations in global code are contained in the Declarative Environment Record
component of the Global Environment Record
and bindings for all built-in globals as well as the binding object are contained in the Object Environment Record
component of the Global Environment Record
.
When this code is run, when you take a look at the call stack you will see (anonymous)
and on top of that you will see foo
.
(anonymous)
is the global execution context.
function foo() {
debugger
}
foo()
We mentioned already that loops do not create a new execution context, they only modify the running execution context's Environment Record
. When this code is run, you will only see (anonymous)
in the call stack.
for (let i = 0; i < 3; i++) {
debugger
}
// same goes for for in
for (const num of [1, 2, 3]) {
debugger
}
In the below code block, const x = 1
is declared in global scope and is available in all the other files.
<script src="script.js"></script>
<script src="app1.js"></script>
<script src="app2.js"></script>
Block Scope
Curly brace pairs {}
do not always create scopes, as is the case for function declarations and object literals. When a block is evaluated a new Declarative Environment Record
(block scope) is created for let
and const
declarations, and bindings for each block scoped variable declared in the block are instantiated in the Environment Record.
{
// here there is not necessarliy a scope yet
var x = 1
// still no block scope as a block scope is not created for var variables
/* now the block needs to become a scope, by performing step 2
Let blockEnv be NewDeclarativeEnvironment(oldEnv)
*/
const x = 1
}
In other words, a block only becomes a scope if it has to in order to contain it's block scoped declarations (let
, const
).
How Block Scope Is Evaluated According to the Spec
14.2.2 Runtime Semantics: Evaluation
Block : { StatementList }
- Let
oldEnv
be the running execution context'sLexicalEnvironment
. - Let
blockEnv
beNewDeclarativeEnvironment(oldEnv)
. - Perform
BlockDeclarationInstantiation(StatementList, blockEnv)
. - Set the running execution context's LexicalEnvironment to
blockEnv
. - Let blockValue be Completion(Evaluation of StatementList).
- Set the running execution context's LexicalEnvironment to
oldEnv
. - Return ? blockValue.
Here is a short visualization of how that might look given the following code block.
function foo() {
const x = 1
if (x > 0) {
const x = 2
}
console.log(x)
}
foo()
Once line 11
is evaluated, the foo function object will be invoked and the state of the program will look like this:
FooFunctionExecutionContext = {
LexicalEnvironment: {
x: 1,
[[ThisValue]]: window,
[[OuterEnv]]: <ref. to GlobalExecutionContext Environment Record>
}
}
GlobalExecutionContext = {
LexicalEnvironment: {
foo: <ref. to foo function object>,
[[OuterEnv]]: null
}
}
Now line 5
is evaluated and a new Declarative Environment Record is created.
FooFunctionExecutionContext = {
LexicalEnvironment: {
x: 2,
[[ThisValue]]: window,
[[OuterEnv]]: <ref. to GlobalExecutionContext Environment Record>
}
}
GlobalExecutionContext = {
LexicalEnvironment: {
foo: <ref. to foo function object>,
[[OuterEnv]]: null
}
}
When evaluation leaves the block then the Lexical Environment before the block was entered is restored.
FooFunctionExecutionContext = {
LexicalEnvironment: {
x: 1,
[[ThisValue]]: window,
[[OuterEnv]]: <ref. to GlobalExecutionContext Environment Record>
}
}
GlobalExecutionContext = {
LexicalEnvironment: {
foo: <ref. to foo function object>,
[[OuterEnv]]: null
}
}
var
is Function Scoped but not Block Scoped
A new block scope is not created when a var
binding is evaluated inside curly braces {}
.
function foo() {
var x = 1
if (x > 0) {
// no need to create a block scope
var x = 2
}
console.log(x) // 2 not block scoped
}
console.log(x) // Uncaught ReferenceError: x is not defined
var
can be Re-Declared
var x = 1
{
var x = 2
}
// this logs 2 because var x = 2 is in the same scope as var x = 1
// we just wrote
// var x = 1
// var x = 2
// console.log(x)
console.log(x) // 2
let
and const
Cannot be Re-Declared
The reason this works is when the block is evaluated a new Environment Record is created and the running execution context's lexical environment is pointed to this new environment record (blockEnv) and on line 7 of the below code block, the lexical environment is pointed back to oldEnv.
const x = 1
{
const x = 2 // here blockEnv is created
}
// this is in global scope and the lexical environment
// of the global execution context is pointed back to oldEnv
console.log(x) // 1
Shadowing
We already talked about what scope lookup is, the compiler will look for a variable first in it's current scope and if it can't find it, it checks the [[OuterEnv]]
field of the running execution context's Environment Record
stopping as soon as a match is found. Below the x
parameter in the second code block shadows the global variable let x = 1
.
let x = 1
function foo() {
x++
console.log(x) // 2
}
foo()
console.log(x) // 2
Note how the global variable let x = 1
is incremented.
Here in the below code block, the local x
parameter shadows the global let x = 1
.
let x = 1
function foo(x) {
// imagine declaring a variable here
// let x = 1
x++
console.log(x) // 2
}
foo(1)
console.log(x) // 1
Here is another case of shadowing. From the inner forEach
there is no way to console.log the outer num
paramter.
const nums = [[1, 2], [3, 4], [5, 6]]
nums.forEach(num => {
console.log(num) // [1, 2]
num.forEach(num => {
// here there is no way to access the outer num
// because it's been shadowed
// this is a new scope and a new num parameter
console.log(num) // 1 2
})
})
If we needed access to the outer num
parameter from the inner forEach
, then we need to name the parameters differently.
nums.forEach(num => {
num.forEach(i => {
// here we have access to both
// `num` and `i`
})
})
Scope with Loops
let
declarations are special cased by for
loops. If the initialization block is a let
declaration, then everytime after the loop body is evaluated the following occurs:
- A new lexical scope is created with new
let
declared variables - The binding values from the last iteration are used to re-initialize the new variables
afterthought
is evaluated in the new scope
What that means is at first a scope is created for the initialization block and then the values are copied over to the new scope. We will take a deeper look at this later in this article.
Since for loops create a new block scope for variables declared with let
and const
for each loop iteration, in the below code, a new Environment Record
will be created for each loop iteration. This means there is a new foo
variable for every loop iteration.
for (const num of [1, 2, 3]) {
// num is treated as if it were declared here
const foo = num
}
In the case of forEach
we pass a callback to it so for each iteration, a new Execution Context
is created since the callback is called each time, and therefore a new Lexical Environment
for each iteration is created with it's own Environment Record.
[1, 2, 3].forEach(num => {
const foo = num
})
What scope is i
in?
The scoping effect of the initialization block can be understood as if the declaration is made withing the loop body, which creates a new block scope(blockEnv), as long as a let
declaration is present. If var
would be used, the scope of i
would not be the loop body.
for (let i = 0; i < 3; i++) {
// imagine it is declared here
// let i = 0
}
// or try to imagine it like this
let _i = 0
for (; i < 3; i++) {
let i = _i
}
Loops Create a New Block Scope for let
Declarations
This is an infamous interview question a lot of people get wrong and it deals with scope.
for (var i = 0; i < 3; i++) {
setTimeout(() => {
console.log(`val: ${i}`)
}, 1000)
}
// val: 3
// val: 3
// val: 3
The reason this happens is that a new block scope is not created for var
declarations.
The above code can be re-written with let
to achieve the same undesired result.
let i
for (i = 0; i < 3; i++) {
setTimeout(() => {
console.log(`val: ${i}`)
}, 1000)
}
// val: 3
// val: 3
// val: 3
This can be fixed by just using let
.
for (let i = 0; i < 3; i++) {
setTimeout(() => {
console.log(`val: ${i}`)
}, 1000)
}
// 0, 1, 2
Why Does const
Not Work
If a new Environment Record is created for each iteration of our loop then why can't we use const
in the regular for
loop?
for (const i = 0; i < 3; i++) {
console.log(i)
}
// Uncaught TypeError: Assignment to constant variable.
Let's re-write this to better visualize what is happening
const _i = 0
for (; _i < 3; _i++) {
// i is created for each iteration
const i = _i
// const cannot be re-assigned
i++
}
The problem is that const
variables cannot be re-assigned. The problem is coming from trying to increment const _i = 0
. Re-assignment is not allowed for constants.
For Loop Scope Advanced
Remember, whenever a for loop runs and there is a let
declaration in the initialization block, each time after the loop body is evaluated the following happens:
- A new lexical scope is created with new
let
variables - The bindings from the last iteration are used to re-initialize new variables
- the
afterthought
is evaluated in the new scope.
Let's compare the 3 simplified steps that we talked about before with the spec.
A few notes about what is happening below, note in ForLoopEvaluation
line 2, the operation NewDeclarationEnvironment
runs and creates a new Declarative Environment Record
referred to here as loopEnv. On line 6, the running execution context's lexical environment is pointed to this new declarative environment record. On line 12, the operation ForBodyEvaluation
runs and on line 13, the loop finishes and the lexical environment is pointed back to oldEnv from step 1.
14.7.4.2 Runtime Semantics: ForLoopEvaluation
- Let oldEnv be the running execution context's LexicalEnvironment.
- Let loopEnv be NewDeclarativeEnvironment(oldEnv).
- Let isConst be IsConstantDeclaration of LexicalDeclaration.
- Let boundNames be the BoundNames of LexicalDeclaration.
- For each element dn of boundNames, do
- If isConst is true, then
- Perform ! loopEnv.CreateImmutableBinding(dn, true).
- Else,
- Perform ! loopEnv.CreateMutableBinding(dn, false).
- If isConst is true, then
- Set the running execution context's LexicalEnvironment to loopEnv.
- Let forDcl be Completion(Evaluation of LexicalDeclaration).
- If forDcl is an abrupt completion, then
- Set the running execution context's LexicalEnvironment to oldEnv.
- Return ? forDcl.
- If isConst is false, let perIterationLets be boundNames; otherwise let perIterationLets be a new empty List.
- If the first Expression is present, let test be the first Expression; otherwise, let test be empty.
- If the second Expression is present, let increment be the second Expression; otherwise, let increment be empty.
- Let bodyResult be Completion(ForBodyEvaluation(test, increment, Statement, perIterationLets, labelSet)).
- Set the running execution context's LexicalEnvironment to oldEnv.
- Return ? bodyResult.
Here notice step 2, the CreatePerIterationEnvironment
operation runs, this is the first iteration of the loop. Here the variables are copied over from loopEnv which was setup in ForLoopEvaluation step 2. In this first iteration, the increment expression does not occur, it starts in the second iteration.
14.7.4.3 ForBodyEvaluation ( test, increment, stmt, perIterationBindings, labelSet )
- Let V be undefined.
- Perform ? CreatePerIterationEnvironment(perIterationBindings).
- Repeat,
- If test is not empty, then
- Let testRef be ? Evaluation of test.
- Let testValue be ? GetValue(testRef).
- If ToBoolean(testValue) is false, return V.
- Let result be Completion(Evaluation of stmt).
- If LoopContinues(result, labelSet) is false, return ? UpdateEmpty(result, V).
- If result.[[Value]] is not empty, set V to result.[[Value]].
- Perform ? CreatePerIterationEnvironment(perIterationBindings).
- If increment is not empty, then
- Let incRef be ? Evaluation of increment.
- Perform ? GetValue(incRef).
- If test is not empty, then
Below on step 1.d a new Environment Record, thisIterationEnv, is created and the Lexical Environment of the current execution context is pointed at it.
Step 1.e shows how the variables from the last iteration, lastIterationEnv, are copied over to thisIterationEnv, the new Environment Record from step 1.d.
14.7.4.4 CreatePerIterationEnvironment ( perIterationBindings )
- If perIterationBindings has any elements, then
- Let lastIterationEnv be the running execution context's LexicalEnvironment.
- Let outer be lastIterationEnv.[[OuterEnv]].
- Assert: outer is not null.
- Let thisIterationEnv be NewDeclarativeEnvironment(outer).
- For each element bn of perIterationBindings, do
- Perform ! thisIterationEnv.CreateMutableBinding(bn, false).
- Let lastValue be ? lastIterationEnv.GetBindingValue(bn, true).
- Perform ! thisIterationEnv.InitializeBinding(bn, lastValue).
- Set the running execution context's LexicalEnvironment to thisIterationEnv.
- Return unused.
for (let i = 0; i < 3; i++) {
console.log(i)
}
// first evaluation of the loop setup in ForLoopEvaluation step 2
// loopEnv
{
let i
i = 0;
__i = { i }
}
// CreatePerIterationEnvironment(perIterationBindings)
// thisIterationEnv
// first iteration no increment expression
{
let { i } = __i
if (i < 3) console.log(i) // 0
__i = { i }
}
// second iteration increment begins
{
let { i } = __i
i++
if (i < 3) console.log(i) // 1
__i = { i }
}
{
let { i } = __i
i++
if (i < 3) console.log(i) // 2
__i = { i }
}
{
let { i } = __i // 2
i++ // 3
// this will not run but a scope was created for it
if (i < 3) console.log(i)
}
Note that the first iteration of the loop the initialization block is creates a new scope to initialize the variables and then those values are copied over to the next scope inside of CreatePerIteration (step 1e). Step 1e details how the bindings from the lastIterationEnv are used to initialize new bindings for thisIterationEnv.
Now this is a trick question, we will take an example from mdn and break it down. This is a good example because it really shows how well you understand scope with for loops and closures are also present.
for (
let i = 0, getI = () => i, incrementI = () => i++;
getI() < 3;
incrementI()
) {
console.log(i);
}
// 0, 0, 0
On line 2, both functions form a closure which is a function bundled together with reference to it's lexical environment or outer Environment Record
([[OuterEnv]]
).
Also remember that the variable values are copied over to the new scope.
// ForLoopEvaluation (loopEnv)
{
let i, getI, incrementI
i = 0
// both of these functions close over this particular i variable
getI = () => i
incrementI = () => i++
$$lastIterationEnv = { i, getI, incrementI }
}
// First iteration
// note this is special cased and the increment is not evaluated the first iteration
{
// this is a new `i` variable
let { i, getI, incrementI } = $$lastIterationEnv
// getI() returns 1 instead of 0
// i inside the loop body is not updated however
// now the value i = 0 is copied over to the next scope (environment record)
if (getI() < 3) console.log(i) // 0
$$lastIterationEnv = { i, getI, incrementI }
}
{
let { i, getI, incrementI } = $$lastIterationEnv
// updated `i` from first scope due to closure, not the `i` variable in this scope
// more technically, updated `i` in a different environment record, not this
// particular environment record.
incrementI() // 1
if (getI() < 3) console.log(i) // 0
$$lastIterationEnv = { i, getI, incrementI }
}
{
let { i, getI, incrementI } = $$lastIterationEnv
incrementI() // 2
if (getI() < 3) console.log(i) // 0
$$lastIterationEnv = { i, getI, incrementI }
}
{
let { i, getI, incrementI } = $$lastIterationEnv
incrementI() // 3
if (getI() < 3) console.log(i) // does not run
$$lastIterationEnv = { i, getI, incrementI }
}
Parameter Scope
This is a bit interesting but if you read the spec closely, whenever there are parameters that have defaults, a seperate scope is created for those parameters.
In the below code block, x = 2
is not re-assigning let x = 1
from the outer scope, the compiler sees it as there is a new block scope and x = 2
is an attempt to re-assign let x = 3
.
let x = 1
{
x = 2 // this is trying to re-assign let x = 3 not let x = 1
let x = 3
}
// Uncaught ReferenceError: Cannot access 'x' before initialization
The reason this happens is because in the execution phase, x
is hoisted to the top of its scope.
let x = 1
{
// x: uninitialized (hoisted to the top of the scope/environment record)
x = 2
// the only way to initialize let or const is with assignment and declaration statement
// ie not x = 2, rather let x = 2. Same with `const`
let x = 3
}
// Uncaught ReferenceError: Cannot access 'x' before initialization
Now that we understand a bit about errors and how they work, let's break down the below code block. The first thing to note is that default parameters are being used here, the second thing to note is the param b = () => a
is forming a closure. The fact that default parameters are being used means that a seperate scope will be formed to prevent parameter expressions, b = () => a
, from closing over declarations in the function body.
The scope of the function body is nested inside the parent scope created for the non simple parameters.
function sum(/* new scope */ a = 1, b = () => a) {
// here a new scope is created
return a + b()
}
sum() // 2
The same rules still apply, the nested child scope of the function body has access to the parent scope created for the parameters.
function sum(a = 1, b = () => a) {
console.log(a) // 1
console.log(b) // () => a
return a + b()
}
sum() // 2
// this will not work since a parent scope
// can not reach into a child scope
function sum(a = 1, b = () => console.log(c)) {
const c = 2
b()
}
sum()
// Uncaught ReferenceError: c is not defined
function sum(a = b, b = 1) {
return a + b
}
sum()
// Uncaught ReferenceError: Cannot access 'b' before initialization
// but this works
function sum(a = 1, b = a) {
return a + b
}
sum() // 2
// this will also work
function sum(b = () => a, a = 1) {
return a + b() // by this time both parameters have been intialized
}
sum() // 2
There is one interesting edge case detailed in the spec under FunctionDeclarationInstantiation
on line 28.e.6.
NOTE: A var with the same name as a formal parameter initially has the same value as the corresponding initialized parameter.
What this means is that if you shadow a parameter with var
, it will be initialized to that value rather than the typical undefined
value we expect with hoisting.
function sum(a = 1, b = () => a) {
var a // this will not be auto initialized to undefined
console.log(a) // 1 not undefined
}
sum()
Review
This was a bit of a long article but these are very important concepts to understand. Having a solid foundation in these concepts is essential since this is a complex topic and there is a lot of wrong advice online. Anytime you want to dig deep, make sure to read the official ECMAScript documentation.
- There are four scopes: global, module, function (local), and block.
- The spec details certain operations such as
New Declarative Environment
,NewFunctionEnvironment
ect. These operations createEnvironment Records
. - Every time code is executed in javascript there is always at least the Global Execution Context.
- Every execution context has a
Lexical Environment
component. - A
Lexical Environment
is a pointer to anEnvironment Record
. - When a
for
loop is executed, a new execution context is not created. Rather a newEnvironment Record
is created and the running execution context's lexical environment is pointed to the newEnvironment Record
. - Scopes are
Environment Records
.