Get Started in JavaScript Episode 4: Mastering Advanced JavaScript Concepts

Before diving into this article, I highly recommend reading Episode 1 to learn core JavaScript basics, Episode 2 to level up on JavaScript fundamentals, and Episode 3 for DOM manipulation superpowers. Now let's master some advanced JavaScript wizardry!

Table of Contents

  • Closures for Encapsulation and Information Hiding
  • Taming Asynchronous JavaScript with Callbacks, Promises and Async/Await
  • Unlocking the Magic of ES6 Features
  • Modular Code with CommonJS and ES6 Modules
  • Real World Exercise: Building an Async Progress Tracker

Closures for Encapsulation and Information Hiding

Closures in JavaScript refer to the ability of nested functions to access variables in their outer lexical scope even after the outer function has returned.

This provides powerful capabilities for encapsulation and information hiding in JavaScript programs.

For example:

function wizard() {

  let secretSpell = 'abracadabra'; // Private variable
  
  function castSpell() {
    console.log(secretSpell); // Access private variable
  }

  return castSpell;

}

const spellCaster = wizard();
spellCaster(); // Logs 'abracadabra' 

Here the nested castSpell function maintains access to the private secretSpell variable from the outer wizard function scope. This allows secretSpell to be hidden from the global scope while still being accessible internally through the closure.

We can leverage closures like this for encapsulation and information hiding. Some common use cases:

  • Module pattern - export public API while keeping implementation details private
  • Factory functions - similar to classes, create object instances with private data
  • Avoid global variables - encapsulate logic instead of polluting global namespace

Closures are powerful, but beware of memory leaks!

Beware of Memory Leaks!

A common cause of memory leaks with closures is retaining references to variables that are no longer needed:

function outer() {
  
  const object = {
   prop: 'value' 
  };
  
  function inner() {
    // Use object 
    console.log(object.prop);

    // Loop that takes very long
    for(let i = 0; i < 1000000000; i++) {
      // ...
    }
  }
  
  inner();
  
}

Here, inner function maintains a reference to object even though it is no longer used after initial call. This prevents object from being garbage collected, causing a memory leak if this pattern repeats.

We can avoid this by nulling out unneeded references:

function outer() {

  const object = {
   prop: 'value'
  };

  function inner() {
    
    console.log(object.prop); 
    
    // Null out unneeded reference
    object = null;
    
    for(let i = 0; i < 1000000000; i++) {
      // ...
    }

  }

  inner();

}

Now object can be garbage collected after the initial call, preventing the memory leak.

Taming Asynchronous JavaScript with Callbacks, Promises and Async/Await

Asynchronous logic is at the heart of JavaScript. Callbacks, promises, and async/await provide different approaches for working with asynchronicity.

Untame Callbacks Lead to Callback Hell

A callback is a function passed as an argument to be executed after an asynchronous operation:

function requestData(callback) {
  // Async request
  setTimeout(() => {
    callback(data); // Execute callback 
  }, 1000);
}

requestData(data => {
  // Handle data
});

This works well for simple cases but leads to callback hell when nested:

requestData(data => {
  processData(data, processed => {
    displayData(processed, () => {
      // Callback hell!
    });
  });
});

Nested callbacks make code difficult to read and reason about. So while callbacks are essential in JavaScript, we need better patterns...

Promises - Taming Callbacks with Chains and Error Handling

A promise represents the result of an async operation. It starts in a pending state and can resolve to a value or reject with an error.

We create a promise by instantiating the Promise class:

function requestData() {
  return new Promise((resolve, reject) => {
    // Async request 
    
    if (success) {
      resolve(data);
    } else {  
      reject('Error!'); 
    }
  });
}

Consuming promises is chainable:

requestData()
  .then(data => {
    // Handle data
  })
  .catch(err => {
    // Handle error
  });

This avoids callback hell by chaining promises instead of nesting callbacks. Also better error handling by catching rejections.

Async/Await - Write Async Code Like Sync

Async/await provides syntactic sugar on top of promises. Use the async keyword to make a function return a promise:

async function requestData() {
  // ...
}

Inside an async function we can use await to pause execution until a promise resolves:

async function getData() {
  const data = await requestData();
  return data;
}

Our async code reads like synchronous code while still leveraging promises under the hood.

Async/await makes promise chains and asynchronous JavaScript much cleaner to write and reason about!

Unlocking the Magic of ES6 Features

ES6 (ES2015) introduced many new JavaScript features that provide cleaner, more expressive code:

Arrow Functions - Concise Syntax

Arrow functions have a shortened syntax compared to regular functions:

const func = (x) => x + 1;
// vs
function func(x) {
  return x + 1; 
}

The arrow function is especially useful for:

  • Callbacks:
data.forEach(value => {
  // ...
});
  • Promises:
fetchData().then(data => {
  // ...  
}); 
  • Cleaner function scoping - arrow functions inherit this from surrounding context which is useful when working with classes and objects.

Template Literals - String Interpolation

Template literals allow embedding expressions in strings with ${...}:

const name = 'John';
console.log(`Hello ${name}!`); // Hello John!

This is useful for:

  • String interpolation
  • Multiline strings
  • Raw string literals by tagging template strings

Destructuring - Concise Object and Array Access

Destructuring allows neatly assigning variables from objects and arrays:

const point = { x: 10, y: 7 };
const { x, y } = point;

console.log(x); // 10
console.log(y); // 7

Useful for:

  • Unpacking objects returned from functions
  • Extracting values from arrays
  • Defining function parameters

Many more great ES6/ES2015 features - these are some highlights!

Modular Code with CommonJS and ES6 Modules

Modules allow encapsulating code into reusable pieces with public APIs:

// module.js
const x = 1;

export const publicFunction = () => {
  // ...
};

Only publicFunction is exported while x remains private to the module.

Other files can import the module to use its public API:

import { publicFunction } from './module.js';

publicFunction();

CommonJS and ES6 Modules are two common standards for implementing modules in JavaScript projects.

CommonJS - The Node.js Standard

Used in Node.js and many libraries:

// module.js
const x = 1;

module.exports = {
  publicFunction
};

// index.js 
const module = require('./module.js');

module.publicFunction();

require() imports the module and module.exports defines its public API.

ES6 Modules - Future JavaScript Standard

Native module support arriving in browsers:

// module.js
export const publicFunction = () => {
  // ...
};

// index.js
import { publicFunction } from './module.js';

Clean import/export syntax but requires build process for browser support today.

Modules enable encapsulation, organization and dependency management. Build tools like webpack leverage modules for production-ready workflows.

Why Modular Code Matters

Modular code provides key benefits:

  • Encapsulation - Can expose public API while keeping implementation details private
  • Organization - Split code into logical modules/files for easier understanding
  • Reuse - Modules allow code to be shared across different parts of application
  • Dependency Management - Explicitly declare dependencies between modules

Well-structured modular code is crucial for managing complexity in large applications.

Key Differences Between CommonJS and ES6 Modules

While both support modular code, there are some key differences:

Syntax:

  • CommonJS uses require() and module.exports
  • ES6 uses import and export

Loading:

  • CommonJS loads modules synchronously
  • ES6 modules are asynchronous

This means CommonJS modules execute on first load, while ES6 modules need to handle async loading.

State Isolation:

  • CommonJS modules share mutable state via module cache
  • ES6 module state is isolated

ES6 enforces better encapsulation by isolating module state.

Cyclic Dependencies:

  • CommonJS allows cyclic dependencies between modules
  • Cyclic dependencies in ES6 causes error

Overall ES6 enforces stricter module boundaries for better encapsulation.

When to Use Each

  • CommonJS - Default for Node.js, good for server-side applications
  • ES6 Modules - Future standard, use for modern browser applications

For cross-platform projects, build tools like webpack can bundle ES6 modules into CommonJS for Node.js compatibility.

Which to Choose?

For new projects using modern tools, ES6 modules are generally preferred for their stricter encapsulation and importance as an emerging standard.

However, CommonJS is still widely used, especially in Node.js ecosystems.

The best practice is to leverage build tools like webpack or Babel to compile ES6 modules down to CommonJS for interoperability across both systems. This gives you the best of both worlds!

Real World Exercise: Building an Async Progress Tracker

Let's apply some of these advanced concepts by building an asynchronous progress tracker module.

We want a simple API like:

const tracker = createTracker();

tracker.onComplete(() => {
  console.log('Operation complete!');   
});

tracker.trackProgress(0.5); // 50% progress
tracker.trackProgress(0.5); // 100% progress  

Where onComplete registers a callback to be called when progress reaches 100%.

Here is how we can implement this:

// tracker.js
export function createTracker() {

  let progress = 0;

  const listeners = [];

  function notifyComplete() {
    listeners.forEach(listener => listener());
  }

  return {
    // Public API
    onComplete(callback) {
      listeners.push(callback); 
    },
    
    trackProgress(amount) {
      progress += amount;
      
      if (progress >= 1) {
        notifyComplete();
      }
    }
  }
  
}

We use a closure to encapsulate the private progress state and notifyComplete helper.

The public API exposes onComplete and trackProgress methods to consumers.

onComplete registers callbacks in the listeners array. When progress completes, notifyComplete fires all listeners.

We can use the tracker like:

// index.js

import { createTracker } from './tracker.js';

const tracker = createTracker();

tracker.onComplete(() => {
  console.log('Done!');
});

// Progress updates...

tracker.trackProgress(0.5);  
tracker.trackProgress(0.5); 

// Logs 'Done!' when progress completes

This demonstrates how closures, modules and callbacks can work together to build reusable async utilities in JavaScript!

Resources for Further Reading

Conclusion

We've covered a lot of ground across closures, asynchronous patterns, ES6 features and modules. These advanced concepts will level up your JavaScript skills for tackling complex projects and being an effective modern JS developer!

Let me know in the comments if you have any other advanced topics you'd like me to cover in future episodes of this series. Thanks for reading!