angular / angular.js

AngularJS - HTML enhanced for web apps!
https://angularjs.org
MIT License
58.82k stars 27.5k forks source link

Optimize $watch and $parse for frozen/immutable objects #6391

Open alexanderkjeldaas opened 10 years ago

alexanderkjeldaas commented 10 years ago

I see a lot of people having problems with too many $watch expressions. I have seen solutions such as bindonce, but that solution brings a lot of complexity on its own.

I suggest that AngularJS integrates and encourage a separation between mutable and immutable data, and detect frozen objects in $watch expressions. This has testability, complexity and speed advantages.

No new APIs or syntax is required for this.

Immutable data in Ecmascript 5.1 can be created using Object.freeze(). When a $watch expression is evaluated, and all constituent objects are frozen, then the result of the expression is a constant.

Current vs new watch expressions:

function watch(epression, scope) {
    return $parse(expression).bind(scope);
}

function transmogrifyingWatch(expression, scope, onConstant) {
    var f = $parse(expression);
    // initial invariant.  when it is broken, we do something
    var inv = function() { return !scope.isFrozen(); };
    return function() {
        if (!inv()) {
             // slow path, leftmost properties have changed their frozen status
             f = .. new optimized function taking into consideration that the invariant is broken
             inv = new invariant
             if (f is constant) {
                onConstant(f.apply(scope));
             }
        }
        return f.apply(scope);
   };
} 

Of course, the invariant can be part of the f function as well by integrating it into the current $parse.

For all current code, this only adds a simple test for scope.isFrozen in the evaluation function, but it opens up for much more performant UIs by having watches that automatically learn about immutable data.

The onConstant callback can be used to remote a $watch from the digest cycle. Thus properly designed programs would be able to have thousands of $watches as long as they use immutable data to back them.

caitp commented 10 years ago

If you $watch a constant expression, angular will automatically remove the watch after the first listener is called. https://github.com/angular/angular.js/blob/f7d28cd377f06224247b950680517a187a7b6749/src/ng/rootScope.js#L336-L342

alexanderkjeldaas commented 10 years ago

What I am proposing is more general and I think it could solve the "can't have > 2000 $watch expressions" issue. $parse detects constants, but will not detect immutable data.

What I am proposing is sort of the dynamic version of the constant detection that $parse does.

<div ng-controler='persons'>
  <div ng-freeze ng-repeat='p in persons'>
    <div ng-bind='p.name'></div>
  </div>
</div>

app.directive('ngFreeze', function() {
   return { link: function (scope) { scope.freeze(); }};
})
.controller('persons', function($scope, ...) {
    ...
    $scope.persons = ...  // 10000 persons
    ...
    angular.forEach($scope.persons, function(x) { x.freeze(); });
});

In this example, for each of the persons, their $scope, and $scope.p, and $scope.p.name are all immutable. This can be detected by looking first at $scope.isFrozen(). If that fails, then look no further. But when they are all frozen, the expression can be evaluated exactly like the constant expressions from $parse. Thus the $watch can be removed.

There is a middle road also. When $scope.isFrozen() == true, then the function that evaluates the expression can directly bind p in its closure. When later p.isFrozen() == true, then the function that evaluates the expression can directly bind the name object in its closure.

This leads to more efficient evaluation (although there is a slight cost, so there is a trade-off).

This is not possible to do simply by $parse, because the immutability information can only be known by accessing the scope, and $parse does not know about scopes.

Therefore you can think of it as a more dynamic constant detector than the one $parse knows. I also believe that this immutability detector can be pretty useful in a lot of real-life situations.

$parse fails at this point: A field lookup is a primary, so at this point the primary.constant == false. https://github.com/angular/angular.js/blob/f7d28cd377f06224247b950680517a187a7b6749/src/ng/parse.js#L444

caitp commented 10 years ago

So what you're saying is that this is a duplicate issue of all of the bindonce stuff :p I think you might want to take a look at and perhaps comment on the discussions in the angular 2.0 drafts: https://docs.google.com/document/d/1f5VWROeTI2kJwVKbNsrHuEz5IqtZe14OpoxM9fEYJNU/edit#heading=h.6rzfundz70u6

alexanderkjeldaas commented 10 years ago

I think this is more powerful than bind-once, and less complex than namespaces. I've added a few comments to the design doc.

Using immutable data instead of bind-sometimes is good engineering. Triggers that only sometimes fire cause lots of complexity.

However, declaring data to be immutable does not introduce any compleity at all. Rather, it reduces complexity by giving the system stronger invariants. So this is a rare win/win change I think.

In this proposal the programmer exactly expresses his intent at one place, by indicating that a given object is immutable. Thus there is no API change at all in Angular. The ng-freeze directive I made above is just a shorthand for expressing that a given $scope is immutable. If the programmer violates this invariant in code, the JS environment will scream. So even this directive is optional and does not add any new semantics at all.

I will note that I haven't spent a lot of time thinking about this, but having hacked Haskell for some time, this problem screams 'take advantage of immutable data!', and since ES5 has some support for it, it should be used.

nmccready commented 9 years ago

Any work on this?