How Web Apps Work: JavaScript and the DOM

This is a post in the How Web Apps Work series.


An overview of the concepts, terms, and data flow used in web apps: JavaScript and the DOM

Web development is a huge field with a vast array of concepts, terms, tools, and technologies. For people just getting started in web dev, this landscape is often bewildering - it's unclear what most of these pieces are, much less how they fit together.

This series provides an overview of fundamental web dev concepts and technologies, what these pieces are, why they're needed, and how they relate to each other. It's not a completely exhaustive reference to everything in web development, nor is it a "how to build apps" guide. Instead, it's a map of the territory, intended to give you a sense of what the landscape looks like, and enough information that you can go research these terms and topics in more depth if needed.

Some of the descriptions will be more oriented towards modern client-side app development with JavaScript, but most of the topics in the series are fundamental enough that they apply to server-centric applications as well.

Other posts in this series cover additional topics, such as:

New terms will be marked in italics. I'll link references for some of them, but encourage you to search for definitions yourself. Also, some of the descriptions will be simplified to avoid taking up too much space or dealing with edge cases. This post in particular does not attempt to be a "how to program" tutorial or complete reference to JS, but will point out common gotchas and differences from other languages like Java, C++, and Python.

The MDN JavaScript docs have a complete set of resources on JavaScript ranging from intro-level tutorials to detailed API references.

FreeCodeCamp has multiple courses that cover JavaScript usage as well as other aspects of web dev.

The Modern JavaScript Tutorial is an excellent and thorough set of explanations of JS syntax and DOM APIs.

My JavaScript for Java Developers slides also cover much of this post's content as well, showing examples of JS syntax and concepts in a cheatsheet-type format. I'll link relevant sections of these slides throughout this post rather than copy entire large code blocks.

Table of Contents 🔗︎

JavaScript Overview 🔗︎

JavaScript is a dynamic interpreted language that is primarily used in web browsers, but can also be used outside a browser environment. It was created in the mid-1990s by Brendan Eich at Netscape, and takes inspiration from the Java, Scheme, and Self languages. Since then, it has grown from a tiny scripting language to the world's most popular language. It's definitely not a "toy" - it can be used to write anything from small scripts to HTTP server applications to complex client-side web apps with hundreds of thousands of lines of code.

JS is multi-paradigm - it can be used for Object-Oriented Programming similar to Java or C#, but can also be used in a Functional Programming style as well.

Note: Despite its name, JavaScript has no actual relation to the Java language! Some aspects of JS's syntax were indeed borrowed from Java, but they are completely different languages. (slides: Java vs JavaScript comparison table)

Language Evolution 🔗︎

To understand modern JS, it helps to understand how the language has evolved over time. (slides: JS language timeline) There's a neat timeline of milestones in JS history that helps visualize some of the key events and changes over time.

JS was standardized early on by the ECMA standards organization. Since "JavaScript" was trademarked, the standardized name is "ECMAScript", and the specification is often referred to as the "ES Spec". Early revisions were numbered: "ES2", "ES3", etc. The ES spec is maintained by a committee known as TC39.

The ES spec has had several new revisions published over time, which added new language syntax and features. However, the ES4 spec revision grew so large that it was abandoned in 2003 due to disagreements over potential features.

As a result, when the ES5 spec came out in 2009, it only added some relatively minor changes to the language.

The next major spec revision, ES6, didn't come out until 2015. Because of the long wait between revisions and the rapid growth in JS popularity and usage, the ES6 spec was huge and doubled the amount of syntax and features in the language.

You can roughly divide JS history and syntax into "before ES6" and "after ES6". All the old syntax still works, but ES6 drastically changed how JS developers write code.

After the ES6 spec was finished, the TC39 committee changed how they design changes to the JS language. Now, individual features are proposed and go through a series of stages over time (proposal, early implementation, completed implementation, finalized). New spec revisions are published yearly, and any new features finalized since the last revision are added to the new spec. Revisions are now known by year: ES2016, ES2017, and so on. (slides: TC39 stages and process)

To give a sense of how these revisions have changed the language over time, here's an incomplete list of new features by revision:

  • ES3 (1999): regular expressions; try/catch blocks and exceptions
  • ES5 (2009): object and array methods; JSON parsing; function binding; syntax cleanup
  • ES6 (2015): let/const variable declarations; arrow functions; classes; object literal shorthand; template strings; promises; generators; default function arguments; array spread syntax; module syntax; proxies
  • ES2016: Array.includes(), ** exponent operator
  • ES2017: async/await functions; Object.values() / Object.entries(); trailing commas in function args
  • ES2018: async iteration; object rest/spread operators; Promise.finally()

As you can see, ES6 was huge, while ES2016 was tiny. Since then, the rate of new features has been slower, but steady. (slides: ES spec revisions and features)

Runtime Environments 🔗︎

JavaScript was originally invented to give web browsers the ability to have interactive logic running inside a web page. By the mid 2000s, all browsers included a JS interpreter, and developers were starting to write larger client-side applications in JS.

In 2009, Ryan Dahl announced Node.js - a JS runtime for executing JS outside of a browser environment. Node is built on top of Chrome's V8 JS engine, and adds a standard library of APIs for things like working with the local filesystem, network sockets, and more. Node also popularized the CommonJS module format and the NPM package management tool.

Today, JS is widely used in multiple environments for many kinds of projects. The primary use cases are interactivity in web pages, full web app clients, web app servers, and build tools.

So, some JS code is meant to run exclusively in a browser, some is meant to run exclusively under Node, and some code can work in both environments.

JS Development Constraints 🔗︎

The other key factor to understand in how JS has evolved and how it's used is the set of unique constraints web developers face. There's a great quote from one of the early browser developers that encapsulates this:

The by-design purpose of JavaScript was to make the monkey dance when you moused over it. Scripts were often a single line. We considered ten line scripts to be pretty normal, hundred line scripts to be huge, and thousand line scripts were unheard of. The language was absolutely not designed for programming in the large, and our implementation decisions, performance targets, and so on, were based on that assumption.

Compared to many other languages, JS is very different:

  • No built-in module definition system (until ES6)
  • No built-in private / public encapsulation
  • Prototypal-based inheritance system unlike most languages
  • No static type declarations or compilation
  • Dynamically modified objects and data
  • Minimal standard library
  • Variations in browser capabilities
  • Entire codebase has to be shipped to the browser every time a page is loaded, then parsed and executed

Because of this, web client developers need to:

  • Minimize bytes sent over the wire
  • Handle browser compatibility issues
  • Fill in gaps in the JS standard library and language spec
  • Reuse and share code between apps
  • Build increasingly complex full-blown applications that just happen to live inside a browser

Core Language 🔗︎

Basic Syntax 🔗︎

JS uses much of the typical syntax seen in the C family of languages (C, C++, Java, and C#). Statements end with semicolons, variable names are case-sensitive, blocks are denoted with curly braces, comments can be written with // for a single line and /* */ for multiple lines, conditional logic uses if/else, and there are variations of for and while for looping.

Semicolons are actually optional - the interpreter will automatically insert them in places where they're strictly needed. That said, there's an ongoing argument between people who prefer using semis and those who avoid them. (Personally, I'm in favor of always using semicolons.)

The console.log() statement is the standard method for printing to the screen or the browser's debugging console. It accepts multiple arguments, including strings, objects, arrays, and other values, and will attempt to pretty-format any complex value. Other console methods exist, such as console.error() for error-formatted messages, as well as specialty formatting methods like console.table() and console.group(). (slides: comments and logging)

The const and let keywords are used to declare variables, and are block-scoped - they scope the lifetime of variable to the nearest curly brace block. let variables can be assigned a new value, while const variables cannot be changed to point to something else. There's also an older var keyword, which has more confusing scoping behavior - it's function-scoped, which means that no matter where you use var to declare a variable, it's hoisted and acts as if it was declared at the first line of the function it's in. Modern JS usage avoids var, since const and let behave more consistently. (slides: basic variable declarations)

As a personal recommendation, I suggest using const as the default, and let if you plan to reassign to that variable later, but there's others who suggest just using let all the time.

JS only has a single number type: a 64-bit floating point number equivalent to a double in other languages. It can hold integer values as well. (slides: numbers and math methods)

Strings can be written using three different quotes: single quotes and double quotes are equivalent, while the newer template literal strings use backticks and can have variable values interpolated in the middle. (slides: string syntax and methods)

Booleans are written as true and false. Objects and arrays are normally written using object/array literal syntax, as {} for objects and [] for arrays. (slides: basic object syntax, basic array syntax)

Object keys are always strings, but if the key name is a valid JS variable name, the quotes can be omitted (and usually are).

const text1 = 'Test A';
let text2 = 'Test B';
const value = 42;
// This is a template literal string
const text3 = `The number is ${value}`;
/* 
  This log statement can take multiple arguments
*/
console.log('First variable: ', text1, 'second variable: ', text2);

const object1 = {
  field1: 123,
  field2: true,
};

const array1 = [text1, value, object1, 99];

Data Types 🔗︎

JS has a variety of core data types. The basic data types are standalone values called primitives:

  • undefined : a variable that has not been assigned a value
  • null: a value that represents "no value"
  • String: text
  • Number : a 64-bit floating point number (aka IEEE double)
  • Boolean : a true / false value
  • Symbol: a unique reference value that can be used as a form of singleton identifier

All other values in JS are objects. This includes actual plain objects, as well as other types that extend from the Object type:

  • Object: a collection of string key / any value properties
    • Function : a function that can be called
    • Array : an expandable list of values
    • Date : a date/time value
    • RegExp : a regular expression

The difference between null and undefined is subtle. undefined represents "there is no meaningful value here", while null means "there is a value, but it's empty / not available".

Most of the other core data types have both a constructor function form and a literal syntax form, like new String('abcd') vs 'abcd', or new Array() vs []. Always prefer using the literal syntax to create new values instead of creating them via constructors.

Functions, arrays, dates, and regexps are technically objects as well.

Conditional Logic 🔗︎

JS uses the standard C-like if/else if/else syntax for conditions, as well as the ternary operator (const value = condition ? trueValue : falseValue). There's also a typical switch/case/break statement. Comparisons include the usual < and > operators and &&, ||, and ! boolean logic operators. (slides: conditional statements)

There are several forms of loops. You can do C-style counting loops with for(let i = 0; i < someValue; i++). The for (let key in obj) form iterates over keys, while for (let item of items) form will loop over values inside an iterable like an array. (slides: loops)

try/catch/finally allows handling errors. There's an Error class, but technically any value can be thrown.

Comparisons and Boolean Conversions 🔗︎

Unlike strictly-typed languages, JS frequently does implicit conversions or coercions of values in various contexts. The most common example is treating values as truthy or falsy. In addition to the actual true and false boolean values, using other values in a comparison statement will implicitly convert them to their boolean equivalent. (slides: "truthy" and "falsy")

For reference:

  • "Truthy" values: all objects and arrays (even if empty); non-zero numbers; non-empty strings; dates; functions
  • "Falsy" values: null, undefined, 0, NaN, and empty strings

(Note that the "empty object/array" behavior differs from Python, where those are considered "falsy".)

So, a comparison with an empty object like if ({}) { console.log('Truthy!')} will actually convert to true and print something, while a comparison with null like if (null) { console.log('Falsy!')} will convert to false and not print anything.

The boolean/comparison operators will all implicitly convert values to their boolean equivalent as well. A doubled negation operator is often used to convert a value to its boolean equivalent. For example, an empty string like '' is falsy, so !!'' would implicitly convert '' to false, !false to true, and !true to a final result of false.

JS also has two different comparison operators: == and ===. The double-equals == operator does loose comparisons that include implicit conversions of values. So, 0 == '' is true, because they will both end up being implicitly converted to false, and false is equal to false. The triple-equals === operator does strict reference comparisons that check to see if the two values are literally identical. For objects and arrays, it compares to see if they are the same reference in memory. So, {} === {} is false, because each of those is a separate new object reference.

You should almost always prefer using === strict reference comparisons to avoid unexpected implicit conversions. (slides: comparisons and coercion)

Functions 🔗︎

JS has two different ways to define standalone functions: the function keyword, and the () => {} "arrow function" syntax. They have some differences in behavior. (slides: function syntax and behavior)

  • function declarations are hoisted. Since arrow functions are assigned as variables, the declaration behavior is based on the keyword you use ( const, let, or var)
  • function declarations create their own value for the this keyword used inside. Arrow functions inherit the value of the this keyword as it existed at the time and scope they were declared in.
  • arrow functions can have optional parentheses if declared with only one parameter, and omitting curly braces adds an implicit return statement: const timesTwo = num => num * 2

JS functions are very flexible with arguments and parameters. No matter how many parameters were declared, you can always call a function with more or fewer arguments. If I have function myFunc(a, b, c) { }, but call it as myFunc(1, 2), then inside of the function a === 1, b === 2, and c === undefined because we didn't provide a value when we called it. If I call it as myFunc(1, 2, 3, 4), then the 4 argument is mostly ignored since we didn't declare a fourth parameter name. However, all function arguments can be accessed as an array-like value using the arguments keyword.

Function parameters can have default values provided, which will be used if the incoming value is undefined. If we change the example to function myFunc(a, b, c=42){ }, and call it as myFunc(1, 2), then a === 1, b === 2, and c === 42. This is frequently used for initialization. Function arguments can also be destructured - extracting specific fields from object and array parameters. (slides: function declarations and arguments)

Since functions are just variables, they can be passed around like any other variable.

JS in Depth 🔗︎

Objects 🔗︎

Object fields can be accessed with dot notation (obj.a.b.c) or bracket notation (obj["a"]["b"["c"]). Always use dot notation as long as the field names are known ahead of time. Use bracket notation if using a variable to look up a field ( obj[key]), or if the field name is not a valid JS identifer ( obj["some-field"]).

Reading a field that doesn't exist returns undefined, rather than throwing an error.

Objects can be modified at any time. New fields can be added, existing fields can be reassigned, and fields can be deleted with delete obj.field.

ES6 introduced a shorthand syntax for declaring objects when a key:value pair should be added based on the name and value of an existing variable - you can omit the : value portion:

// Shorthand syntax for declaring key/value pairs:
let x = 0,
  y = 1;

let es5Obj = { x: x, y: y };
// Create a key 'x' whose value is the variable 'x'
let es6Obj = { x, y };

There's also a computed property syntax that allows dynamically constructing keys based on the values of variables and expressions:

let name = 'abc';

let es5Obj = {};
es5[name] = 123;

let es6Obj = {
  // Use the value of variable 'name' as the property key


};
// {abc : 123, abc2 : 456}

Functions can be declared inside an object using function or arrow functions as the values, or directly inline as const obj = { someMethod() {} }. (slides: Object literal syntax)

Objects can be destructured, which is a shorthand for creating local variables based on the names of object fields. Destructuring statements can provide default values if that field is undefined, or change the local variable name that's created (slides: object destructuring and spreads):

let obj1 = { a: 1, b: 2, c: 3, d: 4 };

// can create local variables the long way
let a = obj1.a;
let b = obj1.b;

// or destructure to shorten it:
let { a, b } = obj1;

// Local variables can be given different names:
let { a: differentA, c } = obj1;

// And have default values in case of undefined:
let { doesNotExist = 123 } = obj1;

New objects can be created by using the object spread operator to copy values onto a new object:

let obj2 = { a: 1, b: 2, e: 5 };

let obj3 = { ...obj2, a: 99 };

There are static methods on the Object built-in type that allow getting all of the keys or values as arrays (Object.keys(someObj)), or an array of key/value pairs as 2-element arrays (Object.entries(somObj)). Object.assign(target, src1, src1) can be used to mutate existing objects by copying properties, or create new objects by passing an empty object as the target. (slides: Object static methods)

Arrays 🔗︎

Arrays are 0-indexed, but can be sparse - any index can be assigned at any time (arr[97] = "stuff"). Arrays may hold any value and a mixture of different types of values. Like with objects, accessing a non-existing array index returns undefined.

Like with objects, you can use destructuring to read values from arrays (const [a, , c] = ["a", "b", "c"]), and spread operators to create arrays (const arr3 = [...arr1, "b", "c", ...arr2, "d"]). (slides: array syntax, array destructuring)

Arrays are actually objects as well, and have numerous methods built in for various purposes. Some of these methods mutate the existing array, others return new arrays or other values. (slides: array methods, array iteration methods, array searching)

Name Description Arguments Returns Mutates
slice() Copies an array subset Indices to copy A new array No
concat() Creates array with more values New values/arrays to add A new array No
splice() Inserts/deletes values Index to mutate/delete, values to insert Items removed Yes
join() Stringifies array contents String used to separate items A new string No
push() Inserts new items at end of array Items to insert Array size after Yes
pop() Removes last item from array None Last array item Yes
unshift() Inserts new items at start of array Items to insert Array size after Yes
shift() Removes first item from array None First array item Yes
sort() Sorts existing array Callback to compare two items Existing array reference Yes
reverse() Reverses order of items in array None Existing array reference Yes
map() New array by transforming values Callback to transform one item New array with callback return values No
filter() New array with values matching comparison Callback to compare one item New array with original items where callback returned true No
forEach() General iteration for side effects Callback to run logic for one item Nothing No
reduce() Calculates one result value based on all items Callback to accumulate running result Final callback result value No
indexOf() Finds index of exact value match Value to search for index or -1 No
includes() Checks if exact value is in array Value to search fo boolean No
find() Find item by comparison Callback to compare one item First match or undefined No
findIndex() Find index of item by comparison Callback to compare one item index or -1 No
some() Checks if any items match comparison Callback to compare one item boolean No
every() Checks if all items match comparison Callback to compare one item boolean No

To emphasize, array.sort() and array.reverse() mutate the existing array!. This can be very surprising if you're not expecting it. In many cases it's a good idea to make a copy of an array using [...arr] or arr.slice() first and then sort/reverse the copy, especially if you're using something like React or Redux.

The core iteration methods each have a different semantic meaning and purpose:

  • map(): creates a new array, with the same size as the original array, whose values are based on a transformation of the original values
    • const doubledNumbers = [1, 2, 3].map(num => num * 2)
  • filter(): creates a new array, with some of the values from the original, based on values where the comparison returned true:
    • const evenNumbers = [1, 2, 3].filter(num => num % 2 === 0)
  • forEach(): general iteration over the array, usually to produce side effects
    • [1, 2, 3].forEach(num => console.log(num))
  • reduce(): calculates a single result value based on "accumulating" a running result at each step, plus a starting value
    • const total = [1, 2, 3].reduce( (previousResult, currentValue) => previousResult + currentValue, 0)

Scoping and Closures 🔗︎

As mentioned above, the let, const, and var keywords differ in their scoping behavior. let and const are block-scoped - they only exist within the curly braces block where they're declared, and the same variable name can be reused and shadowed inside of nested blocks. var is function-scoped - no matter where it's used, the declaration is hoisted to act as if it was written on the first line of the function it's inside, and reusing the same name will override the earlier declaration. The function declaration keyword is also hoisted. (slides: variable scope and hoisting)

There is a top level "global object" that can always be referenced, and any variables declared in the top-level global scope using var become fields on that object. In a browser, the object is window, and under Node, it's global.

Code inside functions can reference variables outside functions, because those variables are in an outer scope. Functions can be declared inside of functions, and close over variables by referencing them. A nested function that references a variable in the parent scope is called a closure. This allows nested functions to continue to refer to and modify variables long after the outer function has finished running. This is frequently used when passing callback functions for use as event handlers.

Understanding closures is a critical part of mastering JavaScript behavior. (slides: functions and closures)

// Functions can create and return functions
function createPrintName() {
  let name = 'Mark';

  function printName() {
    console.log(name);
  }

  return printName;
}

// Functions can capture references to values.
// These are called "closures".
function makeCounter() {
  let timesCalled = 0;
  return function counter() {
    timesCalled++;
    console.log(`Times called: ${timesCalled}`);
  };
}

const actualCounter = makeCounter();
actualCounter(); // "Times called: 1"
actualCounter(); // "Times called: 2"

Functions and this 🔗︎

The behavior of the this keyword in JS is one of the most confusing and difficult topics to grasp, especially if you are coming from a language like C# or Java.

Unlike other languages, the value of this can point to different references depending on how a function is declared and what syntax was used to call it. Instead of always pointing to a specific instance of a class, what this points to is based on the execution context of a function.

This is a long and complicated topic, so I'll summarize it briefly here and suggest reading up on it further.

There are four ways to call a function in JS, which each have a different effect on the value of this inside of the function (slides: understanding this):

  • "Function invocation": someFunction(1, 2) (slides: function invocation)
    • this points to undefined or the global object, based on use of strict mode
  • "Method invocation": someObject.someFunction(1, 2) (slides: method invocation)
    • this points to someObject because of the dot operator
  • "Constructor invocation": new SomeFunction(1, 2) (slides: constructor invocation)
    • this points to the new function instance
  • "Indirect invocation": someFunction.call(thisArg, 1, 2) or someFunction.apply(thisArg, [1, 2])
    • this points to the first argument of .call() or .apply()

Functions can be bound to create a new function with pre-fixed values for some of their arguments, including forcing the value of this. (slides: indirect invocation and binding)

Arrow functions inherit the value of this at the time they were defined (slides: arrow functions and this)

Classes and Prototypes 🔗︎

JS inheritance is unlike other most major languages - it's based on a system of prototypes for objects, and is known as prototypal inheritance. Loosely put, inheritance is dynamic and determined at runtime by tracing field lookups up a chain of references for a given object, instead of being defined strictly at definition time like in Java or C#. (slides: prototypal inheritance)

// Define a function to act as a "class"
function Animal(name, isAwake) {
  this.name = name;
  this.isAwake = isAwake;
}

// Create methods by adding functions to the "prototype"
Animal.prototype.wakeUp = function () {
  this.isAwake = true;
};

Animal.prototype.sleep = function () {
  this.isAwake = false;
};

// Create an instance
let jinx = new Animal('Jinx', true);

// Call an instance method
jinx.sleep();

ES6 added an actual class keyword to the language, but it is effectively syntax sugar over the existing prototypal inheritance behavior (slides: classes):

class Animal {
  constructor(name, isAwake) {
    this.name = name;
    this.isAwake = isAwake;
  }

  wakeUp() {
    this.isAwake = true;
  }

  sleep() {
    this.isAwake = false;
  }
}

class Dog extends Animal {
  constructor(name, isAwake, isWaggingTail) {
    super(name, isAwake);
    this.isWaggingTail = isWaggingTail;
  }

  bark() {
    console.log('Woof! Woof!');
  }
}

let spot = new Dog('Spot', true, false);
spot.sleep();

Async 🔗︎

Timers and the Event Loop 🔗︎

JS execution is based on a single-threaded event loop + queue. Conceptually, the behavior is while(queue.waitForMessage()) queue.processNextMessage().

Events are added to the queue, and have JS code attached. This includes mouse events, timers, network requests, and much more. The event loop pops the next event off the queue, and executes the entire attached code to completion. The currently executing script cannot be interrupted. The script may start timers, which will add events with scripts to the queue for later execution.

While there is only one JS execution thread, the browser itself may add events to the queue while code is executing. Rather than blocking for async requests, they will schedule events when they complete.

Note that if you have an infinite loop in your code, the event loop is "blocked" and cannot move on to process the next event!

JS logic relies heavily on callbacks - passing a function reference to be called and executed at some future point in time. Callback usage can be synchronous or asynchronous depending on the use case. (slides: callbacks and timers)

JS provides two core functions for scheduling async logic: setTimeout(callback, ms), which runs the callback function once, and setInterval(callback, ms), which runs the callback repeatedly. Both functions return a unique ID value that can be used to cancel the queued timer via clearTimeout() or clearInterval().

Understanding the JS event loop is critical to understanding how to use async logic in JS! See the video What the heck is the event loop anyway? for an excellent explanation, as well as this accompanying interactive event loop visualization tool.

Promises 🔗︎

Nested async calls often result in a "callback pyramid of doom":

fetchData('/endpoint1', (result1) => {
  fetchData('/endpoint2', (result2) => {
    fetchData('/endpoint3', (result3) => {
      // do something with results 1 through 3
    });
  });
});

This also makes error handling very difficult.

The JS Promise data type provides a structured way to handle future async results (slides: promise basics. Promises are objects can be in one of three states:

  • Pending: created, but there is no result yet
  • Fulfilled: completed, with a positive/successful result
  • Rejected: completed, with an error result

A Promise has settled or resolved once it is either fulfilled or rejected.

Promises can be chained using somePromise.then(callback) and somePromise.catch(callback). When somePromise resolves, any provided chained callbacks will be run - .then() callbacks if it fulfilled, or .catch() callbacks if it rejected. Callbacks can even be chained after the promise has resolved, in which case they will be executed almost immediately.

Promise chains effectively form a pipeline. Each .then() or .catch() returns a new promise. (slides: creating and chaining promises, combining and resolving promises)

let promise1 = new Promise((resolve, reject) => {
  // This callback executes synchronously, immediately
  // Could "resolve" the promise with a value:
  resolve('a');
});

promise1
  .then((firstValue) => {
    // "a"
    return 'b';
  })
  .then((secondValue) => {
    // "b"
    // _Not_ returning a value returns `undefined`
  })
  .then((thirdValue) => {
    // undefined
  });

Inside a promise callback, you can run whatever calculations you want, but you can only do 3 things to complete the logic:

  • Return a value: resolves the promise successfully, with that value. Note that returning nothing or undefined is the same as resolving the promise successfully with undefined
  • Return another promise. The new promise for the callback will resolve or reject based on the promise you returned.
  • Throw an error. This rejects the promise, with that error.

async/await Syntax for Promises 🔗︎

Chaining Promises can also be difficult. The newer async/await syntax lets you write Promise-handling logic with what appears to be synchronous-style syntax, including use of try/catch for handling errors.

A function must be declared using the async keyword in order to use the await keyword inside. Every async function then automatically returns a Promise with whatever value is returned. Rejected promises in an async try/catch will jump to the catch block. (slides: async/await)

// This function:
function function1() {
  let resultPromise = Promise.resolve(42);
  return resultPromise;
}

// Is the same as this function:
async function function2() {
  return 42; // converted to a promise!
}

Overall, async/await syntax is much nicer to read than promise chains, and should usually be preferred:

// This promise chain:
function fetchStuff() {
  return fetchData('/endpoint1')
    .then((result) => {
      return firstProcessStep(result);
    })
    .then((processedResult) => {
      console.log(`Processed result: ${processedResult}`);
      return processedResult;
    })
    .catch((err) => {
      console.error('PANIC!', err);
    });
}

// Can convert to:
async function alsoFetchStuff() {
  try {
    let result = await fetchData('/endpoint1');
    let processedResult = firstProcessStep(result);
    console.log(`Processed result: ${processedResult}`);
    return processedResult;
  } catch (err) {
    console.error('PANIC!', err);
  }
}

Other Topics 🔗︎

JS Module Formats 🔗︎

The ES Module format is now the standard for writing most new code. CommonJS modules are still widely used with Node and as a publishing format. For details and examples of the various module formats, see Client Development and Deployment: JS Module Formats

Regular Expressions 🔗︎

JS has regular expressions available for complex text matching. Regexes can be declared using slashes, like /Have a good (day|afternoon|evening)/. (slides: regular expressions)

Regex objects have methods like .test(str) and .match(str), and can be passed to some string methods as well ( "Hello world!".replace(/world/, 'dog')).

Immutability 🔗︎

Mutation means changing the contents of an existing object or array in memory. For example, obj1.a = 42 mutates the existing object pointed to by obj1, and arr1.push('abcd') mutates the existing array.

Immutability is the concept of updating data by copying existing values and modifying the copies, rather than mutating the original values. JS is inherently a mutable language, so you have to explicitly write logic to make updates immutably. (slides: immutability)

For objects, this usually is done via the object spread operator:

let updatedObj2 = {
  ...obj2, // copy all fields from obj2,
  nested: {
    // provide a new `nested` value
    ...obj2.nested, // copy fields from `obj2.nested`
    d: 123, // but overwrite `obj2.nested.d` in this copy
  },
};

Arrays can be copied using the array spread operator or array.slice(). See the array methods table above for details on which array methods mutate the existing array, vs methods that return a new array.

There are many utility libraries that can help with immutable updates, but by far the best is Immer. Immer provides a produce function that will wrap your original data in a Proxy object and let you "mutate" the value in a callback, but then converts all the mutations into safe immutable updates and returns the result:

// hand-written immutable update:
function toggleTodo(todos, index) {
  return todos.map((todo, i) => {
    // Keep the items that aren't changing
    if (i !== index) return todo;

    // Return a new copied object for the item that is changing
    return { ...todo, completed: !todo.completed };
  });
}

// simpler with Immer because we can "mutate":
import produce from 'immer';

function toggleTodo(todos, index) {
  return produce(todos, (draftTodos) => {
    const todo = draftTodos[index];
    todo.completed = !todo.completed; // Safe "mutation"!
  });
}

Lodash 🔗︎

JS has a small and limited standard library built in. Lodash is a separate library of utility functions, and is one of the most widely used libraries in the JS ecosystem. It provides dozens of utility functions for everything from array and object operations, to working with functions, to checking the type of a value, to converting strings between different formats. (slides: Lodash)

// Arrays
_.difference(['a, b'], ['c', 'b']); // ["a"]
_.head(['a', 'b', 'c']); // "a"
_.tail(['a', 'b', 'c']); // "c"
_.intersection(['a', 'b'], ['b', 'c']); // ["b"]

// "Collections" (objects and arrays)
_.groupBy([6.1, 4.2, 6.3], Math.floor);
// => { '4': [4.2], '6': [6.1, 6.3] }

let items = [
  { n: 'f', a: 48 },
  { n: 'b', a: 36 },
  { n: 'w', a: 38 },
];
let sortedItems = _.sortBy(items, 'a');
// => [{n: "b", a:36}, {n: "w", a: 38}, {n: "f", a:48}]

// Objects
let obj1 = { a: 2, b: 3, c: 7 };
_.mapValues(obj1, (val) => val * 2); // {a : 4, b : 6, c : 14}

_.omit(obj1, ['b']); // {a : 2, c : 7}
_.pick(obj1, ['b', 'c']); // {b : 3, c: 7}

// Strings
_.camelCase('Foo Bar'); // "fooBar"
_.kebabCase('Foo Bar'); // "foo-bar"

DOM 🔗︎

HTML is a text document format, organized into a hierarchy of nested tags like <h1>, <p>, and <img>, that represents the desired structure and content of a web page.

Browsers download an HTML file, and parse the HTML tags. They then create a set of internal data structures that match the requested page content, and use that to lay out and draw the actual pixels on screen.

Browsers also expose a live representation of the current page content to JS. This is known as the Document Object Model (DOM). While every browser has its own different internal data structures for a page, the DOM is a standardized set of APIs for interacting with the content of a page. The DOM APIs enable JS code to create, read, modify, and delete the actual contents of a page, dynamically, after the initial HTML has been parsed and the page was loaded.

The DOM spec defines classes that correspond to each HTML element type. For example, a <div> tag, when parsed, will result in an HTMLDivElement class instance being created and added to the DOM. Each instance is called a DOM node, because the DOM contents form a tree.

Different DOM classes support different methods - an HTMLInputElement instance has different methods available than an HTMLDivElement instance, but all DOM node instances share a common core set of base methods.

DOM Query APIs 🔗︎

The DOM API allows querying for nodes based on selectors, such as the type of tag, ID attribute, classnames, node relational structure, and more. (slides: DOM query and manipulation)

The root element for a page is globally accessible via the global document object. Most queries are run against document to find nodes anywhere inside a page.

The primary query APIs are:

  • getElementById(): finds a DOM node based on an ID attribute string. (Note that the ID string should be provided without a '#' prefix here, unlike other places where an actual selector is being used to query)
  • querySelectorAll(): finds all DOM nodes that match the provided CSS selector query
  • querySelector(): finds the first DOM node that matches the provided CSS selector query

These same query APIs also exist on the DOM node subclasses, so you can run someNode.querySelectorAll() to scope a search to that particular portion of the page's DOM tree.

Note that querySelectorAll() returns a NodeList object. This is an array-like object. It implements a couple array-type methods like .forEach(), but is not an actual array. It's common to convert a NodeList result into an actual array before doing further work with the query results.

Other query methods like getElementsByName also return a NodeList, but those results are "live" views whose contents automatically change as the DOM is updated. This behavior can be confusing if you're not ready for it.

DOM Manipulation 🔗︎

DOM nodes support a wide variety of manipulation operations.

New DOM nodes can be created with document.createElement('p'), which returns an instance of the requested node type, or copied with .cloneNode().

DOM nodes have .innerHTML and .innerText properties, which allow reading and writing the entire contents of a node at once. Input nodes typically have properties like .name and .value.

Nodes have a .classlist property, which is an object that supports adding, removing, and toggling the existence of individual CSS classnames on that node (divNode.classlist.toggle('active')).

Nodes have a .style property that allows reading and mutating any of the style-related properties, including values that were defined using CSS (divNode.style.backgroundColor = 'red'). Unlike CSS, the style property names are camelCased instead of kebab-cased, because that matches JS naming conventions.

Nodes have properties that point to related nodes in the tree: .children, .firstChild/lastChild, .nextSibling/previousSibling, and .parentNode.

Nodes can be inserted and moved around with with .appendChild(), .insertBefore()/insertAfter().

Event Listeners 🔗︎

DOM nodes implement an event listener interface, which looks like node.addEventListener(eventName, callback). There's a wide variety of available events that will be triggered by the browser as the user interacts, such as 'click', 'mousemove', and 'keydown'. (slides: DOM event handling)

Event handlers receive an event object with numerous fields describing the current event. Inside of an event handler callback, this will point to the actual DOM node as long as you declared the callback with function (arrow functions always use the same this value from the scope where they were defined).

When a DOM event is triggered, it bubbles upwards from the original target node, through all of its parent nodes, to the root document, firing any matching event handlers along the way. After the event has reached the root, it then runs back down from the root to the original node, known as the capturing phase.

Because events bubble up, it's possible to add a single event listener on some parent node that catches all events of a certain type on any children. This pattern is known as event delegation.

Any event handler may cancel the rest of the handler processing by calling e.stopPropagation(). The browser also often has default behavior that will be run regardless of any user-provided listeners, which can be canceled with with e.preventDefault().

jQuery 🔗︎

For many years, the DOM APIs were poorly defined, had widely varying support between browsers, and had limited query capabilities built in. The community created many libraries to fill in these gaps and provide common capabilities across browsers. jQuery became the most popular DOM manipulation library by far. Many of its capabilities have been added to the DOM APIs themselves, and the need for jQuery has lessened as the ecosystem has evolved, but it's still one of the most widely used JS libraries. (slides: jQuery examples)

jQuery is an abstraction layer for querying nodes from the DOM and quickly manipulating them via chained operator functions. It also has utilities for interacting with inputs, creating DOM nodes from HTML strings, provides a nicer abstraction over the browser's built-in APIs for making AJAX requests, adds some animation capabilities, and has a plugin system that allows developers to extend its capabilities.

jQuery is not a framework for developing entire applications - it's best suited for adding interactivity to an existing static or server-rendered web page. jQuery is not normally needed in today's modern client-rendered web app frameworks like React or Vue, because those frameworks take care of creating and updating the DOM for you, and using jQuery would interfere with their operations.

Once loaded, the jQuery API is globally accessible as a variable named $. DOM queries are run by passing a selector, like $("#demo .content"), which returns an array-like object with numerous methods that can be chained. It's a common practice to prefix jQuery result object variable names with $, like $demo.css("backgroundColor", "green").slideUp(500).

Further Resources 🔗︎


This is a post in the How Web Apps Work series. Other posts in this series: