Javascript Fundamentals 1
This series of posts is composed of various notes regarding the basic fundamentals of how Javascript works under the hood.
Execution Context
Whenever a stack frame is added to the call stack in Javascript, a new execution context is created.
function printName() {
return 'Tim';
};
function findName() {
return printName();
};
function sayMyName() {
return findName();
};
sayMyName();
Here when we call 'sayMyName()' an execution context is created and added to the call stack. Each function call will create a new execution context. When return is called within a function, the return value is passed down the stack.
In javascript, there is a base global execution context which runs at the bottom of the stack which is made by the engine. This is popped off the stack once our program is finished running. This global context gives us the Global Object and 'this' keyword.
We can test this in the browser by opening an empty tab in our browser, and note that we will have access to 'this' which will point us to the 'window' object, which is our global object within the browser. We can also access this global object directly with 'window'. Whenever we add functions, variables, etc to our program, these are all added to our global execution context.
Lexical Environment
The lexical environment refers to where our code is written at compile time. So for example, if we declare functions within the global context, their lexical environment is in this global context. For example:
function a() {
function b() {
return 'hi'
}
return b();
}
In this context function a's lexical environment is the global execution context. Function b's lexical environment is 'a()'. This is how our compiler determines where our data should be put, and what our data can be accessed by. Every time we make a new function, these functions all have their own local environments with their own access to different data and variables based on their scope. This is what we call lexical scope.
Hoisting
The way our variables and functions are accessible within our scope is done by Hoisting. Functions are always hoisted, while variables are partially hoisted.
console.log('1')
console.log(teddy) // call teddy above where it is undefined, the result will be 'undefined'
console.log(sing()) // call sing above where it is defined, the result still calls the function
var teddy = 'bear';
function sing() {
console.log('ohh la la la')
}
In this example we can see that while teddy's result is undefined, our sing function is still called despite being invoked above its definition. This is because our declared functions are always hoisted, so they are available to be called anywhere in the file. Variables are not hoisted in this way however. The javascript engine when running a program actually has two phases when running files. First, it has a creation phase, followed by an execution phase. During the creation phase of our javascript engine, it is going to go through our file before it runs and reserve memory for all of our functions and variables. Hoisting happens during the creation phase, but variable assignments will happen during the execution phase. So the result of our creation phase will then operate as if it were written like this:
console.log('1-------')
var teddy = undefined; // our variable is hoisted, but not the value of the variable
function sing() { // our function declaration is fully hoisted
console.log('ohh la la la')
}
console.log(teddy)
console.log(sing())
teddy = 'bear';
This will not be the case for functions if the function keyword is not the first word on a line. For example:
(function sing() { // our function is not hoisted
console.log('ohh la la la')
})
It is also not the case for function expressions, only for function declarations. For example:
// function expression
var sing2 = function() { // this variable declaration is going to be hoisted, but the value of the variable will be undefined just like teddy previously - so we will get an error trying to call it in our file before its value has been assigned - which happens during execution phase.
console.log('uhh la la')
}
An interesting thing to see is what happens when we define the same var or the same function multiple times. For example:
function a() {
console.log('hi')
}
function a() {
console.log('bye')
}
var num = 1;
var num = 2;
console.log(num)
When we run this, our function result will print 'bye', and our num result will print 2. This is because when the engine runs through its creation phase, it will reassign the value of the function each time it is defined, and for our var it will simply ignore the 'var' declaration and just reassign its value.
We can make this more complicated with a fun little exercise. In the following code, what do you think the result will be?
var favouriteFood = 'pizza';
var food = function() {
console.log("original favourite food: " + favouriteFood);
var favouriteFood = 'sushi';
console.log('new favourite food: ' + favouriteFood);
};
food();
The answer is that our original favourite food will be undefined, but our new favourite food will be sushi. This is because of the way the javascript engine hoists variables and function expressions. When we call our function, a new creation and execution phase is run within scope of our function.
The result of our creation phase will then operate as if it were written like this:
var favouriteFood = undefined; // hoisted from our variable.
var food = undefined; // hoisted from our function expression (variable)
favouriteFood = 'pizza'; // value will be 'pizza'
// our function is then hoisted, so it will become something like:
food = function() {
var favouriteFood = undefined;
console.log("original favourite food: " + favouriteFood);
var favouriteFood = 'sushi';
console.log('new favourite food: ' + favouriteFood);
};
food();
This may seem confusing, and can make code seem unpredictable due to the hoisting. This is why some people will argue that it is a bad practice. So how can we avoid this in javascript? The simple solution is with the keywords 'const' and 'let' which came along in ES5 and ES6. If we changed all of our variables in the above example to const, instead of using hoisting, we would receive an error saying 'favouriteFood is not defined' in our function. Rather than running our code, it would pick up the error ahead of time and we would have to rewrite our code.
How about when we do this with functions? What do you think our result will be in this example?:
function bigBrother() {
function littleBrother() {
return 'it is me!';
}
return littleBrother();
function littleBrother() {
return 'no me!';
}
}
bigBrother();
In this case, our code will print 'no me!'. This is because during the creation phase of the bigBrother function will go through the file first and set the value of the function to the last value given. All of this is determined by lexical scope.
Function Invocation
As mentioned earlier, we have both function expressions and function declarations.
Function declarations are declared with the 'function' syntax:
function declaration() {
console.log('declaration')
}
Function expressions are declared as a variable:
var expression = function() {
console.log('expression')
}
Function expressions can also take the form of arrow functions in modern Javascript like this. It has the same effect
var arrow = () => {
console.log('arrow expression')
}
When we need to execute a function, we call this function invocation / call / execution. Every time we call these functions the engine creates a new execution context on top of our global context in the call stack. Within the execution context, we get a new 'this' keyword, as well as 'arguments'. This is an object of all of the functions parameters.
function top3Animals(animal1, animal2, animal3) {
console.log('arguments', arguments);
return `The top 3 animals are ${animal1}, ${animal2}, and ${animal3}`
}
top3Animals('frog', 'pigeon', 'tiger');
We'll see in the console log that our arguments log contains the given animals.
The arguments object can be dangerous to use directly as we cannot use array methods on this object, which in turn makes it very difficult for the compiler to optimise our code. If we want to treat the arguments as an array in modern javascript we can use the Array.from() method. We can then use array methods on it as it is transformed into an array.
In cases where we want to have an undefined number of parameters we can do it in modern javascript (ES6 onwards) by using Rest parameters. In this case we could use the spread operator do something like this:
function topAnimals(...args) {
console.log('arguments', args);
}