Jumat, 29 November 2024

Document Object Model Prototypes, Part 2: Accessor (getter/setter) Support

 

Document Object Model Prototypes, Part 2: Accessor (getter/setter) Support

Travis Leithead
Microsoft Corporation

November 1, 2008

Contents

Introduction

This article is the second installment of a two-part series that introduces advanced JavaScript techniques in Windows Internet Explorer 8. This part of the series continues the introduction of Document Object Model (DOM) prototypes in Internet Explorer 8 by describing accessor properties.

An accessor property, also called a getter/setter property is a new type of JavaScript property available in Internet Explorer 8. By using accessor properties, Web developers can create or customize dynamic data-like properties that execute JavaScript code when their values are accessed or modified. The DOM prototype hierarchy introduced in the previous article defines all of its properties as built-in accessors. Web developers can also customize DOM built-in accessors to fine-tune the default behavior of the DOM. This article explains the new accessor property (or getter/setter property) syntax, provides a usage overview, and uses scenarios to demonstrate this property's value.

Two Kinds of Properties: Data and Accessor Properties

It is common practice among Web developers to add custom properties to the Document Object Model (DOM). This existing object extensibility allows added properties to save state, track application status, and so on. Prior to Internet Explorer 8, JavaScript supported only one type of property: one that could store and retrieve a value (ECMAScript 3.1 refers to these as "data properties," while other languages use terms such as "field" and "instance variable" to refer to this concept). In terms of implementation, these existing properties have one "variable slot" that holds a value. Data properties are automatically defined when the assignment operator (=) is used in JavaScript, as shown in this example:

document.data = 5; // Creates a data property named "data"
console.log( document.data ); // Answer: 5 (you guessed it!)

The following figure illustrates the relationship between getter and setter accessors.

Visualizing JavaScript data properties
Figure 1: Visualizing JavaScript data properties

Prior to Internet Explorer 8, JavaScript developers could use only data properties in their code, yet it was clear that some built-in properties in JavaScript and the DOM were not data properties. For example, the built-in DOM property "innerHTML" does much more than simply store a value:

// Access the innerHTML property:
// (gets the sub-element tree as a string)
var str = document.getElementById('element1').innerHTML;
// Assign a string to the innerHTML property:
// (Causes the DOM to parse the string and create a
// new sub-element tree under 'element1')
document.getElementById('element2').innerHTML = str;
// Getting (accessing) and setting (assigning to) yield different behavior:
// (The same API "stringifies" and "parses" depending on if it is read or written to.)

Web developers cannot create similar properties (such as innerHTML) that mimic built-in DOM properties without new functionality in the JavaScript language. This functionality gap widens because many Web developers would like to extend and enhance the built-in properties already available in the DOM. To support this needed behavior, the JavaScript language has added "getter/setter" properties. For the purpose of brevity in this article, I will call getter/setter properties by their ECMAScript 3.1 name of "accessor" properties.

Instead of just storing or retrieving a value, accessor properties call a user-provided function each time they are set or retrieved. Unlike normal properties, accessor properties do not include an implicit "slot" to store a value. Instead, the accessor property itself stores a "getter" (a function that is executed when the property is retrieved) or a "setter" (a function that is executed when the property is assigned a value). The following figure depicts a mental model of an accessor property:

Visualizing JavaScript accessor properties
Figure 2: Visualizing JavaScript accessor properties

Syntax

A special syntax is necessary to define an accessor property because the assignment operator (=) defines a data property by default. Internet Explorer 8 is the first browser to adopt the ECMAScript 3.1 syntax for defining accessor properties:

Object.defineProperty(  [(DOM object) object],
                        [(string)     property name],
                        [(descriptor) property definition] );
All parameters are required.
Return value: the first parameter (object) passed to the function.

A new syntax is also needed to retrieve an accessor property's definition (the getter or setter functions themselves) because simply reading an accessor property will invoke its getter function:

Object.getOwnPropertyDescriptor( [(DOM object) object],
                                 [(string)     property name] );
All parameters are required.
return value: A "property descriptor" object

Note the following restrictions:

  • Both of these new APIs are defined only on the JavaScript global "Object" constructor.

  • The first parameter (the object on which to attach the accessor) supports only DOM instances, interface objects and interface prototype objects in Internet Explorer 8; for more information, see Part 1 of this series. We plan to expand accessor support for custom and built-in JavaScript objects, constructors, and prototypes in a future release.

The following example demonstrates the defineProperty API by defining an accessor called "JSONposition" for an image. The "getter" for this new property converts the image's coordinates into a JSON string. The "setter" reads the JSON string and modifies the image's position accordingly:

// Create a property descriptor object
var posPropDesc = new Object();
// Define the getter
posPropDesc.get = function ()
{
  var coords = new Object();
  coords.x = parseInt(this.currentStyle.left);
  coords.y = parseInt(this.currentStyle.top);
  coords.w = parseInt(this.currentStyle.width);
  coords.h = parseInt(this.currentStyle.height);
  return JSON.stringify(coords);
}
// Define the setter
posPropDesc.set = function (JSONString)
{
  var coords = JSON.parse(JSONString);
  if (coords.x) this.style.left   = coords.x + "px";
  if (coords.y) this.style.top    = coords.y + "px";
  if (coords.w) this.style.width  = coords.w + "px";
  if (coords.h) this.style.height = coords.h + "px";
}
// Define the new accessor property "JSONposition" on a new image
var img = Object.defineProperty(new Image(), "JSONposition", posPropDesc);
img.src = "...";
// Call the new property
img.JSONposition = '{"w":400,"h":100}';
// Read the image's current position
console.log(img.JSONposition);

In this example, defineProperty creates a new accessor property on an image (first parameter) with the name "JSONposition"; the third parameter is an object called a property descriptor that defines the new property's behavior.

Property descriptors are a generic way of describing both the property "type" and its "attributes." As the previous example illustrates, the "getter" and "setter" are two of the possible properties in a property descriptor. Adding either of these properties to a property descriptor will cause defineProperty to create an accessor property. When a getter is not specified, accessing the property value returns the value undefined. Similarly, when a setter is not specified, assigning a value to the accessor does nothing. This is illustrated in the following table:

 Getter function onlySetter function onlyBoth functions
Property Access ("get")Invoke the getter functionReturn undefinedInvoke the getter function
Property Assignment ("set")No operationInvoke the setter functionInvoke the setter function
Table 1: Possible access and assignment results for combinations of getter or setter functions on a JavaScript accessor property

Accessor properties can also be incrementally defined using multiple calls to the defineProperty API. For example, one call to defineProperty might define only a getter. Later, defineProperty might be called again on the same property name to define a setter. At this point, the property has both a getter and setter:

Object.defineProperty(window, "prop",
   { get: function() { return "Can get"; } } );
// ...
Object.defineProperty(window, "prop",
   { set: function(x) { console.log("Can set " + x); } } );
// Now both getter and setter are defined for the property

Similarly, defining either a getter or setter to be undefined essentially unsets whatever getter or setter was previously in place:

Object.defineProperty( document.body, "secondChild",
{
  get: function ()
  {
    return this.firstChild.nextSibling;
  },
  set: function ( element )
  {
    throw new Error("Sorry! This property can't be " +
                    "set. Better luck next time.");
  }
} );
// Changed my mind: don't be so strict about throwing
// an error when setting this property...
Object.defineProperty( document.body, "secondChild",
                       { set: undefined } );

Another keyword that is possible in a property descriptor is the "value" keyword; "value" signals the creation of a data property:

console.log( Object.defineProperty(
   document, "data", { value: 5 } ).data );

This code is exactly equivalent to the data property created in the first code sample in this article. Note that if a property descriptor contains a combination of "value" and "get/set" keywords, then the defineProperty API will return an error.

Property descriptors may also include additional keywords to control the "attributes" of the property. These are reserved for future use; currently Internet Explorer 8 supports only the following attribute keyword values:

Property type"Writeable" attribute"Configurable" attribute"Enumeratable" attribute
data propertytruetruetrue
accessor propertyN/Atruefalse
Table 2: Valid values for the writable, configurable, and enumerable property descriptor attributes on both a data and accessor property

If you get the combination wrong, the defineProperty API will return an error, as shown in the following code sample.

try
{
  Object.defineProperty(document, "test",
  {
    get: function()
    {
      return 'This is just a test';
    },
    configurable: false;
  } );
}
catch(e)
{
  console.log(e.message);
  // 'configurable' attribute on the property
  // descriptor cannot be set to 'false' on this object
}

To remove an accessor or data property, simply delete it from the object to which it was defined:

delete document.test;
delete document.data;

Accessor Properties and DOM Prototypes

Accessor properties together with the DOM prototype hierarchy complete the scenario of allowing Web developers to have full customization of built-in DOM properties. Accessor properties are the means to customize the DOM's built-in functionality using user-defined JavaScript functionality; the DOM prototype hierarchy is the means for "scoping" the extent of these customizations.

DOM built-in properties are defined on interface prototype objects. As described in Part I, these objects are arranged into a hierarchy; all DOM instances inherit the properties defined at each level in their prototype chain.

One of those built-in properties and a prime target for customization is the innerHTML property.

Prototype chain for a Div instance
Figure 3: Prototype chain for a div instance

With this view of the DOM prototype hierarchy, the Web developer can choose to customize the built-in innerHTML property itself, or override that property on a lower level in this hierarchy. To customize the built-in property, use the defineProperty API, passing in the Element.prototype object as the first parameter, the "innerHTML" string as the second, and the getter or setter functions as part of the property descriptor:

// Customize the built-in innerHTML property
Object.defineProperty(Element.prototype, "innerHTML", /* property descriptor */);

In most cases, the Web developer's objective will not be to simply replace the innerHTML property with new functionality, but rather supplement the existing functionality of innerHTML. In these cases, it is important to be able to invoke the original behavior of innerHTML from within the new getter or setter code. Do this by caching the original accessor's property descriptor (before customizing it):

// Save (cache) the original behavior of innerHTML
var originalInnerHTMLpropDesc = Object.getOwnPropertyDescriptor(Element.prototype, "innerHTML");
// Define my customizations, but use innerHTML when done...
Object.defineProperty(Element.prototype, "innerHTML",
{
  set: function ( htmlContent )
  {
    // TODO: add new innerHTML getter code
    // Call original innerHTML when done...
    originalInnerHTMLpropDesc.set.call(this, htmlContent);
  }
}

At this point, the setter for innerHTML has been customized for all DOM element instances. The getter for innerHTML continues to work as before.

Perhaps the goal of the Web developer is to customize innerHTML for only a subset of element types—only DIV elements, for example. By defining innerHTML at the HTMLDivElement.prototype level, the Web developer overrides the built-in innerHTML property (for DIV element instances only) because the override is found first when JavaScript visits a DIV element's prototype chain:

// Customize innerHTML for DIV Elements only
// (other element types are unaffected)
Object.defineProperty(HTMLDivElement.prototype,
   "innerHTML", /* property descriptor */);

A property override blocks the inheritance of any getter or setter functions from a same-named property at a higher level in the prototype chain. For example, if the property descriptor in the previous sample code contained only the definition for a getter, the setter for this override would be undefined; it would not inherit the setter from higher in the prototype chain.

Finally, when the Web developer wants to apply an override only to a DOM instance, use the defineProperty API with the instance directly:

// Create a div element instance
// with a customized innerHTML property
var div = Object.defineProperty(document.createElement('DIV'),
   "innerHTML",  /* property descriptor */);

As described, accessor properties used together with DOM prototypes create very powerful scenarios. To be most effective, the Web developer should understand the Internet Explorer 8 DOM prototype hierarchy to learn what interface prototype objects define which properties.

Special Case: Deleting Built-in DOM Properties

In the previous section, I concluded by describing how the JavaScript delete operator will remove any accessor or data property. In some cases this may not appear to work. This is because the Internet Explorer 8 interface prototype objects first inherit from internal prototypes (unavailable to JavaScript) that implement internal versions of the same properties available on the "public" prototypes. This implementation detail is evident only when deleting built-in DOM properties from their prototype objects. The prototype hierarchy in the following figure depicts this implementation detail: deleting the innerHTML property from Element.prototype causes the internal innerHTML property to be inherited. This has the appearance of "restoring" (by inheritance) a built-in property to its default state.

Prototype chain for a div instance with implementation-specific internal prototypes
Figure 4: Prototype chain for a div instance with implementation-specific internal prototypes

Powerful Scenarios

To demonstrate the capabilities of accessor properties in conjunction with DOM prototypes, consider two potential scenarios: in the first scenario, a Web page provides a mechanism for a user to make document annotations (such as for online document review and collaboration) and then injects those annotations into the Web page using innerHTML. In this scenario, the Web page has two criteria: the first is ensuring that the injected content is safe by using toStaticHTML; the second is removing certain stylistic elements and attributes of the HTML user-input to prevent layout problems on the page. To simplify this two-step process, the Web page replaces the default functionality of innerHTML with the following custom code (shortened for brevity):

// Save a copy of the built-in property
var innerHTMLdescriptor = Object.getOwnPropertyDescriptor(Element.prototype, 'innerHTML');
// Define the new filter, which makes arbitrary HTML safe and then strips fancy formatting
Object.defineProperty(Element.prototype, 'innerHTML',
  {
    set: function(htmlVal)
      {
        var safeHTML = toStaticHTML(htmlVal);
        // TODO: Code which filters out style attributes + removes stylistic tags from safeHTML
        // Invoke the built-in innerHTML behavior when done.
        innerHTMLdescriptor.set.call(this, safeHTML);
      }
  });

In the second scenario, a framework Web developer defines a new method to make Internet Explorer 8 more cross-browser compatible. Many JavaScript frameworks currently implement custom code to handle cross-browser incompatibilities or to implement abstractions that do the same. In this example, a Web developer adds an addEventListener API to Internet Explorer 8 (addEventListener is part of the W3C DOM L2 Events standard). Note that this example applies the new API at the appropriate places in the DOM prototype hierarchy to prevent the need for a separate abstraction layer of JavaScript code for consumers of the Web developer's framework to learn (code abbreviated for brevity):

// Apply addEventListener to all the prototypes where it should be available.
HTMLDocument.prototype.addEventListener =
Element.prototype.addEventListener =
Window.prototype.addEventListener = function (type, fCallback, capture)
{
  var modtypeForIE = "on" + type;
  if (capture)
  {
    throw new Error("This implementation of addEventListener does not support the capture phase");
  }
  var nodeWithListener = this;
  this.attachEvent(modtypeForIE, function (e) {
    // Add some extensions directly to 'e' (the actual event instance)
    // Create the 'currentTarget' property (read-only)
    Object.defineProperty(e, 'currentTarget', {
      get: function() {
         // 'nodeWithListener' as defined at the time the listener was added.
         return nodeWithListener;
      }
    });
    // Create the 'eventPhase' property (read-only)
    Object.defineProperty(e, 'eventPhase', {
      get: function() {
        return (e.srcElement == nodeWithListener) ? 2 : 3; // "AT_TARGET" = 2, "BUBBLING_PHASE" = 3
      }
    });
    // Create a 'timeStamp' (a read-only Date object)
    var time = new Date(); // The current time when this anonymous function is called.
    Object.defineProperty(e, 'timeStamp', {
      get: function() {
        return time;
      }
    });
    // Call the function handler callback originally provided...
    fCallback.call(nodeWithListener, e); // Re-bases 'this' to be correct for the callback.
  });
}

// Extend Event.prototype with a few of the W3C standard APIs on Event
// Add 'target' object (read-only)
Object.defineProperty(Event.prototype, 'target', {
  get: function() {
    return this.srcElement;
  }
});
// Add 'stopPropagation' and 'preventDefault' methods
Event.prototype.stopPropagation = function () {
  this.cancelBubble = true;
};
Event.prototype.preventDefault = function () {
  this.returnValue = false;
};

Relationship to Standards

Standards are an important factor to ensure browser interoperability for the Web developer. The accessor property syntax has only recently begun standardization. As such, many browsers support an older, legacy syntax:

// Legacy version of Object.defineProperty(document, "test",
// { getter: /*...*/, setter: /*...*/ } );
document.__defineGetter__("test", /* getter function */ );
document.__defineSetter__("test", /* setter function */ );

// Legacy version of Object.getOwnPropertyDescriptor(document, "test");
document.__lookupGetter__("test");
document.__lookupSetter__("test");

One important difference to note in the behavior of __lookupGetter__/__lookupSetter__ is that these APIs visit the prototype chain of the given object to find the getter or setter functions, respectively, while getOwnPropertyDescriptor, as its name implies, checks only the object's "own" properties.

Until other browsers can support the standard accessor property syntax, we recommend using feature-level detection to handle browser interoperability issues (including checking the Internet Explorer 8 restriction of DOM objects only):

if (Object.defineProperty)
{
  // Use the standards-based syntax
  var DOMonly = false;
  try
  {
    Object.defineProperty(new Object(), "test", {get:function(){return true;}});
  }
  catch(e)
  {
    DOMonly = true;
  }
}
else if (document.__defineGetter__)
{
  // Use the legacy syntax
}
else
{
  //neither defineProperty or __defineGetter__ supported
}

Restricted Properties

Some built-in DOM properties provide important information to Web applications that help them make security decisions, gather analytics, or provide customized functionality. For these reasons, the following properties cannot be replaced by using Object.defineProperty:

  • location.hash
  • location.host
  • location.hostname
  • location.href
  • location.search
  • document.domain
  • document.referrer
  • document.URL
  • navigator.userAgent
  • [properties of window]

Summary

Accessor properties (also known as getter/setter properties), give Web developers the power to create and customize built-in properties available in the DOM. This article has introduced the accessor property syntax, provided an overview of how to use these properties, and shown how they work together with the DOM prototype hierarchy to complete many Web developers' scenarios.

Tidak ada komentar: