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.
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.
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:
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.
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:
<!-- 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.
// canvas.js
let canvas = document.getElementById('canvas');
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.
context.fillRect(10,10, 100, 100);
Now, open the HTML file in your browser. You should see something like this:
// 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);
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);
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:
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:
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.
The steps to create a fractal pattern are fairly straightforward, but here are some pointers to keep in mind:
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:
Follow these steps to create a fractal:
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.
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:
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.
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:
// 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();
// 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.
// 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.
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 high-level steps for the activity are as follows:
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 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:
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.
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:
// Sets key 'dog' with value 'woof'
sessionStorage.setItem('dog', 'woof');
sessionStorge.getItem('dog');
// gets the value of key 'dog'
sessionStorage.removeItem('dog');
// removes the key 'dog' and its value
sessionStorage.clear();
// clears all sessionStorage for the current origin
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.
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:
<!-- 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:
// 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 {
let firstName = localStorage.getItem('firstName');
let lastName = localStorage.getItem('lastName');
let inputFName = document.getElementById('first-name');
let inputLName = document.getElementById('last-name');
if (firstName) {
inputFName.value = firstName;
}
if (lastName) {
inputLName.value = lastName;
}
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:
The output is displayed as follows, with the two names stored:
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 is another form of client-side data storage that differs from web storage in some important ways:
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:
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:
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:
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.
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.
request.onsuccess = event => {
let db = event.target.result;
db.onerror = error => {
console.log(error);
}
let transaction= db.transaction('mammals', 'readwrite');
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"]
});
transaction.oncomplete = () => {
db.close();
};
};
indexedDB-v2.js
1 let request = window.indexedDB.open('animals', 1);
2
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 };
The full code is available at: https://packt.live/2q8v5bX
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);
3
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)
The full code is available at: https://packt.live/2pdYCAr
Looking again at the Application tab of Chrome's developer tools, we will see our newly created cephalopod store and its two new entries:
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.
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.
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:
let request = window.indexedDB.open('animals', 3); // version 3 of the DB
request.onupgradeneeded = event => {
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.
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 index = store.index('traits')
let animalReq = index.getAll('Furry');
animalReq.onsuccess = (event) => {
console.log(event.target.result);
};
animalReq.onerror = (error) => {
console.log(error); // handle any error
};
};
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.
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:
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:
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.
{
userId: 1,
id: 1,
completed: false,
title: "delectusautautem"
}
let dbRequest = window.indexedDB.open('tasks', 1);
dbRequest.onupgradeneeded = event => {
// handle the upgradeneeded event
let db = event.target.result;
db.createObjectStore('todos', {
keyPath: 'id'
});
};
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();
};
};
};
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);
The full code is available at: https://packt.live/2qRT6Ek
This results in the following output:
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:
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.
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 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:
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:
Let's go through the following steps:
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:
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:
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:
function complicatedForLoop() {
let n = 0;
performance.mark('forLoopStart');
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}`);
};
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}`)
}
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?
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.
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:
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:
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.
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:
<!-- index.html -->
<!DOCTYPE html>
<html>
<head>
<script src='scripts.js'></script>
</head>
<body>
<h1>The Echo Chamber</h1>
<h4 id='socket-status'>Socket is closed</h4>
<h6>Group Chat</h6>
<ul id='group-list'></ul>
<input type="text" id='group-input'>
<h6>Private Chat</h6>
<ul id='dm-list'></ul>
<input type="text" id='dm-input'>
</body>
</html>
This results in the following output:
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.
// scripts.js
// wait for page load
document.addEventListener('DOMContentLoaded', () => {
let socket = new WebSocket("wss://echo.websocket.org"); // create new
socket connection
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
socket.onopen = event => {
document.getElementById('socket-status').innerText = "Socket is open";
// set the status on open
};
socket.onclose = event => {
document.getElementById('socket-status').innerText = "Socket is closed";
// set the status on close
};
// prepare to receive socket messages
socket.onmessage = event => {
// parse the data
let messageData = JSON.parse(event.data);
// create a new HTML <li> element
let newMessage = document.createElement('li');
// set the <li> element's innerText to the message text
newMessage.innerText = messageData.message;
// if it's a group message
if (messageData.action === 'group') {
// append to the group list
groupList.append(newMessage);
} else {
// append to the dm list
dmList.append(newMessage);
};
};
// For each input element
Array.from(document.getElementsByTagName('input')).forEach(input => {
// add a keydown event listener
input.addEventListener('keydown', event => {
// if it's keyCode 13 (the enter key)
if (event.keyCode === 13) {
// declare the message data object
let messageData = {
message: event.target.value,
};
// check the message type by looking at the input element's ID
if (event.target.id === 'group-input') {
messageData.action = 'group';
} else {
messageData.action = 'dm';
};
// stringify the message and send it through the socket connection
socket.send(JSON.stringify(messageData));
// 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:
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.
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:
The expected output should be as follows:
Note
The solution to this activity can be found on page 734.
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.