Some JavaScript Concepts

The Event Loop ➰

JavaScript is a single-threaded, non-blocking, asynchronous language. To understand how it handles concurrent operations, it is important to understand several key components:

Runtime Architecture

The JavaScript runtime consists of two main components:

  • Heap: Where memory allocation happens
  • Call Stack: Where stack frames are stored and execution contexts are tracked

🚨  Loupe - JavaScript's call stack/event loop/callback queue visualizer

The Call Stack

The call stack is single-threaded, meaning it can only execute one operation at a time. It's a data structure that records the current position in the program. When you:

  • Enter a function: Something is pushed onto the stack
  • Return from a function: Something is popped off the stack

💡 The stack trace shows the state of the stack when an error is encountered.

Blocking Operations

Blocking occurs when operations that take significant time (like network requests or image processing) prevent the execution of subsequent code. The call stack is blocked until such requests are complete before moving on to the next piece of code.

Asynchronous Operations

JavaScript handles asynchronous operations through several mechanisms:

Callbacks

Callbacks are functions that another function calls, either synchronously or asynchronously.

// Synchronous callback example
[1, 2, 3, 4].forEach((i) => {
  console.log(i);
});

// Asynchronous callback example - setTimeout is non-blocking
function asyncForEach(array, cb) {
  array.forEach((i) => {
    setTimeout(cb, 0);
  });
}

Concurrency and the Browser Environment

The browser is more than just the JS runtime - it consists of Web APIs that are essentially threads that can be called. When a Web API is called:

  1. It's pushed from the call stack to the Web API stack
  2. Runs until completion
  3. Gets pushed to the Task Queue (not directly back to the event loop)

Task Queue and Event Loop

The Task Queue is where callbacks wait to be executed. The Event Loop's job is to:

  1. Look at the stack
  2. Look at the task queue
  3. If the stack is empty, push the first thing in the queue to the stack
  4. Tasks are completed in the order they are queued

Micro-Tasks

Micro-tasks have higher priority than the regular task queue:

  • Contains callbacks from Promises and MutationObserver
  • Runs when the call stack becomes empty
  • Runs to completion before the event loop continues
  • Can cause blocking if there's an infinite task in the micro-tasks queue

The Main Thread

The main thread is where:

  • JavaScript execution happens
  • Rendering occurs
  • The DOM lives

Programming Paradigms

Functional Programming

Functional programming (declarative programming) expresses everything within the program as a function. The main idea is to avoid side effects and use pure functions.

Pure Functions

Functions that:

  • Always return the same output for the same input
  • Have no side effects
  • Don't depend on or modify external state
// Pure function example
function greet(name) {
  return "Hi, I'm " + name;
}

Higher-Order Functions

Functions that either:

  • Take other functions as parameters
  • Return functions
function makeAdjectifier(adjective) {
  return function (string) {
    return adjective + " " + string;
  };
}

const coolifier = makeAdjectifier("cool");
console.log(coolifier("conference")); // "cool conference"

Best Practices

  • Avoid for or while loops, prefer higher-order functions like map, filter, reduce
  • Avoid mutating global data. Unexpected behaviour can occur if data is modified in one part of the program and other parts of the program depends on that data.
  • Use immutable data structures when possible
// Immutable array transformation
const rooms = ["room1", "room2", "room3", "room4"];
const newRooms = rooms.map((room) => (room === "room4" ? "room5" : room));
// [ 'room1', 'room2', 'room3', 'room5' ]

Issues with Immutability

A new copy of the data is always made every time. One of the solutions to this is to use persistent data structures. Persistent data structures can be implemented using a technique called "structural sharing". This technique involves creating new versions of data structures that share as much of their underlying structure with the previous version as possible, rather than creating a completely new copy. Immutable js and mori js implement persistent data structure with this technique. Another technique is called path copying which copies only a part of the data structure that is being modified this can be done with libraries like immer js

Handling Immutability

Solutions for performance issues with immutability:

  • Persistent Data Structures: Using "structural sharing" (Immutable.js, Mori.js)
  • Path Copying: Only copying modified parts (Immer.js)

Object-Oriented Programming

Javascript supports OOP through its implementation of objects and constructors . The basic concepts of OOP in JS include objects, classes, inheritance and encapsulation.

OBJECT - is a collection of properties and methods that can be used to represent real-world objects. Properties are the data or state of the object and methods are the actions or behaviours that the object can perform.

CONSTRUCTORS - This allows an object template to be created. The constructor sets the initial methods and properties for an object or class and the new keyword is used to create new instances of that object.

INHERITANCE - Inheritance is JS is implemented though a prototype chain. Every object has a prototype which is an object from which it inherits its methods.

ENCAPSULATION - in JavaScript refers to the practice of hiding the internal details of an object, and only exposing a public interface for interacting with it. In JavaScript, encapsulation is typically achieved through closures or by defining getters and setters for object properties.

Objects and Classes

// Using class syntax
class Person {
  #name;
  #age;

  constructor(name, age) {
    this.#name = name;
    this.#age = age;
  }

  speak() {
    console.log(`Hello, my name is ${this.#name}`);
  }

  // Getter
  get name() {
    return this.#name;
  }

  // Setter
  set name(newName) {
    this.#name = newName;
  }
}

// Using object literals
const person = {
  name: "John Doe",
  age: 30,
  speak() {
    console.log(`Hello, my name is ${this.name}`);
  },
};

Inheritance

class Student extends Person {
  #major;

  constructor(name, age, major) {
    super(name, age);
    this.#major = major;
  }

  study() {
    console.log(`${this.name} is studying ${this.#major}`);
  }
}

JavaScript Execution

Synchronous Code

Code that executes sequentially on the JavaScript runtime, blocking until each operation completes.

Asynchronous Code

Code that executes non-blocking operations, allowing other code to run while waiting for operations to complete.

Callbacks (Traditional Approach)

console.log("Start");

function loginUser(email, password, callback) {
  setTimeout(() => {
    callback({ email, password });
  }, 5000);
}

function getUserVideos(email, callback) {
  setTimeout(() => {
    callback(["video1", "video2", "video3", "video4"]);
  }, 2000);
}

loginUser("user@example.com", "123456", (user) => {
  console.log(user);
  getUserVideos(user.email, (videos) => {
    console.log(videos);
  });
});

console.log("Finish");

Promises

function loginUser(email, password) {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      resolve({ email, password });
    }, 5000);
  });
}

function getUserVideos(email) {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      resolve(["video1", "video2", "video3", "video4"]);
    }, 2000);
  });
}

// Promise chaining
loginUser("user@example.com", "123456")
  .then((user) => getUserVideos(user.email))
  .then((videos) => console.log(videos))
  .catch((error) => console.error(error));

// Async/await syntax
async function getContent() {
  try {
    const user = await loginUser("user@example.com", "123456");
    const videos = await getUserVideos(user.email);
    return videos;
  } catch (error) {
    console.error(error);
  }
}

Running Multiple Promises

function getUserVideos() {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      resolve({ videos: ["video1", "video2", "video3", "video4"] });
    }, 3000);
  });
}

function getUserTracks() {
  return new Promise((resolve, reject) => {
    setTimeout(() => {
      resolve({ songs: ["song1", "song2", "song3", "song4"] });
    }, 6000);
  });
}

// Run promises concurrently
Promise.all([getUserVideos(), getUserTracks()])
  .then(([videos, tracks]) => {
    console.log(videos, tracks);
  })
  .catch((error) => console.error(error));

Closures

A closure is a function that maintains access to its creation scope, even after the parent function has returned. They are created at function creation time.

function makeCounter() {
  let count = 0;
  return {
    increment() {
      return ++count;
    },
    decrement() {
      return --count;
    },
    getCount() {
      return count;
    },
  };
}

const counter = makeCounter();
console.log(counter.increment()); // 1
console.log(counter.increment()); // 2
console.log(counter.getCount()); // 2

Modules

In Node.js, a module is a self-contained unit of code that exports one or more properties and methods, making them available to other parts of the application. Each file in a Node.js application is considered a module with its own scope, which means that the variables and functions defined in that module are not available outside of it unless they are explicitly exported.

Modules in Node.js use the CommonJS module system, which provides a way to organize code into reusable modules and to include and use them in other parts of the application using the require() function is used to include and run the code from a module in the current file, and the module.exports and exports objects are used to define and export the properties and methods of a module.

Node.js also provides a built-in module object, which provides properties and methods for working with modules, such as module.id and module.parent.

Modules provide a way to organize and structure code, as well as a way to share and reuse code across an application. It also allows you to break down complex code into smaller, more manageable pieces.

CommonJS Modules (Node.js)

// Exporting
module.exports = {
  hello() {
    console.log("Hello, World!");
  },
};

// Importing
const myModule = require("./myModule");

ES Modules

// Exporting
export function hello() {
  console.log("Hello, World!");
}

// Importing
import { hello } from "./myModule.js";