Advanced JavaScript Concepts: A Crash Course for Front-End Developers
Introduction
Welcome to this crash course on advanced JavaScript! This chapter builds upon the fundamentals of JavaScript and explores key concepts essential for any front-end developer. If you are new to JavaScript, it is highly recommended to first review the JavaScript Fundamentals Crash Course to ensure a solid foundation.
This chapter will cover a curated selection of advanced JavaScript topics that are frequently encountered in front-end development interviews and daily coding tasks. While not exhaustive, these topics are crucial for professional front-end work.
This is structured as a comprehensive learning resource, designed to be studied at your own pace. Feel free to pause, take breaks, and most importantly, code along with the examples provided. By the end of this chapter, you will have a strong understanding of advanced JavaScript concepts, empowering you to tackle more complex front-end challenges.
Topics Covered
This chapter will delve into the following advanced JavaScript concepts:
- Scope: Nested Functions Scope
- Closures
- Currying
- The
this
Keyword - Prototype and Prototypal Inheritance
- Classes
- Iterators and Iterables
- Generators
Asynchronous JavaScript concepts will be addressed in a subsequent chapter. Let’s begin by revisiting the concept of scope, starting with nested functions.
Scope: Nested Functions Scope
Revisiting Scope in JavaScript Fundamentals
In the JavaScript Fundamentals Crash Course, we explored the concept of scope, which determines the accessibility of variables within different parts of your code. We identified three primary types of scope:
-
Block Scope: Variables declared within curly braces
{}
(e.g., withinif
statements,for
loops, or standalone blocks) are only accessible within that block.Block Scope: Variables declared using
let
orconst
within a block of code (defined by curly braces{}
) are only accessible within that specific block and its nested blocks. This helps in creating more predictable and less error-prone code by limiting variable visibility. -
Function Scope: Variables declared inside a function are only accessible within that function.
Function Scope: Variables declared using
var
,let
, orconst
inside a function are local to that function. They cannot be accessed from outside the function, ensuring data encapsulation and preventing naming conflicts. -
Global Scope: Variables declared outside of any function or block are in the global scope and can be accessed from anywhere in your JavaScript code.
Global Scope: Variables declared outside of any function or block are considered global. They can be accessed and modified from anywhere in the script. However, excessive use of global variables is generally discouraged as it can lead to naming collisions and make code harder to maintain and debug.
We also learned that variables in the global scope are accessible within both block and function scopes.
Nested Functions Scope: An Example
Let’s examine how scope behaves when functions are nested within each other. Consider the following JavaScript code:
let a = 10; // Global scope
function outer() {
let b = 20; // Function scope of 'outer'
function inner() {
let c = 30; // Function scope of 'inner'
console.log(a, b, c);
}
inner(); // Invoking 'inner' function within 'outer'
}
outer(); // Invoking 'outer' function
When this code is executed, the output will be:
10 20 30
Let’s break down how the JavaScript engine resolves variable access within the inner
function’s console.log
statement:
- Variable
c
: JavaScript first checks ifc
is defined within the scope of theinner
function. It is, and its value is30
. - Variable
b
: JavaScript then checks ifb
is defined within the scope of theinner
function. It is not. Therefore, it moves one level up to the scope of theouter
function.b
is defined in theouter
function scope with a value of20
. - Variable
a
: JavaScript checks fora
in theinner
function scope (not found), then in theouter
function scope (not found). It continues moving up the scope chain until it reaches the global scope.a
is found in the global scope with a value of10
.
This process illustrates lexical scoping.
Lexical Scoping: Also known as static scoping, lexical scoping refers to the ability of a function to access variables from its surrounding or “parent” scopes in the hierarchy at the time the function is defined, not when it is executed. This scope is determined by the physical location of the function in the code.
In essence, when dealing with nested functions, JavaScript’s variable lookup begins in the innermost function’s scope and proceeds outwards through each enclosing scope until it reaches the global scope.
Key takeaway: Nested functions have access to variables declared in their own scope as well as variables declared in any of their outer (enclosing) scopes.
Closures
Building upon our understanding of nested functions and scope, we now explore closures, a fundamental concept in JavaScript.
Defining Closures
According to Mozilla Developer Network (MDN), a closure is:
Closure (MDN Definition): The combination of a function bundled together (enclosed) with references to its surrounding state (the lexical environment). In other words, a closure gives you access to an outer function’s scope from an inner function. In JavaScript, closures are created every time a function is created, at function creation time.
While this definition is accurate, it might not be immediately intuitive. Let’s delve deeper into what closures are and how they work with a practical example.
Understanding Closures with an Example
Consider a simplified version of our nested function example:
function outer() {
let counter = 0;
function inner() {
counter++;
console.log(counter);
}
inner(); // Invoking 'inner' within 'outer'
}
outer(); // First invocation of 'outer'
outer(); // Second invocation of 'outer'
When you run this code, the output is:
1
1
This is because each time outer()
is called, a new execution context is created. The counter
variable is initialized to 0
within each execution context, incremented to 1
by inner()
, and then the value 1
is logged. Each call to outer()
results in an independent execution and a fresh counter
.
Now, let’s modify the code to return the inner
function from outer
:
function outer() {
let counter = 0;
function inner() {
counter++;
console.log(counter);
}
return inner; // Returning the 'inner' function
}
const fn = outer(); // Assign the returned 'inner' function to 'fn'
fn(); // First invocation of 'fn' (which is 'inner')
fn(); // Second invocation of 'fn'
Running this modified code yields a different output:
1
2
This output demonstrates the concept of closures. We expected 1
and 1
as before, but we got 1
and 2
. This is because of how closures work in JavaScript.
A Simpler Definition of Closures
Here’s a more straightforward definition of closures:
Closure (Simplified Definition): In JavaScript, when a function is returned from another function, it’s not just the function definition that is returned. It’s also returned along with its surrounding scope. This creates a persistent memory for the returned function, allowing it to remember and access variables from its outer function’s scope, even after the outer function has finished executing. This combination of the function and its scope chain is called a closure.
Breakdown of the Closure Example
In our example:
- When
outer()
is called, it returns theinner
function. Importantly, it also “closes over” thecounter
variable from its scope. - The returned
inner
function is assigned to the variablefn
. - When
fn()
is invoked for the first time, it accesses thecounter
variable from its closed-over scope.counter
is incremented to1
, and1
is logged. - Crucially,
fn
remembers the state ofcounter
(which is now1
). - When
fn()
is invoked a second time, it again accesses the samecounter
variable from its closed-over scope.counter
is incremented again to2
, and2
is logged.
The key takeaway is that closures allow inner functions to retain access to variables in their outer function’s scope even after the outer function has completed execution. This persistent memory and access are what define a closure.
Closures are a powerful feature in JavaScript, enabling data encapsulation, creating private variables, and implementing patterns like function factories.
Currying
Now that we understand closures, let’s move on to currying, a technique in functional programming that utilizes closures.
What is Currying?
Currying: Currying is a transformation process in functional programming where a function that takes multiple arguments is transformed into a sequence of nested functions. Each nested function takes only one argument at a time.
Consider a function f(a, b, c)
. Currying transforms it into f(a)(b)(c)
. It’s important to note that currying doesn’t immediately call the function; it transforms it.
Currying Example in JavaScript
Let’s start with a function sum
that adds three numbers:
function sum(a, b, c) {
return a + b + c;
}
console.log(sum(2, 3, 5)); // Output: 10
To curry this sum
function, we’ll create a curry
function that takes sum
as an argument and returns its curried version:
function curry(fn) {
return function(a) {
return function(b) {
return function(c) {
return fn(a, b, c); // Call the original function with all arguments
};
};
};
}
const curriedSum = curry(sum);
console.log(curriedSum(2)(3)(5)); // Output: 10
In this curry
function:
- It takes a function
fn
(in our case,sum
) as input. - It returns a series of nested functions. Each function takes one argument (
a
,b
,c
). - The innermost function finally calls the original function
fn
(oursum
function) with all the accumulated arguments.
We can break down the invocation curriedSum(2)(3)(5)
:
const add2 = curriedSum(2); // add2 is now a function that expects 'b'
const add3 = add2(3); // add3 is now a function that expects 'c'
const add5 = add3(5); // add5 is now the result of sum(2, 3, 5)
console.log(add5); // Output: 10
Applications of Currying
Currying is useful for creating reusable and specialized functions. For example, you can create functions like logInfo
, logError
, etc., where the logging level is pre-set, and you only need to provide the message.
Currying enhances function composition and code reusability in functional programming paradigms.
The this
Keyword
The this
keyword in JavaScript is a source of confusion for many developers. Understanding how this
behaves is crucial for mastering the language.
Understanding this
this
Keyword: In JavaScript, thethis
keyword within a function refers to the object that the function belongs to. Its value is dynamic and determined entirely by how the function is called (its invocation context).
The value of this
is not defined by where the function is declared, but rather by how it is invoked.
Determining this
Value: Invocation Rules
There are four primary rules to determine the value of this
in JavaScript:
- Implicit Binding:
- Explicit Binding:
- New Binding:
- Default Binding:
Let’s examine each rule with examples.
1. Implicit Binding
Rule: When a function is invoked using dot notation (.
), the object to the left of the dot becomes the value of this
inside the function.
Example:
const person = {
name: "Vishwas",
sayMyName: function() {
console.log("My name is " + this.name);
}
};
person.sayMyName(); // Output: My name is Vishwas
In person.sayMyName()
, person
is to the left of the dot. Therefore, inside sayMyName
, this
refers to the person
object.
2. Explicit Binding
Rule: Explicit binding allows you to explicitly set the value of this
using methods like call()
, apply()
, or bind()
.
Example using call()
:
function sayMyName() {
console.log("My name is " + this.name);
}
const person = {
name: "Vishwas"
};
sayMyName.call(person); // Output: My name is Vishwas
sayMyName.call(person)
invokes sayMyName
and explicitly sets this
to the person
object using the call()
method. The first argument to call()
is always the desired this
value.
3. New Binding
Rule: When a function is invoked using the new
keyword (constructor function), this
inside the function will always point to a newly created empty object.
Example:
function Person(name) {
// 'this' is an empty object {}
this.name = name; // Add 'name' property to 'this'
// Implicitly return 'this'
}
const p1 = new Person("Vishwas");
const p2 = new Person("Batman");
console.log(p1.name); // Output: Vishwas
console.log(p2.name); // Output: Batman
When new Person("Vishwas")
is executed:
- A new empty object
{}
is created. Person
function is called withthis
bound to the new object.this.name = name
adds thename
property to the new object.- The new object (now with the
name
property) is implicitly returned.
4. Default Binding
Rule: If none of the other three rules apply, JavaScript defaults to the global scope for this
. In non-strict mode, this
will point to the global object (e.g., window
in browsers, global in Node.js). In strict mode, this
will be undefined
.
Example (Non-strict mode in Node.js):
function sayMyName() {
console.log("My name is " + this.name);
}
// In global scope (Node.js), 'this' refers to the global object
sayMyName(); // Output: My name is undefined (because global object likely doesn't have 'name' property directly)
// However, if you declare a global variable 'name'
global.name = "Superman"; // In Node.js, use 'global' to set global properties
sayMyName(); // Output: My name is Superman
Precedence of Rules:
When multiple rules could apply, the order of precedence is:
- New Binding (highest priority)
- Explicit Binding
- Implicit Binding
- Default Binding (lowest priority)
Understanding these rules is essential for correctly predicting and controlling the behavior of this
in your JavaScript code.
Prototype and Prototypal Inheritance
Prototype and prototypal inheritance are core concepts in JavaScript’s object-oriented nature. They provide a mechanism for object sharing and inheritance that is different from class-based inheritance in languages like Java or C++.
Understanding Prototypes
Prototype (in JavaScript): In JavaScript, every function has a property called
prototype
. Thisprototype
property is an object itself. When a function is used as a constructor (invoked withnew
), theprototype
object is used as the prototype for the newly created objects (instances).
Let’s define a Person
constructor function:
function Person(firstName, lastName) {
this.firstName = firstName;
this.lastName = lastName;
}
const p1 = new Person("Bruce", "Wayne");
const p2 = new Person("Clark", "Kent");
We can add a method to all Person
instances using the prototype
property:
Person.prototype.getFullName = function() {
return `${this.firstName} ${this.lastName}`;
};
console.log(p1.getFullName()); // Output: Bruce Wayne
console.log(p2.getFullName()); // Output: Clark Kent
Here, Person.prototype.getFullName
adds the getFullName
method to the prototype
object of the Person
function. Now, every instance of Person
(like p1
and p2
) can access and use the getFullName
method.
Prototypal Inheritance
Prototypal Inheritance: JavaScript’s inheritance mechanism is based on prototypes. Objects inherit properties and methods from their prototypes. When you try to access a property on an object, and the object itself doesn’t have that property, JavaScript looks up the prototype chain to find the property.
Let’s create a Superhero
that inherits from Person
. A superhero is a person but has additional superhero-specific properties and methods.
function Superhero(firstName, lastName, isSuperhero) {
// Call Person constructor to inherit firstName and lastName
Person.call(this, firstName, lastName);
this.isSuperhero = isSuperhero;
}
// Set up prototype chain: Superhero.prototype inherits from Person.prototype
Superhero.prototype = Object.create(Person.prototype);
Superhero.prototype.fightCrime = function() {
console.log("Fighting crime!");
};
// Correct the constructor property of Superhero.prototype
Superhero.prototype.constructor = Superhero;
const batman = new Superhero("Bruce", "Wayne", true);
console.log(batman.getFullName()); // Output: Bruce Wayne (inherited from Person)
batman.fightCrime(); // Output: Fighting crime! (Superhero specific)
console.log(batman.isSuperhero); // Output: true (Superhero specific property)
Explanation of Prototypal Inheritance Steps:
- Constructor Chaining:
Person.call(this, firstName, lastName);
calls thePerson
constructor withthis
context ofSuperhero
instance, inheritingfirstName
andlastName
properties. - Prototype Delegation:
Superhero.prototype = Object.create(Person.prototype);
sets up the prototype chain.Superhero.prototype
now inherits fromPerson.prototype
.Object.create
creates a new object that prototypally inherits fromPerson.prototype
. - Adding Superhero-Specific Methods:
Superhero.prototype.fightCrime = ...
adds methods specific toSuperhero
to its prototype. - Constructor Correction:
Superhero.prototype.constructor = Superhero;
is important to correct theconstructor
property ofSuperhero.prototype
, which would otherwise incorrectly point toPerson
.
Now, batman
(a Superhero
instance) inherits properties and methods from both Superhero.prototype
and Person.prototype
, demonstrating prototypal inheritance.
Classes
Introduced in ECMAScript 2015 (ES6), the class
keyword provides a more familiar syntax for creating objects and dealing with inheritance in JavaScript, especially for developers coming from class-based languages.
Classes as Syntactical Sugar
JavaScript Classes: Classes in JavaScript are primarily syntactical sugar over the existing prototypal inheritance mechanism. They provide a cleaner and more structured way to work with prototypes and inheritance but do not introduce a new object-oriented model.
Let’s rewrite our Person
and Superhero
examples using classes.
Person Class
class Person {
constructor(firstName, lastName) {
this.firstName = firstName;
this.lastName = lastName;
}
getFullName() {
return `${this.firstName} ${this.lastName}`;
}
}
const classPerson1 = new Person("Bruce", "Wayne");
console.log(classPerson1.getFullName()); // Output: Bruce Wayne
Class Syntax Breakdown:
class Person { ... }
: Declares a class namedPerson
.constructor(firstName, lastName) { ... }
: Defines the constructor method. This method is called when you create a new instance usingnew Person()
. It initializes object properties.getFullName() { ... }
: Defines a method on thePerson.prototype
.
Superhero Class (Inheritance with extends
and super
)
class Superhero extends Person {
constructor(firstName, lastName, isSuperhero) {
super(firstName, lastName); // Call the parent class (Person) constructor
this.isSuperhero = isSuperhero;
}
fightCrime() {
console.log("Fighting crime!");
}
}
const classBatman = new Superhero("Bruce", "Wayne", true);
console.log(classBatman.getFullName()); // Output: Bruce Wayne (inherited)
classBatman.fightCrime(); // Output: Fighting crime!
console.log(classBatman.isSuperhero); // Output: true
Class Inheritance Syntax:
class Superhero extends Person { ... }
: DeclaresSuperhero
class that inherits fromPerson
class usingextends
.super(firstName, lastName);
: Inside theSuperhero
constructor,super()
calls the constructor of the parent class (Person
). It’s essential to callsuper()
first in the constructor of the derived class to correctly initialize inherited properties.
Classes provide a more structured and readable way to define objects and inheritance in JavaScript, but it’s crucial to remember they are built upon the foundation of prototypal inheritance.
Iterables and Iterators
Iterables and iterators are protocols introduced in ECMAScript 2015 (ES6) to standardize how objects can be iterated over in JavaScript. They provide a uniform way to access elements of various data structures.
Iteration Before Iterables and Iterators
Before ES6, iteration in JavaScript relied heavily on for
loops, while
loops, and do...while
loops. Iterating over strings and arrays, for example, required different approaches:
// Iterating over a string (pre-ES6 approach)
const str = "vishwas";
for (let i = 0; i < str.length; i++) {
console.log(str[i]);
}
// Iterating over an array (pre-ES6 approach)
const arr = ['v', 'i', 's', 'h', 'w', 'a', 's'];
for (let i = 0; i < arr.length; i++) {
console.log(arr[i]);
}
This approach had some drawbacks:
- Complexity of Accessing Elements: Requires manual index tracking and condition checks.
- Data Structure Specificity: Iteration logic was often tied to the specific data structure (string vs. array).
Iterables and Iterators: A Uniform Approach
Iterables and iterators provide a more abstract and uniform way to iterate over data structures.
Iterable: An object that implements the iterable protocol. This protocol requires an object to have a method at the key
Symbol.iterator
. This method must return an iterator. Built-in iterables in JavaScript include:String
,Array
,Map
, andSet
.
Iterator: An object that conforms to the iterator protocol. This protocol requires an object to have a
next()
method. Thenext()
method must return an object with two properties:
value
: The current element being iterated over.done
: A boolean value indicating whether there are more elements to iterate over (false
if more elements exist,true
if iteration is complete).
The for...of
loop was introduced to work with iterables.
// Iterating over a string using for...of (ES6 iterables)
const str = "vishwas";
for (const char of str) {
console.log(char);
}
// Iterating over an array using for...of (ES6 iterables)
const arr = ['v', 'i', 's', 'h', 'w', 'a', 's'];
for (const item of arr) {
console.log(item);
}
The for...of
loop simplifies iteration by automatically handling the iterator and providing each element in sequence, abstracting away the complexity of index management.
Creating a Custom Iterable and Iterator
Let’s create a custom object that is iterable:
const obj = {
[Symbol.iterator]: function() { // Iterable protocol: Symbol.iterator method
let step = 0;
const iterator = { // Iterator protocol: iterator object
next: function() { // Iterator protocol: next() method
step++;
if (step === 1) {
return { value: "hello", done: false };
} else if (step === 2) {
return { value: "world", done: false };
} else {
return { value: undefined, done: true }; // End of iteration
}
}
};
return iterator; // Return the iterator
}
};
for (const word of obj) {
console.log(word); // Output: hello, then world
}
Breakdown of Custom Iterable/Iterator:
- Iterable Protocol:
obj[Symbol.iterator] = function() { ... }
makesobj
iterable by defining theSymbol.iterator
method. - Iterator Object: Inside
Symbol.iterator
, we create aniterator
object with anext()
method. - Iterator Protocol: The
next()
method returns an object withvalue
anddone
properties according to the iterator protocol. - Iteration Logic: The
step
variable and conditional logic withinnext()
define the sequence of values to be iterated over (“hello”, “world”).
This example demonstrates how to create your own iterables and iterators in JavaScript, although in practice, you’ll primarily use built-in iterables and the for...of
loop.
Generators
Generators are a special type of function in JavaScript that simplify the creation of iterators. They provide a more concise and readable way to define custom iteration behavior.
What are Generators?
Generator Function: A generator function is a special type of function in JavaScript that can be paused and resumed during its execution. It allows you to define an iterative algorithm in a more declarative and less verbose way.
Generator functions are defined using the function*
syntax (function keyword followed by an asterisk). They use the yield
keyword to pause execution and return a value.
Generator Example: Rewriting the Iterable Example
Let’s rewrite our “hello world” iterable example using a generator function:
function* generatorFunction() { // Generator function syntax
yield "hello"; // Pause execution and yield "hello"
yield "world"; // Pause execution and yield "world"
}
const generatorObject = generatorFunction(); // Invoke generator to get iterator
for (const word of generatorObject) {
console.log(word); // Output: hello, then world
}
Generator Function Breakdown:
function* generatorFunction() { ... }
: Defines a generator function namedgeneratorFunction
.yield "hello";
andyield "world";
: Theyield
keyword pauses the generator function’s execution at eachyield
statement and returns the value followingyield
.const generatorObject = generatorFunction();
: Invoking a generator function does not execute the function code immediately. Instead, it returns a generator object. This generator object is an iterator.for...of generatorObject
: Thefor...of
loop iterates over the generator object (iterator). Each iteration implicitly calls thenext()
method of the iterator. Whennext()
is called, the generator function resumes execution from where it was paused at the lastyield
.
Benefits of Generators for Iterators
Generators significantly simplify iterator creation by:
- Implicit Iterator Creation: Generator functions automatically create iterator objects.
- Simplified
next()
Logic: You don’t need to manually implement thenext()
method or manage state variables likestep
. The generator function’s execution flow andyield
statements inherently define the iteration logic. - Readability: Generator functions often lead to more readable and concise code for defining iterative behaviors.
Generators are a powerful tool for creating custom iterators and managing complex iteration logic in JavaScript.
Conclusion
This crash course has covered a range of advanced JavaScript concepts crucial for front-end developers. From understanding scope and closures to mastering the this
keyword, prototypes, classes, iterators, and generators, you now have a solid foundation in these essential topics.
Remember to practice coding along with the examples and explore further resources to deepen your understanding. These concepts are not only important for front-end interviews but are also frequently applied in daily development tasks.
The next step in your JavaScript journey is to delve into asynchronous JavaScript, which will be covered in a subsequent chapter. Continue to explore, practice, and build upon this knowledge to become a proficient JavaScript developer!