Question

In an Angular scope, I have a collection of objects that carry some data, plus x and y coordinates. Some other scope variables must be recomputed based on the x and y values. What is the best way to do it efficiently?

  • If I use $scope.$watch(collection, handler) or $scope.$watchCollection(collection, handler), I don't get notified about changes to the objects it contains.
  • If I use $scope.$watch(collection, handler, true), I do get notified, but when anything changes, not only x and y. Plus, I don't know which element of the collection was changed (and I imagine that this deep comparison is rather costly).

Ideally, I would like to write something like $scope.$watchObjects(collection, ["x", "y"], handler), where my handler would be called with the changed object and possibly its index. Is there an easy way to do that?

Was it helpful?

Solution 4

Based on Slaven Tomac’s answer, here's what I came up with. Basically: this uses a $watchCollection to detect when items are inserted or added on the collection. For each added item, it starts monitoring it. For each removed item, it stops monitoring it. It then informs a listener each time an object changes.

This further allows to refine what should be considered as a change in the object itself or a change in the collection only. The sameId function is used to test whether two objects a and b should be considered to be the same (it could just a === b, but it could be something more sophisticated — in particular, if you pass in a field name as the sameId argument [e.g., "id"], then two objects will be considered to be “the same.”)

The createArrayDiffs is adapted from a similar change-detection method in the Eclipse Modeling Framework and is interesting in its own right: it returns a list of changes that happened between an array and another array. Those changes are insertions, removals, and object changes (according to the passed fields).

Sample usage:

watchObjectsIn($rootScope, "activities", "id", ["x", "y"], function (oldValue, newValue) {
    console.log("Value of an object changed: from ", oldValue, " to ", newValue);
});

Of course, I'm interested in any simpler and/or more efficient solution!

Implementation (compiled TypeScript):

function watchObjectsIn(scope, expr, idField, watchedFields, listener) {
    var fieldCompareFunction = makeFieldCompareFunction(watchedFields);
    var unbindFunctions = [];
    function doWatch(elem, i) {
        var unbindFunction = scope.$watch(function () {
            return elem;
        }, function (newValue, oldValue) {
            if (newValue === oldValue)
                return;
            if (!fieldCompareFunction(oldValue, newValue))
                listener(oldValue, newValue);
        }, true);
        unbindFunctions.push(unbindFunction);
    }
    function unwatch(elem, i) {
        unbindFunctions[i]();
        unbindFunctions.splice(i, 1);
    }
    scope.$watchCollection(expr, function (newArray, oldArray) {
        if (isUndef(newArray))
            return;

        var diffs = createArrayDiffs(oldArray, newArray, idField, fieldCompareFunction);
        if (diffs.length === 0 && newArray.length !== unbindFunctions.length) {
                for (var i = unbindFunctions.length - 1; i >= 0; i--) {
                unwatch(null, 0);
            }
            diffs = createArrayDiffs([], newArray, idField);
        }

        _.forEach(diffs, function (diff) {
            switch (diff.changeType()) {
                case 0 /* Addition */:
                    doWatch(diff.newValue, diff.position);
                    break;
                case 1 /* Removal */:
                    unwatch(diff.oldValue, diff.position);
                    break;
                case 2 /* Change */:
                    listener(diff.oldValue, diff.newValue);
                    break;
            }
        });
    });
}

function isUndef(v) {
    return typeof v === "undefined";
}
function isDef(v) {
    return typeof v !== "undefined";
}

function parseIntWithDefault(str, deflt) {
    if (typeof deflt === "undefined") { deflt = 0; }
    var res = parseInt(str, 10);
    return isNaN(res) ? deflt : res;
}

function cssIntOr0(query, cssProp) {
    return parseIntWithDefault(query.css(cssProp));
}

function randomStringId() {
    return Math.random().toString(36).substr(2, 9);
}

var ArrayDiffChangeType;
(function (ArrayDiffChangeType) {
    ArrayDiffChangeType[ArrayDiffChangeType["Addition"] = 0] = "Addition";
    ArrayDiffChangeType[ArrayDiffChangeType["Removal"] = 1] = "Removal";
    ArrayDiffChangeType[ArrayDiffChangeType["Change"] = 2] = "Change";
})(ArrayDiffChangeType || (ArrayDiffChangeType = {}));

var ArrayDiffEntry = (function () {
    function ArrayDiffEntry(position, oldValue, newValue) {
        this.position = position;
        this.oldValue = oldValue;
        this.newValue = newValue;
    }
    ArrayDiffEntry.prototype.changeType = function () {
        if (isUndef(this.oldValue))
            return 0 /* Addition */;
        if (isUndef(this.newValue))
            return 1 /* Removal */;
        return 2 /* Change */;
    };

    return ArrayDiffEntry;
})();

function makeFieldCompareFunction(fields) {
    return function (o1, o2) {
        for (var i = 0; i < fields.length; i++) {
            var fieldName = fields[i];
            if (o1[fieldName] !== o2[fieldName])
                return false;
        }
        return true;
    };
}

function createArrayDiffs(oldArray, newArray, sameId, sameData, undefined) {
    if (isUndef(sameId)) {
        sameId = angular.equals;
    } else if (_.isString(sameId)) {
        var idFieldName = sameId;
        sameId = function (o1, o2) {
            return o1[idFieldName] === o2[idFieldName];
        };
    }

    var doDataChangedCheck = isDef(sameData);
    if (doDataChangedCheck && !_.isFunction(sameData)) {
        if (_.isString(sameData))
            sameData = [sameData];
        var fieldsToCheck = sameData;
        sameData = makeFieldCompareFunction(fieldsToCheck);
    }

    var arrayDiffs = [];

    function arrayIndexOf(array, element, index) {
        for (var i = index; i < array.length; i++) {
            if (sameId(array[i], element))
                return i;
        }
        return -1;
    }

    var oldArrayCopy = oldArray ? oldArray.slice() : [];

    var index = 0;
    var i;
    for (i = 0; i < newArray.length; i++) {
        var newValue = newArray[i];
        if (oldArrayCopy.length <= index) {
            arrayDiffs.push(new ArrayDiffEntry(index, undefined, newValue));
        } else {
            var done;
            do {
                done = true;
                var oldValue = oldArrayCopy[index];
                if (!sameId(oldValue, newValue)) {
                    var oldIndexOfNewValue = arrayIndexOf(oldArrayCopy, newValue, index);
                    if (oldIndexOfNewValue !== -1) {
                        var newIndexOfOldValue = arrayIndexOf(newArray, oldValue, index);
                        if (newIndexOfOldValue === -1) {
                            arrayDiffs.push(new ArrayDiffEntry(index, oldValue, undefined));
                            oldArrayCopy.splice(index, 1);
                            done = false;
                        } else if (newIndexOfOldValue > oldIndexOfNewValue) {
                            if (oldArrayCopy.length <= newIndexOfOldValue) {
                                newIndexOfOldValue = oldArrayCopy.length - 1;
                            }
                            arrayDiffs.push(new ArrayDiffEntry(index, oldValue, undefined));
                            oldArrayCopy.splice(index, 1);
                            arrayDiffs.push(new ArrayDiffEntry(newIndexOfOldValue, undefined, oldValue));
                            oldArrayCopy.splice(newIndexOfOldValue, 0, oldValue);
                            done = false;
                        } else {
                            arrayDiffs.push(new ArrayDiffEntry(oldIndexOfNewValue, newValue, undefined));
                            oldArrayCopy.splice(oldIndexOfNewValue, 1);
                            arrayDiffs.push(new ArrayDiffEntry(index, undefined, newValue));
                            oldArrayCopy.splice(index, 0, newValue);
                        }
                    } else {
                        oldArrayCopy.splice(index, 0, newValue);
                        arrayDiffs.push(new ArrayDiffEntry(index, undefined, newValue));
                    }
                } else {
                    if (doDataChangedCheck && !sameData(oldValue, newValue)) {
                        arrayDiffs.push(new ArrayDiffEntry(i, oldValue, newValue));
                    }
                }
            } while(!done);
        }
        index++;
    }
    for (i = oldArrayCopy.length; i > index;) {
        arrayDiffs.push(new ArrayDiffEntry(--i, oldArrayCopy[i], undefined));
    }

    return arrayDiffs;
}

OTHER TIPS

Could you do:

angular.forEach(colletion, function(object) {
    $scope.$watch(object, function() {
        ... I'm not sure what would you like to do with object here...
    })

}, true)

I am pretty sure it was in this video: https://www.youtube.com/watch?v=zyYpHIOrk_Y but somewhere I saw Angular devs talking about mapping the data you are watching to a smaller subset, something like this maybe:

$scope.$watchCollection(function() {
    return yourList.map(function(listItem) {
        return { 'x': listItem.x, 'y': listItem.y };
    };
}, function(newVal, oldVal) {
    // perform calculations
});

That would leave you $watching just an array of objects having x and y properties.

$scope.$watch('collection', function() {
    ...
}, true);

Keep in mind that the collection must be declared on the $scope.

Licensed under: CC-BY-SA with attribution
Not affiliated with StackOverflow
scroll top