0

Use Case

I have a set of controls on a page, roughly 60+. Each control evaluates a string expression to decide whether or not the control is disabled based on data entered within the form itself. The expression is created by the end-user, so it could be anything.

The problem I have is that forms and evaluations are fairly complex and are slowing down the UI. The final code will have expressions that require look ups against data in scoped objects and will contain loops etc...

Demonstration

This is a jsfiddle to simulate what I am trying to improve. It isn't the final code as that module is fairly large. However, this should give a picture of what I'm trying to achieve (or reduce). This demonstration is fast because the expression is fairly simple, but I'd like to be able to reduce the number of times the function is is called.

[edit] I've updated the demonstration to include as much of the core logic as I can for the final production code. This is as close as it gets to what will be accomplished.

Template

<div ng-app ng-controller="ctrl">
    <div ng-repeat="widget in widgets">
        <label>Widget #{{$index}}
            <input type="text" ng-model="widget.value" 
                   ng-disabled="is(widget.expression)" />
        </label>
    </div>
</div>

Controller

var ctrl = function ($scope) {
    $scope.widgets = [];

    /** Core Logic Below Here **/
    var getValueByName = function (name) {
        for (i = 0, k = $scope.widgets.length; i < k; i++) {
            if ($scope.widgets[i].name == name) {
                return $scope.widgets[i].value;
            }
        }
        return 0;
    };

    var replaceNameWithValue = function () {
        var result = this.replace(/( |^)(widget_\d+?)( |$)/g, function ($0, $1, $2, $3) {
            return parseInt(getValueByName($2), 10);
        });
        return result;
    };

    $scope.is = function (expression) {
        console.log('firing');
        try {
            var exp = replaceNameWithValue.call(expression);
            return $scope.$eval(exp);
        } catch (e) {}
        return false;
    };

    /** Logic to generate random test cases **/
    var numWidgets = 60;
    var operators = ['+', '-', '*'];
    var comparators = ['===', '!==', '>', '<', '>=', '<='];

    var getRandomInt = function (max) {
        return Math.floor(Math.random() * max);
    };

    var makeExpression = function () {
        var a = getRandomInt(numWidgets);
        var b = getRandomInt(numWidgets);
        var c = getRandomInt(20);
        var op = operators[getRandomInt(operators.length)];
        var cf = comparators[getRandomInt(comparators.length)];

        return {
            expression: ['widget_' + a, op, 'widget_' + b, cf, c].join(' ')
        };
    };

    var makeRandomObject = function (index) {
        var obj = makeExpression();
        obj.name = "widget_" + index;
        obj.value = 0;
        return obj;
    };

    var preLoad = function (numWidgets) {
        for (i = 0; i < numWidgets; i++) {
            $scope.widgets.push(makeRandomObject(i));
        }
    };

    preLoad(numWidgets);
};

Question

How can I improve the performance for these evaluation?

Solution?

I noticed that the evaluations are executed on the keyUp event. I suspect I can't use the native ng-disabled and will have to create a directive that fires on the blur event, but I'd like to see what other ideas are available.

Pete
  • 3,246
  • 4
  • 24
  • 43
  • With the latest 1.3 versions of AngularJs you can set a value for debounce for ng-model-options that will delay model updates (and in turn calls to ng-disabled) until after a set time period. [See this answer](http://stackoverflow.com/a/23045547/238427) – JoseM Apr 30 '14 at 20:32
  • @JoseM's suggestion in another Fiddle: http://jsfiddle.net/marcolepsy/rXuHd/1/ This is much simpler, but I'm leaving my original answer for reference as another approach – Marc Kline Apr 30 '14 at 21:22

1 Answers1

1

Instead of invoking a function for each widget every time Angular runs a digest cycle, you can continue to use ngDisabled by having it evaluate a simple value that changes only under certain conditions dictated by the link function of a directive.

Here is a modified Fiddle. To do this you need to:

Add an attribute restricted directive, which decorates elements with the following behavior:

  • Registers an observer in the parent controller
  • Binds listeners on the element which when fired invoke a function that checks all expressions

...

directive('widgetWatcher', function($timeout){
    return {
        restrict: 'A',
        require: 'ngModel',
        scope: true,
        link: function(scope, elm, attr) {          

            // register observer for this directive by passing its scope
            scope.addExpressionObserver(scope);

            // Check expressions at most once every 1s or immediately when input is blurred
            var debounce;
            elm.bind('input', function() {
                $timeout.cancel(debounce);
                debounce = $timeout( function() {
                    scope.$apply(function() {
                        scope.checkExpressions();
                    });
                }, 1000);
            });
            elm.bind('blur', function() {
                scope.$apply(function() {
                    scope.checkExpressions();
                });
            });

        }
    }
})

Add an array to the parent controller along with:

  • A function to add observers to the array (the scope of each directive)
  • Another to iterate over the collection, evaluating each expression and setting the isDisabled scope variable for each widget ... this gets called by a directive where input or blur has taken place

...

$scope.expressionObservers = [];

$scope.addExpressionObservers = function(dScope) {
    $scope.expressionObservers.push(dScope);
};

$scope.checkExpressions = function() {
    angular.forEach($scope.expressionWatchers, function(val){
        val.isDisabled = $scope.is(val.widget.expression);
    });
};

Don't know if this solves all of your problems or if there isn't a better way to do something like this in Angular, but hopefully it at least gives you another approach to consider (which is what you asked for).

Marc Kline
  • 9,399
  • 1
  • 33
  • 36
  • The debounce solution looks good and I'll probably implement that or run an unstable version of angular. Thanks for taking the time on this. – Pete Apr 30 '14 at 21:25
  • My pleasure. I answer on SO mainly to learn new things and I did so here. The only potential downside I can imagine to throttling the .$setViewValue() call would be seen if you had other watchers on the ng-model value that would not benefit from the throttling. I bet that won't be a problem for you though. – Marc Kline Apr 30 '14 at 21:36