14
The Document Object Model

WHAT'S IN THIS CHAPTER?

  • Understanding the DOM as a hierarchy of nodes
  • Working with the various node types
  • Coding the DOM around browser incompatibilities and gotchas
  • Mutation Observers

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.

The Document Object Model (DOM) is an application programming interface (API) for HTML and XML documents. The DOM represents a document as a hierarchical tree of nodes, allowing developers to add, remove, and modify individual parts of the page. Evolving out of early Dynamic HTML (DHTML) innovations from Netscape and Microsoft, the DOM is now a truly cross-platform, language-independent way of representing and manipulating pages for markup.

DOM Level 1 became a W3C recommendation in October 1998, providing interfaces for basic document structure and querying. This chapter focuses on the features and uses of the DOM as it relates to HTML pages in the browser and the DOM JavaScript API.

HIERARCHY OF NODES

Any HTML or XML document can be represented as a hierarchy of nodes using the DOM. There are several node types, each representing different information and/or markup in the document. Each node type has different characteristics, data, and methods, and each may have relationships with other nodes. These relationships create a hierarchy that allows markup to be represented as a tree, rooted at a particular node. For instance, consider the following HTML:

<html>
 <head>
  <title>Sample Page</title>
 </head>
 <body>
  <p>Hello World!</p>
 </body>
</html>

This simple HTML document can be represented in a hierarchy, as illustrated in Figure 14-1.

Illustration of a simple HTML document represented in a hierarchy containing element nodes for two texts: Sample Page and Hello world!

FIGURE 14-1

A document node represents every document as the root. In this example, the only child of the document node is the <html> element, which is called the document element. The document element is the outermost element in the document within which all other elements exist. There can be only one document element per document. In HTML pages, the document element is always the <html> element. In XML, where there are no predefined elements, any element may be the document element.

Every piece of markup can be represented by a node in the tree: HTML elements are represented by element nodes, attributes are represented by attribute nodes, the document type is represented by a document type node, and comments are represented by comment nodes. In total, there are 12 node types, all of which inherit from a base type.

The Node Type

DOM Level 1 describes an interface called Node that is to be implemented by all node types in the DOM. The Node interface is implemented in JavaScript as the Node type, which is accessible in all browsers except Internet Explorer. All node types inherit from Node in JavaScript, so all node types share the same basic properties and methods.

Every node has a nodeType property that indicates the type of node that it is. Node types are represented by one of the following 12 numeric constants on the Node type:

  • Node.ELEMENT_NODE (1)
  • Node.ATTRIBUTE_NODE (2)
  • Node.TEXT_NODE (3)
  • Node.CDATA_SECTION_NODE (4)
  • Node.ENTITY_REFERENCE_NODE (5)
  • Node.ENTITY_NODE (6)
  • Node.PROCESSING_INSTRUCTION_NODE (7)
  • Node.COMMENT_NODE (8)
  • Node.DOCUMENT_NODE (9)
  • Node.DOCUMENT_TYPE_NODE (10)
  • Node.DOCUMENT_FRAGMENT_NODE (11)
  • Node.NOTATION_NODE (12)

A node’s type is easy to determine by comparing against one of these constants, as shown here:

if (someNode.nodeType == Node.ELEMENT_NODE){
 alert("Node is an element.");
}

This example compares the someNode.nodeType to the Node.ELEMENT_NODE constant. If they're equal, it means someNode is actually an element.

Not all node types are supported in web browsers. Developers most often work with element and text nodes. The support level and usage of each node type is discussed later in the chapter.

The nodeName and nodeValue Properties

Two properties, nodeName and nodeValue, give specific information about the node. The values of these properties are completely dependent on the node type. It’s always best to test the node type before using one of these values, as the following code shows:

if (someNode.nodeType == 1){
 value = someNode.nodeName;  // will be the element's tag name
}

In this example, the node type is checked to see if the node is an element. If so, the nodeName value is assigned to a variable. For elements, nodeName is always equal to the element’s tag name, and nodeValue is always null.

Node Relationships

All nodes in a document have relationships to other nodes. These relationships are described in terms of traditional family relationships as if the document tree were a family tree. In HTML, the <body> element is considered a child of the <html> element; likewise the <html> element is considered the parent of the <body> element. The <head> element is considered a sibling of the <body> element, because they both share the same immediate parent, the <html> element.

Each node has a childNodes property containing a NodeList. A NodeList is an array-like object used to store an ordered list of nodes that are accessible by position. Keep in mind that a NodeList is not an instance of Array even though its values can be accessed using bracket notation and the length property is present. NodeList objects are unique in that they are actually queries being run against the DOM structure, so changes will be reflected in NodeList objects automatically. It is often said that a NodeList is a living, breathing object rather than a snapshot of what happened at the time it was first accessed.

The following example shows how nodes stored in a NodeList may be accessed via bracket notation or by using the item() method:

let firstChild = someNode.childNodes[0];
let secondChild = someNode.childNodes.item(1);
let count = someNode.childNodes.length;

Note that using bracket notation and using the item() method are both acceptable practices, although most developers use bracket notation because of its similarity to arrays. Also note that the length property indicates the number of nodes in the NodeList at that time. It’s possible to convert NodeList objects into arrays using Array.prototype.slice() as was discussed earlier for the arguments object. Consider the following example:

let arrayOfNodes = Array.prototype.slice.call(someNode.childNodes,0);

Each node has a parentNode property pointing to its parent in the document tree. All nodes contained within a childNodes list have the same parent, so each of their parentNode properties points to the same node. Additionally, each node within a childNodes list is considered to be a sibling of the other nodes in the same list. It’s possible to navigate from one node in the list to another by using the previousSibling and nextSibling properties. The first node in the list has null for the value of its previousSibling property, and the last node in the list has null for the value of its nextSibling property, as shown in the following example:

if (someNode.nextSibling === null){
  alert("Last node in the parent's childNodes list.");
} else if (someNode.previousSibling === null){
  alert("First node in the parent's childNodes list.");
}

Note that if there’s only one child node, both nextSibling and previousSibling will be null.

Another relationship exists between a parent node and its first and last child nodes. The firstChild and lastChild properties point to the first and last node in the childNodes list, respectively. The value of someNode.firstChild is always equal to someNode.childNodes[0], and the value of someNode.lastChild is always equal to someNode.childNodes[someNode.childNodes.length-1]. If there is only one child node, firstChild and lastChild point to the same node; if there are no children, then firstChild and lastChild are both null. All of these relationships help to navigate easily between nodes in a document structure. Figure 14-2 illustrates these relationships.

Illustration of a relationship that exists between a parent node and its first and last child nodes. The “firstChild” and “lastChild” properties point to the first and last node in the childNodes list.

FIGURE 14-2

With all of these relationships, the childNodes property is really more of a convenience than a necessity because it’s possible to reach any node in a document tree by simply using the relationship pointers. Another convenience method is hasChildNodes(), which returns true if the node has one or more child nodes and is more efficient than querying the length of the childNodes list.

One final relationship is shared by every node. The ownerDocument property is a pointer to the document node that represents the entire document. Nodes are considered to be owned by the document in which they were created (typically the same in which they reside), because nodes cannot exist simultaneously in two or more documents. This property provides a quick way to access the document node without needing to traverse the node hierarchy back up to the top.

Manipulating Nodes

Because all relationship pointers are read-only, several methods are available to manipulate nodes. The most-often-used method is appendChild(), which adds a node to the end of the childNodes list. Doing so updates all of the relationship pointers in the newly added node, the parent node, and the previous last child in the childNodes list. When complete, appendChild() returns the newly added node. Here is an example:

let returnedNode = someNode.appendChild(newNode);
alert(returnedNode == newNode);        // true
alert(someNode.lastChild == newNode);  // true

If the node passed into appendChild() is already part of the document, it is removed from its previous location and placed at the new location. Even though the DOM tree is connected by a series of pointers, no DOM node may exist in more than one location in a document. So if you call appendChild()and pass in the first child of a parent, as the following example shows, it will end up as the last child:

// assume multiple children for someNode
let returnedNode = someNode.appendChild(someNode.firstChild);
alert(returnedNode == someNode.firstChild); // false
alert(returnedNode == someNode.lastChild);  // true

When a node needs to be placed in a specific location within the childNodes list, instead of just at the end, the insertBefore() method may be used. The insertBefore() method accepts two arguments: the node to insert and a reference node. The node to insert becomes the previous sibling of the reference node and is ultimately returned by the method. If the reference node is null, then insertBefore() acts the same as appendChild(), as this example shows:

// insert as last child
returnedNode = someNode.insertBefore(newNode, null);
alert(newNode == someNode.lastChild);  // true
      
// insert as the new first child
returnedNode = someNode.insertBefore(newNode, someNode.firstChild);
alert(returnedNode == newNode);        // true
alert(newNode == someNode.firstChild); // true
      
// insert before last child
returnedNode = someNode.insertBefore(newNode, someNode.lastChild);
alert(newNode == someNode.childNodes[someNode.childNodes.length - 2]); // true

Both appendChild() and insertBefore() insert nodes without removing any. The replaceChild() method accepts two arguments: the node to insert and the node to replace. The node to replace is returned by the function and is removed from the document tree completely while the inserted node takes its place. Here is an example:

// replace first child
let returnedNode = someNode.replaceChild(newNode, someNode.firstChild);
      
// replace last child
returnedNode = someNode.replaceChild(newNode, someNode.lastChild);

When a node is inserted using replaceChild(), all of its relationship pointers are duplicated from the node it is replacing. Even though the replaced node is technically still owned by the same document, it no longer has a specific location in the document.

To remove a node without replacing it, you can use the removeChild() method. This method accepts a single argument, which is the node to remove. The removed node is then returned as the function value, as this example shows:

// remove first child
let formerFirstChild = someNode.removeChild(someNode.firstChild);
      
// remove last child
let formerLastChild = someNode.removeChild(someNode.lastChild);

As with replaceChild(), a node removed via removeChild() is still owned by the document but doesn’t have a specific location in the document.

All four of these methods work on the immediate children of a specific node, meaning that to use them you must know the immediate parent node (which is accessible via the previously mentioned parentNode property). Not all node types can have child nodes, and these methods will throw errors if you attempt to use them on nodes that don’t support children.

Other Methods

Two other methods are shared by all node types. The first is cloneNode(), which creates an exact clone of the node on which it’s called. The cloneNode() method accepts a single Boolean argument indicating whether to do a deep copy. When the argument is true, a deep copy is used, cloning the node and its entire subtree; when false, only the initial node is cloned. The cloned node that is returned is owned by the document but has no parent node assigned. As such, the cloned node is an orphan and doesn’t exist in the document until added via appendChild(), insertBefore(), or replaceChild(). For example, consider the following HTML:

<ul>
 <li>item 1</li>
 <li>item 2</li>
 <li>item 3</li>
</ul>

If a reference to this <ul> element is stored in a variable named myList, the following code shows the two modes of the cloneNode() method:

let deepList = myList.cloneNode(true);
alert(deepList.childNodes.length);   // 3 (IE < 9) or 7 (others)
      
let shallowList = myList.cloneNode(false);
alert(shallowList.childNodes.length); // 0

In this example, deepList is filled with a deep copy of myList. This means deepList has three list items, each of which contains text. The variable shallowList contains a shallow copy of myList, so it has no child nodes. The difference in deepList.childNodes.length is due to the different ways that white space is handled in Internet Explorer 8 and earlier as compared to other browsers. Internet Explorer prior to version 9 did not create nodes for white space.

The last remaining method is normalize(). Its sole job is to deal with text nodes in a document subtree. Because of parser implementations or DOM manipulations, it’s possible to end up with text nodes that contain no text or text nodes that are siblings. When normalize() is called on a node, that node’s descendants are searched for both of these circumstances. If an empty text node is found, it is removed; if text nodes are immediate siblings, they are joined into a single text node. This method is discussed further later on in this chapter.

The Document Type

JavaScript represents document nodes via the Document type. In browsers, the document object is an instance of HTMLDocument (which inherits from Document) and represents the entire HTML page. The document object is a property of window and so is accessible globally. A Document node has the following characteristics:

  • nodeType is 9.
  • nodeName is "#document".
  • nodeValue is null.
  • parentNode is null.
  • ownerDocument is null.
  • Child nodes may be a DocumentType (maximum of one), Element (maximum of one), ProcessingInstruction, or Comment.

The Document type can represent HTML pages or other XML-based documents, though the most common use is through an instance of HTMLDocument through the document object. The document object can be used to get information about the page and to manipulate both its appearance and the underlying structure.

Document Children

Though the DOM specification states that the children of a Document node can be a DocumentType, Element, ProcessingInstruction, or Comment, there are two built-in shortcuts to child nodes. The first is the documentElement property, which always points to the <html> element in an HTML page. The document element is always represented in the childNodes list as well, but the documentElement property gives faster and more direct access to that element. Consider the following simple page:

<html>
 <body>
      
 </body>
</html>

When this page is parsed by a browser, the document has only one child node, which is the <html> element. This element is accessible from both documentElement and the childNodes list, as shown here:

let html = document.documentElement;    // get reference to <html>
alert(html === document.childNodes[0]); // true
alert(html === document.firstChild);    // true

This example shows that the values of documentElement, firstChild, and childNodes[0] are all the same—all three point to the <html> element.

As an instance of HTMLDocument, the document object also has a body property that points to the <body> element directly. Because this is the element most often used by developers, document.body tends to be used quite frequently in JavaScript, as this example shows:

let body = document.body; // get reference to <body>

Both document.documentElement and document.body are supported in all major browsers.

Another possible child node of a Document is a DocumentType. The <!DOCTYPE> tag is considered to be a separate entity from other parts of the document, and its information is accessible through the doctype property (document.doctype in browsers), as shown here:

let doctype = document.doctype;  // get reference to <!DOCTYPE>

Comments that appear outside of the <html> element are, technically, child nodes of the document. Once again, browser support varies greatly as to whether these comments will be recognized and represented appropriately. Consider the following HTML page:

<!-- first comment -->
<html>
 <body>
      
 </body>
</html>
<!-- second comment -->

This page seems to have three child nodes: a comment, the <html> element, and another comment. Logically, you would expect document.childNodes to have three items corresponding to what appears in the code. In practice, however, browsers handle comments outside of the <html> element in different ways with respect to ignoring one or both of the comment nodes.

For the most part, the appendChild(), removeChild(), and replaceChild() methods aren’t used on document because the document type (if present) is read-only and there can be only one element child node (which is already present).

Document Information

The document object, as an instance of HTMLDocument, has several additional properties that standard Document objects do not have. These properties provide information about the web page that is loaded. The first such property is title, which contains the text in the <title> element and is displayed in the title bar or tab of the browser window. This property can be used to retrieve the current page title and to change the page title such that the changes are reflected in the browser title bar. Changing the value of the title property does not change the <title> element at all. Here is an example:

// get the document title
let originalTitle = document.title;
      
// set the document title
document.title = "New page title";

The next three properties are all related to the request for the web page: URL, domain, and referrer. The URL property contains the complete URL of the page (the URL in the address bar), the domain property contains just the domain name of the page, and the referrer property gives the URL of the page that linked to this page. The referrer property may be an empty string if there is no referrer to the page. All of this information is available in the HTTP header of the request and is simply made available in JavaScript via these properties, as shown in the following example:

// get the complete URL
let url = document.URL;
      
// get the domain
let domain = document.domain;
      
// get the referrer
let referrer = document.referrer;

The URL and domain properties are related. For example, if document.URL is http://www.wrox.com/WileyCDA/, then document.domain will be www.wrox.com.

Of these three properties, the domain property is the only one that can be set. There are some restrictions as to what the value of domain can be set to because of security issues. If the URL contains a subdomain, such as p2p.wrox.com, the domain may be set only to "wrox.com" (the same is true when the URL contains "www," such as www.wrox.com). The property can never be set to a domain that the URL doesn’t contain, as this example demonstrates:

// page from p2p.wrox.com
      
document.domain = "wrox.com";       // succeeds
      
document.domain = "nczonline.net";  // error!

The ability to set document.domain is useful when there is a frame or iframe on the page from a different subdomain. Pages from different subdomains can't communicate with one another via JavaScript because of cross-domain security restrictions. By setting document.domain in each page to the same value, the pages can access JavaScript objects from each other. For example, if a page is loaded from www.wrox.com and it has an iframe with a page loaded from p2p.wrox.com, each page’s document.domain string will be different, and the outer page and the inner page are restricted from accessing each other’s JavaScript objects. If the document.domain value in each page is set to "wrox.com", the pages can then communicate.

A further restriction in the browser disallows tightening of the domain property once it has been loosened. This means you cannot set document.domain to "wrox.com" and then try to set it back to "p2p.wrox.com" because the latter would cause an error, as shown here:

// page from p2p.wrox.com
      
document.domain = "wrox.com";     // loosen - succeeds
      
document.domain = "p2p.wrox.com"; // tighten - error!

Locating Elements

Perhaps the most common DOM activity is to retrieve references to a specific element or sets of elements to perform certain operations. This capability is provided via a number of methods on the document object. The Document type provides two methods to this end: getElementById() and getElementsByTagName().

The getElementById() method accepts a single argument—the ID of an element to retrieve—and returns the element if found, or null if an element with that ID doesn’t exist. The ID must be an exact match, including character case, to the id attribute of an element on the page. Consider the following element:

<div id="myDiv">Some text</div>

This element can be retrieved using the following code:

let div = document.getElementById("myDiv"); // retrieve reference to the <div>

The following code, however, would return null:

let div = document.getElementById("mydiv"); // null

If more than one element with the same ID are in a page, getElementById() returns the element that appears first in the document.

The getElementsByTagName() method is another commonly used method for retrieving element references. It accepts a single argument—the tag name of the elements to retrieve—and returns a NodeList containing zero or more elements. In HTML documents, this method returns an HTMLCollection object, which is very similar to a NodeList in that it is considered a “live” collection. For example, the following code retrieves all <img> elements in the page and returns an HTMLCollection:

let images = document.getElementsByTagName("img");

This code stores an HTMLCollection object in the images variable. As with NodeList objects, items in HTMLCollection objects can be accessed using bracket notation or the item() method. The number of elements in the object can be retrieved via the length property, as this example demonstrates:

alert(images.length);       // output the number of images
alert(images[0].src);       // output the src attribute of the first image
alert(images.item(0).src);  // output the src attribute of the first image

The HTMLCollection object has an additional method, namedItem(), that lets you reference an item in the collection via its name attribute. For example, suppose you had the following <img> element in a page:

<img src="myimage.gif" name="myImage">

A reference to this <img> element can be retrieved from the images variable like this:

let myImage = images.namedItem("myImage");

In this way, an HTMLCollection gives you access to named items in addition to indexed items, making it easier to get exactly the elements you want. You can also access named items by using bracket notation, as shown in the following example:

let myImage = images["myImage"];

For HTMLCollection objects, bracket notation can be used with either numeric or string indices. Behind the scenes, a numeric index calls item() and a string index calls namedItem().

To retrieve all elements in the document, pass in * to getElementsByTagName(). The asterisk is generally understood to mean "all" in JavaScript and Cascading Style Sheets (CSS). Here’s an example:

let allElements = document.getElementsByTagName("*");

This single line of code returns an HTMLCollection containing all of the elements in the order in which they appear. So the first item is the <html> element, the second is the <head> element, and so on.

A third method, which is defined on the HTMLDocument type only, is getElementsByName(). As its name suggests, this method returns all elements that have a given name attribute. The getElementsByName() method is most often used with radio buttons, all of which must have the same name to ensure the correct value gets sent to the server, as the following example shows:

<fieldset>
 <legend>Which color do you prefer?</legend>
 <ul>
  <li>
   <input type="radio" value="red" name="color" id="colorRed">
   <label for="colorRed">Red</label>
  </li>
  <li>
   <input type="radio" value="green" name="color" id="colorGreen">
   <label for="colorGreen">Green</label>
  </li>
  <li>
   <input type="radio" value="blue" name="color" id="colorBlue">
   <label for="colorBlue">Blue</label>
  </li>
 </ul>
</fieldset>

In this code, the radio buttons all have a name attribute of "color" even though their IDs are different. The IDs allow the <label> elements to be applied to the radio buttons, and the name attribute ensures that only one of the three values will be sent to the server. These radio buttons can all then be retrieved using the following line of code:

let radios = document.getElementsByName("color");

As with getElementsByTagName(), the getElementsByName() method returns an HTMLCollection. In this context, however, the namedItem() method always retrieves the first item (since all items have the same name).

Special Collections

The document object has several special collections. Each of these collections is an HTMLCollection object and provides faster access to common parts of the document, as described here:

  • document.anchors—Contains all <a> elements with a name attribute in the document.
  • document.applets—Contains all <applet> elements in the document. This collection is deprecated because the <applet> element is no longer recommended for use.
  • document.forms—Contains all <form> elements in the document. The same as document.getElementsByTagName("form").
  • document.images—Contains all <img> elements in the document. The same as document.getElementsByTagName("img").
  • document.links—Contains all <a> elements with an href attribute in the document.

These special collections are always available on HTMLDocument objects and, like all HTMLCollection objects, are constantly updated to match the contents of the current document.

DOM Conformance Detection

Because there are multiple levels and multiple parts of the DOM, it became necessary to determine exactly what parts of the DOM a browser has implemented. The document.implementation property is an object containing information and functionality tied directly to the browser’s implementation of the DOM. DOM Level 1 specifies only one method on document.implementation, which is hasFeature(). The hasFeature() method accepts two arguments: the name and version of the DOM feature to check for. If the browser supports the named feature and version, this method returns true, as with this example:

let hasXmlDom = document.implementation.hasFeature("XML", "1.0");

The various values that can be tested are listed in the following table.

FEATURE SUPPORTED VERSIONS DESCRIPTION
Core 1.0, 2.0, 3.0 Basic DOM that spells out the use of a hierarchical tree to represent documents
XML 1.0, 2.0, 3.0 XML extension of the Core that adds support for CDATA sections, processing instructions, and entities
HTML 1.0, 2.0 HTML extension of XML that adds support for HTML-specific elements and entities
Views 2.0 Accomplishes formatting of a document based on certain styles
StyleSheets 2.0 Relates style sheets to documents
CSS 2.0 Support for Cascading Style Sheets Level 1
CSS2 2.0 Support for Cascading Style Sheets Level 2
Events 2.0, 3.0 Generic DOM events
UIEvents 2.0, 3.0 User interface events
TextEvents 3.0 Events fired from text input devices
MouseEvents 2.0, 3.0 Events caused by the mouse (click, mouseover, and so on)
MutationEvents 2.0, 3.0 Events fired when the DOM tree is changed
MutationNameEvents 3.0 Events fired when DOM elements or element attributes are renamed
HTMLEvents 2.0 HTML 4.01 events
Range 2.0 Objects and methods for manipulating a range in a DOM tree
Traversal 2.0 Methods for traversing a DOM tree
LS 3.0 Loading and saving between files and DOM trees synchronously
LS-Async 3.0 Loading and saving between files and DOM trees asynchronously
Validation 3.0 Methods to modify a DOM tree and still make it valid
XPath 3.0 Language for addressing parts of an XML document

Although it is a nice convenience, the drawback of using hasFeature() is that the implementer gets to decide if the implementation is indeed conformant with the various parts of the DOM specification. It’s very easy to make this method return true for any and all values, but that doesn’t necessarily mean that the implementation conforms to all the specifications it claims to. Safari 2.x and earlier, for example, return true for some features that aren’t fully implemented. In most cases, it’s a good idea to use capability detection in addition to hasFeature() before using specific parts of the DOM.

Document Writing

One of the older capabilities of the document object is the ability to write to the output stream of a web page. This capability comes in the form of four methods: write(), writeln(), open(), and close(). The write() and writeln() methods each accept a string argument to write to the output stream. write() simply adds the text as is, whereas writeln() appends a new-line character ( ) to the end of the string. These two methods can be used as a page is being loaded to dynamically add content to the page, as shown in the following example:

<html>
<head>
 <title>document.write() Example</title>
</head>
<body>
 <p>The current date and time is:
 <script type="text/javascript">
  document.write("<strong>" + (new Date()).toString() + "</strong>");
 </script>
</p>
</body>
</html>

This example outputs the current date and time as the page is being loaded. The date is enclosed by a <strong> element, which is treated the same as if it were included in the HTML portion of the page, meaning that a DOM element is created and can later be accessed. Any HTML that is output via write() or writeln() is treated this way.

The write() and writeln() methods are often used to dynamically include external resources such as JavaScript files. When including JavaScript files, you must be sure not to include the string "</script>" directly, as the following example demonstrates, because it will be interpreted as the end of a script block and the rest of the code won’t execute.

<html>
<head>
 <title>document.write() Example</title>
</head>
<body>
 <script type="text/javascript">
  document.write("<script type="text/javascript" src="file.js">" +  
   "</script>");
 </script>
</body>
</html>

Even though this file looks correct, the closing "</script>" string is interpreted as matching the outermost <script> tag, meaning that the text "); will appear on the page. To avoid this, you simply need to change the string, as shown here:

<html>
<head>
 <title>document.write() Example</title>
</head>
<body>
 <script type="text/javascript">
  document.write("<script type="text/javascript" src="file.js">" +  
   "</script>");
 </script>
</body>
</html>

The string "</script>" no longer registers as a closing tag for the outermost <script> tag, so there is no extra content output to the page.

The previous examples use document.write() to output content directly into the page as it’s being rendered. If document.write() is called after the page has been completely loaded, the content overwrites the entire page, as shown in the following example:

<html>
<head>
 <title>document.write() Example</title>
</head>
<body>
 <p>This is some content that you won't get to see because it will be 
 overwritten.</p>
 <script type="text/javascript">
  window.onload = function(){
   document.write("Hello world!");
  };
 </script>
</body>
</html>

In this example, the window.onload event handler is used to delay the execution of the function until the page is completely loaded. When that happens, the string "Hello world!" overwrites the entire page content.

The open() and close() methods are used to open and close the web page output stream, respectively. Neither method is required to be used when write() or writeln() is used during the course of page loading.

The Element Type

Next to the Document type, the Element type is most often used in web programming. The Element type represents an XML or HTML element, providing access to information such as its tag name, children, and attributes. An Element node has the following characteristics:

  • nodeType is 1.
  • nodeName is the element’s tag name.
  • nodeValue is null.
  • parentNode may be a Document or Element.
  • Child nodes may be Element, Text, Comment, ProcessingInstruction, CDATASection, or EntityReference.

An element’s tag name is accessed via the nodeName property or by using the tagName property; both properties return the same value (the latter is typically used for clarity). Consider the following element:

<div id="myDiv"></div>

This element can be retrieved and its tag name accessed in the following way:

let div = document.getElementById("myDiv");
alert(div.tagName); // "DIV"
alert(div.tagName == div.nodeName);  // true

The element in question has a tag name of div and an ID of "myDiv". Note, however, that div.tagName actually outputs "DIV" instead of "div". When used with HTML, the tag name is always represented in all uppercase; when used with XML (including XHTML), the tag name always matches the case of the source code. If you aren’t sure whether your script will be on an HTML or XML document, it’s best to convert tag names to a common case before comparison, as this example shows:

if (element.tagName == "div"){ // AVOID! Error prone!
 // do something here
}
      
if (element.tagName.toLowerCase() == "div"){ // Preferred - works in all documents
 // do something here
}

This example shows two comparisons against a tagName property. The first is quite error prone because it won’t work in HTML documents. The second approach, converting the tag name to all lowercase, is preferred because it will work for both HTML and XML documents.

HTML Elements

All HTML elements are represented by the HTMLElement type, either directly or through subtyping. The HTMLElement inherits directly from Element and adds several properties. Each property represents one of the following standard attributes that are available on every HTML element:

  • id—A unique identifier for the element in the document.
  • title—Additional information about the element, typically represented as a tooltip.
  • lang—The language code for the contents of the element (rarely used).
  • dir—The direction of the language, "ltr" (left-to-right) or "rtl" (right-to-left); also rarely used.
  • className—The equivalent of the class attribute, which is used to specify CSS classes on an element. The property could not be named class because class is an ECMAScript reserved word.

Each of these properties can be used both to retrieve the corresponding attribute value and to change the value. Consider the following HTML element:

<div id="myDiv" class="bd" title="Body text" lang="en" dir="ltr"></div>

All of the information specified by this element may be retrieved using the following JavaScript code:

let div = document.getElementById("myDiv");
alert(div.id);     // "myDiv"
alert(div.className); // "bd"
alert(div.title);   // "Body text"
alert(div.lang);    // "en"
alert(div.dir);    // "ltr"

It’s also possible to use the following code to change each of the attributes by assigning new values to the properties:

div.id = "someOtherId";
div.className = "ft";
div.title = "Some other text";
div.lang = "fr";
div.dir ="rtl";

Not all of the properties affect the page when overwritten. Changes to id or lang will be transparent to the user (assuming no CSS styles are based on these values), whereas changes to title will be apparent only when the mouse is moved over the element. Changes to dir will cause the text on the page to be aligned to either the left or the right as soon as the property is written. Changes to className may appear immediately if the class has different CSS style information than the previous one.

As mentioned previously, all HTML elements are represented by an instance of HTMLElement or a more specific subtype. The following table lists each HTML element and its associated type (italicized elements are deprecated).

ELEMENT TYPE ELEMENT TYPE
A HTMLAnchorElement INPUT HTMLInputElement
ABBR HTMLElement INS HTMLModElement
ACRONYM HTMLElement ISINDEX HTMLIsIndexElement
ADDRESS HTMLElement KBD HTMLElement
APPLET HTMLAppletElement LABEL HTMLLabelElement
AREA HTMLAreaElement LEGEND HTMLLegendElement
B HTMLElement LI HTMLLIElement
BASE HTMLBaseElement LINK HTMLLinkElement
BASEFONT HTMLBaseFontElement MAP HTMLMapElement
BDO HTMLElement MENU HTMLMenuElement
BIG HTMLElement META HTMLMetaElement
BLOCKQUOTE HTMLQuoteElement NOFRAMES HTMLElement
BODY HTMLBodyElement NOSCRIPT HTMLElement
BR HTMLBRElement OBJECT HTMLObjectElement
BUTTON HTMLButtonElement OL HTMLOListElement
CAPTION HTMLTableCaptionElement OPTGROUP HTMLOptGroupElement
CENTER HTMLElement OPTION HTMLOptionElement
CITE HTMLElement P HTMLParagraphElement
CODE HTMLElement PARAM HTMLParamElement
COL HTMLTableColElement PRE HTMLPreElement
COLGROUP HTMLTableColElement Q HTMLQuoteElement
DD HTMLElement S HTMLElement
DEL HTMLModElement SAMP HTMLElement
DFN HTMLElement SCRIPT HTMLScriptElement
DIR HTMLDirectoryElement SELECT HTMLSelectElement
DIV HTMLDivElement SMALL HTMLElement
DL HTMLDListElement SPAN HTMLElement
DT HTMLElement STRIKE HTMLElement
EM HTMLElement STRONG HTMLElement
FIELDSET HTMLFieldSetElement STYLE HTMLStyleElement
FONT HTMLFontElement SUB HTMLElement
FORM HTMLFormElement SUP HTMLElement
FRAME HTMLFrameElement TABLE HTMLTableElement
FRAMESET HTMLFrameSetElement TBODY HTMLTableSectionElement
H1 HTMLHeadingElement TD HTMLTableCellElement
H2 HTMLHeadingElement TEXTAREA HTMLTextAreaElement
H3 HTMLHeadingElement TFOOT HTMLTableSectionElement
H4 HTMLHeadingElement TH HTMLTableCellElement
H5 HTMLHeadingElement THEAD HTMLTableSectionElement
H6 HTMLHeadingElement TITLE HTMLTitleElement
HEAD HTMLHeadElement TR HTMLTableRowElement
HR HTMLHRElement TT HTMLElement
HTML HTMLHtmlElement U HTMLElement
I HTMLElement UL HTMLUListElement
IFRAME HTMLIFrameElement VAR HTMLElement
IMG HTMLImageElement

Each of these types has attributes and methods associated with it. Many of these types are discussed throughout this book.

Getting Attributes

Each element may have zero or more attributes, which are typically used to give extra information about the particular element or its contents. The three primary DOM methods for working with attributes are getAttribute(), setAttribute(), and removeAttribute(). These methods are intended to work on any attribute, including those defined as properties on the HTMLElement type. Here’s an example:

let div = document.getElementById("myDiv");
alert(div.getAttribute("id"));   // "myDiv"
alert(div.getAttribute("class")); // "bd"
alert(div.getAttribute("title")); // "Body text"
alert(div.getAttribute("lang"));  // "en"
alert(div.getAttribute("dir"));  // "ltr"

Note that the attribute name passed into getAttribute() is exactly the same as the actual attribute name, so you pass in "class" to get the value of the class attribute (not className, which is necessary when the attribute is accessed as an object property). If the attribute with the given name doesn’t exist, getAttribute() always returns null.

The getAttribute() method can also retrieve the value of custom attributes that aren’t part of the formal HTML language. Consider the following element:

<div id="myDiv" my_special_attribute="hello!"></div>

In this element, a custom attribute named my_special_attribute is defined to have a value of "hello!". This value can be retrieved using getAttribute() just like any other attribute, as shown here:

let value = div.getAttribute("my_special_attribute");

Note that attribute names are case-insensitive, so "ID" and "id" are considered the same attribute. Also note that, according to HTML5, custom attributes should be prepended with data- in order to validate.

All attributes on an element are also accessible as properties of the DOM element object itself. There are, of course, the five properties defined on HTMLElement that map directly to corresponding attributes, but all recognized (noncustom) attributes get added to the object as properties. Consider the following element:

<div id="myDiv" align="left" my_special_attribute="hello"></div>

Because id and align are recognized attributes for the <div> element in HTML, they will be represented by properties on the element object. The my_special_attribute attribute is custom and so won’t show up as a property on the element.

Two types of attributes have property names that don’t map directly to the same value returned by getAttribute(). The first attribute is style, which is used to specify stylistic information about the element using CSS. When accessed via getAttribute(), the style attribute contains CSS text while accessing it via a property that returns an object. The style property is used to programmatically access the styling of the element and so does not map directly to the style attribute.

The second category of attribute that behaves differently is event-handler attributes such as onclick. When used on an element, the onclick attribute contains JavaScript code, and that code string is returned when using getAttribute(). When the onclick property is accessed, however, it returns a JavaScript function (or null if the attribute isn’t specified). This is because onclick and other event-handling properties are provided such that functions can be assigned to them.

Because of these differences, developers tend to forego getAttribute() when programming the DOM in JavaScript and instead use the object properties exclusively. The getAttribute() method is used primarily to retrieve the value of a custom attribute.

Setting Attributes

The sibling method to getAttribute() is setAttribute(), which accepts two arguments: the name of the attribute to set and the value to set it to. If the attribute already exists, setAttribute() replaces its value with the one specified; if the attribute doesn’t exist, setAttribute() creates it and sets its value. Here is an example:

div.setAttribute("id", "someOtherId");
div.setAttribute("class", "ft");
div.setAttribute("title", "Some other text");
div.setAttribute("lang","fr");
div.setAttribute("dir", "rtl");

The setAttribute() method works with both HTML attributes and custom attributes in the same way. Attribute names get normalized to lowercase when set using this method, so "ID" ends up as "id".

Because all attributes are properties, assigning directly to the property can set the attribute values, as shown here:

div.id = "someOtherId";
div.align = "left";

Note that adding a custom property to a DOM element, as the following example shows, does not automatically make it an attribute of the element:

div.mycolor = "red";
alert(div.getAttribute("mycolor"));  // null (except in Internet Explorer)

This example adds a custom property named mycolor and sets its value to "red". In most browsers, this property does not automatically become an attribute on the element, so calling getAttribute() to retrieve an attribute with the same name returns null.

The last method is removeAttribute(), which removes the attribute from the element altogether. This does more than just clear the attribute’s value; it completely removes the attribute from the element, as shown here:

div.removeAttribute("class");

This method isn’t used very frequently, but it can be useful for specifying exactly which attributes to include when serializing a DOM element.

The attributes Property

The Element type is the only DOM node type that uses the attributes property. The attributes property contains a NamedNodeMap, which is a “live” collection similar to a NodeList. Every attribute on an element is represented by an Attr node, each of which is stored in the NamedNodeMap object. A NamedNodeMap object has the following methods:

  • getNamedItem(name)—Returns the node whose nodeName property is equal to name.
  • removeNamedItem(name)—Removes the node whose nodeName property is equal to name from the list.
  • setNamedItem(node)—Adds the node to the list, indexing it by its nodeName property.
  • item(pos)—Returns the node in the numerical position pos.

Each node in the attributes property is a node whose nodeName is the attribute name and whose nodeValue is the attribute’s value. To retrieve the id attribute of an element, you can use the following code:

let id = element.attributes.getNamedItem("id").nodeValue;

Following is a shorthand notation for accessing attributes by name using bracket notation:

let id = element.attributes["id"].nodeValue;

It’s possible to use this notation to set attribute values as well, retrieving the attribute node and then setting the nodeValue to a new value, as this example shows:

element.attributes["id"].nodeValue = "someOtherId";

The removeNamedItem() method functions the same as the removeAttribute() method on the element—it simply removes the attribute with the given name. The following example shows how the sole difference is that removeNamedItem() returns the Attr node that represented the attribute:

let oldAttr = element.attributes.removeNamedItem("id");

The setNamedItem() is a rarely used method that allows you to add a new attribute to the element by passing in an attribute node, as shown in this example:

element.attributes.setNamedItem(newAttr);

Generally speaking, because of their simplicity, the getAttribute(), removeAttribute(), and setAttribute() methods are preferred to using any of the preceding attribute methods.

The one area where the attributes property is useful is to iterate over the attributes on an element. This is done most often when serializing a DOM structure into an XML or HTML string. The following code iterates over each attribute on an element and constructs a string in the format attribute1=“value1” attribute2=“value2”:

function outputAttributes(element) {
 let pairs = [];

 for (let i = 0, len = element.attributes.length; i < len; ++i) {
   const attribute = element.attributes[i];
   pairs.push(`${attribute.nodeName}="${attribute.nodeValue}"`);
 }

 return pairs.join(" ");
}

This function uses an array to store the name-value pairs until the end, concatenating them with a space in between. (This technique is frequently used when serializing into long strings.) Using the attributes.length property, the for loop iterates over each attribute, outputting the name and value into a string. Browsers differ on the order in which they return attributes in the attributes object. The order in which the attributes appear in the HTML or XML code may not necessarily be the order in which they appear in the attributes object.

Creating Elements

New elements can be created by using the document.createElement() method. This method accepts a single argument, which is the tag name of the element to create. In HTML documents, the tag name is case-insensitive, whereas it is case-sensitive in XML documents (including XHTML). To create a <div> element, the following code can be used:

let div = document.createElement("div");

Using the createElement() method creates a new element and sets its ownerDocument property. At this point, you can manipulate the element’s attributes, add more children to it, and so on. Consider the following example:

div.id = "myNewDiv";
div.className = "box";

Setting these attributes on the new element assigns information only. Because the element is not part of the document tree, it doesn’t affect the browser’s display. The element can be added to the document tree using appendChild(), insertBefore(), or replaceChild(). The following code adds the newly created element to the document’s <body> element:

document.body.appendChild(div);

Once the element has been added to the document tree, the browser renders it immediately. Any changes to the element after this point are immediately reflected by the browser.

Element Children

Elements may have any number of children and descendants because elements may be children of elements. The childNodes property contains all of the immediate children of the element, which may be other elements, text nodes, comments, or processing instructions. There is a significant difference between browsers regarding the identification of these nodes. For example, consider the following code:

<ul id="myList">
 <li>Item 1</li>
 <li>Item 2</li>
 <li>Item 3</li>
</ul>

When this code is parsed, the <ul> element will have seven elements: three <li> elements and four text nodes representing the white space between <li> elements. If the white space between elements is removed, as the following example demonstrates, all browsers return the same number of child nodes:

<ul id="myList"><li>Item 1</li><li>Item 2</li><li>Item 3</li></ul>

Using this code, browsers return three child nodes for the <ul> element. Oftentimes, it’s necessary to check the nodeType before performing an action, as the following example shows:

for (let i = 0, len = element.childNodes.length; i < len; ++i) {
 if (element.childNodes[i].nodeType == 1) {
  // do processing
 }
}

This code loops through each child node of a particular element and performs an operation only if nodeType is equal to 1 (the element node type identified).

To get child nodes and other descendants with a particular tag name, elements also support the getElementsByTagName() method. When used on an element, this method works exactly the same as the document version except that the search is rooted on the element, so only descendants of that element are returned. In the <ul> code earlier in this section, all <li> elements can be retrieved using the following code:

let ul = document.getElementById("myList");
let items = ul.getElementsByTagName("li");

Keep in mind that this works because the <ul> element has only one level of descendants. If there were more levels, all <li> elements contained in all levels would be returned.

The Text Type

Text nodes are represented by the Text type and contain plain text that is interpreted literally and may contain escaped HTML characters but no HTML code. A Text node has the following characteristics:

  • nodeType is 3.
  • nodeName is "#text".
  • nodeValue is text contained in the node.
  • parentNode is an Element.
  • Child nodes are not supported.

The text contained in a Text node may be accessed via either the nodeValue property or the data property, both of which contain the same value. Changes to either nodeValue or data are reflected in the other as well. The following methods allow for manipulation of the text in the node:

  • appendData(text)—Appends text to the end of the node.
  • deleteData(offset, count)—Deletes count number of characters starting at position offset.
  • insertData(offset, text)—Inserts text at position offset.
  • replaceData(offset, count, text)—Replaces the text starting at offset through offset + count with text.
  • splitText(offset)—Splits the text node into two text nodes separated at position offset.
  • substringData(offset, count)—Extracts a string from the text beginning at position offset and continuing until offset + count.

In addition to these methods, the length property returns the number of characters in the node. This value is the same as using nodeValue.length or data.length.

By default, every element that may contain content will have at most one text node when content is present. Here is an example:

<!-- no content, so no text node -->
<div></div>  
      
<!-- white space content, so one text node -->
<div> </div>  
      
<!-- content, so one text node -->
<div>Hello World!</div>

The first <div> element in this code has no content, so there is no text node. Any content between the opening and closing tags means that a text node must be created, so the second <div> element has a single text node as a child even though its content is white space. The text node’s nodeValue is a single space. The third <div> also has a single text node whose nodeValue is "Hello World!". The following code lets you access this node:

let textNode = div.firstChild; // or div.childNodes[0]

Once a reference to the text node is retrieved, it can be changed like this:

div.firstChild.nodeValue = "Some other message";

As long as the node is currently in the document tree, the changes to the text node will be reflected immediately. Another note about changing the value of a text node is that the string is HTML- or XML-encoded (depending on the type of document), meaning that any less-than symbols, greater-than symbols, or quotation marks are escaped, as shown in this example:

// outputs as "Some <strong>other</strong> message"
div.firstChild.nodeValue = "Some <strong>other</strong> message";

This is an effective way of HTML-encoding a string before inserting it into the DOM document.

Creating Text Nodes

New text nodes can be created using the document.createTextNode() method, which accepts a single argument—the text to be inserted into the node. As with setting the value of an existing text node, the text will be HTML- or XML-encoded, as shown in this example:

let textNode = document.createTextNode("<strong>Hello</strong> world!");

When a new text node is created, its ownerDocument property is set, but it does not appear in the browser window until it is added to a node in the document tree. The following code creates a new <div> element and adds a message to it:

let element = document.createElement("div");
element.className = "message";
      
let textNode = document.createTextNode("Hello world!");
element.appendChild(textNode);
      
document.body.appendChild(element);

This example creates a new <div> element and assigns it a class of "message". Then a text node is created and added to that element. The last step is to add the element to the document’s body, which makes both the element and the text node appear in the browser.

Typically, elements have only one text node as a child. However, it is possible to have multiple text nodes as children, as this example demonstrates:

let element = document.createElement("div");
element.className = "message";
      
let textNode = document.createTextNode("Hello world!");
element.appendChild(textNode);
      
let anotherTextNode = document.createTextNode("Yippee!");
element.appendChild(anotherTextNode);
      
document.body.appendChild(element);

When a text node is added as a sibling of another text node, the text in those nodes is displayed without any space between them.

Normalizing Text Nodes

Sibling text nodes can be confusing in DOM documents because there is no simple text string that can’t be represented in a single text node. Still, it is not uncommon to come across sibling text nodes in DOM documents, so there is a method to join sibling text nodes together. This method is called normalize(), and it exists on the Node type (and thus is available on all node types). When normalize() is called on a parent of two or more text nodes, those nodes are merged into one text node whose nodeValue is equal to the concatenation of the nodeValue properties of each text node. Here’s an example:

let element = document.createElement("div");
element.className = "message";
      
let textNode = document.createTextNode("Hello world!");
element.appendChild(textNode);
      
let anotherTextNode = document.createTextNode("Yippee!");
element.appendChild(anotherTextNode);
      
document.body.appendChild(element);
      
alert(element.childNodes.length);  // 2
      
element.normalize();
alert(element.childNodes.length);  // 1
alert(element.firstChild.nodeValue); // "Hello world!Yippee!"

When the browser parses a document, it will never create sibling text nodes. Sibling text nodes can appear only by programmatic DOM manipulation.

Splitting Text Nodes

The Text type has a method that does the opposite of normalize(): the splitText() method splits a text node into two text nodes, separating the nodeValue at a given offset. The original text node contains the text up to the specified offset, and the new text node contains the rest of the text. The method returns the new text node, which has the same parentNode as the original. Consider the following example:

let element = document.createElement("div");
element.className = "message";
      
let textNode = document.createTextNode("Hello world!");
element.appendChild(textNode);
      
document.body.appendChild(element);
      
let newNode = element.firstChild.splitText(5);
alert(element.firstChild.nodeValue); // "Hello"
alert(newNode.nodeValue);            // " world!"
alert(element.childNodes.length);    // 2

In this example, the text node containing the text "Hello world!" is split into two text nodes at position 5. Position 5 contains the space between "Hello" and "world!", so the original text node has the string "Hello" and the new one has the text " world!" (including the space).

Splitting text nodes is used most often with DOM parsing techniques for extracting data from text nodes.

The Comment Type

Comments are represented in the DOM by the Comment type. A Comment node has the following characteristics:

  • nodeType is 8.
  • nodeName is "#comment".
  • nodeValue is the content of the comment.
  • parentNode is a Document or Element.
  • Child nodes are not supported.

The Comment type inherits from the same base as the Text type, so it has all of the same string-manipulation methods except splitText(). Also similar to the Text type, the actual content of the comment may be retrieved using either nodeValue or the data property.

A comment node can be accessed as a child node from its parent. Consider the following HTML code:

<div id="myDiv"><!-- A comment --></div>

In this case, the comment is a child node of the <div> element, which means it can be accessed like this:

let div = document.getElementById("myDiv");
let comment = div.firstChild;
alert(comment.data); // "A comment"

Comment nodes can also be created using the document.createComment() method and passing in the comment text, as shown in the following code:

let comment = document.createComment("A comment");

Not surprisingly, comment nodes are rarely accessed or created, because they serve very little purpose algorithmically. Additionally, browsers don’t recognize comments that exist after the closing </html> tag. If you need to access comment nodes, make sure they appear as descendants of the <html> element.

The CDATASection Type

CDATA sections are specific to XML-based documents and are represented by the CDATASection type. Similar to Comment, the CDATASection type inherits from the base Text type, so it has all of the same string manipulation methods except for splitText(). A CDATASection node has the following characteristics:

  • nodeType is 4.
  • nodeName is "#cdata-section".
  • nodeValue is the contents of the CDATA section.
  • parentNode is a Document or Element.
  • Child nodes are not supported.

CDATA sections are valid only in XML documents, so most browsers will incorrectly parse a CDATA section into either a Comment or an Element. Consider the following:

<div id="myDiv"><![CDATA[This is some content.]]></div>

In this example, a CDATASection node should exist as the first child of the <div>; however, none of the four major browsers interpret it as such. Even in valid XHTML pages, the browsers don’t properly support embedded CDATA sections.

True XML documents allow the creation of CDATA sections using document.createCDataSection() and pass in the node’s content.

The DocumentType Type

A DocumentType object contains all of the information about the document’s doctype and has the following characteristics:

  • nodeType is 10.
  • nodeName is the name of the doctype.
  • nodeValue is null.
  • parentNode is a Document.
  • Child nodes are not supported.

DocumentType objects cannot be created dynamically in DOM Level 1; they are created only as the document’s code is being parsed. For browsers that support it, the DocumentType object is stored in document.doctype. DOM Level 1 describes three properties for DocumentType objects: name, which is the name of the doctype; entities, which is a NamedNodeMap of entities described by the doctype; and notations, which is a NamedNodeMap of notations described by the doctype. Because documents in browsers typically use an HTML or XHTML doctype, the entities and notations lists are typically empty. (They are filled only with inline doctypes.) For all intents and purposes, the name property is the only useful one available. This property is filled with the name of the doctype, which is the text that appears immediately after <!DOCTYPE. Consider the following HTML 4.01 strict doctype:

<!DOCTYPE HTML PUBLIC "-// W3C// DTD HTML 4.01// EN" 
 "http:// www.w3.org/TR/html4/strict.dtd">

For this doctype, the name property is "HTML":

alert(document.doctype.name); // "HTML"

The DocumentFragment Type

Of all the node types, the DocumentFragment type is the only one that has no representation in markup. The DOM defines a document fragment as a “lightweight” document, capable of containing and manipulating nodes without all of the additional overhead of a complete document. DocumentFragment nodes have the following characteristics:

  • nodeType is 11.
  • nodeName is "#document-fragment".
  • nodeValue is null.
  • parentNode is null.
  • Child nodes may be Element, ProcessingInstruction, Comment, Text, CDATASection, or EntityReference.

A document fragment cannot be added to a document directly. Instead, it acts as a repository for other nodes that may need to be added to the document. Document fragments are created using the document.createDocumentFragment() method, shown here:

let fragment = document.createDocumentFragment();

Document fragments inherit all methods from Node and are typically used to perform DOM manipulations that are to be applied to a document. If a node from the document is added to a document fragment, that node is removed from the document tree and won’t be rendered by the browser. New nodes that are added to a document fragment are also not part of the document tree. The contents of a document fragment can be added to a document via appendChild() or insertBefore(). When a document fragment is passed in as an argument to either of these methods, all of the document fragment’s child nodes are added in that spot; the document fragment itself is never added to the document tree. For example, consider the following HTML:

<ul id="myList"></ul>

Suppose you would like to add three list items to this <ul> element. Adding each item directly to the element causes the browser to rerender the page with the new information. To avoid this, the following code example uses a document fragment to create the list items and then add them all at the same time:

let fragment = document.createDocumentFragment();
let ul = document.getElementById("myList");
      
for (let i = 0; i < 3; ++i) {
 let li = document.createElement("li");
 li.appendChild(document.createTextNode(`Item ${i + 1}`));
 fragment.appendChild(li);
}
      
ul.appendChild(fragment); 

This example begins by creating a document fragment and retrieving a reference to the <ul> element. The for loop creates three list items, each with text indicating which item they are. To do this, an <li> element is created and then a text node is created and added to that element. The <li> element is then added to the document fragment using appendChild(). When the loop is complete, all of the items are added to the <ul> element by calling appendChild() and passing in the document fragment. At that point, the document fragment’s child nodes are all removed and placed onto the <ul> element.

The Attr Type

Element attributes are represented by the Attr type in the DOM. The Attr type constructor and prototype are accessible in all browsers. Technically, attributes are nodes that exist in an element’s attributes property. Attribute nodes have the following characteristics:

  • nodeType is 11.
  • nodeName is the attribute name.
  • nodeValue is the attribute value.
  • parentNode is null.
  • Child nodes are not supported in HTML.
  • Child nodes may be Text or EntityReference in XML.

Even though they are nodes, attributes are not considered part of the DOM document tree. Attribute nodes are rarely referenced directly, with most developers favoring the use of getAttribute(), setAttribute(), and removeAttribute().

There are three properties on an Attr object: name, which is the attribute name (same as nodeName); value, which is the attribute value (same as nodeValue); and specified, which is a Boolean value indicating if the attribute was specified in code or if it is a default value.

New attribute nodes can be created by using document.createAttribute() and passing in the name of the attribute. For example, to add an align attribute to an element, the following code can be used:

let attr = document.createAttribute("align");
attr.value = "left";
element.setAttributeNode(attr);
      
alert(element.attributes["align"].value);    // "left"
alert(element.getAttributeNode("align").value); // "left"
alert(element.getAttribute("align"));      // "left"

In this example, a new attribute node is created. The name property is assigned by the call to createAttribute(), so there is no need to assign it directly afterward. The value property is then assigned to "left". To add the newly created attribute to an element, you can use the element’s setAttributeNode() method. Once the attribute is added, it can be accessed in any number of ways: via the attributes property, using getAttributeNode(), or using getAttribute(). Both attributes and getAttributeNode() return the actual Attr node for the attribute, whereas getAttribute() returns only the attribute value.

WORKING WITH THE DOM

In many cases, working with the DOM is fairly straightforward, making it easy to re-create with JavaScript what normally would be created using HTML code. There are, however, times when using the DOM is not as simple as it may appear. Browsers are filled with hidden gotchas and incompatibilities that make coding certain parts of the DOM more complicated than coding its other parts.

Dynamic Scripts

The <script> element is used to insert JavaScript code into the page, either by using the src attribute to include an external file or by including text inside the element itself. Dynamic scripts are those that don’t exist when the page is loaded but are included later by using the DOM. As with the HTML element, there are two ways to do this: pulling in an external file or inserting text directly.

Dynamically loading an external JavaScript file works as you would expect. Consider the following <script> element:

<script src="foo.js"></script>

The DOM code to create this node is as follows:

let script = document.createElement("script");
script.src = "foo.js";
document.body.appendChild(script);

As you can see, the DOM code exactly mirrors the HTML code that it represents. Note that the external file is not downloaded until the <script> element is added to the page on the last line. The element could be added to the <head> element as well, though this has the same effect. This process can be generalized into the following function:

function loadScript(url) {
 let script = document.createElement("script");
 script.src = url;
 document.body.appendChild(script);
}

This function can now be used to load external JavaScript files via the following call:

loadScript("client.js");

Once loaded, the script is fully available to the rest of the page. This leaves only one problem: how do you know when the script has been fully loaded? Unfortunately, there is no standard way to handle this. Some events are available depending on the browser being used, as discussed in the Events chapter.

The other way to specify JavaScript code is inline, as in this example:

<script>
 function sayHi() {
  alert("hi");
 }
</script>

Using the DOM, it would be logical for the following to work:

let script = document.createElement("script");
script.appendChild(document.createTextNode("function sayHi(){alert('hi');}"));
document.body.appendChild(script);

This works in Firefox, Safari, Chrome, and Opera. In older versions of Internet Explorer, however, this causes an error. Internet Explorer treats <script> elements as special and won’t allow regular DOM access to child nodes. A property called text exists on all <script> elements that can be used specifically to assign JavaScript code to, as in the following example:

var script = document.createElement("script");
script.text = "function sayHi(){alert('hi');}";
document.body.appendChild(script);

This updated code works in Internet Explorer, Firefox, Opera, and Safari 3 and later. Safari versions prior to 3 don’t support the text property correctly; however, these older versions will allow the assignment of code using the text-node technique. If you need to do this in an earlier Safari version, the following code can be used:

var script = document.createElement("script");
var code = "function sayHi(){alert('hi');}";
try {
 script.appendChild(document.createTextNode("code"));
} catch (ex){
 script.text = "code";
}
document.body.appendChild(script);

Here, the standard DOM text-node method is attempted first because it works in everything but Internet Explorer, which will throw an error. If that line causes an error, that means it is Internet Explorer, and the text property must be used. This can be generalized into the following function:

function loadScriptString(code){
 var script = document.createElement("script");
 script.type = "text/javascript";
 try {
  script.appendChild(document.createTextNode(code));
 } catch (ex){
  script.text = code;
 }
 document.body.appendChild(script);
}

The function is called as follows:

loadScriptString("function sayHi(){alert('hi');}");

Code loaded in this manner is executed in the global scope and is available immediately after the script finishes executing. This is essentially the same as passing the string into eval() in the global scope.

Importantly, all <script> elements created with innerHTML will never execute. The browser will dutifully create the <script> element and the script text inside it, but the parser will flag the <script> element as one that should never execute. Once created using innerHTML, there is no way to force the script to run later.

Dynamic Styles

CSS styles are included in HTML pages using one of two elements. The <link> element is used to include CSS from an external file, whereas the <style> element is used to specify inline styles. Similar to dynamic scripts, dynamic styles don’t exist on the page when it is loaded initially; rather, they are added after the page has been loaded.

Consider this typical <link> element:

<link rel="stylesheet" type="text/css" href="styles.css">

This element can just as easily be created using the following DOM code:

let link = document.createElement("link");
link.rel = "stylesheet";
link.type = "text/css";
link.href = "styles.css";
let head = document.getElementsByTagName("head")[0];
head.appendChild(link);

This code works in all major browsers without any issue. Note that <link> elements should be added to the <head> instead of the body for this to work properly in all browsers. The technique can be generalized into the following function:

function loadStyles(url){
 let link = document.createElement("link");
 link.rel = "stylesheet";
 link.type = "text/css";
 link.href = url;
 let head = document.getElementsByTagName("head")[0];
 head.appendChild(link); 
}

The loadStyles() function can then be called like this:

loadStyles("styles.css");

Loading styles via an external file is asynchronous, so the styles will load out of order with the JavaScript code being executed. Typically, it’s not necessary to know when the styles have been fully loaded.

The other way to define styles is using the <style> element and including inline CSS, such as this:

<style type="text/css">
body {
 background-color: red;
}
</style>

Logically, the following DOM code should work:

let style = document.createElement("style");
style.type = "text/css";
style.appendChild(document.createTextNode("body{background-color:red}"));
let head = document.getElementsByTagName("head")[0];
head.appendChild(style);

This code works in Firefox, Safari, Chrome, and Opera but not in Internet Explorer. Internet Explorer treats <style> nodes as special, similar to <script> nodes, and so won’t allow access to its child nodes. In fact, Internet Explorer throws the same error as when you try to add a child node to a <script> element. The workaround for Internet Explorer is to access the element’s styleSheet property, which in turn has a property called cssText that may be set to CSS code as this code sample shows:

let style = document.createElement("style");
style.type = "text/css";
try{
 style.appendChild(document.createTextNode("body{background-color:red}"));
} catch (ex){
 style.styleSheet.cssText = "body{background-color:red}";
}
let head = document.getElementsByTagName("head")[0];
head.appendChild(style);

Similar to the code for adding inline scripts dynamically, this new code uses a try-catch statement to catch the error that Internet Explorer throws and then responds by using the Internet Explorer–specific way of setting styles. The generic solution is as follows:

function loadStyleString(css){
 let style = document.createElement("style");
 style.type = "text/css";
 try{
  style.appendChild(document.createTextNode(css));
 } catch (ex){
  style.styleSheet.cssText = css;
 }
 let head = document.getElementsByTagName("head")[0];
 head.appendChild(style);
}

The function can be called as follows:

loadStyleString("body{background-color:red}");

Styles specified in this way are added to the page instantly, so changes should be seen immediately.

Manipulating Tables

One of the most complex structures in HTML is the <table> element. Creating new tables typically means numerous tags for table rows, table cells, table headers, and so forth. Because of this complexity, using the core DOM methods to create and change tables can require a large amount of code. Suppose you want to create the following HTML table using the DOM:

<table border="1" width="100%">
 <tbody>
  <tr>
   <td>Cell 1,1</td>
   <td>Cell 2,1</td>
  </tr>
  <tr>
   <td>Cell 1,2</td>
   <td>Cell 2,2</td>
  </tr>
 </tbody>
</table>

To accomplish this with the core DOM methods, the code would look something like this:

// create the table
let table = document.createElement("table");
table.border = 1;
table.width = "100%";
      
// create the tbody
let tbody = document.createElement("tbody");
table.appendChild(tbody);
      
// create the first row
let row1 = document.createElement("tr");
tbody.appendChild(row1);
let cell1_1 = document.createElement("td");
cell1_1.appendChild(document.createTextNode("Cell 1,1"));
row1.appendChild(cell1_1);
let cell2_1 = document.createElement("td");
cell2_1.appendChild(document.createTextNode("Cell 2,1"));
row1.appendChild(cell2_1);
      
// create the second row
let row2 = document.createElement("tr");
tbody.appendChild(row2);
let cell1_2 = document.createElement("td");
cell1_2.appendChild(document.createTextNode("Cell 1,2"));
row2.appendChild(cell1_2);
let cell2_2= document.createElement("td");
cell2_2.appendChild(document.createTextNode("Cell 2,2"));
row2.appendChild(cell2_2);
      
// add the table to the document body
document.body.appendChild(table);

This code is quite verbose and a little hard to follow. To facilitate building tables, the HTML DOM adds several properties and methods to the <table>, <tbody>, and <tr> elements.

The <table> element adds the following:

  • caption—Pointer to the <caption> element (if it exists).
  • tBodies—An HTMLCollection of <tbody> elements.
  • tFoot—Pointer to the <tfoot> element (if it exists).
  • tHead—Pointer to the <thead> element (if it exists).
  • rows—An HTMLCollection of all rows in the table.
  • createTHead()—Creates a <thead> element, places it into the table, and returns a reference.
  • createTFoot()—Creates a <tfoot> element, places it into the table, and returns a reference.
  • createCaption()—Creates a <caption> element, places it into the table, and returns a reference.
  • deleteTHead()—Deletes the <thead> element.
  • deleteTFoot()—Deletes the <tfoot> element.
  • deleteCaption()—Deletes the <caption> element.
  • deleteRow(pos )—Deletes the row in the given position.
  • insertRow(pos )—Inserts a row in the given position in the rows collection.

The <tbody> element adds the following:

  • rows—An HTMLCollection of rows in the <tbody> element.
  • deleteRow(pos )—Deletes the row in the given position.
  • insertRow(pos )—Inserts a row in the given position in the rows collection and returns a reference to the new row.

The <tr> element adds the following:

  • cells—An HTMLCollection of cells in the <tr> element.
  • deleteCell(pos )—Deletes the cell in the given position.
  • insertCell(pos )—Inserts a cell in the given position in the cells collection and returns a reference to the new cell.

These properties and methods can greatly reduce the amount of code necessary to create a table. For example, the previous code can be rewritten using these methods as follows (the highlighted code is updated):

// create the table
let table = document.createElement("table");
table.border = 1;
table.width = "100%";
      
// create the tbody
let tbody = document.createElement("tbody");
table.appendChild(tbody);
      
// create the first row
tbody.insertRow(0);
tbody.rows[0].insertCell(0);
tbody.rows[0].cells[0].appendChild(document.createTextNode("Cell 1,1"));
tbody.rows[0].insertCell(1);
tbody.rows[0].cells[1].appendChild(document.createTextNode("Cell 2,1"));
      
// create the second row
tbody.insertRow(1);
tbody.rows[1].insertCell(0);
tbody.rows[1].cells[0].appendChild(document.createTextNode("Cell 1,2"));
tbody.rows[1].insertCell(1);
tbody.rows[1].cells[1].appendChild(document.createTextNode("Cell 2,2"));
      
// add the table to the document body
document.body.appendChild(table);

In this code, the creation of the <table> and <tbody> elements remains the same. What has changed is the section creating the two rows, which now makes use of the HTML DOM table properties and methods. To create the first row, the insertRow() method is called on the <tbody> element with an argument of 0, which indicates the position in which the row should be placed. After that point, the row can be referenced by tbody.rows[0] because it is automatically created and added into the <tbody> element in position 0.

Creating a cell is done in a similar way—by calling insertCell() on the <tr> element and passing in the position in which the cell should be placed. The cell can then be referenced by tbody.rows[0].cells[0] because the cell has been created and inserted into the row in position 0.

Using these properties and methods to create a table makes the code much more logical and readable, although technically both sets of code are correct.

Using NodeLists

Understanding a NodeList object and its relatives, NamedNodeMap and HTMLCollection, is critical to a good understanding of the DOM as a whole. Each of these collections is considered “live,” which is to say that they are updated when the document structure changes such that they are always current with the most accurate information. In reality, all NodeList objects are queries that are run against the DOM document whenever they are accessed. For instance, the following results in an infinite loop:

let divs = document.getElementsByTagName("div");
      
for (let i = 0; i < divs.length; ++i){
 let div = document.createElement("div");
 document.body.appendChild(div);
}

The first part of this code gets an HTMLCollection of all <div> elements in the document. Because that collection is “live,” any time a new <div> element is added to the page, it gets added into the collection. Because the browser doesn’t want to keep a list of all the collections that were created, the collection is updated only when it is accessed again. This creates an interesting problem in terms of a loop such as the one in this example. Each time through the loop, the condition i < divs.length is being evaluated. That means the query to get all <div> elements is being run. Because the body of the loop creates a new <div> element and adds it to the document, the value of divs.length increments each time through the loop; thus i will never equal divs.length because both are being incremented.

Using an ES6 iterator doesn’t fix the situation because an ever-growing live collection remains the subject of iteration. This will still result in an infinite loop:

for (let div of document.getElementsByTagName("div")){
 let newDiv = document.createElement("div");
 document.body.appendChild(newDiv);
}

Any time you want to iterate over a NodeList, it’s best to initialize a second variable with the length and then compare the iterator to that variable, as shown in the following example:

let divs = document.getElementsByTagName("div");
      
for (let i = 0, len = divs.length; i < len; ++i) {
 let div = document.createElement("div");
 document.body.appendChild(div);
}

In this example, a second variable, len, is initialized. Because len contains a snapshot of divs.length at the time the loop began, it prevents the infinite loop that was experienced in the previous example. This technique has been used through this chapter to demonstrate the preferred way of iterating over NodeList objects.

Alternately, if you want to avoid having the second variable, you can also iterate the list in reverse:

let divs = document.getElementsByTagName("div");
      
for (let i = divs.length - 1; i >= 0; --i) {
 let div = document.createElement("div");
 document.body.appendChild(div);
}

Generally speaking, it is best to limit the number of times you interact with a NodeList. Because a query is run against the document each time, try to cache frequently used values retrieved from a NodeList.

MUTATION OBSERVERS

The MutationObserver API, a relatively recent addition to the DOM specification, allows you to asynchronously execute a callback when the DOM is modified. With a MutationObserver, you are able to observe an entire document, a DOM subtree, or just a single element. Furthermore, you are also able to observe changes to element attributes, child nodes, text, or any combination of the three.

Basic usage

A MutationObserver instance is created by calling the MutationObserver constructor and passing a callback function:

let observer = new MutationObserver(() => console.log('DOM was mutated!'));

The observe() method

This instance begins unassociated with any part of the DOM. To link this observer with the DOM, the observe() method is used. This method accepts two required arguments: the target DOM node which is observed for changes, and the MutationObserverInit object.

The MutationObserverInit object is used to control what changes the observer should watch for. It takes the form of a dictionary of key/value configuration options. For example, the following code creates an observer and configures it to watch for attribute changes on the body element:

let observer = new MutationObserver(() => console.log('<body> attributes changed'));

observer.observe(document.body, { attributes: true });

At this point, any attribute changes to the <body> element will be detected by the MutationObserver instance, and the callback will asynchronously execute. Modifications to children or other non-attribute DOM mutations will not schedule a callback. This behavior is demonstrated here:

let observer = new MutationObserver(() => console.log('<body> attributes changed'));

observer.observe(document.body, { attributes: true });

document.body.className = 'foo';
console.log('Changed body class');

// Changed body class
// <body> attributes changed

Note that the callback console.log executes second, indicating that the callback does not synchronously execute with the actual DOM mutation.

Working with Callbacks and MutationRecords

Each callback is provided with an array of MutationRecord instances. Each instance contains information about what kind of mutation occurred and what part of the DOM was affected. Because it is possible that multiple qualifying mutations occurred before a callback is executed, each callback invocation is passed the queued backup of MutationRecord instances.

The MutationRecord array for a single attribute mutation is shown here:

let observer = new MutationObserver(
  (mutationRecords) => console.log(mutationRecords));

observer.observe(document.body, { attributes: true });

document.body.setAttribute('foo', 'bar');

// [
//  {
//     addedNodes: NodeList [],
//     attributeName: "foo",
//     attributeNamespace: null,
//     nextSibling: null,
//     oldValue: null,
//     previousSibling: null
//     removedNodes: NodeList [],
//     target: body
//     type: "attributes"
//  } 
// ]

A similar mutation involving a namespace is shown here:

let observer = new MutationObserver(
  (mutationRecords) => console.log(mutationRecords));

observer.observe(document.body, { attributes: true });

document.body.setAttributeNS('baz', 'foo', 'bar');

// [
//  {
//     addedNodes: NodeList [],
//     attributeName: "foo",
//     attributeNamespace: "baz",
//     nextSibling: null,
//     oldValue: null,
//     previousSibling: null
//     removedNodes: NodeList [],
//     target: body
//     type: "attributes"
//  } 
// ] 

Sequential modifications will generate multiple MutationRecord instances, and the next callback invocation will be passed all the pending instances in the order they were enqueued:

let observer = new MutationObserver(
    (mutationRecords) => console.log(mutationRecords));

observer.observe(document.body, { attributes: true });

document.body.className = 'foo'; 
document.body.className = 'bar';
document.body.className = 'baz';

// [MutationRecord, MutationRecord, MutationRecord]

A MutationRecord instance will have the following properties:

KEY VALUE
target The node that was affected by the mutation.
type A string indicating what type of mutation occurred. Can be "attributes", "characterData", or "childList".
oldValue When enabled in the MutationObserverInit object, attributes or characterData mutations will set this field to the value that was replaced. This value is only provided when attributeOldValue or characterDataOldValue is true, otherwise this value is null.
A childList mutation will always set this field to null.
attributeName For attributes mutations, the string name of the attribute which was modified.
For all other mutations, this field is set to null.
attributeNamespace For attributes mutations which make use of namespaces, the string namespace of the attribute which was modified.
For all other mutations, this field is set to null.
addedNodes For childList mutations, returns a NodeList of nodes added in the mutation.
Defaults to an empty NodeList.
removedNodes For childList mutations, returns a NodeList of nodes removed in the mutation.
Defaults to an empty NodeList.
previousSibling For childList mutations, returns the previous sibling Node of the mutated node.
Defaults to null.
nextSibling For childList mutations, returns the next sibling Node of the mutated node.
Defaults to null.

The second argument to the callback is the MutationObserver instance that detected the mutation, demonstrated here:

let observer = new MutationObserver(
  (mutationRecords, mutationObserver) => console.log(mutationRecords, mutationObserver));

observer.observe(document.body, { attributes: true });

document.body.className = 'foo';

// [MutationRecord], MutationObserver

The disconnect() method

By default, a MutationObserver callback will execute for every DOM mutation in its designated purview until the element is garbage collected. To terminate callback execution early, the disconnect() method can be invoked. This example demonstrates how invoking disconnect() synchronously will halt callbacks, but will also discard any pending asynchronous callbacks, even if they were from a DOM mutation during the observation window:

let observer = new MutationObserver(() => console.log('<body> attributes changed'));

observer.observe(document.body, { attributes: true });

document.body.className = 'foo';

observer.disconnect();

document.body.className = 'bar';

// (nothing logged)

To allow for these queued callbacks to execute before invoking disconnect(), a setTimeout could be employed to allow for pending callbacks to execute:

let observer = new MutationObserver(() => console.log('<body> attributes changed'));

observer.observe(document.body, { attributes: true });

document.body.className = 'foo';

setTimeout(() => {
 observer.disconnect();
 document.body.className = 'bar';
}, 0);

// <body> attributes changed 

Multiplexing a MutationObserver

A MutationObserver can be associated with many different target elements by calling observe() multiple times. The target property on the MutationRecord can identify which element was subject to that particular mutation. This behavior is demonstrated here:

let observer = new MutationObserver(
               (mutationRecords) => console.log(mutationRecords.map((x) => x.target)));

// Append two children to body
let childA = document.createElement('div'),
  childB = document.createElement('span');
document.body.appendChild(childA);
document.body.appendChild(childB);

// Observe both children
observer.observe(childA, { attributes: true });
observer.observe(childB, { attributes: true });

// Perform mutation on each child
childA.setAttribute('foo', 'bar');
childB.setAttribute('foo', 'bar');

// [<div>, <span>]

The disconnect() method is a blunt tool in that it will disconnect all observed nodes:

let observer = new MutationObserver(
               (mutationRecords) => console.log(mutationRecords.map((x) => x.target)));

// Append two children to body
let childA = document.createElement('div'),
    childB = document.createElement('span');
document.body.appendChild(childA);
document.body.appendChild(childB);

// Observe both children
observer.observe(childA, { attributes: true });
observer.observe(childB, { attributes: true });

observer.disconnect();

// Perform mutation on each child
childA.setAttribute('foo', 'bar');
childB.setAttribute('foo', 'bar');

// (nothing logged)

Reusing a MutationObserver

Invoking disconnect() is not an end-of-life event for a MutationObserver. The same instance can be reattached to a node. The following example demonstrates this behavior by disconnecting and then reconnecting in two consecutive async blocks:

let observer = new MutationObserver(() => console.log('<body> attributes changed'));

observer.observe(document.body, { attributes: true });

// This will register as a mutation
document.body.setAttribute('foo', 'bar');

setTimeout(() => {
 observer.disconnect();

 // This will not register as a mutation
 document.body.setAttribute('bar', 'baz');
}, 0);


setTimeout(() => {
 // Reattach
 observer.observe(document.body, { attributes: true });

 // This will register as a mutation
 document.body.setAttribute('baz', 'qux');
}, 0);

// <body> attributes changed
// <body> attributes changed

Controlling the Observer scope with MutationObserverInit

The MutationObserverInit object is used to control which elements the observer should care about, and what kinds of changes to those elements it should care about. Broadly speaking, the observer can watch for attribute changes, text changes, or child node changes.

The following are the expected properties in the MutationObserverInit object:

KEY VALUE
subtree Boolean indicating if the target element's node subtree should be watched in addition to the target element.
When false, only the target element will be observed for designated mutations. When true, the target element and its entire node subtree will be watched for designated mutations.
Defaults to false.
attributes Boolean indicating if modifications to node attributes should register as a mutation.
Defaults to false.
attributeFilter Array of string values indicating which specific attributes should be observed for mutations.
Setting this value to true will also coerce the value of attributes to true.
Defaults to observing all attributes.
attributeOldValue Boolean indicating if the character data prior to mutation should be recorded in the MutationRecord.
Setting this value to an array will also coerce the value of attributes to true.
Defaults to false.
characterData Boolean indicating if modifications to character data should register as a mutation.
Defaults to false.
characterDataOldValue Boolean indicating if the character data prior to mutation should be recorded in the MutationRecord.
Setting this value to true will also coerce the value of characterData to true.
Defaults to false.
childList Boolean indicating if modifications to the target node's child nodes should register as a mutation.
Defaults to false.

Observing attribute mutations

A MutationObserver is capable of registering when a node attribute is added, removed, or changed. Registering a callback is accomplished by setting the attributes property inside the MutationObserverInit object to true, as demonstrated here:

let observer = new MutationObserver(
    (mutationRecords) => console.log(mutationRecords));

observer.observe(document.body, { attributes: true });

// Add attribute
document.body.setAttribute('foo', 'bar');

// Modify existing attribute
document.body.setAttribute('foo', 'baz');

// Remove attribute
document.body.removeAttribute('foo');

// All three are recorded as mutations
// [MutationRecord, MutationRecord, MutationRecord]

The default behavior is to observe all attribute changes and to not record the old value inside the MutationRecord. If you desired to observe a subset of attributes, the attributeFilter property can be used as a whitelist of attribute names:

let observer = new MutationObserver(
  (mutationRecords) => console.log(mutationRecords));

observer.observe(document.body, { attributeFilter: ['foo'] });

// Add whitelisted attribute
document.body.setAttribute('foo', 'bar');

// Add excluded attribute
document.body.setAttribute('baz', 'qux');

// Only a single mutation record is created for the 'foo' attribute mutation
// [MutationRecord]

If you desired to preserve the old value inside the mutation record, the attributeOldValue can be set to true:

let observer = new MutationObserver(
    (mutationRecords) => console.log(mutationRecords.map((x) => x.oldValue)));

observer.observe(document.body, { attributeOldValue: true });

document.body.setAttribute('foo', 'bar');
document.body.setAttribute('foo', 'baz'); 
document.body.setAttribute('foo', 'qux');

// Each mutation records the previous value
// [null, 'bar', 'baz']

Observing character data mutations

A MutationObserver is capable of registering when a textual node (such as Text, Comment, or ProcessingInstruction nodes) has its character data added, removed, or changed. This is accomplished by setting the characterData property inside the MutationObserverInit object to true, as demonstrated here:

let observer = new MutationObserver(
    (mutationRecords) => console.log(mutationRecords));

// Create an initial text node to observe
document.body.innerText = 'foo';

observer.observe(document.body.firstChild, { characterData: true });

// Identical string assignment
document.body.innerText = 'foo';

// New string assignment
document.body.innerText = 'bar';

// Node setter assignment
document.body.firstChild.textContent = 'baz';

// All three are recorded as mutations
// [MutationRecord, MutationRecord, MutationRecord]

The default behavior is to not record the old value inside the MutationRecord. If you desired to preserve the old value inside the mutation record, the attributeOldValue can be set to true:

let observer = new MutationObserver(
    (mutationRecords) => console.log(mutationRecords.map((x) => x.oldValue)));
document.body.innerText = 'foo';

observer.observe(document.body.firstChild, { characterDataOldValue: true });

document.body.innerText = 'foo';
document.body.innerText = 'bar';
document.body.firstChild.textContent = 'baz';

// Each mutation records the previous value
// ["foo", "foo", "bar"]

Observing child mutations

A MutationObserver is capable of registering when an element has a child node added or removed. This is accomplished by setting the childList property inside the MutationObserverInit object to true.

A child node addition is demonstrated here:

// clear body
document.body.innerHTML = '';

let observer = new MutationObserver(
    (mutationRecords) => console.log(mutationRecords));

observer.observe(document.body, { childList: true });

document.body.appendChild(document.createElement('div'));

// [
//  {
//    addedNodes: NodeList[div],
//    attributeName: null, 
//    attributeNamespace: null,
//    oldValue: null, 
//    nextSibling: null,
//    previousSibling: null,
//    removedNodes: NodeList[],
//    target: body,
//    type: "childList",
//  }
// ]

A child node removal is demonstrated here:

// clear body
document.body.innerHTML = '';

let observer = new MutationObserver(
  (mutationRecords) => console.log(mutationRecords));

observer.observe(document.body, { childList: true });

document.body.appendChild(document.createElement('div'));

// [
//  {
//    addedNodes: NodeList[],
//    attributeName: null, 
//    attributeNamespace: null,
//    oldValue: null, 
//    nextSibling: null,
//    previousSibling: null,
//    removedNodes: NodeList[div],
//    target: body,
//    type: "childList",
//  }
// ]

A child reordering, although it can be performed with a single method, will register as two separate mutations since it is technically a node removal and subsequent re-addition:

// clear body
document.body.innerHTML = '';

let observer = new MutationObserver(
  (mutationRecords) => console.log(mutationRecords));

// Create two initial children
document.body.appendChild(document.createElement('div'));
document.body.appendChild(document.createElement('span'));

observer.observe(document.body, { childList: true });

// Reorder children
document.body.insertBefore(document.body.lastChild, document.body.firstChild);

// Two mutations are registered: 0-index record is removal, 1-index record is addition
// [
//  {
//    addedNodes: NodeList[],
//    attributeName: null, 
//    attributeNamespace: null,
//    oldValue: null, 
//    nextSibling: null,
//    previousSibling: div,
//    removedNodes: NodeList[span],
//    target: body,
//    type: childList,
//  },
//  {
//    addedNodes: NodeList[span],
//    attributeName: null, 
//    attributeNamespace: null,
//    oldValue: null, 
//    nextSibling: div,
//    previousSibling: null,
//    removedNodes: NodeList[],
//    target: body,
//    type: "childList",
//  }
// ] 

Observing subtree mutations

By default, the MutationObserver is scoped to only observe modifications to a single element and its child node list. This scope can be expanded to the entirety of its DOM subtree by setting the subtree property inside the MutationObserverInit object to true.

Watching a subtree for attribute mutations can be accomplished in the following fashion:

// clear body
document.body.innerHTML = '';

let observer = new MutationObserver(
  (mutationRecords) => console.log(mutationRecords));

// Create initial element
document.body.appendChild(document.createElement('div'));

// Observe the <body> subtree
observer.observe(document.body, { attributes: true, subtree: true });

// Modify <body> subtree
document.body.firstChild.setAttribute('foo', 'bar');

// Subtree modification registers as mutation
// [
//  {
//    addedNodes: NodeList[],
//    attributeName: "foo", 
//    attributeNamespace: null,
//    oldValue: null, 
//    nextSibling: null,
//    previousSibling: null,
//    removedNodes: NodeList[],
//    target: div,
//    type: "attributes",
//  }
// ] 

Interestingly, the subtree designation of a node will persist even when that node is moved out of the observed tree. This means that after a subtree node leaves that specific subtree, mutations which are now technically outside the observed subtree will still register as qualified mutations.

This behavior is demonstrated here:

// clear body
document.body.innerHTML = '';

let observer = new MutationObserver(
    (mutationRecords) => console.log(mutationRecords));

let subtreeRoot = document.createElement('div'),
    subtreeLeaf = document.createElement('span');

// Create initial subtree of height 2
document.body.appendChild(subtreeRoot);
subtreeRoot.appendChild(subtreeLeaf);

// Observe the subtree
observer.observe(subtreeRoot, { attributes: true, subtree: true });

// Move node in subtree outside of observed subtree
document.body.insertBefore(subtreeLeaf, subtreeRoot);

subtreeLeaf.setAttribute('foo', 'bar');

// subtree modification still registers as mutation
// [MutationRecord] 

Async Callbacks and the Record Queue

The MutationObserver specification is designed for performance, and at the core of its design is the asynchronous callback and record queue model. To allow for a large number of mutations to be registered without degrading performance, information about each qualifying mutation (determined by the observer instance) is captured in a MutationRecord and then enqueued on a record queue. This queue is unique to each MutationObserver instance and represents an in-order record of each DOM mutation.

Behavior of the Record Queue

Each time a MutationRecord is added to a MutationObserver's record queue, the observer callback (initially provided to the MutationObserver constructor) is scheduled as a microtask only if there is no callback microtask already scheduled, for example the queue length is > 0. This ensures there is no dual-callback processing of the record queue's contents.

It is possible that, by the time the callback's microtask asynchronously executes, more mutations have occurred than the one which initially scheduled the callback microtask. The invoked callback is passed an array of MutationRecord instances as they appear in the record queue. The callback is responsible for fully handling each instance in the array, as they will not persist after the function exits. Following the callback execution, it is expected that each MutationRecord is no longer needed, so the record queue is emptied and its contents discarded.

The takeRecords() method

It is possible to drain a MutationObserver instance's record queue with the takeRecords() method. This will return the array of MutationRecord instances which exist in the queue, and empty the queue itself. This is demonstrated here:

let observer = new MutationObserver(
    (mutationRecords) => console.log(mutationRecords));

observer.observe(document.body, { attributes: true });

document.body.className = 'foo';
document.body.className = 'bar';
document.body.className = 'baz';

console.log(observer.takeRecords());
console.log(observer.takeRecords());

// [MutationRecord, MutationRecord, MutationRecord]
// []

This is useful when you would like to call disconnect() but wish to handle all pending MutationRecord instances in the queue which are discarded by calling disconnect().

Performance, Memory, and Garbage Collection

The MutationEvent, introduced in the DOM Level 2 specification, defined a handful of events which were fired upon various DOM mutations. In implementation, because of the nature of events in the browser, this specification led to substantial performance problems, and in the DOM Level 3 specification these events were deprecated. The MutationObserver was introduced to replace these with a more pragmatic and performant design.

Delegating execution of mutation callbacks to a microtask ensures that the synchronous nature of events and the messiness that accompanies them was avoided. The record queue implementation for the MutationObserver specification ensures that even a preponderance of mutation events will not unduly bog down the browser.

Nevertheless, using a MutationObserver will still incur some overhead, and it is important to understand where this overhead will manifest.

MutationObserver References

The reference relationship between a MutationObserver and the node (or nodes) it observes is asymmetric. A MutationObserver has a weak reference to the target node it is observing. Because this reference is weak, it will not prevent the target node from being garbage collected.

However, a node has a strong reference to its MutationObserver. If the target node is removed from the DOM and subsequently garbage collected, the associated MutationObserver is also garbage collected.

MutationRecord References

Each MutationRecord instance in the record queue will contain at least one reference to an existing DOM node; in the case of a childList mutation, it will contain many references. The default behavior of the record queue and callback processing is to drain the queue, process each MutationRecord, and allow them to go out of scope and be garbage collected.

There may be a situation where it is useful to preserve a full record of mutations from a given observer. Preserving each MutationRecord instance will also preserve the node references it contains, thereby preventing the nodes from being garbage collected. If prompt node garbage collection is needed, prefer to extract the minimum required information from each MutationRecord into a new object, and discard the MutationRecord.

SUMMARY

The Document Object Model (DOM) is a language-independent API for accessing and manipulating HTML and XML documents. DOM Level 1 deals with representing HTML and XML documents as a hierarchy of nodes that can be manipulated to change the appearance and structure of the underlying documents using JavaScript.

The DOM is made up of a series of node types, as described here:

  • The base node type is Node, which is an abstract representation of an individual part of a document; all other types inherit from Node.
  • The Document type represents an entire document and is the root node of a hierarchy. In JavaScript, the document object is an instance of Document, which allows for querying and retrieval of nodes in a number of different ways.
  • An Element node represents all HTML or XML elements in a document and can be used to manipulate their contents and attributes.
  • Other node types exist for text contents, comments, document types, the CDATA section, and document fragments.

DOM access works as expected in most cases, although there are often complications when working with <script> and <style> elements. Because these elements contain scripting and stylistic information, respectively, they are often treated differently in browsers than other elements.

Perhaps the most important thing to understand about the DOM is how it affects overall performance. DOM manipulations are some of the most expensive operations that can be done in JavaScript, with NodeList objects being particularly troublesome. NodeList objects are “live,” meaning that a query is run every time the object is accessed. Because of these issues, it is best to minimize the number of DOM manipulations.

The MutationObserver was introduced to replace the less-performant MutationEvent. It allows for efficient and precise DOM mutation monitoring with a relatively simple API.

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

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