8. Browser APIs

Overview

By the end of this chapter, you will be able to explain what a web/browser API is; draw in HTML using JavaScript; create and control audio in the browser; store data in the browser; decide on the type of storage to use in different circumstances; measure and track a website's performance; and create and manage a sustained, two-way connection between the browser and server.

In this chapter, you will learn about interesting browser APIs and look at JavaScript's extended capabilities.

Introduction

In the previous chapter, we looked at the different examples of JavaScript runtime environments and had an overview of their components. We'll now look at one of those components in greater depth, that is, the Browser Object Model (BOM), and the APIs it exposes to JavaScript.

The BOM is a set of properties and methods that are made available to JavaScript by the browser. Now, you've come across many parts of the BOM already, with methods such as setTimeOut(), and the document property, with its many methods, such as addEventListener(). It's a subtle but important point that the methods and properties we'll cover in this chapter are not part of the JavaScript programming language; that is to say, they're not part of the ECMAScript specification – the specification to which JavaScript engines are built – but they are methods and properties of the browser, and they form the interface between JavaScript and the browser (and by extension, between JavaScript and the rest of the system it's being run on).

In the same way that every browser has its own implementation of the JavaScript engine, each browser implements the BOM in a slightly different way. So, it's important to check cross-browser compatibility for the features you want to use as a developer and implement fallbacks or polyfills for browsers that don't support certain features. It's often the case that the current version of a particular browser supports a certain functionality, but older versions do not.

As we've already mentioned, you've seen many of the more commonly used methods of the BOM. Here, we will look at some of the most commonly used and useful browser APIs in more detail, as well as some less frequently used yet powerful aspects of the BOM that will greatly increase the number and functionality of tools at your disposal, and which can allow you to build some super cool and possibly even useful features into your sites and apps.

Canvas

Images and diagrams are a fundamental part of creating engaging websites and applications. We already know how to include images and videos in our pages, but we can also draw our own images, diagrams, and even complex visuals such as charts or game elements by using JavaScript and the Canvas API. The Canvas API allows us to draw graphics programmatically inside an HTML <canvas> element using JavaScript. With it, we can draw paths and rectangles, and control things such as stroke and fill color, line dashes, and arc radiuses (or radii, if that's your flavor).

The process of drawing inside an HTML canvas using JavaScript can be broken down into a few distinct steps:

  • Get a reference to the HTML's canvas element.
  • Get a new canvas rendering context that the graphic is drawn onto.
  • Set various drawing styles and options as required (for example, line width and fill color).
  • Define the paths that will make up the graphics.
  • "Stroke" or fill the defined paths and shapes – this is the step where the actual drawing takes place.

In the following exercise, we will start by using the fillRect() method. This method is one of the methods we can use for drawing on the canvas and, as its name suggests, draws a rectangle and fills it with color. To describe the rectangle, we require four pieces of information: the x and y coordinates of the top-left corner, the rectangle's width, and its height. Therefore, the parameters we will pass to fillRect() are an x coordinate, a y coordinate, the width, and the height.

Exercise 8.01: Drawing Shapes Using Canvas Elements

Let's get started with an exercise in which we'll learn how to work with a Canvas element, some of the components that make up the API, and how we can use it to draw simple shapes. The Canvas API has many methods and interfaces. In this exercise, we'll look at a few of the most commonly used ones. Let's get started:

  1. Create an HTML file called index.html with a <canvas> element and a reference to a JavaScript file in the HTML body in the DevTools console. We'll call the JavaScript file canvas.js:

    <!-- index.html -->

    <!DOCTYPE html>

    <html>

      <head>

      </head>

      <body>

        <canvas id='canvas' style="border: 1px solid"></canvas>

        <script src='canvas.js'></script>

      </body>

    </html>

    We've given the canvas element an ID of 'canvas' so that we can select it easily in JavaScript, and an inline style of "border: 1px solid" so that we can see the area that the canvas takes up on the HTML page.

  2. Next, we'll create our canvas.js file in the same directory as index.html and declare a variable that will hold a reference to our HTML canvas element:

    // canvas.js

    let canvas = document.getElementById('canvas');

  3. Now, we'll create a rendering context by calling the getContext() method with the '2d' parameter since we will be drawing 2D graphics. This method takes in a string to denote the context type and returns a drawing context that's used to draw and modify the graphics we want to display. There are several types of context, but for this introduction to Canvas, we will be looking only at '2d' contexts:

    let context = canvas.getContext('2d');

    Now that we have the context, we can start drawing. The canvas object works on a grid system, with its origin in the top left, so that the 0,0 coordinates are at the top left of the canvas. It's from this origin that we can draw our graphics.

  4. Finally, we'll use the fillRect() method to draw a 100 by 100 pixel rectangle on the canvas:

    context.fillRect(10,10, 100, 100);

    Now, open the HTML file in your browser. You should see something like this:

    Figure 8.1: Simple canvas with a square inside

    Figure 8.1: Simple canvas with a square inside

  5. You've probably already noticed that the canvas element is fairly small. By default, a canvas is 300 by 150 pixels, so let's add a couple of lines to our JavaScript so that the canvas' dimensions match the size of our window when we load the HTML page. We'll use the window object's innerWidth and innerHeight properties – which tell us the viewport's width and height – to set the canvas' width and height:

    // canvas.js

    let canvas = document.getElementById('canvas');

    canvas.width = window.innerWidth;

    canvas.height = window.innerHeight;

    let context = canvas.getContext('2d');

    context.fillRect(10,10, 100, 100);

  6. Now that we have a much larger canvas (assuming your browser window is bigger than 300 x 150 pixels), we can start to play around with this and other drawing methods. Let's add a few more rectangles but mix things up a bit:

    canvas.js

    let canvas = document.getElementById('canvas');

    canvas.width = window.innerWidth;

    canvas.height = window.innerHeight;

    let context = canvas.getContext('2d');

    context.fillStyle = 'yellow';

    context.fillRect(10,10,200,200);

    context.fillStyle = 'black';

    context.strokeRect(230, 10, 200, 200);

    context.setLineDash([10]);

    context.strokeRect(450, 10, 200, 200);

    context.setLineDash([0]);

    context.strokeStyle = 'red';

    context.strokeRect(10, 230, 200, 200);

    context.fillRect(450, 230, 200, 200);

    context.clearRect(500, 280, 100, 100);

  7. We should now have five new rectangles that look something like this (there's actually six if you include clearRect() inside the filled black rectangle at the end):
    Figure 8.2: Six more rectangles

Figure 8.2: Six more rectangles

In this exercise, we learned how to draw all formats of rectangles using the canvas element.

The additional lines of code in the exercise are pretty self-explanatory, but there are still a few things to point out:

  • The fillStyle and strokeStyle properties can take any valid CSS color value (hexadecimal, RBG, RGBA, HSL, HSLA, or named color).
  • The setLineDash property takes an array of numbers that determine the distances of drawn lines and spaces. The list is repeated, so if you pass [5], the lines and spaces will repeat at 5 pixels in length each. If you pass [5, 15], then all the lines will be 5 pixels, and all the spaces will be 15 pixels.
  • Once set, fillStyle, strokeStyle, and setLineDashvalues persist for anything drawn in the same context, so you need to make sure that you reset the value if you need to, as we have with the setLineDash property (otherwise the red rectangle will be dashed) and fillStyle.
  • The clearRect method can be used to remove a drawn area from another part of the canvas.

Manipulate Shapes Using Path

Now that we've mastered drawing rectangles, we'll start drawing more interesting shapes with paths using some of the other methods available on the context object. A path is a list of points that are joined by lines. We can manipulate the properties of these paths, such as their curvature, color, and thickness. This time, we'll go through the methods first, and then see them in action:

  • beginPath(): Starts a new path list
  • moveTo(x,y): Sets the point at which the next path will be drawn from
  • lineTo(x,y): Creates a line from the current point to the coordinates passed to the method
  • closePath(): Creates a line from the most recent point to the first point, thereby closing off the shape
  • stroke(): Draws the shape that's been described
  • fill(): Fills in the described shape with a solid color

These methods can be used to draw a big triangle that takes up most of the canvas' width and height. You can replace the previous code in canvas.js with the following, or create a new JavaScript file and change the <script> tag's source attribute in the HTML file to reflect the new JavaScript file:

canvas-1.js

let canvas = document.getElementById('canvas');

const width = window.innerWidth;

const height = window.innerHeight;

canvas.width = width

canvas.height = height

let context = canvas.getContext('2d');

context.beginPath();

context.moveTo(50, 50);

context.lineTo(width - 50, 50);

context.lineTo(width / 2, height - 50);

context.closePath();

context.stroke();

First of all, we assigned the window's innerWidth and innerHeight values to a variable because we'll be using them more than once. After getting a reference to the canvas object, we begin a new path and move our starting point to 50 pixels on both axes. Next, we plot a line from our current point to a point 50 pixels less than the innerWidth, and 50 pixels from the top. Then, we plot a line to a point half the innerWidth, and 50 pixels from the bottom. The final two methods are used to close the path and draw the entire shape with the stroke() method.

Figure 8.3: A triangle

Figure 8.3: A triangle

The steps to create a fractal pattern are fairly straightforward, but here are some pointers to keep in mind:

  • The starting point should be the middle of the canvas element.
  • The line is drawn in one of four directions.
  • The line drawing part of the function should repeat as long as the point is within the bounds of the canvas.
  • In the preceding example, the lines increase in length after every two plots of a line. You can, however, get a similar result by increasing the line length on every line.

Activity 8.01: Creating a Simple Fractal

We're now going to put what we've learned about HTML Canvas into practice. This time, we'll be using JavaScript to repeat the drawing steps to create a very simple fractal. Have a go at creating a pattern like this:

Figure 8.4: A basic pattern

Figure 8.4: A basic pattern

Follow these steps to create a fractal:

  1. Initialize a variable with coordinates for a starting point in the middle of the canvas.
  2. Create a loop. For every iteration of the loop, alternate between increasing and decreasing the coordinates and drawing the line:
    • Increase or decrease the coordinate values to move the point outward in a spiral
    • Draw a line from the previous point to the new one
  3. End the loop when the point reaches any edge of the canvas.

Spend some time trying to do this on your own before checking the solution.

Note

The solution to this activity can be found on page 732.

For now, we'll move on to another web API: the Web Audio API.

Web Audio API

This API provides a set of methods and objects that we can use to add audio to HTML from a variety of sources, even allowing developers to create new sounds from scratch. The API is feature-rich and supports effects such as panning, low-pass filters, and many others that can be combined to create different kinds of audio applications.

Like the Canvas API, the Audio API starts with an audio context, and then multiple audio nodes are created within the context to form an audio processing graph:

Figure 8.5. The audio context and its audio processing graph

Figure 8.5. The audio context and its audio processing graph

An audio node can be a source, a destination, or an audio processor, such as a filter or a gain node, and they can be combined to create the desired audio output, which can then be passed to the user's speakers or headphones.

Exercise 8.02: Creating an Oscillator Waveform

In this exercise, we will see how we can create a simple oscillator waveform in JavaScript and output it to the system's audio output device. Let's get started:

  1. Let's start by creating an audio context and adding a volume and oscillator node. Type or copy and paste the following code into the console window of Google Chrome's Developer Tools (accessible using the F12 key):

    // create the audio context

    let context = new AudioContext();

    // create a gain node

    let gain = context.createGain();

    // connect the gain node to the context destination

    gain.connect(context.destination);

    // create an oscillator node

    let osci = context.createOscillator();

  2. Now, we'll set the oscillator type to 'sawtooth' and set the frequency of oscillation to 100. Instead of 'sawtooth', you can set the oscillator type to 'sine', 'square', or 'triangle'. Feel free to experiment with the frequencies as well:

    // set the oscillation type

    osci.type = 'sawtooth';

    // set the oscillation frequency

    osci.frequency.value = 100;

    Note

    The frequency of a waveform refers to how often the waveform completes one cycle or period, with 1 Hertz (1 Hz) being 1 cycle per second. We perceive higher frequency sound waves as being higher pitched.

  3. Finally, we'll connect the oscillator to the gain node and call the oscillator's start() method:

    // connect the oscillator node to the gain node

    osci.connect(gain);

    // start the oscillation node playing

    osci.start();

If you run this code with the volume up, you should hear a continuous oscillating sound (the sound is similar to the static noise you hear on the radio when the channel you want cannot be found). Some browsers will not play a sound with the Audio API until the user has interacted in some way with the screen. This is to stop developers from making annoying pages that play unwanted sounds. If you encounter an error, just click somewhere on the screen before running the code.

We can add multiple source nodes, either of the same type (an oscillator, in our example) or of different types, and they can each be controlled separately, or share other audio nodes such as gain or pan nodes. Of course, we can also make our audio contexts respond to some external input, such as user inputs or time events.

Activity 8.02: Playing Sound and Controlling Frequency Using Two Oscillators

Let's make better use of the audio API by adding some interactivity. In this activity, we will have two oscillators playing a sound, and the user will be able to control their frequencies by moving their cursor around on an HTML page. One oscillator's frequency will be controlled by the cursor's x position, with the frequency increasing as the cursor moves toward the right of the page, and the other oscillator's frequency is controlled by the y position, with the frequency increasing as the cursor moves toward the bottom of the page.

Before checking the solution, see if you can achieve this goal; it'll be good practice for the activity at the end of this chapter.

Some points to get you started:

  • The two oscillators should be in the same context and be connected to the same volume node.
  • There are four preset oscillator types available to the oscillator node interface: 'sine' (default), 'square', 'sawtooth', and 'triangle'. Both our oscillators can have different types, so play around with them.

The high-level steps for the activity are as follows:

  1. Initialize an audio context and a volume node.
  2. Create a gain node and connect it to the context's destination.
  3. Initialize two oscillators (one for each coordinate of the cursor).
  4. Set the oscillator types, connect them to the gain node, and call their start() methods.
  5. Create an event listener that listens for the mousemove event on the document.
  6. Set the oscillators' frequencies based on the cursor's position.

    Note

    The solution to this activity can be found on page 733.

Before we move on to the next web API, here's some information on how we can extract data from a currently playing sound and use it to visualize that sound in our app.

Audio Visualization

Audio visualization is a graphical representation of a sound. It's common to see this in an audio program, and it can produce very interesting patterns and shapes. Web audio has many kinds of audio nodes. One that opens up a lot of possibilities for audio visualization is the analyzer node, which gives you access to the waveform and frequency data of its audio input. Unless you're a sound technician, the inner workings of the node are quite arcane, so we'll just get straight into how we access the data. There is one additional property and one method we'll use to get some data that is useful for visualization:

  • frequencyBinCount: This essentially tells us how many data points we have available to us for our data visualizations.
  • getFloatTimeDomainData(): This method takes in a Float32Array as a parameter and copies the current waveform data to it. (A Float32Array is a special kind of array that takes in 32-bit floating-point numbers. The array represents the waveform when broken up into however many items there are in the array. Each item represents the amplitude of that part of the waveform, from -1 to 1).

If we have, for example, an oscillator node, we can create an analyzer node, connect it to the oscillator, and use the preceding two properties to get the waveform data for the sound that's playing at that precise moment:

let oscillator = audioContect.createOscillator(); // create the oscillator

let analyser = audioContect.createAnalyser(); // create the analyser node

oscillator.connect(analyser); // connect the oscillator and the analyser

oscillator.start(); // start the oscillator playing

let waveform = new Float32Array(analyser.frequencyBinCount);

// create a Float32Array which will hold the waveform data in the next step

analyser.getFloatTimeDomainData(waveform); // get the waveform data for the
sounds at this precise moment.

The getFloatTimeDomainData function would be called every frame when creating an audio visualization. The information in this subsection will be useful for the activity at the end of this chapter, so refer back to it then.

Web Storage API

Storing data in the browser can be a great way of improving the user's experience. It can save the user having to wait while the same data is fetched from the server, and it can be used to instantly bring a previously visited page back to the state it was left in, meaning, for example, that the user doesn't have to refill in the same parts of a form. The Web Storage API is used to store data in the browser in the form of key/value pairs. It can be used to store data that a user has entered into a form to allow them to easily come back to it and complete it later, or it could be preferences a user has chosen in the web app, data that you want to pass from one page to another within the same origin, or any other piece of data that you think would be useful to store. The Web Storage API is synchronous, so setting and retrieving data will block other JavaScript code until the web storage methods have completed. Web storage is intended for storing relatively small amounts of data, in which case being synchronous won't have a noticeable effect on performance.

You've probably heard of cookies as being an example of data storage within a browser. Web storage is somewhat similar to cookies, although their respective use cases are different: cookies are meant for sending data to the server, whereas web storage is designed for storage on the client-side only. Also, web storage allows for much more data to be stored – typically, web storage has a limit of 10 MB (although, like with so much in the world of web development, this depends entirely on the browser in question), while cookies are limited to 4 KB. Another key difference is that cookies must either have an expiration date set, or they will expire at the end of the session, while on the other hand, one kind of web storage is only removed via JavaScript, or by clearing the browser's cache.

The Web Storage API is very simple, but before we get into that, let's look at the two variants of web storage and cover some other key points about the interface.

There are two kinds of web storage available through the API: sessionStorage and localStorage. The main difference between these two is that sessionStorage will only persist for as long as the current session is active; that is to say until the browser window is closed. localStorage, on the other hand, has no expiration date and will live on the client machine until cleared, either via JavaScript or by clearing the browser's cache. Both sessionStorage and localStorage work on the same origin principle, meaning that data that's stored by a particular domain is only available to that domain.

The methods that are available to both sessionStorage and localStorage are the same, and the API is very simple to use. There are five methods at our disposal, but only three or four are commonly used:

  • The setItem() method is how we store a key/value pair in web storage. It takes two parameters, both of the string type. The first is the item's key, while the second is its value:

    // Sets key 'dog' with value 'woof'

    sessionStorage.setItem('dog', 'woof');

  • The getItem() method allows us to get any item set in the store. It takes a single parameter, that is, the key of the item we want to retrieve. If an item of the supplied key doesn't exist in the store, then it will return null:

    sessionStorge.getItem('dog');

    // gets the value of key 'dog'

  • The removeItem() method takes one parameter, that is, the key of the item you wish to remove:

    sessionStorage.removeItem('dog');

    // removes the key 'dog' and its value

  • The clear() method clears the whole storage for the current page's origin and takes no parameters:

    sessionStorage.clear();

    // clears all sessionStorage for the current origin

  • The key() method takes an index as its parameter and returns the key of the items at that index, or null if no item exists for the index:

    sessionStorage.key(0);

    // returns the key of item at index 0 (if any)

There are also the sessionStorage.length and localStorage.length properties, which return the number of items stored in the browser storage object.

Web storage objects behave much like JavaScript objects in that we can access their properties through dot notation as well as by using the setItem and getItem methods:

sessionStorage.planet = 'Mars';

// sets an item with the key 'planet'

sessionStorage.planet;

// returns the string 'Mars'

An important point to note is that the value of an item must be a primitive data type, but that doesn't mean we can't store more complicated objects using web storage. If we want to store an object in web storage, we can stringify it using the JSON object obj when we set it, and then parse it when we want to retrieve it again:

let obj = {

  name: 'Japan',

  continent: 'Asia'

};

sessionStorage.setItem('country', JSON.stringify(obj));

We can then combine sessionStorage.getItem() with JSON.parse() to retrieve the object:

JSON.parse(sessionStorage.getItem('country'));

// Outputs the country object 'obj' defined above.

Exercise 8.03: Storing and Populating Name Using the localStorage API

Let's make a simple web page that takes some user information and stores it using the localStorageAPI so that it will be displayed when the user visits the page next. Browser support for web storage is very strong these days. Still, it's important to account for the possibility that web storage is not supported, so make sure to warn the user in case web storage is unsupported in their browser. In this exercise, let's ask the user for their first name and last name. Let's get started:

  1. First of all, let's create an HTML file with standard boilerplate HTML, and add a couple of input boxes for the user's first and last name, along with a warning message in case the browser doesn't support web storage. We'll set the <p> tags' display style to none by default:

    <!-- index.html -->

    <!DOCTYPE html>

    <html>

      <head>

      </head>

      <body>

        <input type="text" id='first-name' placeholder='First name'>

        <input type="text" id='last-name' placeholder='Last name'>

        <p style='display: none;' id='warning'>Your browser doesn't support local storage</p>

        <script src='storage.js'></script>

      </body>

    </html>

    If you open this HTML file in the browser, it will look like this:

    Figure 8.6. The HTML page with two input boxes

    Figure 8.6. The HTML page with two input boxes

  2. Next, we'll create a JavaScript file, starting off with a check to see whether the localStorage method is available on the window object. If it's not available, we simply return and set the warning message to display block, thus alerting the user that there will be reduced functionality on the page:

    // storage.js

    if (!window.localStorage) {

      // if localStorage is not supported then display the warning and return
    out to stop the rest of the code from being run.

      document.getElementById('warning').style.display = 'block';

    } else {

  3. If the browser does support localStorage, we'll proceed to assigning any values for the firstName and lastName keys that are currently held in localStorage to variables of the same name:

      let firstName = localStorage.getItem('firstName');

      let lastName = localStorage.getItem('lastName');

  4. Then, we'll grab the two input elements, and if firstName or lastName have a value, then that value is set as the respective text input's value, thereby populating any string saved in localStorage back into the relevant text input:

      let inputFName = document.getElementById('first-name');

      let inputLName = document.getElementById('last-name');

      if (firstName) {

        inputFName.value = firstName;

      }

      if (lastName) {

        inputLName.value = lastName;

      }

  5. The final thing we need to do is add an event listener to the two text inputs and store their current values in localStorage each time the input event is fired:

      inputFName.addEventListener('input', event => {

        localStorage.setItem('firstName', event.target.value);

      });

      inputLName.addEventListener('input', event => {

        localStorage.setItem('lastName', event.target.value);

      });

    }

The error output will be displayed as follows:

Figure 8.7: Output error

Figure 8.7: Output error

The output is displayed as follows, with the two names stored:

Figure 8.8: The HTML page with two input variables stored and populated

Figure 8.8: The HTML page with two input variables stored and populated

This completes our simple application. Assuming localStorage is supported, any string that's entered into either of the text inputs will be saved and repopulated, even after the page is refreshed or the browser or tab is closed.

Note

Here, our method of feature detection is not robust, and it will not detect, for example, when the feature has been disabled in the browser. A better approach for production code is to attempt to set and get an item in localStorage. If the get value is as expected, then we know that local storage is working.

While the web storage API is extremely useful for storing relatively small amounts of data, it's not well suited for storing larger files or data structures. Firstly, we can only store string values in web storage, and more importantly, since the API is synchronous, an application would take a performance hit if it were storing and retrieving large amounts of data.

In cases where we want the client to store large datasets, files, or blobs, we can make use of anther browser API: the IndexedDB API.

IndexedDB

IndexedDB is another form of client-side data storage that differs from web storage in some important ways:

  • Unlike web storage, it is well suited to storing large amounts of data, and for storing many different data types.
  • The API has much greater functionality than the web storage API, allowing us to do things such as perform queries against indexed data.
  • It's an asynchronous API, so working with data stored in indexedDB won't block the execution of other code.

These last two points hint at the biggest drawback with using indexedDB over web storage: its API and workflow are more complex than the simple get and set methods we use for web storage. IndexedDB is often criticized for having an overly complex API, but it's necessary to ensure data integrity (more on that soon), and anyway, if we take the time to understand some core concepts, then we'll see that it's actually not that complex after all.

Like web storage, indexedDB follows the same origin rule, meaning that only pages at the same domain, protocol, and port can access a particular instance of indexedDB. Before we start working with indexedDB, let's examine some of its components and core concepts.

An indexedDB database comprises one or more object stores. An object store, as its name suggests, is a container for the objects we are storing in the database. Like web storage, objects in indexedDB are stored as key/value pairs, but unlike web storage, the value doesn't need to be of the string type. The value could be any JavaScript data type, or even blobs or files.

These objects are typically all of the same type, but they do not need to have the exact same structure as each other, as you may expect with traditional databases. For example, let's say we're storing data on employees. Two objects in the object store may have the salary property, but one's value could be 30,000 and the other could be thirty thousand.

Object stores can be linked to indexes (which are actually just a different kind of object store). These indexes are used to efficiently query the data we store in the database. The indexes are maintained automatically. We'll look at how we can use them in more detail shortly:

Figure 8.9: Layout of indexedDB

Figure 8.9: Layout of indexedDB

With indexedDB, all of our Create, Read, Update, and Delete (CRUD) operations are performed inside of a transaction, which we'll look at in detail soon. Working inside of a transaction can seem like a convoluted way of doing things, but it's an effective way of preventing write operations happening on the same record at the same time. Consider two pages open on the same page, both of which are trying to update the same record. When a transaction is open on one page, the other is unable to perform operations on the same record.

The process of working with indexedDB can be broken down into four steps:

  1. Open the database.
  2. Create an object store if the required store doesn't exist yet.
  3. Process the transaction: create, read, update, or delete a record or records.
  4. Close the transaction.

Exercise 8.04: Creating an Object Store and Adding Data

Let's create a database that will hold records of animals. We'll go through the preceding steps in more detail to create a database, create an object store, start a transaction, and add some data to the database. Add the code that follows into the console of the Google Chrome developer tools:

  1. We'll initialize a variable called request with the indexedDB.open() method and pass the database name animals and the database version number 1 as parameters. It returns a request object, which in turn will receive one of three events: success, error, or upgradeneeded:

    let request = window.indexedDB.open('animals', 1);

    When we call open for the first time, the upgradeneeded event is triggered, and we can attach an onupgradeneeded event handler function, in which we will define our object store.

  2. Then, we'll define a function to handle the onupgradeneeded event, assign the database at event.target.results in a db variable, and create a 'mammals' object store:

    request.onupgradeneeded = event => { // handle the upgradeneeded event

      let db = event.target.result;

      db.createObjectStore('mammals', {

        keyPath: 'species'

      });

    };

    Notice that we pass a second parameter, 1, to the open method. This is the database's version number, which we can change to allow changes to object stores, or to add new object stores. We'll see how this works later.

    The database itself is accessible at the request object's result property. We can access it either through the event object at event.target, or through the request object (the event target is the request object).

    We then use the createObjectStore() method of the database to create a new store. We pass this method a name, which can be any string, but which should typically describe what kind of data is being stored. We also pass in an object, with a key of keypath and a value of the key we want to use to address the objects we store, and for accessing the objects stored.

  3. Now that we've created our database, we can go ahead and insert some objects. This time, when we call the open method of the indexedDB object -- assuming there are no errors -- the success event will be triggered, and we access the database and proceed with the transaction. Let's run through what we are doing with the onsuccess handler. Assign the database to a db variable again and handle the errors that may occur (for now, we'll just log them to the console):

    request.onsuccess = event => {

      let db = event.target.result;

      db.onerror = error => {

        console.log(error);

      }

  4. Create a transaction with the storeName property of 'mammals' and the type of 'readwrite'. This limits the transaction to only be able to perform read/write operations to the 'mammals' object store:

      let transaction= db.transaction('mammals', 'readwrite');

  5. Next, we assign the object store to the store variable and add two records to the store:

      let store = transaction.objectStore('mammals');

      store.put({

        species: "Canis lupus",

        commonName: "Wolf",

        traits: ["Furry", "Likes to howl at moon"]

      });

      store.put({

        species: "Nycticebuscoucang",

        commonName: "Slow Loris",

        traits: ["Furry", "Every day is Sunday"]

      });

  6. Then, we define the action that should happen when the transaction receives the 'complete' event, which is to close the database, thereby completing our transaction:

      transaction.oncomplete = () => {

        db.close();

      };

    };

  7. After running this code, and assuming there were no errors, you can open Chrome's developer tools, navigate to the Application tab, and expand the IndexedDB storage item on the left-hand side. In here, you'll see your newly created animals database, containing its mammals object store, and the two entries we added previously:
    Figure 8.10: Viewing IndexedDB in DevTools

    Figure 8.10: Viewing IndexedDB in DevTools

  8. Now that you've saved some data in the database, let's learn how to retrieve it again. The process of retrieving data follows a similar pattern to storing it in the first place. When we created the object store, we set the keyPath to species as we know this will be a unique property. We can use this property to access a particular entry in the object store:

    indexedDB-v2.js

    1 let request = window.indexedDB.open('animals', 1);

    3 request.onsuccess = event => {

    4   let db = event.target.result;

    5   db.onerror = error => {

    6     // handle an error

    7     console.log(error);

    8   }

    9   let trx = db.transaction('mammals', 'readonly');

    10   let store = trx.objectStore('mammals');

    11   let animalReq = store.get('Nycticebuscoucang');

    12   animalReq.onsuccess = (event) => {

    13     console.log(event.target.result);

    14   };

  9. Like we did previously, we must initiate a request to open the database and attach an onsuccess handler to that request. When the success event is emitted, we can access the database through either request.result or through the event object, that is, event.target.result. We can now create a transaction by calling the database's transaction() method and specify the object store and transaction type we want with mammals and readwrite.
  10. Next, we access the store by calling the objectStore() method of the transaction. We can now call the get() method and pass in the keyPath value of the entry we want to access. This get() method returns another request object, which also receives events for successes and errors. We attach one final success handler to the onsuccess property, which will access the event.target.result property. This contains the entry we are looking for.

    When we first created the database, and every time we subsequently made a request to open it, we passed a database version number as the second parameter to the indexedDB.open() method. As long as we keep the version number the same, the database will open with consistent object stores, but we will not be allowed to make any changes to the structure of the stores, nor will we be able to add new object stores to the database. If we want to modify an object store or add a new one, we need to upgrade our database. We do this by simply creating an open request and passing a new version number to the second parameter.

    This will trigger the request's onupgradeneeded event and allow us to create a version change transaction, which is the only type of transaction in which we can modify or add an object store. Version numbers must be integers, and any new version must be of a higher value than the database's current version number.

    Let's say we want to add another object store, this time for animals in the cephalopod class. The process of handling the upgradeneeded event is the same as when we first created the database. When a new object store is added, the success event will be triggered on the request object. This means we can add entries to our new object store immediately after creating it:

indexedDB-v3.js

2 let request = window.indexedDB.open('animals', 2);

4 // handle the upgradeneeded event

5 request.onupgradeneeded = event => {

6   let db = event.target.result;

7   // Our new cephalopods store

8   db.createObjectStore('cephalopods', {

9     keyPath: 'species'

10   });

11 };

12 

13 request.onsuccess = event => {

14   let db = event.target.result;

15   db.onerror = error => {

16     console.log(error)

Looking again at the Application tab of Chrome's developer tools, we will see our newly created cephalopod store and its two new entries:

Figure 8.11: New object store and entries in indexedDB

Figure 8.11: New object store and entries in indexedDB

In this exercise, we created a database that holds records of animals. You can further try to add different object stores and add data to it.

Querying IndexedDB

As well as accessing data by its key (species, in our examples so far), we can run simple queries against an object store to return multiple entries that match our query term. The data in indexedDB needs to be indexed by any key that we want to use for queries; unlike other databases, there is no in-built search functionality with indexedDB. If we decided we wanted to use a different key than the keyPath we set when we created our objectStore, we would need to create a new index.

Exercise 8.05: Querying the Database

In this exercise, we will see how we can use a different key to the keyPath that we used when we created our objectStore. To do so, we will use the createIndex method, which takes in two parameters and an options object as the third parameter. The first is the name we want to associate the new index with, while the second is the data key we want to link to the index. Doing this requires updating the database version once again when we create the database open request. Let's work through the exercise to see how we can achieve this. Like we did previously, follow along in a code snippet in Google Chrome's developer tools:

  1. Make a new request to open the animals database and assign a function to the onupgradeneeded event:

    let request = window.indexedDB.open('animals', 3); // version 3 of the DB

    request.onupgradeneeded = event => {

  2. Access the mammals store through event.target.transaction.objectStore and call the createIndex() method on it:

      let store = event.target.transaction.objectStore('mammals');

      store.createIndex('traits', 'traits', {multiEntry: true, unique: false});

    };

    As we mentioned previously, the createIndex method takes in two parameters. In our example, we use traits for both of these parameters. The third parameter is an options object. Here, you can set the unique property to true so that the database does not allow duplicates of this key to be stored, or to false to allow multiple records with the same value for this key. You can also set a multiEntry parameter. If it's set to true, then the database will add an entry for every item in an array; if it's set to false, then the entire array will be indexed as one entry. Setting this to true will allow us to query entries by a single trait, as we'll see now.

  3. Next, we instantiate a database open request object for version 3 of our database and create another onsuccess event handler function:

    let request = window.indexedDB.open('animals', 3);

    request.onsuccess = event => {

  4. We then get hold of the resulting database, create a transaction, access the store, and call the store's index() method with the name of the index we wish to query against:

      let db = event.target.result;

      let trx = db.transaction('mammals', 'readonly');

      let store = trx.objectStore('mammals');

      let index = store.index('traits')

  5. Then, we call index.getAll() with the value of Furry and assign the returned value to the animalReq variable. As usual, this object receives a success event, through which we can access an array of all the records matching our query:

      let animalReq = index.getAll('Furry');

      animalReq.onsuccess = (event) => {

        console.log(event.target.result);

      };

  6. Lastly, we create an error event handler to deal with any errors that may arise:

      animalReq.onerror = (error) => {

        console.log(error); // handle any error

      };

    };

  7. If we run this code, we should get all the database entries that match our query:
Figure 8.12: The result from accessing all the Furry mammals in the database

Figure 8.12: The result from accessing all the Furry mammals in the database

In this exercise, we learned to use a different key to the keyPath and the createIndex method, which took two parameters and an options object as the third parameter.

IndexedDB Cursor

As we mentioned previously, indexedDB does not have native record search functionality for unindexed record keys. If we want this functionality in our database, we're on our own. IndexedDB does, however, provide us with a cursor, which is an object representing a location in an object store, and which we can use to iterate through objects in the database. Like other parts of the indexedDB API, the cursor object is event-based, so we must wait for a success event to be fired before proceeding with our operations:

let request = window.indexedDB.open('animals', 3);

request.onsuccess = event => {

  let db = event.target.result;

  let trx = db.transaction('mammals', 'readonly');

  let store = trx.objectStore('mammals');

  let cursorReq = store.openCursor();

  cursorReq.onsuccess = e => {

    let cursor = e.target.result;

    if (cursor) {

      console.log(cursor.value); // do something with this entry.

      cursor.continue();

    } else {

      console.log('end of entries');

    };

  };

};

Once again, we'll walk through the processes of gaining access to the database, opening a transaction, and accessing the store we're interested in. We can now use the openCursor() method of the object store to create our cursor. This method can take two optional parameters: a range of keys within which the cursor can iterate, and a direction that tells the cursor which direction to move in through the records when its continue() method or advance() method is called. The possible values for the direction parameter are next, nextunique, prev, and prevunique, with the default of next.

In our case, we haven't provided any parameters to the openCursor() method, so it will iterate through all the keys and will move forward over each individual record.

We then define an anonymous callback function that will be fired on the cursor request's success event. This method is where we would process the record according to our needs. We can do things such as conditionally adding a record to an array based on certain property values, which would turn the whole process into a custom search method or delete particular records by calling the cursor.delete() method. In our example, we are simply logging the record to the console and then calling the continue() method. Calling continue() moves the cursor on to the next record, which then triggers the cursorReq object's success event, starting this part of the process again. If the cursor has reached the end of the records, the cursor object will be null, and we can terminate the process.

There's been a lot to cover in indexedDB – this is unsurprising, really, given that it's a comprehensive client-side database that comes with a lot more functionality, and therefore complexity, than the Web Storage API we looked at previously.

Before we move on to an exercise to solidify our understanding of indexedDB, here is a quick recap of what we've covered:

  • IndexedDB is suitable for storing large amounts of data.
  • It can store many more data types than web storage (any JavaScript data type, files, or blobs).
  • It's event-based – pretty much all operations are requested from the database and receive various events.
  • It's asynchronous.
  • It comprises the database, one or more object stores, data objects, and indexes (a kind of object store).
  • All operations happen inside of a transaction, which ensures that all the operations complete successfully or that the object store is reverted back to its pre-transaction state.
  • We can query records against specified indexes.
  • We can use a cursor to iterate through records in an object store and use this to create our own search features, as required for our application.

Exercise 8.06: Fetching, Storing, and Querying Data

In this exercise, we'll be fetching some data from a remote API, storing it in an indexedDB database, and then writing our own function to query the database for a particular subset of data. We'll do this by adding 200 todo items to the database and retrieving the tasks that are not complete.

The API we'll be calling can be found at https://jsonplaceholder.typicode.com. If we make a get request to its todos route, we will get a list of todo items in response.

We will then create an indexedDB database and an object store and store all this data in the store. In this example, we will use the fetch API, which is another Browser API that's used for making HTTP requests in JavaScript. Let's get started:

  1. In a new snippet in Google Chrome's developer tools, we'll get the data from the API:

    const http = new XMLHttpRequest();

    http.open('GET', 'https://jsonplaceholder.typicode.com/todos');

    http.send();

    http.onload = event => {

      let todos = JSON.parse(event.target.responseText);

    Here, we're using the XMLHttpRequest() constructor to make a new HTTP get request to our API endpoint.

  2. Then, we're setting a function to the load event listener of the HTTP request object. This event handler is where we receive our todos data from the API and is where we will write the rest of our code. If we were to console log the todos variable, we would see an array of objects in the following format:

    {

      userId: 1,

      id: 1,

      completed: false,

      title: "delectusautautem"

    }

  3. Once we have our data in the todos variable, we'll create a new database called tasks and a new object store called todos and set the object store's keyPath to the id property of our todo items (again, everything is happening inside the http object's onload handler):

      let dbRequest = window.indexedDB.open('tasks', 1);

      dbRequest.onupgradeneeded = event => {

        // handle the upgradeneeded event

        let db = event.target.result;

        db.createObjectStore('todos', {

          keyPath: 'id'

        });

      };

  4. We can now go ahead and add our todo items to the database. Like we did previously, we'll add some lines of code to our http.onload event handler. This time, we'll add an onsuccess function to our dbRequest object, in which we'll get the database from the success event object and start a readwrite transaction targeting the todos store. We'll access the store from the transaction use a forEach loop to loop through the items in the todos array, and push each one into the database:

    dbRequest.onsuccess = event => {

        let db = event.target.result;

        let trx = db.transaction('todos', 'readwrite');

        let store = trx.objectStore('todos');

        todos.forEach(item => {

          store.put(item);

        });

        trx.oncomplete = () => {

          console.log('close');

          db.close();

        };

      };

    };

  5. Select the Application tab of the developer tools and expand the IndexedDB list on the left-hand side. Here, you should find our tasks database containing the todos object store, which should now have our 200 todo items:
    Figure 8.13: Showing indexedDB after the data has been added

    Figure 8.13: Showing indexedDB after the data has been added

  6. With our data safely in the database, we'll write a query function to get all of our todo items that have completed set to false. First, we'll instantiate an empty array to hold our uncompleted todos. Then, we'll use the indexedDB cursor interface to iterate through the records. For each record, we'll check to see whether the completed property is false. If it is, we'll push the record into the array. Since we already have our data in the database, it's best to comment out the last block of code, otherwise, we'll make the HTTP request again and save duplicates of all the todos:

exercise-8_06_1.js

1 let dbRequest = window.indexedDB.open('tasks', 1);

2 let outstandingTodos = [];

3 dbRequest.onsuccess = event => {

4   let db = event.target.result;

5   let trx = db.transaction('todos', 'readonly');

6   let store = trx.objectStore('todos');

7   let cursorReq = store.openCursor();

8   cursorReq.onsuccess = e => {

9     let cursor = e.target.result;

10     if (cursor) {

11       console.log(cursor.value)

12       if (!cursor.value.completed) outstandingTodos.push(cursor.value);

This results in the following output:

Figure 8.14: The console output from our query function

Figure 8.14: The console output from our query function

We can see that the completed property is false from the preceding figure and uncompleted are true. In this exercise, we learned to fetch some data from a remote API, storing it in an indexedDB database, and then writing our own function to query the database for a particular subset of data.

This section has covered one of the more complicated web APIs. Here's a quick recap of the IndexedDB API's core principles:

  • IndexedDB databases comprise the database, which contains one or more object stores, which contain the actual data objects
  • (Almost) everything happens with events, so you use event handlers a lot.
  • Transactions are where the business happens. Transactions apply to only one object store and can be read-only, read-write, or version-change.
  • You can fetch items by their key name if they have been indexed by that key, or you can use a cursor to iterate through a set of records.

Now, we'll look at a browser API we can use to give us information on how performant a site or application is. This API is surprisingly called the Performance API.

Performance API

When we're building sites and web apps, it's important to be able to measure the performance of our applications to help ensure good user experiences. We do this during development, testing stages, and in production. As our application grows and we add new features, it's equally important to make sure that the changes we're making aren't negatively affecting performance. There are a number of ways to measure this and some useful tools to help us. One such set of tools is the browser's Performance API and other closely related APIs.

The Performance API allows us to time events with extreme accuracy: the time measurements we have access to are expressed in milliseconds but are accurate to about 5 microseconds. With these APIs, we can accurately measure the time it takes to complete specific actions, such as the following:

  • The time it took to render the first pixel on our page
  • The time between a user clicking an element and the next action (for example, the start of an animation, or sending a request to the server)
  • The time it takes for various page resources to load
  • The time it takes for information to be sent from the browser to the server, and then to get a reply

The API also gives us access to particular data that the browser collects during events leading up to our site being loaded, such as the following:

  • The type of navigation that leads to the page being loaded (from history, navigation event, or page reload)
  • How long it took for the DNS to respond with the IP address of the webserver
  • How long it took to establish a TCP connection

You can also create custom measurements to see how long particular processes take in the application. Together, all of this information can be used to create detailed accounts of a site's performance, help identify areas of the application which need optimization, and track the performance improvements (or hits) as you make changes to your site.

Let's say you want to know how long your page takes to load. That's a reasonable question to ask, but you have to be a bit more specific about what you mean before answering this question accurately and usefully. First, you need to ask yourself what information you actually want: from a developer's point of view, this question could be interpreted as "how long does it take for my webserver to send all the requested resources to the browser, and for the browser to then process and render them?", but from the user's perspective, the question would be more akin to, "how long does it take from the moment I click a link to the moment the page has fully loaded?". Both of these questions are important, but the user's question requires more information to answer than the developer's. So, we can start to see that we need to break down all the events taking place to be able to answer these, and other, questions. This is where the Performance API comes in: it gives us many metrics we can use, including from processes that happen before our page is requested.

First, let's break down some of the key steps that take place when a user clicks on a link to a site at a new domain. In practice, there are more steps involved than those shown here, but it's not really necessary to unpick the whole process for this example:

Figure 8.15: Overview of processes after a user clicks a link

Figure 8.15: Overview of processes after a user clicks a link

Let's go through the following steps:

  1. When a user clicks on a link – say a Google search result – the browser sends a request to the domain name server (DNS) and receives the IP address of the webserver for that domain.
  2. The browser then opens a TCP connection with the server at the IP address.
  3. When this connection process has finished, the browser requests the page data.
  4. The server responds with that data and the browser process and displays the page to the user. This is a very high-level, stripped-down, simplified account of what happens when a browser wants to load a page, and it assumes nothing went wrong. The takeaway here is that there's a lot going on and that there are many potential areas for navigation and page loads to be slowed down. Using the Performance API gives us the timings for many key events.

Open your browser to any page. In the console, you can view the performance data for that page. We can get a navigation timing object from the browser, which will give us much of the information we're looking for. First, we'll assign the navigation entry of the Performance API to a variable:

let navTiming = performance.getEntriesByType("navigation")[0]; // this returns an array, but we're only interested in one object.

The getEntriesByType method returns all the performance timing entries that the browser has stored of the specified type. Here, we've said we want all the navigation type entries (there's only one entry, so we'll get an array with one object).

After assigning a reference to the 0th object in the returned array, we can view the object by entering the variable's name (that is, navTiming) in the console:

Figure 8.16: Expanded navigation timing object

Figure 8.16: Expanded navigation timing object

Expanding the navigation entry object, we can see many properties that we can use to calculate how long the various actions took during navigation and loading the current page. Let's run through a couple of examples so that you get the idea:

let dnsLookupTime = navTiming.domainLookupEnd - navTiming.domainLookupStart;

This will give us the total time take for the domain name service to respond with the IP address of the requested domain. The browser will typically cache the IP address of a particular domain, so it may well result in zero if you've previously visited the page you're testing. Let's take a look at the following code:

let tcpConnectTime = navTiming.connectEnd - navTiming.connectStart

The connectStart and connectEnd properties are the times at which the client established a TCP connection with the server and the time at which the connection process was complete. Taking one from the other gives us the total connection time. Let's take a look at the following code:

navTiming.domComplete;

The domComplete property is the time at which the browser finished loading the document and all its resources, such as CSS and images, and the document.readyState property is set to complete. This would be the answer to our user's question: "how long does it take from the moment I click a link to the moment page has fully loaded?".

As you can see, there are many other metrics you can use in this navigation timing entry for timing the navigation and loading a page. But what about once our page has loaded and the user is interacting with it? We obviously want to be able to measure the performance of our site during its usage, and the Performance API gives us some useful methods for doing just that.

We can use the Performance API to measure the performance of any part of our site or application by making use of the mark() and measure() methods of the interface. For example, let's say part of your application involves some CPU-intensive processing that you want to optimize. You can use performance marks to measure the time it takes to a high degree of precision and measure the success of different optimization approaches:

function complicatedFunction() {

  let n = 0;

  for (let i = 0; i< 1e9;) {

    n = n + i++;

  }

  return n;

};

Here, we've defined a function that performs some arbitrary calculation 1,000,000,000 times (1 x 109 times) and returns the result, n. If we want to know how long it takes to complete the for loop, we can use the performance.mark() method at the beginning and end of the loop, then use the performance.measure() method to measure the two marks and return the resulting measure:

functioncomplicatedFunction() {

  let n = 0;

  performance.mark('compStart');

  for (let i = 0; i< 1e9;) {

    n = n + i++;

  };

  performance.mark('compEnd');

  console.log(n);

  performance.measure('compMeasure', 'compStart', 'compEnd');

  console.log(performance.getEntriesByName('compMeasure')[0].duration);

};

Calling the mark method creates a performance timeline entry with the name provided (we called our compStart and compEnd). We can then use performance.measure() to create a performance.measure entry, which will give us the precise times between the start and end marks. Running complicatedFunction() will give us the following output:

Figure 8.17: Output from running the function

Figure 8.17: Output from running the function

Exercise 8.07: Assessing Performance

Let's say we want to add a new feature to our app that involves a similar CPU-intensive process to our preceding example, so we want to make sure we write the function in the most efficient way possible. We can use the Performance API's mark() and measure() methods to find the precise time taken to run a particular section of code, and we can then compare two different implementations of the same logic. In this exercise, we will use the mark() method to mark the start and endpoints of the blocks of code we want to compare, and we will use the measure() method to measure the exact time between the marks. Our output will be the time difference.

Let's take the preceding example and compare the performance of different looping functions in JavaScript. Let's get started:

  1. This first function will measure the performance of a for loop. Start by declaring a function and initializing a variable that will hold a value that was used in the loop:

    function complicatedForLoop() {

      let n = 0;

  2. Now, we'll use the performance.mark() method to mark the start of the looping function, and we'll give the mark a name of forLoopStart:

      performance.mark('forLoopStart');

  3. Next, we'll run the for loop, which does the same calculations as it did in the preceding example:

      for (let i = 0; i< 1e9;) {

        n = n + i++;

      }

      performance.mark('forLoopEnd');

      console.log(n);

      performance.measure('forLoopMeasure', 'forLoopStart', 'forLoopEnd');

      console.log(`for loop: ${performance.getEntriesByName('forLoopMeasure')[0].duration}`);

    };

  4. This second function will measure the performance of a while loop:

    function complicatedWhileLoop() {

      let n = 0;

      let i = 0;

      performance.mark('whileLoopStart');

      while(i<1e9) {

        n = n + i++;

      }

      performance.mark('whileLoopEnd');

      console.log(n);

      performance.measure('whileLoopMeasure', 'whileLoopStart', 'whileLoopEnd');

      console.log(`while loop: ${performance.getEntriesByName('whileLoopMeasure')[0].duration}`)

    }

  5. Now, let's run both of these functions and see how the performance compares:

    complicatedForLoop();

    complicatedWhileLoop();

Here, we have declared two functions, both of which produce the same result, but using different JavaScript looping functions: a for loop and a while loop. We are marking the moment before each loop starts, and again marking the moment the loops end. We then measure the marks and log the measured duration to the console. What results did you get?

Figure 8.18: Results from the performance tests on a for loop and a while loop

Figure 8.18: Results from the performance tests on a for loop and a while loop

Your results may be quite different, depending on the system and the JavaScript engine you're running the code on, but you should still see a marked difference between the two loop statements. This section has dipped into a fairly advanced topic and one that may not be quite as exciting as, say, drawing triangles. However, application performance is important to keep in mind as failing to do so can lead to slow apps that people will find frustrating and may ultimately abandon.

Web Socket API

Typically, when a browser connects to a server during normal browsing, it does so over HTTP or HTTPS. For the purposes of this topic, all we really need to know about HTTP is that each time a browser wants to send or receive a piece of information from the server, it has to open a new connection to that server, make its request, and then close the connection. This is fine for most situations, but it's a one-way street; the server cannot open a connection with the browser. This means that if the server receives some new data, it has no way of alerting the browser, and instead has to rely on the browser querying the server at some point and asking for the data. A lot of the time, this is ok because we developers know when we can expect new data to be available, or we know when in our application we want to request any new data.

Relying on a developers' savviness falls short, of course, in situations where we don't have full control over when or how often new data is made available to the server. The classic example of such a situation is with real-time chat apps, for example, Facebook's instant messaging or WeChat. We're probably all familiar with the basic functionality of these apps: two or more people can send messages to each other, and they will appear on the receiver's device instantly (minus the network latency and processing time).

But how is this functionality achieved? If we think about this in terms of HTTP, there's no elegant solution: Client A wants to send a message to Client B via the server. Sending the message from Client A to the server is no problem – the client can open an HTTP connection, send the message over, and the server will receive the message. But when it comes to the server relaying that message to Client B, the server is unable to open the connection on its end. In this situation, the solution would be for all connected clients to ask the server whether there are any new messages at regular intervals, say every 30 seconds. This is not a great solution; it means there will be lots of unnecessary opening and closing of connections, each one carrying a relatively large amount of data, in the form of HTTP headers. Also, if a client sends a message at second 1, then the receiving client won't know about that message for at least 29 seconds – and what if it's something important?

WebSockets are an alternative way for browsers and clients to communicate with each other and allow for bidirectional communication; that is to say that the server can send a message to the client at any time. The connection process is fairly simple at a high level: the client connects to a server over HTTP with a WebSocket handshake request containing an Upgrade header (which basically tells the server that the client wants to upgrade the protocol to WebSocket), the server sends a handshake response, and the HTTP connection is upgraded to a WebSocket connection. And then the party starts.

This WebSocket connection stays active indefinitely, and the server keeps a list of connected clients that it can talk to at any moment. If a connection breaks, then the client can try to open the connection again. As long as the connection is active, then either side can send messages to the other at any moment, and the other side can reply to those messages if necessary. It's an open, bidirectional channel of communication, and it's up to us developers to decide what we're going to use it for.

WebSocket messages can contain data of several types, including Strings, ArrayBuffers, and Blobs. To send and receive JavaScript objects, we can easily convert them into strings by using the JSON object before sending them and then parse them on the receiving side.

Setting up a WebSocket server is fairly involved and would be too much detail to include in this chapter. However, we can easily set up a WebSocket client and connect to one of a number of WebSocket testing servers online.

There are several WebSocket testing servers that we can use. In this example, we will use the server at wss://echo.websocket.org. If it's not working, feel free to find another online. One thing to note is that the client and server must start on the same HTTP protocol, so if the page that you've opened the console on is on HTTPS, then the WebSocket server must be on the WSS protocol (and not WS).

Open your browser to any page of your choosing, open the developer tools, and open the console.

WebSocket connections are event-driven, so when we create a connection, we must assign functions to the events we want to handle.

To start off, let's create a new WebSocket connection using the browser's WebSocket constructor function. It takes the server address as a parameter:

let socket = new WebSocket('wss://echo.websocket.org');

If you run this code, and then access the socket object in the console, you will see the new connection object we have created:

Figure 8.19: The WebSocket connection object

Figure 8.19: The WebSocket connection object

Here, we can see the URL of the server we're connected to, and also that there are event listeners for open and close events, error events, and message events. For now, we will just declare a handler for the onmessage property:

socket.onmessage = event => console.log(event);

Now, we are ready to send a message to the WebSocket server:

socket.send("Hello websocket server");

The server I have chosen – and surely any other WebSocket testing server – will simply output whatever message you send to it back as a response. Since we have an event handler attached to the message event, which will log the event to the console, we should get this event object logged to our console soon after we send the message:

Figure 8.20: The response from the WebSocket server

Figure 8.20: The response from the WebSocket server

We now have a functioning WebSocket connection. If the server were programmed to do so, it would be able to send us messages at any time, as long as the connection stays open. Because WebSockets are useful for many different kinds of applications, there is no specific functionality built into them, even though there are some very common use cases. It's up to us to develop systems to handle different kinds of messages, for example, sending a message with a "join chat group" action versus a regular "send message to the user" action.

Exercise 8.08: Creating Chat Rooms Using Websockets

Let's create a small application to make some extended use of this WebSocket server. We'll be creating an application with two chat rooms: one is a group chat and one is a direct message chat room with a single user. We're a bit limited by the WebSocket server's functionality since all it does it send the message it receives back to the client. Since we're only one client, and the server will only respond with the message we send, it'll be a bit of a lonely chat.

For this application, we'll need an HTML page with two lists of chat messages: one for the group chat and one for the direct messaging chat. We'll also need an input box for both chat threads so that we can type our messages in, as well as a few other elements along the way. We'll give most of the elements relevant IDs so that we can easily get hold of them in JavaScript later on. Let's get started:

  1. Let's start off by creating an HTML page, adding our opening HTML tag, adding a head tag with a script referencing a JavaScript file in the DevTools console, and adding our opening body tag:

    <!-- index.html -->

    <!DOCTYPE html>

    <html>

      <head>

        <script src='scripts.js'></script>

      </head>

      <body>

  2. Now, inside the body, we'll add an <h1> element as our page's title:

        <h1>The Echo Chamber</h1>

  3. Let's add an <h4> element, which will let us know if the socket is open or closed (the default is closed):

        <h4 id='socket-status'>Socket is closed</h4>

  4. Let's add an <h6> element for our group chat message list header:

        <h6>Group Chat</h6>

  5. Let's add a  <ul> element to which we will append new group messages:

        <ul id='group-list'></ul>

  6. Let's add an <input> element in which we will write messages to the group chat:

        <input type="text" id='group-input'>

  7. Let's add another <h6> element for the private chat room:

        <h6>Private Chat</h6>

  8. Let's add a <ul> element for the private chat messages list:

        <ul id='dm-list'></ul>

  9. The following is the input we need for writing private messages:

        <input type="text" id='dm-input'>

  10. Finally, we need to add our closing <body> and <html> tags:

      </body>

    </html>

    This results in the following output:

    Figure 8.21: Our new chat app's HTML

    Figure 8.21: Our new chat app's HTML

    Now for the JavaScript: let's go through the functionality we need in a bit more detail. We'll need to get hold of some of our HTML elements so that we can work with them in our JavaScript. We need to open a new web socket connection to our server, that is, wss://echo.websocket.org. We'll want to notify the user when the socket is open or closed, so we'll add socket event handlers for onopen and onclose and set our <h4> element's text accordingly. We'll listen for when a user has pressed the Enter key on either of the input boxes and then send a message to the socket server. The server will echo our messages back to us, so we'll want to listen for incoming messages, decode them, and attach them to the end of the correct message list.

    That's a high-level breakdown of what our JavaScript will do, so let's walk through the code.

  11. We'll start the JavaScript file with an event listener listening for the DOMContentLoaded event, and we'll put our code inside the event listener's callback function:

    // scripts.js

    // wait for page load

    document.addEventListener('DOMContentLoaded', () => {

  12. Next, we'll create a new socket connection to our chosen server:

      let socket = new WebSocket("wss://echo.websocket.org"); // create new
    socket connection

  13. Let's grab the references to the various HTML elements we'll need:

      let dmInput = document.getElementById('dm-id'); // get the DM text input

      let groupInput = document.getElementById('group-input'); // get the group
    text input

      let dmList = document.getElementById('dm-list'); // get the dm messages
    list

      let groupList = document.getElementById('group-list'); // get the group
    messages list

  14. Now, we'll set the socket's onopen event handler function, which will set the socket-status element's inner text to Socket is open:

      socket.onopen = event => {

        document.getElementById('socket-status').innerText = "Socket is open";

        // set the status on open

      };

  15. We'll also set a function for the socket's onclose event, which will revert the status to Socket is closed:

      socket.onclose = event => {

        document.getElementById('socket-status').innerText = "Socket is closed";

        // set the status on close

      };

  16. Next, we'll set the socket's onmessage function. This event is triggered when a message is received from the websocket server:

      // prepare to receive socket messages

      socket.onmessage = event => {

  17. We'll parse the incoming data from a string back to a JavaScript object using the JSON object's parse() method and assign the result to a variable:

        // parse the data

        let messageData = JSON.parse(event.data);

  18. We'll create a new <li> element and assign it to a variable called newMessage:

        // create a new HTML <li> element

        let newMessage = document.createElement('li');

  19. Next, we'll set the inner text value of newMessage <li> to the value of the message data's message property:

        // set the <li> element's innerText to the message text

        newMessage.innerText = messageData.message;

  20. Now, we'll check whether the message is meant for the group chat, and if it is, we'll append it to the groupList:

        // if it's a group message

        if (messageData.action === 'group') {

          // append to the group list

          groupList.append(newMessage);

  21. If it's not meant for the group chat, then we'll append it to the DM list instead, and then close off this event handler function:

        } else {

          // append to the dm list

          dmList.append(newMessage);

        };

      };

  22. Next, will iterate through both of the HTML's input elements:

      // For each input element

      Array.from(document.getElementsByTagName('input')).forEach(input => {

  23. We'll add a keydown event listener to the input elements and assign a handler function to the event:

        // add a keydown event listener

        input.addEventListener('keydown', event => {

  24. If the keydown event was triggered by the key with ascii code 13 (the carriage return key), then we'll begin the process of sending the message to the socket server. The first thing we need to do is create an object with a message key called message and the value of the input box's text. We can get this from the event object's target.value properties. We'll name the message object messageData:

          // if it's keyCode 13 (the enter key)

          if (event.keyCode === 13) {

            // declare the message data object

            let messageData = {

              message: event.target.value,

            };

  25. Now, we'll check whether the target input is the one with the ID of group-input, in which case we'll set an action property on the messageData variable with a value of group:

            // check the message type by looking at the input element's ID

            if (event.target.id === 'group-input') {

              messageData.action = 'group';

  26. Otherwise, we'll assign the same property, but with a value of dm:

            } else {

              messageData.action = 'dm';

            };

  27. Then, we'll turn the messageData object into a string with the JSON.stringify() method and send it to the websocket server with the send() method of the socket connection object we created at the start:

            // stringify the message and send it through the socket connection

            socket.send(JSON.stringify(messageData));

  28. Finally, we'll clear the target input box and close off the functions:

            // clear the input element

            event.target.value = '';

          };

        });

      });

    });

Open the HTML file in your browser and if you type a message into either of the input boxes, you should see it echoed back in the chat list:

Figure 8.22: The Echo Chamber chat app with message

Figure 8.22: The Echo Chamber chat app with message

This is a quick insight into how we can add our own functionality to the WebSocket API. Websockets can be useful any time we need real-time data to be shown in the browser, such as stock market price updates, or when it makes sense to have a sustained open connection with the server, such as in a chat app.

Activity 8.03: Audio Visualization

We're going to tie together a couple of the interfaces we looked at right at the start of this chapter, that is, the Canvas API and the Web Audio API. The aim of this activity is to create a page that is displaying a graphic, and for that graphic to animate based on the Audio API's getFloatTimeDomainData method that we looked in the Audio API section. The Audio API's sound should be controlled by the user, and the graphic should represent the audio in some way (the animation could change based on the sound's volume, or its frequency, for example).

This is quite a broad specification for the activity, but you can build the exercises for the two APIs to come up with something, or you can make use of information in the Audio Visualization subsection of the Web Audio API section earlier in this chapter. See what you can come up with before checking out the solution.

The high-level steps for the activity are as follows:

  1. Create a simple HTML file with a link to a JavaScript file.
  2. Add an event listener on the document that's listening for a click event.
  3. Set up an HTML canvas element and a canvas rendering context.
  4. Set up an Audio context with one or more oscillators, or other audio sources if you like.
  5. Connect an audio analyzer to the audio context.
  6. Start the audio source.
  7. Inside a continuous loop, draw in the Canvas context using the output from the audio API's getFloatTimeDomainData() method to modify one or more of the parameters of the graphic on each iteration of the loop.

The expected output should be as follows:

Figure 8.23: One frame of the audio visualization output image

Figure 8.23: One frame of the audio visualization output image

Note

The solution to this activity can be found on page 734.

Summary

In this chapter, we've looked at a few of the most useful and interesting browser APIs that open up a wide range of functionality that we can make use of in our JavaScript applications. We've seen that while these APIs are commonly accessed through JavaScript, they are not a part of the ECMAScript specification to which JavaScript engines are programmed and are not part of JavaScript's core functionality. Even though we covered quite a lot of information in this chapter, there are many more APIs available to us. When working with browser APIs, it's important to check how much browser support there is for that particular feature, as some APIs are experimental or non-standard, while others are deprecated or obsolete. Often, some browsers will fully support a feature, others will support certain aspects of the same interface, and then others will not support it at all. It is a bit of a minefield, but make use of caniuse.com, which you looked at earlier in this book, to steer yourself and your projects in the right direction.

For a list of available Web APIs, check out the Mozilla Developer Network's page: https://developer.mozilla.org/en-US/docs/Web/API.

So far, you have been mostly learning about traditional, browser-based JavaScript. However, there are many other environments outside of the browser where JavaScript can run. In the next chapter, we'll look at some of these other environments, notably Node.js, which is typically used for server-side JavaScript execution.

..................Content has been hidden....................

You can't read the all page of ebook, please click here login for view all page.
Reset