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.
There are a few options...
1. Override the push method
Going the quick and dirty route, you could override the push()
method for your array1:
Object.defineProperty(myArray, "push", {
// hide from for..in and prevent further overrides (via default descriptor values)
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;
}
});
1 Alternatively, if you'd like to target all arrays, you could override Array.prototype.push()
. Use caution, though; other code in your environment may not like or expect that kind of modification. Still, if a catch-all sounds appealing, just replace myArray
with Array.prototype
.
Now, that's just one method and there are lots of ways to change array content. We probably need something more comprehensive...
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();
delete _self[_array.length];
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 == null ? 0 : index < 0 ? _array.length + index : index;
howMany = howMany == null ? _array.length - index : howMany > 0 ? 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: index,
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);
var length = _array.length;
if (n % 1 === 0 && n >= 0) {
if (n < length) {
_self.splice(n);
} else if (n > length) {
_self.push.apply(_self, new Array(n - length));
}
} else {
throw new RangeError("Invalid array length");
}
_array.length = n;
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("extracting first element via shift()");
x.shift();
console.log("updated array: %o", x.slice());
})();
See Object.defineProperty()
for reference.
That gets us closer but it's still not bullet proof... which brings us to:
3. Proxies
A Proxy object offers another solution to the modern browser. It allows 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.
Here's a stripped down sample:
(function() {
if (!("Proxy" in window)) {
console.warn("Your browser doesn't support Proxies.");
return;
}
// our backing array
var array = ["a", "b", "c", "d"];
// a proxy for our array
var proxy = new Proxy(array, {
deleteProperty: function(target, property) {
delete target[property];
console.log("Deleted %s", property);
return true;
},
set: function(target, property, value, receiver) {
target[property] = value;
console.log("Set %s to %o", property, value);
return true;
}
});
console.log("Set a specific index..");
proxy[0] = "x";
console.log("Add via push()...");
proxy.push("z");
console.log("Add/remove via splice()...");
proxy.splice(1, 3, "y");
console.log("Current state of array: %o", array);
})();
From reading all the answers here, I have assembled a simplified solution that does not require any external libraries.
It also illustrates much better the general idea for the approach:
function processQ() {
// ... this will be called on each .push
}
var myEventsQ = [];
myEventsQ.push = function() { Array.prototype.push.apply(this, arguments); processQ();};
I used the following code to listen to changes to an array.
/* @arr array you want to listen to
@callback function that will be called on any change inside array
*/
function listenChangesinArray(arr,callback){
// Add more methods here if you want to listen to them
['pop','push','reverse','shift','unshift','splice','sort'].forEach((m)=>{
arr[m] = function(){
var res = Array.prototype[m].apply(arr, arguments); // call normal behaviour
callback.apply(arr, arguments); // finally call the callback supplied
return res;
}
});
}
Hope this was useful :)
I found the following which seems to accomplish this: https://github.com/mennovanslooten/Observable-Arrays
Observable-Arrays extends underscore and can be used as follow: (from that page)
// For example, take any array:
var a = ['zero', 'one', 'two', 'trhee'];
// Add a generic observer function to that array:
_.observe(a, function() {
alert('something happened');
});
The most upvoted Override push method solution by @canon has some side-effects that were inconvenient in my case:
It makes the push property descriptor different (
writable
andconfigurable
should be settrue
instead offalse
), which causes exceptions in a later point.It raises the event multiple times when
push()
is called once with multiple arguments (such asmyArray.push("a", "b")
), which in my case was unnecessary and bad for performance.
So this is the best solution I could find that fixes the previous issues and is in my opinion cleaner/simpler/easier to understand.
Object.defineProperty(myArray, "push", {
configurable: true,
enumerable: false,
writable: true, // Previous values based on Object.getOwnPropertyDescriptor(Array.prototype, "push")
value: function (...args)
{
let result = Array.prototype.push.apply(this, args); // Original push() implementation based on https://github.com/vuejs/vue/blob/f2b476d4f4f685d84b4957e6c805740597945cde/src/core/observer/array.js and https://github.com/vuejs/vue/blob/daed1e73557d57df244ad8d46c9afff7208c9a2d/src/core/util/lang.js
RaiseMyEvent();
return result; // Original push() implementation
}
});
Please see comments for my sources and for hints on how to implement the other mutating functions apart from push: 'pop', 'shift', 'unshift', 'splice', 'sort', 'reverse'.
if (!Array.prototype.forEach)
{
Object.defineProperty(Array.prototype, 'forEach',
{
enumerable: false,
value: function(callback)
{
for(var index = 0; index != this.length; index++) { callback(this[index], index, this); }
}
});
}
if(Object.observe)
{
Object.defineProperty(Array.prototype, 'Observe',
{
set: function(callback)
{
Object.observe(this, function(changes)
{
changes.forEach(function(change)
{
if(change.type == 'update') { callback(); }
});
});
}
});
}
else
{
Object.defineProperties(Array.prototype,
{
onchange: { enumerable: false, writable: true, value: function() { } },
Observe:
{
set: function(callback)
{
Object.defineProperty(this, 'onchange', { enumerable: false, writable: true, value: callback });
}
}
});
var names = ['push', 'pop', 'reverse', 'shift', 'unshift'];
names.forEach(function(name)
{
if(!(name in Array.prototype)) { return; }
var pointer = Array.prototype[name];
Array.prototype[name] = function()
{
pointer.apply(this, arguments);
this.onchange();
}
});
}
var a = [1, 2, 3];
a.Observe = function() { console.log("Array changed!"); };
a.push(8);
Not sure if this covers absolutely everything, but I use something like this (especially when debugging) to detect when an array has an element added:
var array = [1,2,3,4];
array = new Proxy(array, {
set: function(target, key, value) {
if (Number.isInteger(Number(key)) || key === 'length') {
debugger; //or other code
}
target[key] = value;
return true;
}
});
An interesting collection library is https://github.com/mgesmundo/smart-collection. Allows you to watch arrays and add views to them as well. Not sure about the performance as I am testing it out myself. Will update this post soon.
I fiddled around and came up with this. The idea is that the object has all the Array.prototype methods defined, but executes them on a separate array object. This gives the ability to observe methods like shift(), pop() etc. Although some methods like concat() won't return the OArray object. Overloading those methods won't make the object observable if accessors are used. To achieve the latter, the accessors are defined for each index within given capacity.
Performance wise... OArray is around 10-25 times slower compared to the plain Array object. For the capasity in a range 1 - 100 the difference is 1x-3x.
class OArray {
constructor(capacity, observer) {
var Obj = {};
var Ref = []; // reference object to hold values and apply array methods
if (!observer) observer = function noop() {};
var propertyDescriptors = Object.getOwnPropertyDescriptors(Array.prototype);
Object.keys(propertyDescriptors).forEach(function(property) {
// the property will be binded to Obj, but applied on Ref!
var descriptor = propertyDescriptors[property];
var attributes = {
configurable: descriptor.configurable,
enumerable: descriptor.enumerable,
writable: descriptor.writable,
value: function() {
observer.call({});
return descriptor.value.apply(Ref, arguments);
}
};
// exception to length
if (property === 'length') {
delete attributes.value;
delete attributes.writable;
attributes.get = function() {
return Ref.length
};
attributes.set = function(length) {
Ref.length = length;
};
}
Object.defineProperty(Obj, property, attributes);
});
var indexerProperties = {};
for (var k = 0; k < capacity; k++) {
indexerProperties[k] = {
configurable: true,
get: (function() {
var _i = k;
return function() {
return Ref[_i];
}
})(),
set: (function() {
var _i = k;
return function(value) {
Ref[_i] = value;
observer.call({});
return true;
}
})()
};
}
Object.defineProperties(Obj, indexerProperties);
return Obj;
}
}
I wouldn't recommend you to extend native prototypes. Instead, you can use a library like new-list; https://github.com/azer/new-list
It creates a native JavaScript array and lets you subscribe to any change. It batches the updates and gives you the final diff;
List = require('new-list')
todo = List('Buy milk', 'Take shower')
todo.pop()
todo.push('Cook Dinner')
todo.splice(0, 1, 'Buy Milk And Bread')
todo.subscribe(function(update){ // or todo.subscribe.once
update.add
// => { 0: 'Buy Milk And Bread', 1: 'Cook Dinner' }
update.remove
// => [0, 1]
})
精彩评论