YouTube Courses - Learn Smarter

YouTube-Courses.site transforms YouTube videos and playlists into structured courses, making learning more efficient and organized.

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., within if statements, for loops, or standalone blocks) are only accessible within that block.

    Block Scope: Variables declared using let or const 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, or const 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:

  1. Variable c: JavaScript first checks if c is defined within the scope of the inner function. It is, and its value is 30.
  2. Variable b: JavaScript then checks if b is defined within the scope of the inner function. It is not. Therefore, it moves one level up to the scope of the outer function. b is defined in the outer function scope with a value of 20.
  3. Variable a: JavaScript checks for a in the inner function scope (not found), then in the outer 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 of 10.

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:

  1. When outer() is called, it returns the inner function. Importantly, it also “closes over” the counter variable from its scope.
  2. The returned inner function is assigned to the variable fn.
  3. When fn() is invoked for the first time, it accesses the counter variable from its closed-over scope. counter is incremented to 1, and 1 is logged.
  4. Crucially, fn remembers the state of counter (which is now 1).
  5. When fn() is invoked a second time, it again accesses the same counter variable from its closed-over scope. counter is incremented again to 2, and 2 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:

  1. It takes a function fn (in our case, sum) as input.
  2. It returns a series of nested functions. Each function takes one argument (a, b, c).
  3. The innermost function finally calls the original function fn (our sum 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, the this 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:

  1. Implicit Binding:
  2. Explicit Binding:
  3. New Binding:
  4. 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:

  1. A new empty object {} is created.
  2. Person function is called with this bound to the new object.
  3. this.name = name adds the name property to the new object.
  4. 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:

  1. New Binding (highest priority)
  2. Explicit Binding
  3. Implicit Binding
  4. 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. This prototype property is an object itself. When a function is used as a constructor (invoked with new), the prototype 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:

  1. Constructor Chaining: Person.call(this, firstName, lastName); calls the Person constructor with this context of Superhero instance, inheriting firstName and lastName properties.
  2. Prototype Delegation: Superhero.prototype = Object.create(Person.prototype); sets up the prototype chain. Superhero.prototype now inherits from Person.prototype. Object.create creates a new object that prototypally inherits from Person.prototype.
  3. Adding Superhero-Specific Methods: Superhero.prototype.fightCrime = ... adds methods specific to Superhero to its prototype.
  4. Constructor Correction: Superhero.prototype.constructor = Superhero; is important to correct the constructor property of Superhero.prototype, which would otherwise incorrectly point to Person.

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 named Person.
  • constructor(firstName, lastName) { ... }: Defines the constructor method. This method is called when you create a new instance using new Person(). It initializes object properties.
  • getFullName() { ... }: Defines a method on the Person.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 { ... }: Declares Superhero class that inherits from Person class using extends.
  • super(firstName, lastName);: Inside the Superhero constructor, super() calls the constructor of the parent class (Person). It’s essential to call super() 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, and Set.

Iterator: An object that conforms to the iterator protocol. This protocol requires an object to have a next() method. The next() 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:

  1. Iterable Protocol: obj[Symbol.iterator] = function() { ... } makes obj iterable by defining the Symbol.iterator method.
  2. Iterator Object: Inside Symbol.iterator, we create an iterator object with a next() method.
  3. Iterator Protocol: The next() method returns an object with value and done properties according to the iterator protocol.
  4. Iteration Logic: The step variable and conditional logic within next() 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:

  1. function* generatorFunction() { ... }: Defines a generator function named generatorFunction.
  2. yield "hello"; and yield "world";: The yield keyword pauses the generator function’s execution at each yield statement and returns the value following yield.
  3. 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.
  4. for...of generatorObject: The for...of loop iterates over the generator object (iterator). Each iteration implicitly calls the next() method of the iterator. When next() is called, the generator function resumes execution from where it was paused at the last yield.

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 the next() method or manage state variables like step. The generator function’s execution flow and yield 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!