18 min read
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 other important, and often misunderstood, topics in JavaScript.
Execution Context
Everytime a function is invoked, a new execution context is created and appended to the top of the callstack. Even if there is no function invocation, for example a for
loop running in global scope, there is always at least the global execution context.
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. 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
.
Execution Context and Scope
This environment record is what is commonly referred to as scope.
A for
loop will not create a new execution context, it only modifies the environment record of the running execution context, and if the for
loop is in global scope, then it modifies the environment record of the global execution context. Also, if you have some global code and a block is evaluated, such as an if
statement 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.
Environment Record
. Anytime a function is invoked, a new execution context is created and every execution context has a LexicalEnvironment
component which is a pointer to an Environment Record
.function greet(name) {
console.log(`hi ${name}`)
}
greet('david')
The below code represents how this might look, notice the topmost execution context is the function execution context, it is ordered just as it would be if it were in the callstack.
GreetExecutionContext = {
LexicalEnvironment: GreetEnvironmentRecord
}
GreetEnvironmentRecord = {
type: FunctionEnvironmentRecord,
bindings: {
name: 'david',
arguments: {
0: 'david',
length: 1
}
},
[[ThisValue]]: window, // for browsers
[[ThisBindingStatus]]: "initialized",
[[FunctionObject]]: <reference to greet>,
[[NewTarget]]: undefined,
[[OuterEnv]]: GlobalEnvironmentRecord
}
GlobalExecutionContext = {
LexicalEnvironment: GlobalEnvironmentRecord
}
GlobalEnvironmentRecord = {
Type: GlobalEnvironmentRecord,
[[ObjectRecord]]: {
Type: ObjectEnvironmentRecord,
[[BindingObject]]: <global object>, // e.g., window in browsers
Bindings: {
// Built-in globals (e.g., Math, Array, JSON, etc.)
// Also properties created via `var` (not used in this example)
},
[[OuterEnv]]: null
},
[[DeclarativeRecord]]: {
Type: DeclarativeEnvironmentRecord,
Bindings: {
foo: <ref. to greet function object>,
},
[[OuterEnv]]: null
},
[[GlobalThisValue]]: <global object>, // The value returned by this in global scope
[[OuterEnv]]: null
}
How Scope Gets Updated
Here is a function, it's clear when this function is invoked, a new execution context will be created and appended to the top of the callstack.
function foo() {
const x = 1
if (x > 0) {
const x = 2
console.log(x) // 2
}
console.log(x) // 1
}
foo()
But how will the function's execution context handle the if
block?
Let's first look at how the execution context is created for a function.
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 )
- 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 a new 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.
- 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 in 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.
14.2.2 Runtime Semantics: Block Evaluation
- Let oldEnv be the running execution context's LexicalEnvironment.
- Let blockEnv be NewDeclarativeEnvironment(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 lexical environment is pointed to the new environment record. On step 2, a new block scope is created. When the block is exited the running execution contexts lexical environment is pointed back to the previous environment record 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
When an identifier such as a variable or function name is looked up, the engine first looks for it in the current scope’s environment record. If it’s not found there, it follows the [[OuterEnv]]
link to the outer environment and continues this lookup through each enclosing environment until it either finds the binding or reaches the global scope. If the identifier cannot be resolved anywhere in this chain, a ReferenceError
is thrown.
This chain of environment records linked by [[OuterEnv]]
is called the scope chain, and the process of following it is known as scope lookup.
const name = 'david'
function greet() {
console.log(`hi ${name}`)
}
greet()
When this code is executed, the JavaScript engine checks the current environment record for a binding for the identifier name
. If it doesn’t find one, it follows the [[OuterEnv]]
link to the next outer environment record and continues this process up the scope chain until it either finds the binding, or reaches the global scope. Here the binding is found in the global environment record.
GreetExecutionContext = {
LexicalEnvironment: GreetEnvironmentRecord
}
GreetEnvironmentRecord = {
type: FunctionEnvironmentRecord,
bindings: {
arguments: {
length: 0
}
},
[[ThisValue]]: window, // for browsers
[[ThisBindingStatus]]: "initialized",
[[FunctionObject]]: <reference to greet>,
[[NewTarget]]: undefined,
[[OuterEnv]]: GlobalEnvironmentRecord
}
GlobalExecutionContext = {
LexicalEnvironment: GlobalEnvironmentRecord
}
GlobalEnvironmentRecord = {
Type: GlobalEnvironmentRecord,
[[ObjectRecord]]: {
Type: ObjectEnvironmentRecord,
[[BindingObject]]: <global object>, // e.g., window in browsers
Bindings: {
// Built-in globals (e.g., Math, Array, JSON, etc.)
// Also properties created via `var` (not used in this example)
},
[[OuterEnv]]: null
},
[[DeclarativeRecord]]: {
Type: DeclarativeEnvironmentRecord,
Bindings: {
foo: <ref. to greet function object>,
name: 'david'
},
[[OuterEnv]]: null
},
[[GlobalThisValue]]: <global object>,
[[OuterEnv]]: null
}
Lexical Scope
Lexical scope refers to how scopes are determined during the lexing phase of compilation based on the physical structure of the code.
When the engine parses this code, the lexical structure tells it that foo
will create a function environment record when executed, and baz
will also create its own function environment record when executed. Notice the environment records will not be instantiated until the execution phase, at this point the parser has setup a blueprint for how the program will execute based on code structure. The environment record for baz
will have its [[OuterEnv]]
set to the environment record of foo
at runtime, reflecting the lexical nesting of the code.
This lexical structure tells the engine how each environment record’s [[OuterEnv]]
should be linked at runtime, forming the scope chain used to resolve identifiers.
function foo() {
const bar = 'bar'
function baz() {
console.log(bar)
}
}
Types of Scope
There are four types of scope:
- global scope
- function scope or local scope
- module scope
- block scope
The spec details Environment Record Operations
, each one of these operations creates a new Environment Record.
NewDeclarativeEnvironment
- block scopeNewObjectEnvironment
(used when NewGlobalEnvironment is invoked since the Global Environment Record consists of an Object Environment Record and a Declarative Environment Record).NewFunctionEnvironment
- function or local scopeNewGlobalEnvironment
- global scopeNewModuleEnvironment
- module scope
Note that NewFunctionEnvironment
creates a new Function Environment Record
and NewModuleEnvironment
creates a new Module Environment Record
.
Function Environment Records
and Module Environment Records
are subclasses of Declarative Environment Record
.
One other thing to note, it isn't often used but the with
keyword adds an Object Environment Record
for a computed object to the Lexical Environment of the running execution context. It then executes a statement using the augmented Lexical Environment and then restores the original Lexical Environment.
Function Scope (local scope)
Functions create a scope for variables declared with var
, let
and const
. When a function is invoked, a new function scope or more specifically, a new Function Environment Record is created with the NewFunctionEnvironment
environment record 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">
is evaluated, a new Module Environment Record is created by the NewModuleEnvironment
environment record operation.
16.2.1.6.4 InitializeEnvironment ()
- Let env be NewModuleEnvironment(realm.[[GlobalEnv]]).
This line details at what point the module environment record should be created.
<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
A new block scope is created for let x = 3
. In the new environment record, x
is in an uninitialized
state. This binding cannot be accessed until the point where the let x = 3
declaration is executed and the binding is initialized. This period, during which the binding exists but is not yet initialized, is known as the Temporal Dead Zone (TDZ).
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
ReferenceError
occurs when you try to access a variable that doesn't exist.console.log(x)
let x = 1
// Uncaught ReferenceError: x is not defined
Curly braces {}
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 such as let
or const
.
How Block Scope Is Evaluated According to the Spec
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.
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 1 is evaluated, the foo
function object will be invoked and the state of the program will look like this:
FooExecutionContext = {
LexicalEnvironment: FooEnvironmentRecord
}
FooEnvironmentRecord = {
type: FunctionEnvironmentRecord,
Bindings: {
x: 1
},
[[ThisValue]]: window, // for browsers
[[ThisBindingStatus]]: "initialized",
[[FunctionObject]]: <reference to foo>,
[[NewTarget]]: undefined,
[[OuterEnv]]: GlobalEnvironmentRecord
}
GlobalExecutionContext = {
LexicalEnvironment: GlobalEnvironmentRecord
}
GlobalEnvironmentRecord = {
Type: GlobalEnvironmentRecord,
[[ObjectRecord]]: {
Type: ObjectEnvironmentRecord,
[[BindingObject]]: <global object>, // e.g., window in browsers
Bindings: {
// Built-in globals (e.g., Math, Array, JSON, etc.)
// Also properties created via `var` (not used in this example)
},
[[OuterEnv]]: null
},
[[DeclarativeRecord]]: {
Type: DeclarativeEnvironmentRecord,
Bindings: {
foo: <function foo> // This is where `foo` belongs
},
[[OuterEnv]]: null
},
[[GlobalThisValue]]: <global object>, // The value returned by this in global scope
[[OuterEnv]]: null
}
Now line 5
is evaluated and a new Declarative Environment Record is created. The lexical environment for the execution context of foo
is updated.
FooExecutionContext = {
LexicalEnvironment: NewFooEnvironmentRecord
}
NewFooEnvironmentRecord = {
type: DeclarativeEnvironmentRecord,
Bindings: {
x: 2
},
[[OuterEnv]]: FooEnvironmentRecord
}
FooEnvironmentRecord = {
type: FunctionEnvironmentRecord,
Bindings: {
x: 1
},
[[ThisValue]]: window, // for browsers
[[ThisBindingStatus]]: "initialized",
[[FunctionObject]]: <reference to foo>,
[[NewTarget]]: undefined,
[[OuterEnv]]: GlobalEnvironmentRecord
}
GlobalExecutionContext = {
LexicalEnvironment: GlobalEnvironmentRecord
}
GlobalEnvironmentRecord = {
Type: GlobalEnvironmentRecord,
[[ObjectRecord]]: {
Type: ObjectEnvironmentRecord,
[[BindingObject]]: <global object>, // e.g., window in browsers
Bindings: {
// Built-in globals (e.g., Math, Array, JSON, etc.)
// Also properties created via `var` (not used in this example)
},
[[OuterEnv]]: null
},
[[DeclarativeRecord]]: {
Type: DeclarativeEnvironmentRecord,
Bindings: {
foo: <function foo> // This is where `foo` belongs
},
[[OuterEnv]]: null
},
[[GlobalThisValue]]: <global object>, // The value returned by this in global scope
[[OuterEnv]]: null
}
When evaluation leaves the block then the Lexical Environment before the block was entered is restored.
FooExecutionContext = {
LexicalEnvironment: FooEnvironmentRecord
}
FooEnvironmentRecord = {
type: FunctionEnvironmentRecord,
Bindings: {
x: 1
},
[[ThisValue]]: window, // for browsers
[[ThisBindingStatus]]: "initialized",
[[FunctionObject]]: <reference to foo>,
[[NewTarget]]: undefined,
[[OuterEnv]]: GlobalEnvironmentRecord
}
GlobalExecutionContext = {
LexicalEnvironment: GlobalEnvironmentRecord
}
GlobalEnvironmentRecord = {
Type: GlobalEnvironmentRecord,
[[ObjectRecord]]: {
Type: ObjectEnvironmentRecord,
[[BindingObject]]: <global object>, // e.g., window in browsers
Bindings: {
// Built-in globals (e.g., Math, Array, JSON, etc.)
// Also properties created via `var` (not used in this example)
},
[[OuterEnv]]: null
},
[[DeclarativeRecord]]: {
Type: DeclarativeEnvironmentRecord,
Bindings: {
foo: <function foo> // This is where `foo` belongs
},
[[OuterEnv]]: null
},
[[GlobalThisValue]]: <global object>, // The value returned by this in global scope
[[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 redeclaredvar 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
can not be redeclared// here we re-assign a value
let x = 1
x = 2
// here we try to re-declare a value
let x = 1
let x = 2 // Uncaught SyntaxError: Identifier 'x' has already been declared
Notice below we can re-declare x
again since we are using var
.
// here var is used, and is re declared, no error is thrown
var x = 1
var x = 2 // x = 2
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
Everthing we learned up to now still applies with loops. let
and const
are block scoped, and var
is not. For each iteration a new environment record is created.
For each iteration of the below loop, a new block scope is not created because there is no let
or const
declaration. The JavaScript engine is lazy in this sense, as it only instantiates a scope if it has to, and since there's only a var
declaration, and var
is not block scoped, no block scope is created.
for (var 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
The reason we can't use const
is because const
cannot be reassigned.
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
i++
}
// Uncaught TypeError: Assignment to constant variable.
In the case of forEach
, we pass a callback to it so for each iteration, a new execution context is created since a function is being called for each item in the array.
[1, 2, 3].forEach(num => {
const foo = num
})
For Loop Scope Advanced
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.
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).
- 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).
Below on step 1 a new environment record, thisIterationEnv, is created and the lexical environment of the current execution context is pointed at it.
Step 1 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)
}
We can prove this behavior with the below code block. The reason 0 is logged 3 times is because getI
closes over the i
variable, which refers to the variable declared when the loop was first initialized. Subsequent updates to the value of i
actually create new variables called i
, which getI
does not see.
for (
let i = 0, getI = () => i, incrementI = () => i++;
getI() < 3;
incrementI()
) {
console.log(i);
}
// 0, 0, 0
Parameter Scope
Default parameter initializers live in their own scope, which is a parent of the scope created for the function body.
This means that earlier parameters can be referred to in the initializers of later parameters. However, functions and variables declared in the function body cannot be referred to from default value parameter initializers; attempting to do so throws a run-time ReferenceError
.
function sum(/* scope for default params */ a = 1, b = () => a) {
// scope for function body
return a + b()
}
sum() // 2
The parent scope created for default parameters is visible to the child scope created for the function body.
function sum(a = 1, b = () => a) {
console.log(a) // 1
console.log(b) // () => a
return a + b()
}
sum() // 2
However, since the default parameters live in a parent scope of the function body, they can not reach into the child scope.
function sum(a = 1, b = () => console.log(c)) {
const c = 2
b()
}
sum()
// Uncaught ReferenceError: c is not defined
Parameters defined earlier (to the left) are available to later default parameters:
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.
FunctionDeclarationInstantiation
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
JavaScript scope is not hard to understand if you know how the JavaScript engine executes code under the hood. Each time a function is invoked, an if
block is encountered, or a for
loop runs in global scope, a new environment record will be created and that new environment record is what is commonly referred to as scope.
Scope chain is about how environment records reference outer environment records via their [[OuterEnv]]
property.
- There are four scopes: global, module, function (local), and block.
- The spec details certain operations such as
NewDeclarativeEnvironment
,NewFunctionEnvironment
ect. These operations create environment 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 an environment record.
- When a
for
loop is executed, a new execution context is not created. Rather a new environment record is created and the running execution context's lexical environment is pointed to the new environment record.