25
Client-Side Storage

WHAT'S IN THIS CHAPTER?

  • Cookies
  • Browser storage APIs
  • IndexedDB

WROX.COM DOWNLOADS FOR THIS CHAPTER

Please note that all the code examples for this chapter are available as a part of this chapter's code download on the book's website at www.wrox.com/go/projavascript4e on the Download Code tab.

Along with the emergence of web applications came a call for the ability to store user information directly on the client. The idea is logical: information pertaining to a specific user should live on that user's machine. Whether that is login information, preferences, or other data, web application providers found themselves searching for ways to store data on the client. The first solution to this problem came in the form of cookies, a creation of the old Netscape Communications Corporation and described in a specification titled Persistent Client State: HTTP Cookies (still available at http://curl.haxx.se/rfc/cookie_spec.html). Today, cookies are just one option available for storing data on the client.

COOKIES

HTTP cookies, commonly just called cookies, were originally intended to store session information on the client. The specification called for the server to send a Set-Cookie HTTP header containing session information as part of any response to an HTTP request. For instance, the headers of a server response may look like this:

HTTP/1.1 200 OK
Content-type: text/html
Set-Cookie: name=value
Other-header: other-header-value

This HTTP response sets a cookie with the name of "name" and a value of "value". Both the name and the value are URL-encoded when sent. Browsers store such session information and send it back to the server via the Cookie HTTP header for every request after that point, such as the following:

GET /index.jsl HTTP/1.1
Cookie: name=value
Other-header: other-header-value

This extra information being sent back to the server can be used to uniquely identify the client from which the request was sent.

Restrictions

Cookies are, by nature, tied to a specific domain. When a cookie is set, it is sent along with requests to the same domain from which it was created. This restriction ensures that information stored in cookies is available only to approved recipients and cannot be accessed by other domains.

Because cookies are stored on the client computer, restrictions have been put in place to ensure that cookies can't be used maliciously and that they won't take up too much disk space.

In general, if you use the following approximate limits, you will run into no problems across all browser traffic:

  • 300 cookies total
  • 4096 bytes per cookie
  • 20 cookies per domain
  • 81920 bytes per domain

The total number of cookies per domain is limited, although it varies from browser to browser. For example:

  • Latest editions of Internet Explorer and Edge limit cookies to 50 per domain.
  • Latest editions of Firefox limit cookies to 150 per domain.
  • Latest editions of Opera limit cookies to 180 per domain.
  • Safari and Chrome have no hard limit on the number of cookies per domain.

When cookies are set above the per-domain limit, the browser starts to eliminate previously set cookies. Internet Explorer and Opera begin by removing the least recently used (LRU) cookie to allow space for the newly set cookie. Firefox seemingly randomly decides which cookies to eliminate, so it's very important to mind the cookie limit to avoid unintended consequences.

There are also limitations as to the size of cookies in browsers. Most browsers have a byte-count limit of around 4096 bytes, give or take a byte. For best cross-browser compatibility, it's best to keep the total cookie size to 4095 bytes or less. The size limit applies to all cookies for a domain, not per cookie.

If you attempt to create a cookie that exceeds the maximum cookie size, the cookie is silently dropped. Note that one character typically takes one byte, unless you're using multibyte characters—such as some UTF-8 Unicode characters, which can be up to 4 bytes per character.

Cookie Parts

Cookies are made up of the following pieces of information stored by the browser:

  • Name—Unique name to identify the cookie. Cookie names are case-insensitive, so myCookie and MyCookie are considered to be the same. In practice, however, it's always best to treat the cookie names as case-sensitive because some server software may treat them as such. The cookie name must be URL-encoded.
  • Value—String value stored in the cookie. This value must also be URL-encoded.
  • Domain—Domain for which the cookie is valid. All requests sent from a resource at this domain will include the cookie information. This value can include a subdomain (such as www.wrox.com) or exclude it (such as .wrox.com, which is valid for all subdomains of wrox.com). If not explicitly set, the domain is assumed to be the one from which the cookie was set.
  • Path—Path within the specified domain for which the cookie should be sent to the server. For example, you can specify that the cookie be accessible only from http://www.wrox.com/books/ so pages at http://www.wrox.com won't send the cookie information, even though the request comes from the same domain.
  • Expiration—Time stamp indicating when the cookie should be deleted (that is, when it should stop being sent to the server). By default, all cookies are deleted when the browser session ends; however, it is possible to set another time for the deletion. This value is set as a date in GMT format (Wdy, DD-Mon-YYYY HH:MM:SS GMT) and specifies an exact time when the cookie should be deleted. Because of this, a cookie can remain on a user's machine even after the browser is closed. Cookies can be deleted immediately by setting an expiration date that has already occurred.
  • Secure flag—When specified, the cookie information is sent to the server only if an SSL connection is used. For instance, requests to https://www.wrox.com should send cookie information, whereas requests to http://www.wrox.com should not.

Each piece of information is specified as part of the Set-Cookie header using a semicolon-space combination to separate each section, as shown in the following example:

HTTP/1.1 200 OK
Content-type: text/html
Set-Cookie: name=value; expires=Mon, 22-Jan-07 07:10:24 GMT; domain=.wrox.com
Other-header: other-header-value

This header specifies a cookie called "name" that expires on Monday, January 22, 2007, at 7:10:24 GMT and is valid for www.wrox.com and any other subdomains of wrox.com such as p2p.wrox.com.

The secure flag is the only part of a cookie that is not a name-value pair; the word "secure" is simply included. Consider the following example:

HTTP/1.1 200 OK
Content-type: text/html
Set-Cookie: name=value; domain=.wrox.com; path=/; secure
Other-header: other-header-value

Here, a cookie is created that is valid for all subdomains of wrox.com and all pages on that domain (as specified by the path argument). This cookie can be transmitted only over an SSL connection because the secure flag is included.

It's important to note that the domain, path, expiration date, and secure flag are indications to the browser as to when the cookie should be sent with a request. These arguments are not actually sent as part of the cookie information to the server; only the name-value pairs are sent.

Cookies in JavaScript

Dealing with cookies in JavaScript is a little complicated because of a notoriously poor interface, the BOM's document.cookie property. This property is unique in that it behaves very differently depending on how it is used. When used to retrieve the property value, document.cookie returns a string of all cookies available to the page (based on the domain, path, expiration, and security settings of the cookies) as a series of name-value pairs separated by semicolons, as in the following example:

name1=value1;name2=value2;name3=value3

All of the names and values are URL-encoded and so must be decoded via decodeURIComponent().

When used to set a value, the document.cookie property can be set to a new cookie string. That cookie string is interpreted and added to the existing set of cookies. Setting document.cookie does not overwrite any cookies unless the name of the cookie being set is already in use. The format to set a cookie is as follows, and is the same format used by the Set-Cookie header:

name=value; expires=expiration_time; path=domain_path; domain=domain_name; secure

Of these parameters, only the cookie's name and value are required. Here's a simple example:

document.cookie = "name=Nicholas";

This code creates a session cookie called "name" that has a value of "Nicholas". This cookie will be sent every time the client makes a request to the server; it will be deleted when the browser is closed. Although this will work, as there are no characters that need to be encoded in either the name or the value, it's a best practice to always use encodeURIComponent() when setting a cookie, as shown in the following example:

document.cookie = encodeURIComponent("name") + "=" + 
                  encodeURIComponent("Nicholas");

To specify additional information about the created cookie, just append it to the string in the same format as the Set-Cookie header, like this:

document.cookie = encodeURIComponent("name") + "=" + 
                  encodeURIComponent("Nicholas") + "; domain=.wrox.com; path=/";

Because the reading and writing of cookies in JavaScript isn't very straightforward, functions are often used to simplify cookie functionality. There are three basic cookie operations: reading, writing, and deleting. These are all represented in the CookieUtil object as follows:

class CookieUtil {
 static get(name) {
  let cookieName = `${encodeURIComponent(name)}=`,
    cookieStart = document.cookie.indexOf(cookieName),
    cookieValue = null;
      
  if (cookieStart > -1){
   let cookieEnd = document.cookie.indexOf(";", cookieStart);
   if (cookieEnd == -1){
    cookieEnd = document.cookie.length;
   }
   cookieValue = decodeURIComponent(document.cookie.substring(cookieStart
     + cookieName.length, cookieEnd));
   }
          
   return cookieValue;
  }
  
  static set(name, value, expires, path, domain, secure) {
   let cookieText =
    `${encodeURIComponent(name)}=${encodeURIComponent(value)}`
  
   if (expires instanceof Date) {
    cookieText += `; expires=${expires.toGMTString()}`;
   }
  
   if (path) {
    cookieText += `; path=${path}`;
   }
  
   if (domain) {
    cookieText += `; domain=${domain}`;
   }
  
   if (secure) {
    cookieText += "; secure";
   }
  
   document.cookie = cookieText;
  }

  static unset(name, path, domain, secure) {
   CookieUtil.set(name, "", new Date(0), path, domain, secure);
 }
};

The CookieUtil.get() method retrieves the value of a cookie with the given name. To do so, it looks for the occurrence of the cookie name followed by an equal sign in document.cookie. If that pattern is found, then indexOf() is used to find the next semicolon after that location (which indicates the end of the cookie). If the semicolon isn't found, this means that the cookie is the last one in the string, so the entire rest of the string should be considered the cookie value. This value is decoded using decodeURIComponent() and returned. In the case where the cookie isn't found, null is returned.

The CookieUtil.set() method sets a cookie on the page and accepts several arguments: the name of the cookie, the value of the cookie, an optional Date object indicating when the cookie should be deleted, an optional URL path for the cookie, an optional domain for the cookie, and an optional Boolean value indicating if the secure flag should be added. The arguments are in the order in which they are most frequently used, and only the first two are required. Inside the method, the name and value are URL-encoded using encodeURIComponent(), and then the other options are checked. If the expires argument is a Date object, then an expires option is added using the Date object's toGMTString() method to format the date correctly. The rest of the method simply builds up the cookie string and sets it to document.cookie.

There is no direct way to remove existing cookies. Instead, you need to set the cookie again—with the same path, domain, and secure options—and set its expiration date to some time in the past. The CookieUtil.unset() method handles this case. It accepts four arguments: the name of the cookie to remove, an optional path argument, an optional domain argument, and an optional secure argument.

These arguments are passed through to CookieUtil.set() with the value set to a blank string and the expiration date set to January 1, 1970 (the value of a Date object initialized to 0 milliseconds). Doing so ensures that the cookie is removed.

These methods can be used as follows:

// set cookies
CookieUtil.set("name", "Nicholas");
CookieUtil.set("book", "Professional JavaScript");
          
// read the values
alert(CookieUtil.get("name")); // "Nicholas"
alert(CookieUtil.get("book")); // "Professional JavaScript"
          
// remove the cookies
CookieUtil.unset("name");
CookieUtil.unset("book");
          
// set a cookie with path, domain, and expiration date
CookieUtil.set("name", "Nicholas", "/books/projs/", "www.wrox.com",
        new Date("January 1, 2010"));
          
// delete that same cookie
CookieUtil.unset("name", "/books/projs/", "www.wrox.com");
          
// set a secure cookie
CookieUtil.set("name", "Nicholas", null, null, null, true);

These methods make using cookies to store data on the client easier by handling the parsing and cookie string construction tasks.

Subcookies

To get around the per-domain cookie limit imposed by browsers, some developers use a concept called subcookies. Subcookies are smaller pieces of data stored within a single cookie. The idea is to use the cookie's value to store multiple name-value pairs within a single cookie. The most common format for subcookies is as follows:

name=name1=value1&name2=value2&name3=value3&name4=value4&name5=value5

Subcookies tend to be formatted in query string format. These values can then be stored and accessed using a single cookie, rather than using a different cookie for each name-value pair. The result is that more structured data can be stored by a website or web application without reaching the per-domain cookie limit.

To work with subcookies, you need a new set of methods. The parsing and serialization of subcookies are slightly different and a bit more complicated because of the expected subcookie usage. To get a subcookie, for example, you need to follow the same basic steps to get a cookie, but before decoding the value, you need to find the subcookie information as follows:

class SubCookieUtil {
 static get(name, subName) {
  let subCookies = SubCookieUtil.getAll(name);
  return subCookies ? subCookies[subName] : null;
 }
  
 static getAll(name) {
  let cookieName = encodeURIComponent(name) + "=",
    cookieStart = document.cookie.indexOf(cookieName),
    cookieValue = null,
    cookieEnd,
    subCookies,
    parts,
    result = {};
   
  if (cookieStart > -1) {
   cookieEnd = document.cookie.indexOf(";", cookieStart);
   if (cookieEnd == -1) {
    cookieEnd = document.cookie.length;
   }
   cookieValue = document.cookie.substring(cookieStart +
                       cookieName.length, cookieEnd);
   if (cookieValue.length > 0) {
    subCookies = cookieValue.split("&");
    
    for (let i = 0, len = subCookies.length; i < len; i++) {
     parts = subCookies[i].split("=");
     result[decodeURIComponent(parts[0])] =
      decodeURIComponent(parts[1]);
    }
   
    return result;
   }
  }
  return null;
 }
          
 // more code here
};

There are two methods for retrieving subcookies: get() and getAll(). Whereas get() retrieves a single subcookie value, getAll() retrieves all subcookies and returns them in an object whose properties are equal to the subcookie names and the values are equal to the subcookie values. The get() method accepts two arguments: the name of the cookie and the name of the subcookie. It simply calls getAll() to retrieve all of the subcookies and then returns just the one of interest (or null if the cookie doesn't exist).

The SubCookieUtil.getAll() method is very similar to CookieUtil.get() in the way it parses a cookie value. The difference is that the cookie value isn't immediately decoded. Instead, it is split on the ampersand character to get all subcookies into an array. Then, each subcookie is split on the equal sign so that the first item in the parts array is the subcookie name, and the second is the subcookie value. Both items are decoded using decodeURIComponent() and assigned on the result object, which is returned as the method value. If the cookie doesn't exist, then null is returned.

These methods can be used as follows:

// assume document.cookie=data=name=Nicholas&book=Professional%20JavaScript
          
// get all subcookies
let data = SubCookieUtil.getAll("data");
alert(data.name); // "Nicholas"
alert(data.book); // "Professional JavaScript"
          
// get subcookies individually
alert(SubCookieUtil.get("data", "name")); // "Nicholas"
alert(SubCookieUtil.get("data", "book")); // "Professional JavaScript"

To write subcookies, you can use two methods: set() and setAll(). The following code shows their constructs:

class SubCookieUtil {
 // previous code here

 static set(name, subName, value, expires, path, domain, secure) {
  let subcookies = SubCookieUtil.getAll(name) || {};
  subcookies[subName] = value;
  SubCookieUtil.setAll(name, subcookies, expires, path, domain, secure);
 }
  
 static setAll(name, subcookies, expires, path, domain, secure) {
  let cookieText = encodeURIComponent(name) + "=",
    subcookieParts = new Array(),
    subName;
  for (subName in subcookies){
   if (subName.length > 0 && subcookies.hasOwnProperty(subName)){
    subcookieParts.push(
     '${encodeURIComponent(subName)}=${encodeURIComponent(subcookies[subName])}');
   }
  }
    
  if (cookieParts.length > 0) {
   cookieText += subcookieParts.join("&");

   if (expires instanceof Date) {
    cookieText += `; expires=${expires.toGMTString()}`;
   }

   if (path) {
    cookieText += `; path=${path}`;
   }

   if (domain) {
    cookieText += `; domain=${domain}`;
   }

   if (secure) {
    cookieText += "; secure";
   }
  } else {
   cookieText += `; expires=${(new Date(0)).toGMTString()}`;
  }
  document.cookie = cookieText;
 }
          
 // more code here
};

The set() method accepts seven arguments: the cookie name, the subcookie name, the subcookie value, an optional Date object for the cookie expiration day/time, an optional cookie path, an optional cookie domain, and an optional Boolean secure flag. All of the optional arguments refer to the cookie itself and not to the subcookie. In order to store multiple subcookies in the same cookie, the path, domain, and secure flag must be the same; the expiration date refers to the entire cookie and can be set whenever an individual subcookie is written. Inside the method, the first step is to retrieve all of the subcookies for the given cookie name. The logical OR operator is used to set subcookies to a new object if getAll() returns null. After that, the subcookie value is set on the subcookies object and then passed into setAll().

The setAll() method accepts six arguments: the cookie name, an object containing all of the subcookies, and then the rest of the optional arguments used in set(). This method iterates over the properties of the second argument using a for-in loop. To ensure that the appropriate data is saved, use the hasOwnProperty() method to ensure that only the instance properties are serialized into subcookies. Since it's possible to have a property name equal to the empty string, the length of the property name is also checked before being added to the result. Each subcookie name-value pair is added to the subcookieParts array so that they can later be easily joined with an ampersand using the join() method. The rest of the method is the same as CookieUtil.set().

These methods can be used as follows:

// assume document.cookie=data=name=Nicholas&book=Professional%20JavaScript
          
// set two subcookies
SubCookieUtil.set("data", "name", "Nicholas");
SubCookieUtil.set("data", "book", "Professional JavaScript");
          
// set all subcookies with expiration date
SubCookieUtil.setAll("data", { name: "Nicholas", book: "Professional JavaScript" },
  new Date("January 1, 2010"));
          
// change the value of name and change expiration date for cookie
SubCookieUtil.set("data", "name", "Michael", new Date("February 1, 2010"));

The last group of subcookie methods has to do with removing subcookies. Regular cookies are removed by setting the expiration date to some time in the past, but subcookies cannot be removed as easily. In order to remove a subcookie, you need to retrieve all subcookies contained within the cookie, eliminate just the one that is meant to be removed, and then set the value of the cookie back with the remaining subcookie values. Consider the following:

class SubCookieUtil {
 // previous code here
  
 static unset(name, subName, path, domain, secure) {
  let subcookies = SubCookieUtil.getAll(name);
  if (subcookies){
   delete subcookies[subName]; // fix?
   SubCookieUtil.setAll(name, subcookies, null, path, domain, secure);
  }
 }

 static unsetAll(name, path, domain, secure) {
  SubCookieUtil.setAll(name, null, new Date(0), path, domain, secure);
 }
}

The two methods defined here serve two different purposes. The unset() method is used to remove a single subcookie from a cookie while leaving the rest intact; whereas the unsetAll() method is the equivalent of CookieUtil.unset(), which removes the entire cookie. As with set() and setAll(), the path, domain, and secure flag must match the options with which a cookie was created. These methods can be used as follows:

// just remove the "name" subcookie
SubCookieUtil.unset("data", "name");
          
// remove the entire cookie
SubCookieUtil.unsetAll("data");

If you are concerned about reaching the per-domain cookie limit in your work, subcookies are an attractive alternative. You will have to more closely monitor the size of your cookies to stay within the individual cookie size limit.

Cookie Considerations

There is also a type of cookie called HTTP-only. HTTP-only cookies can be set either from the browser or from the server but can be read only from the server because JavaScript cannot get the value of HTTP-only cookies.

Because all cookies are sent as request headers from the browser, storing a large amount of information in cookies can affect the overall performance of browser requests to a particular domain. The larger the cookie information, the longer it will take to complete the request to the server. Even though the browser places size limits on cookies, it's a good idea to store as little information as possible in cookies, to avoid performance implications.

The restrictions on and nature of cookies make them less than ideal for storing large amounts of information, which is why other approaches have emerged.

WEB STORAGE

Web Storage was first described in the Web Applications 1.0 specification of the Web Hypertext Application Technical Working Group (WHAT-WG). The initial work from this specification eventually became part of HTML5 before being split into its own specification. Its intent is to overcome some of the limitations imposed by cookies when data is needed strictly on the client side, with no need to continuously send data back to the server.

The most modern edition of the Web Storage specification is its second edition. The two primary goals of the Web Storage specification are:

  • To provide a way to store session data outside of cookies.
  • To provide a mechanism for storing large amounts of data that persists across sessions.

The second edition of the Web Storage specification includes definitions for two objects: localStorage, the permanent storage mechanism, and sessionStorage, the session-scoped storage mechanism. Both of these browser storage APIs afford you two different ways of storing data in the browser that can survive a page reload. Both localStorage and sessionStorage are available as a property of window in all major vendor browser versions released since 2009.

The Storage Type

The Storage type is designed to hold name-value pairs up to a maximum size (determined by the browser). An instance of Storage acts like any other object and has the following additional methods:

  • clear()—Removes all values; not implemented in Firefox.
  • getItem(name)—Retrieves the value for the given name.
  • key(index)—Retrieves the name of the value in the given numeric position.
  • removeItem(name)—Removes the name-value pair identified by name.
  • setItem(name, value)—Sets the value for the given name.

The getItem(), removeItem(), and setItem() methods can be called directly or indirectly by manipulating the Storage object. Since each item is stored on the object as a property, you can simply read values by accessing the property with dot or bracket notation, set the value by doing the same, or remove it by using the delete operator. Even so, it's generally recommended to use the methods instead of property access to ensure you don't end up overwriting one of the already available object members with a key.

You can determine how many name-value pairs are in a Storage object by using the length property. It's not possible to determine the size of all data in the object, although Internet Explorer 8 provides a remainingSpace property that retrieves the amount of space, in bytes, that is still available for storage.

The sessionStorage Object

The sessionStorage object stores data only for a session, meaning that the data is stored until the browser is closed. This is the equivalent of a session cookie that disappears when the browser is closed. Data stored on sessionStorage persists across page refreshes and may also be available if the browser crashes and is restarted, depending on the browser vendor. (Firefox and WebKit support this, but Internet Explorer does not.)

Because the sessionStorage object is tied to a server session, it isn't available when a file is run locally. Data stored on sessionStorage is accessible only from the page that initially placed the data onto the object, making it of limited use for multipage applications.

Because the sessionStorage object is an instance of Storage, you can assign data onto it either by using setItem() or by assigning a new property directly. Here's an example of each of these methods:

// store data using method
sessionStorage.setItem("name", "Nicholas");
          
// store data using property
sessionStorage.book = "Professional JavaScript";

All modern browsers implement storage writing as a blocking synchronous action, so data added to storage is committed right away. The API implementation might not write the value to disk right away (and prefers to initially use a different physical storage), but this difference is invisible at the JavaScript level, and any writes using some form of Web Storage can immediately be read.

Older Internet Explorer implementations write data asynchronously so there may be a lag between the time when data is assigned and the time that the data is written to disk. For small amounts of data, the difference is negligible. For large amounts of data, you'll notice that JavaScript in Internet Explorer resumes execution faster than in other browsers because it offloads the actual disk write process. You can force disk writing to occur in Internet Explorer 8 by using the begin() method before assigning any new data, and the commit() method after all assignments have been made. Consider the following example:

// IE8 only
sessionStorage.begin();
sessionStorage.name = "Nicholas";
sessionStorage.book = "Professional JavaScript";
sessionStorage.commit();

This code ensures that the values for "name" and "book" are written as soon as commit() is called. The call to begin() ensures that no disk writes will occur while the code is executed. For small amounts of data, this process isn't necessary; however, you may wish to consider this transactional approach for larger amounts of data such as documents.

When data exists on sessionStorage, it can be retrieved either by using getItem() or by accessing the property name directly. Here's an example of each of these methods:

// get data using method
let name = sessionStorage.getItem("name");
          
// get data using property
let book = sessionStorage.book;

You can iterate over the values in sessionStorage using a combination of the length property and key() method, as shown here:

for (let i = 0, len = sessionStorage.length; i < len; i++){
 let key = sessionStorage.key(i);
 let value = sessionStorage.getItem(key);
 alert(`${key}=`${value}`);
}

The name-value pairs in sessionStorage can be accessed sequentially by first retrieving the name of the data in the given position via key() and then using that name to retrieve the value via getItem().

It's also possible to iterate over the values in sessionStorage using a for-in loop:

for (let key in sessionStorage){
 let value = sessionStorage.getItem(key);
 alert(`${key}=${value}`);
}

Each time through the loop, key is filled with another name in sessionStorage; none of the built-in methods or the length property will be returned.

To remove data from sessionStorage, you can use either the delete operator on the object property or the removeItem() method. Here's an example of each of these methods:

// use delete to remove a value
delete sessionStorage.name;
          
// use method to remove a value
sessionStorage.removeItem("book");

The sessionStorage object should be used primarily for small pieces of data that are valid only for a session. If you need to persist data across sessions, then either globalStorage or localStorage is more appropriate.

The localStorage Object

The localStorage object superceded globalStorage in the revised HTML5 specification as a way to store persistent client-side data. In order to access the same localStorage object, pages must be served from the same domain (subdomains aren't valid), using the same protocol, and on the same port.

Because localStorage is an instance of Storage, it can be used in exactly the same manner as sessionStorage. Here are some examples:

// store data using method
localStorage.setItem("name", "Nicholas");
          
// store data using property
localStorage.book = "Professional JavaScript";
          
// get data using method
let name = localStorage.getItem("name");
          
// get data using property
let book = localStorage.book;

The difference between the two storage methods is that data stored in localStorage is persisted until it is specifically removed via JavaScript or the user clears the browser's cache. localStorage data will remain through page reloads, closing windows and tabs, and restarting the browser.

The storage Event

Whenever a change is made to a Storage object, the storage event is fired on the document. This occurs for every value set using either properties or setItem(), every value removal using either delete or removeItem(), and every call to clear(). The event object has the following four properties:

  • domain—The domain for which the storage changed.
  • key—The key that was set or removed.
  • newValue—The value that the key was set to, or null if the key was removed.
  • oldValue—The value prior to the key being changed.

You can listen for the storage event using the following code:

window.addEventListener("storage", 
  (event) => alert('Storage changed for ${event.domain}'));

The storage event is fired for all changes to sessionStorage and localStorage but doesn't distinguish between them.

Limits and Restrictions

As with other client-side data storage solutions, Web Storage also has limitations. These limitations are browser-specific. Generally speaking, the size limit for client-side data is set on a per-origin (protocol, domain, and port) basis, so each origin has a fixed amount of space in which to store its data. Analyzing the origin of the page that is storing the data enforces this restriction.

Storage limits for localStorage and sessionStorage are inconsistent across browsers, but most will limit the per-origin storage to 5MB. A table containing up-to-date storage limits for each medium can be found at https://www.html5rocks.com/en/tutorials/offline/quota-research/.

For more information about Web Storage limits, please see the Web Storage Support Test at http://dev-test.nemikor.com/web-storage/support-test/.

INDEXEDDB

The Indexed Database API, IndexedDB for short, is a structured data store in the browser. IndexedDB came about as an alternative to the now-deprecated Web SQL Database API. The idea behind IndexedDB was to create an API that easily allowed the storing and retrieval of JavaScript objects while still allowing querying and searching.

IndexedDB is designed to be almost completely asynchronous. As a result, most operations are performed as requests that will execute later and produce either a successful result or an error. Nearly every IndexedDB operation requires you to attach onerror and onsuccess event handlers to determine the outcome.

As of 2017, the latest releases of most major vendor browsers (Chrome, Firefox, Opera, Safari) fully support IndexedDB. Internet Explorer 10/11 and Edge browsers partially support IndexedDB.

Databases

IndexedDB is a database similar to databases you've probably used before, such as MySQL or Web SQL Database. The big difference is that IndexedDB uses object stores instead of tables to keep track of data. An IndexedDB database is simply a collection of object stores grouped under a common name—a NoSQL-style implementation.

The first step to using a database is to open it using indexedDB.open() and passing in the name of the database to open. If a database with the given name already exists, then a request is made to open it; if the database doesn't exist, then a request is made to create and open it. The call to indexDB.open() returns an instance of IDBRequest onto which you can attach onerror and onsuccess event handlers. Here's an example:

let db, 
  request,
  version = 1;
  
request = indexedDB.open("admin", version);
request.onerror = (event) => 
 alert(`Failed to open: ${event.target.errorCode}`);
request.onsuccess = (event) => {
 db = event.target.result;
};

Formerly, IndexedDB used the setVersion() method to specify which version should be accessed. This method is now deprecated; as shown here, the version is now specified when opening the database. The version numbers will be converted to an unsigned long long number, so do not use decimal points; use whole integers instead.

In both event handlers, event.target points to request, so these may be used interchangeably. If the onsuccess event handler is called, then the database instance object (IDBDatabase) is available in event.target.result and stored in the database variable. From this point on, all requests to work with the database are made through the database object itself. If an error occurs, an error code stored in event.target.errorCode indicates the nature of the problem.

Object Stores

Once you have established a connection to the database, the next step is to interact with object stores. If the database version doesn't match the one you expect then you likely will need to create an object store. Before creating an object store, however, it's important to think about the type of data you want to store.

Suppose that you'd like to store user records containing username, password, and so on. The object to hold a single record may look like this:

let user = {
 username: "007",
 firstName: "James",
 lastName: "Bond",
 password: "foo"
};

Looking at this object, you can easily see that an appropriate key for this object store is the username property. A username must be globally unique, and it's probably the way you'll be accessing data most of the time. This is important because you must specify a key when creating an object store.

The version of the database determines the database schema, which consists of the object stores in the database and the structures of those object stores. If the database doesn't yet exist, the open() operation creates it; then, an upgradeneeded event is fired. You can set a handler for this event and create the database schema in the handler. If the database exists but you are specifying an upgraded version number, an upgradeneeded event is fired immediately, allowing you to provide an updated schema in the event handler.

Here's how you would create an object store for these users:

request.onupgradeneeded = (event) => {
 const db = event.target.result;
 
 // Delete the current objectStore if it exists. This is useful for testing, 
 // but this will wipe existing data each time this event handler executes.
 if (db.objectStoreNames.contains("users")) {
  db.deleteObjectStore("users");
 }
 
 db.createObjectStore("users", { keyPath: "username" });
};

The keyPath property of the second argument indicates the property name of the stored objects that should be used as a key.

Transactions

Past the creation step of an object store, all further operations are done through transactions. A transaction is created using the transaction() method on the database object. Any time you want to read or change data, a transaction is used to group all changes together. In its simplest form, you create a new transaction as follows:

let transaction = db.transaction();

With no arguments specified, you have read-only access to all object stores in the database. A more targeted strategy is to specify one or more object store names that you want to access:

let transaction = db.transaction("users");

This ensures that only information about the users object store is loaded and available during the transaction. If you want access to more than one object store, the first argument can also be an array of strings:

let transaction = db.transaction(["users", "anotherStore"]);

As mentioned previously, each of these transactions accesses data in a read-only manner. To change that, you must pass in a second argument indicating the access mode. This argument should be one of three strings: "readonly", "readwrite", or "versionchange". You can specify the second argument to transaction():

let transaction = db.transaction("users", "readwrite");

This transaction is capable of both reading and writing into the users object store.

Once you have a reference to the transaction, you can access a particular object store using the objectStore() method and passing in the store name you want to work with. You can then use add() and put() as before, as well as get() to retrieve values, delete() to remove an object, and clear() to remove all objects. Both the get() and delete() methods accept an object key as their argument, and all five of these methods create a new request object. For example:

const transaction = db.transaction("users"),
  store = transaction.objectStore("users"),
  request = store.get("007");
request.onerror = (event) => alert("Did not get the object!");
request.onsuccess = (event) => alert(event.target.result.firstName);

Because any number of requests can be completed as part of a single transaction, the transaction object itself also has event handlers: onerror and oncomplete. These are used to provide transaction-level state information:

transaction.onerror = (event) => {
 // entire transaction was cancelled
};
transaction.oncomplete = (event) => {
 // entire transaction completed successfully
};

Keep in mind that the event object for oncomplete doesn't give you access to any data returned by get() requests, so you still need an onsuccess event handler for those types of requests.

Insertion

Because you now have a reference to the object store, it's possible to populate the object store with data using either add() or put(). Both of these methods accept a single argument, the object to store, and save the object into the object store. The difference between these two occurs only when an object with the same key already exists in the object store. In that case, add() will cause an error while put() will simply overwrite the object. More simply, think of add() as being used for inserting new values while put() is used for updating values. So to initialize an object store for the first time, you may want to do something like this:

// where users is an array of new users
for (let user of users) {
 store.add(user);
}

Each call to add() or put() creates a new update request for the object store. If you want verification that the request completed successfully, you can store the request object in a variable and assign onerror and onsuccess event handlers:

// where users is an array of new users
let request,
  requests = [];
for (let user of users) {
 request = store.add(user);
 request.onerror = () => {
  // handle error
 };
 request.onsuccess = () => {
  // handle success
 };
 requests.push(request);
}

Once the object store is created and filled with data, it's time to start querying.

Querying with Cursors

Transactions can be used directly to retrieve a single item with a known key. When you want to retrieve multiple items, you need to create a cursor within the transaction. A cursor is a pointer into a result set. Unlike traditional database queries, a cursor doesn't gather all of the result set up front. Instead, a cursor points to the first result and doesn't try to find the next until instructed to do so.

Cursors are created using the openCursor() method on an object store. As with other operations with IndexedDB, the return value of openCursor() is a request, so you must assign onsuccess and onerror event handlers. For example:

const transaction = db.transaction("users"),
    store = transaction.objectStore("users"),
    request = store.openCursor();
request.onsuccess = (event) => {
  // handle success
};
request.onfailure = (event) => {
  // handle failure
};

When the onsuccess event handler is called, the next item in the object store is accessible via event.target.result, which holds an instance of IDBCursor when there is a next item or null when there are no further items. The IDBCursor instance has several properties:

  • direction—A numeric value indicating the direction the cursor should travel in and whether or not it should traverse all duplicate values. There are four possible string values: "next", "nextunique", "prev", and "prevunique".
  • key—The key for the object.
  • value—The actual object.
  • primaryKey—The key being used by the cursor. Could be the object key or an index key (discussed later).

You can retrieve information about a single result using the following:

request.onsuccess = (event) => {
 const cursor = event.target.result;
 if (cursor) { // always check
  console.log(`Key: ${cursor.key}, Value: ${JSON.stringify(cursor.value)}`);
 }
};

Keep in mind that cursor.value in this example is an object, which is why it is JSON encoded before being displayed.

A cursor can be used to update an individual record. The update() method updates the current cursor value with the specified object. As with other such operations, the call to update() creates a new request so you need to assign onsuccess and onerror if you want to know the result:

request.onsuccess = (event) => {
 const cursor = event.target.result;
 let value,
   updateRequest;
 if (cursor) { // always check
  if (cursor.key == "foo") {
   value = cursor.value;                 // get current value
   value.password = "magic!";            // update the password
   updateRequest = cursor.update(value); // request the update be saved
   updateRequest.onsuccess = () => {
    // handle success;
   };
   updateRequest.onfailure = () => {
    // handle failure
   };
  }
 }
};

You can also delete the item at that position by calling delete(). As with update(), this also creates a request:

request.onsuccess = (event) => {
 const cursor = event.target.result;
 let value,
   deleteRequest;
 if (cursor) { // always check
  if (cursor.key == "foo") {
   deleteRequest = cursor.delete(); // request the value be deleted
   deleteRequest.onsuccess = () => {
    // handle success;
   };
   deleteRequest.onfailure = () => {
    // handle failure
   };
  }
 }
};

Both update() and delete() will throw errors if the transaction doesn't have permission to modify the object store.

Each cursor makes only one request by default. To make another request, you must call one of the following methods:

  • continue(key )—Moves to the next item in the result set. The argument key is optional. When not specified, the cursor just moves to the next item; when provided, the cursor will move to the specified key.
  • advance(count )—Moves the cursor ahead by count number of items.

Each of these methods causes the cursor to reuse the same request so the same onsuccess and onfailure event handlers are reused until no longer needed. For example, the following iterates over all items in an object store:

request.onsuccess = (event) => {
 const cursor = event.target.result;
 if (cursor) { // always check
  console.log(`Key: ${cursor.key}, Value: ${JSON.stringify(cursor.value)}`);
  cursor.continue(); // go to the next one
 } else {
  console.log("Done!");
 }
};

The call to continue() triggers another request and onsuccess is called again. When there are no more items to iterate over, onsuccess is called one last time with event.target.result equal to null.

Key Ranges

Working with cursors may seem suboptimal given that you're limited in the ways data can be retrieved. Key ranges are used to make working with cursors a little more manageable. A key range is represented by an instance of IDBKeyRange. There are four different ways to specify key ranges. The first is to use the only() method and pass in the key you want to retrieve:

const onlyRange = IDBKeyRange.only("007");

This range ensures that only the value with a key of "007" will be retrieved. A cursor created using this range is similar to directly accessing an object store and calling get("007").

The second type of range defines a lower bound for the result set. The lower bound indicates the item at which the cursor should start. For example, the following key range ensures the cursor starts at the key "007" and continues until the end:

// start at item "007", go to the end
const lowerRange = IDBKeyRange.lowerBound("007");

If you want to start at the item immediately following the value at "007", then you can pass in a second argument of true:

// start at item after "007", go to the end
const lowerRange = IDBKeyRange.lowerBound("007", true);

The third type of range is an upper bound, indicating the key you don't want to go past by using the upperBound() method. The following key ensures that the cursor starts at the beginning and stops when it gets to the value with key "ace":

// start at beginning, go to "ace"
const upperRange = IDBKeyRange.upperBound("ace");

If you don't want to include the given key, then pass in true as the second argument:

// start at beginning, go to the item just before "ace"
const upperRange = IDBKeyRange.upperBound("ace", true);

To specify both a lower and an upper bound, use the bound() method. This method accepts four arguments, the lower bound key, the upper bound key, an optional Boolean indicating to skip the lower bound, and an optional Boolean indicating to skip the upper bound. Here are some examples:

// start at "007", go to "ace"
const boundRange = IDBKeyRange.bound("007", "ace");
// start at item after "007", go to "ace"
const boundRange = IDBKeyRange.bound("007", "ace", true);
// start at item after "007", go to item before "ace"
const boundRange = IDBKeyRange.bound("007", "ace", true, true);
// start at "007", go to item before "ace"
const boundRange = IDBKeyRange.bound("007", "ace", false, true);

Once you have defined a range, pass it into the openCursor() method and you'll create a cursor that stays within the constraints:

const store = db.transaction("users").objectStore("users"),
  range = IDBKeyRange.bound("007", "ace");
  request = store.openCursor(range);
request.onsuccess = function(event){
 const cursor = event.target.result;
 if (cursor) { // always check
  console.log(`Key: ${cursor.key}, Value: ${JSON.stringify(cursor.value)}`);
  cursor.continue(); // go to the next one
 } else {
  console.log("Done!");
 }
}; 

This example outputs only the values between keys "007" and "ace", which are fewer than the previous section's example.

Setting Cursor Direction

There are actually two arguments to openCursor(). The first is an instance of IDBKeyRange and the second is a string indicating the direction. Typically, cursors start at the first item in the object store and progress toward the last with each call to continue() or advance(). These cursors have the default direction value of "next". If there are duplicates in the object store, you may want to have a cursor that skips over the duplicates. You can do so by passing "nextunique" into openCursor() as the second argument:

const transaction = db.transaction("users"),
      store = transaction.objectStore("users"),
      request = store.openCursor(null, "nextunique");

Note that the first argument to openCursor() is null, which indicates that the default key range of all values should be used. This cursor will iterate through the items in the object store starting from the first item and moving toward the last while skipping any duplicates.

You can also create a cursor that moves backward through the object store, starting at the last item and moving toward the first by passing in either "prev" or "prevunique" (the latter, of course, to avoid duplicates). For example:

const transaction = db.transaction("users"),
      store = transaction.objectStore("users"),
      request = store.openCursor(null, "prevunique");

When you open a cursor using "prev" or "prevunique", each call to continue() or advance() moves the cursor backward through the object store instead of forward.

Indexes

For some data sets, you may want to specify more than one key for an object store. For example, if you're tracking users by both a user ID and a username, you may want to access records using either piece of data. To do so, you would likely consider the user ID as the primary key and create an index on the username.

To create a new index, first retrieve a reference to the object store and then call createIndex(), as in this example:

const transaction = db.transaction("users"),
      store = transaction.objectStore("users"),
      index = store.createIndex("username", "username", { unique: true }); 

The first argument to createIndex() is the name of the index, the second is the name of the property to index, and third is an options object containing the key unique. This option should always be specified so as to indicate whether or not the key is unique across all records. Because username may not be duplicated, this index is unique.

The returned value from createIndex() is an instance of IDBIndex. You can also retrieve the same instance via the index() method on an object store. For example, to use an already existing index named "username", the code would be:

const transaction = db.transaction("users"),
      store = transaction.objectStore("users"),
      index = store.index("username");

An index acts a lot like an object store. You can create a new cursor on the index using the openCursor() method, which works exactly the same as openCursor() on an object store except that the result.key property is filled in with the index key instead of the primary key. Here's an example:

const transaction = db.transaction("users"),
    store = transaction.objectStore("users"),
    index = store.index("username"),
    request = index.openCursor();
request.onsuccess = (event) => {
  // handle success
}; 

An index can also create a special cursor that returns just the primary key for each record using the openKeyCursor() method, which accepts the same arguments as openCursor(). The big difference is that event.result.key is the index key and event.result.value is the primary key instead of the entire record.

const transaction = db.transaction("users"),
    store = transaction.objectStore("users"),
    index = store.index("username"),
    request = index.openKeyCursor();
request.onsuccess = (event) => {
  // handle success
  // event.result.key is the index key, event.result.value is the primary key
};

You can also retrieve a single value from an index by using get() and passing in the index key, which creates a new request:

const transaction = db.transaction("users"),
    store = transaction.objectStore("users"),
    index = store.index("username"),
    request = index.get("007");
request.onsuccess = (event) => {
  // handle success
};
request.onfailure = (event) => {
  // handle failure
}; 

To retrieve just the primary key for a given index key, use the getKey() method. This also creates a new request but result.value is equal to the primary key value rather than the entire record:

const transaction = db.transaction("users"),
    store = transaction.objectStore("users"),
    index = store.index("username"),
    request = index.getKey("007");
request.onsuccess = (event) => {
  // handle success
  // event.result.key is the index key, event.result.value is the primary key
};

In the onsuccess event handler in this example, event.result.value would be the user ID.

At any point in time, you can retrieve information about the index by using properties on the IDBIndex object:

  • name—The name of the index.
  • keyPath—The property path that was passed into createIndex().
  • objectStore—The object store that this index works on.
  • unique—A Boolean indicating if the index key is unique.

The object store itself also tracks the indexes by name in the indexNames property. This makes it easy to figure out which indexes already exist on an object using the following code:

const transaction = db.transaction("users"),
    store = transaction.objectStore("users"),
    indexNames = store.indexNames
for (let indexName in indexNames) {
    const index = store.index(indexName);
    console.log(`Index name: ${index.name}
           KeyPath: ${index.keyPath}
           Unique: ${index.unique}`); 
} 

This code iterates over each index and outputs its information to the console.

An index can be deleted by calling the deleteIndex() method on an object store and passing in the name of the index:

const transaction = db.transaction("users"),
    store = transaction.objectStore("users"),
    store.deleteIndex("username");

Because deleting an index doesn't touch the data in the object store, the operation happens without any callbacks.

Concurrency Issues

While IndexedDB is an asynchronous API inside of a web page, there are still concurrency issues. If the same web page is open in two different browser tabs at the same time, it's possible that one may attempt to upgrade the database before the other is ready. The problematic operation is in setting the database to a new version, and so version changes can be completed only when there is just one tab in the browser using the database.

When you first open a database, it's important to assign an onversionchange event handler. This callback is executed when another tab from the same origin opens the DB to a new version. The best response to this event is to immediately close the database so that the version upgrade can be completed. For example:

let request, database;
  
request = indexedDB.open("admin", 1);
request.onsuccess = (event) => {
 database = event.target.result;
 database.onversionchange = () => database.close();
}; 

You should assign onversionchange after every successful opening of a database. Remember, onversionchange will have been called in the other tab(s) as well.

By always assigning these event handlers, you will ensure your web application will be able to better handle concurrency issues related to IndexedDB.

Limits and Restrictions

Many of the restrictions on IndexedDB are exactly the same as those for Web Storage. First, IndexedDB databases are tied to the origin (protocol, domain, and port) of the page, so the information cannot be shared across domains. This means there is a completely separate data store for www.wrox.com as for p2p.wrox.com.

Second, there is a limit to the amount of data that can be stored per origin. The current limit in Firefox is 50MB per origin while Chrome has a limit of 5MB. Firefox for mobile has a limit of 5MB and will ask the user for permission to store more than that if the quota is exceeded.

Firefox imposes an extra limitation that local files cannot access IndexedDB databases at all. Chrome doesn't have this restriction. When running the examples from this book locally, be sure to use Chrome.

SUMMARY

Web Storage defines two objects to save data: sessionStorage and localStorage. The former is used strictly to save data within a browser session because the data is removed once the browser is closed. The latter is used to persist data across sessions.

IndexedDB is a structured data storage mechanism similar to an SQL database. Instead of storing data in tables, data is stored in object stores. Object stores are created by defining a key and then adding data. Cursors are used to query object stores for particular pieces of data, and indexes may be created for faster lookups on particular properties.

With all of these options available, it's possible to store a significant amount of data on the client machine using JavaScript. You should use care not to store sensitive information because the data cache isn't encrypted.

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

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