Sridatta Thatipamala Sridatta Thatipamala - 7 months ago 94
Javascript Question

How to watch for array changes?

In Javascript, is there a way to be notified when an array is modified using push, pop, shift or index-based assignment? I want something that would fire an event that I could handle.

I know about the watch() functionality in SpiderMonkey, but that only works when the entire variable is set to something else.

Answer

There are a few options...

1. Override the push method

Going the quick and dirty route, you could override Array.prototype.push() so that it raises your event:

Object.defineProperty(Array.prototype, "push", {
  configurable: false,
  enumerable: false, // hide from for...in
  writable: false,
  value: function () {
    for (var i = 0, n = this.length, l = arguments.length; i < l; i++, n++) {          
      RaiseMyEvent(this, n, this[n] = arguments[i]); // assign/raise your event
    }
    return n;
  }
});

Of course, that would affect every array... so, you could just override the push() method for your particular array instead:

Object.defineProperty(myArray, "push", {
  configurable: false,
  enumerable: false, // hide from for...in
  writable: false,
  value: function () {
    for (var i = 0, n = this.length, l = arguments.length; i < l; i++, n++) {          
      RaiseMyEvent(this, n, this[n] = arguments[i]); // assign/raise your event
    }
    return n;
  }
});

2. Create a custom observable array

Rather than overriding methods, you could create your own observable array. This particular implementation copies an array into a new array-like object and provides custom push(), pop(), shift(), unshift(), slice(), and splice() methods as well as custom index accessors (provided that the array size is only modified via one of the aforementioned methods or the length property).

function ObservableArray(items) {
  var _self = this,
    _array = [],
    _handlers = {
      itemadded: [],
      itemremoved: [],
      itemset: []
    };

  function defineIndexProperty(index) {
    if (!(index in _self)) {
      Object.defineProperty(_self, index, {
        configurable: true,
        enumerable: true,
        get: function() {
          return _array[index];
        },
        set: function(v) {
          _array[index] = v;
          raiseEvent({
            type: "itemset",
            index: index,
            item: v
          });
        }
      });
    }
  }

  function raiseEvent(event) {
    _handlers[event.type].forEach(function(h) {
      h.call(_self, event);
    });
  }

  Object.defineProperty(_self, "addEventListener", {
    configurable: false,
    enumerable: false,
    writable: false,
    value: function(eventName, handler) {
      eventName = ("" + eventName).toLowerCase();
      if (!(eventName in _handlers)) throw new Error("Invalid event name.");
      if (typeof handler !== "function") throw new Error("Invalid handler.");
      _handlers[eventName].push(handler);
    }
  });

  Object.defineProperty(_self, "removeEventListener", {
    configurable: false,
    enumerable: false,
    writable: false,
    value: function(eventName, handler) {
      eventName = ("" + eventName).toLowerCase();
      if (!(eventName in _handlers)) throw new Error("Invalid event name.");
      if (typeof handler !== "function") throw new Error("Invalid handler.");
      var h = _handlers[eventName];
      var ln = h.length;
      while (--ln >= 0) {
        if (h[ln] === handler) {
          h.splice(ln, 1);
        }
      }
    }
  });

  Object.defineProperty(_self, "push", {
    configurable: false,
    enumerable: false,
    writable: false,
    value: function() {
      var index;
      for (var i = 0, ln = arguments.length; i < ln; i++) {
        index = _array.length;
        _array.push(arguments[i]);
        defineIndexProperty(index);
        raiseEvent({
          type: "itemadded",
          index: index,
          item: arguments[i]
        });
      }
      return _array.length;
    }
  });

  Object.defineProperty(_self, "pop", {
    configurable: false,
    enumerable: false,
    writable: false,
    value: function() {
      if (_array.length > -1) {
        var index = _array.length - 1,
          item = _array.pop();
        delete _self[index];
        raiseEvent({
          type: "itemremoved",
          index: index,
          item: item
        });
        return item;
      }
    }
  });

  Object.defineProperty(_self, "unshift", {
    configurable: false,
    enumerable: false,
    writable: false,
    value: function() {
      for (var i = 0, ln = arguments.length; i < ln; i++) {
        _array.splice(i, 0, arguments[i]);
        defineIndexProperty(_array.length - 1);
        raiseEvent({
          type: "itemadded",
          index: i,
          item: arguments[i]
        });
      }
      for (; i < _array.length; i++) {
        raiseEvent({
          type: "itemset",
          index: i,
          item: _array[i]
        });
      }
      return _array.length;
    }
  });

  Object.defineProperty(_self, "shift", {
    configurable: false,
    enumerable: false,
    writable: false,
    value: function() {
      if (_array.length > -1) {
        var item = _array.shift();
        _array.length === 0 && delete _self[index];
        raiseEvent({
          type: "itemremoved",
          index: 0,
          item: item
        });
        return item;
      }
    }
  });

  Object.defineProperty(_self, "splice", {
    configurable: false,
    enumerable: false,
    writable: false,
    value: function(index, howMany /*, element1, element2, ... */ ) {
      var removed = [],
        item,
        pos;

      index = !~index ? _array.length - index : index;

      howMany = (howMany == null ? _array.length - index : howMany) || 0;

      while (howMany--) {
        item = _array.splice(index, 1)[0];
        removed.push(item);
        delete _self[_array.length];
        raiseEvent({
          type: "itemremoved",
          index: index + removed.length - 1,
          item: item
        });
      }

      for (var i = 2, ln = arguments.length; i < ln; i++) {
        _array.splice(index, 0, arguments[i]);
        defineIndexProperty(_array.length - 1);
        raiseEvent({
          type: "itemadded",
          index: i,
          item: arguments[i]
        });
        index++;
      }

      return removed;
    }
  });

  Object.defineProperty(_self, "length", {
    configurable: false,
    enumerable: false,
    get: function() {
      return _array.length;
    },
    set: function(value) {
      var n = Number(value);
      if (n % 1 === 0 && n >= 0) {
        if (n < _array.length) {
          _self.splice(n);
        } else if (n > _array.length) {
          _self.push.apply(_self, new Array(n - _array.length));
        }
      } else {
        throw new RangeError("Invalid array length");
      }
      return value;
    }
  });

  Object.getOwnPropertyNames(Array.prototype).forEach(function(name) {
    if (!(name in _self)) {
      Object.defineProperty(_self, name, {
        configurable: false,
        enumerable: false,
        writable: false,
        value: Array.prototype[name]
      });
    }
  });

  if (items instanceof Array) {
    _self.push.apply(_self, items);
  }
}

(function testing() {

  var x = new ObservableArray(["a", "b", "c", "d"]);

  console.log("original array: %o", x.slice());

  x.addEventListener("itemadded", function(e) {
    console.log("Added %o at index %d.", e.item, e.index);
  });

  x.addEventListener("itemset", function(e) {
    console.log("Set index %d to %o.", e.index, e.item);
  });

  x.addEventListener("itemremoved", function(e) {
    console.log("Removed %o at index %d.", e.item, e.index);
  });

  console.log("popping and unshifting...");
  x.unshift(x.pop());

  console.log("updated array: %o", x.slice());

  console.log("reversing array...");
  console.log("updated array: %o", x.reverse().slice());

  console.log("splicing...");
  x.splice(1, 2, "x");
  console.log("setting index 2...");
  x[2] = "foo";

  console.log("setting length to 10...");
  x.length = 10;
  console.log("updated array: %o", x.slice());

  console.log("setting length to 2...");
  x.length = 2;
  console.log("updated array: %o", x.slice());

})();
<!-- console visualization; see http://meta.stackexchange.com/a/242491 -->
<script src="http://gh-canon.github.io/stack-snippet-console/console.min.js"></script>

See Object.defineProperty() for reference.

3. Proxies

In the future1, proxies may offer another solution... allowing you to intercept method calls, accessors, etc. Most importantly, you can do this without even providing an explicit property name... which would allow you to test for an arbitrary, index-based access/assignment. You can even intercept property deletion. Proxies would effectively allow you to inspect a change before deciding to allow it... in addition to handling the change after the fact.

1 Browser support is pretty sparse as of this writing.

4. Array.observe()

Update 2016-04-09: It looks like Array.observe() and Object.observe() were dropped in lieu of proxies. I'll leave the following information for now... but you should really look at proxies instead.

Other options for the future are the methods Array.observe() and Object.observe(). While they don't offer the total control of proxies, they do precisely what you're looking for.

The Array.observe() method is used for asynchronously observing changes to Arrays, similar to Object.observe() for objects. It provides a stream of changes in order of occurrence. It's equivalent to Object.observe() invoked with the accept type list ["add", "update", "delete", "splice"].

var myArray = ["a", "b", "c"];

// subscribe to add, update, delete, and splice changes
Array.observe(myArray, function(changes) {
  
  // handle changes... in this case, we'll just log them 
  changes.forEach(function(change) {
    console.log(Object.keys(change).reduce(function(p, c) {
      if (c !== "object" && c in change) {
        p.push(c + ": " + JSON.stringify(change[c]));
      }
      return p;
    }, []).join(", "));
  });
  
});

// down-shift the entire array and delete the last index
myArray.shift();

// set a value at a specific index
myArray[0] = "1";

// add a value
myArray.push("2");

// truncate array
myArray.length = 0;
<!-- console visualization; see http://meta.stackexchange.com/a/242491 -->
<script src="http://gh-canon.github.io/stack-snippet-console/console.min.js"></script>

Two caveats to be aware of:

  1. At the moment, browser support is pretty slim.
  2. The notifications are asynchronous.
Comments