Skip to content
This repository has been archived by the owner on Apr 12, 2024. It is now read-only.

controllerAs in directives doesn't play well with isolate scope #7635

Closed
thorn0 opened this issue May 29, 2014 · 24 comments
Closed

controllerAs in directives doesn't play well with isolate scope #7635

thorn0 opened this issue May 29, 2014 · 24 comments

Comments

@thorn0
Copy link
Contributor

thorn0 commented May 29, 2014

I'd like to use the 'controllerAs' option in my directives. Let me cite the reasoning from An AngularJS Style Guide for Closure Users at Google:

Why? Putting methods and properties directly onto the controller, instead of building up a scope object, fits better with the Google Closure class style. Additionally, using 'controller as' makes it obvious which controller you are accessing when multiple controllers apply to an element. Since there is always a '.' in the bindings, you don't have to worry about prototypal inheritance masking primitives.

But I can see an issue with using this approach if the directive has isolate scope bindings.

angular.module('cmw').directive('fooWidget', function() {
    return {
        controller: function() {
            this.qux = '123';
        },
        controllerAs: 'fooWidget',
        scope: {
            bar: '='
        },
        template: ' {{fooWidget.qux}}  {{bar}} '
    };
});

In this case, the bar property is attached to the scope, not to the controller, which results in a confusing inconsistent situation where different properties should be looked for in different places.

As for now I see two alternatives, both not appealing to me:

  1. to create bindings manually by calling $observe for each attribute needed, or
  2. to forgo using 'controllerAs' at all.

It might make sense to introduce another special character for the scope entries in directive definition objects. This character would mean that the given property is a property of the controller, not the scope. What about dot?

angular.module('cmw').directive('fooWidget', function() {
    return {
        controller: function() {
            this.qux = '123';
        },
        controllerAs: 'fooWidget',
        scope: {
            bar: '=.',
            abc: '=.xyzzy'
        },
        template: ' {{fooWidget.qux}}  {{fooWidget.bar}} '
    };
});
@caitp
Copy link
Contributor

caitp commented May 29, 2014

  1. to create bindings manually by calling $observe for each attribute needed, or
  2. to forgo using 'controllerAs' at all.

Option 3: just reference them as {{bar}} / {{abc}} / {{qux}} in template, the controller can't really do much with them anyways since they are kept in sync with the external scope anyways (barring the use of bind-once), so changes that the controller makes wouldn't be reflected, and would just give the dirty checker more work to do.

Yes, I am aware that you have a bit of a problem when child scopes are in the picture, but there's a workaround for that anyways

@thorn0
Copy link
Contributor Author

thorn0 commented May 29, 2014

Firstly, it's inconsistent. Secondly, we lose all the cited benefits from Google's guide.

@caitp
Copy link
Contributor

caitp commented May 29, 2014

You lose exactly 1) benefit: the ability to safely write the properties from a child scope without using $parent or similar.

Anyways, lets see what other people think. @btford do you have an opinion on this?

@thorn0
Copy link
Contributor Author

thorn0 commented May 29, 2014

I'm keen on TypeScript and prefer the class style for controllers. In the methods code, it looks messy that some properties are accessed like this.prop and other like this.scope.prop.

Also this point is something I don't want to lose:

makes it obvious which controller you are accessing when multiple controllers apply to an element

@thorn0
Copy link
Contributor Author

thorn0 commented May 29, 2014

Here's a plunk to play with: http://plnkr.co/edit/ruwlP0eXOl3vxQbdqkSD
@caitp you can check your objections about dirty checker etc.

@caitp
Copy link
Contributor

caitp commented May 29, 2014

Each time you click the button, with that service being used, you iterate through the scope traversal about 10 times --- Without it, you iterate through scope traversal about 3 times per click. (In the case of this particular plunker, we're actually checking watches 7 times per click with the service being used, and twice per click without it)

Iterating through scope traversal/dirtychecking is not always cheap, it can be expensive when listeners (or watched expressions, for that matter) perform expensive operations

Also important to note is that this plunker demo is a trivial example, whereas a real application would be hurt quite a lot more by this pattern. So don't do this to get around limitations of the compiler, it's not a very good idea

As I said, we should get opinions from other folks about this to determine if it should be WONTFIX or not. I'm not opposed to adding support for this, but I feel like it's probably just going to make the compiler or directive api even crazier than it already is

@thorn0
Copy link
Contributor Author

thorn0 commented May 30, 2014

Sorry, but I just can't get how this code can significantly differ in terms of performance from what we have when using scope: { bar: '=' }. That's what we need to compare.

This fork of the plunk uses standard binding to the scope: http://plnkr.co/edit/7mWyOZIhjXklndFEHnkZ
Does it show any performance gains over the original plunk?

@caitp
Copy link
Contributor

caitp commented May 30, 2014

thorn0: the performance issue is hard to demonstrate with small plunkers like this, because you aren't showing a pathological case.

The most pathological case would be where you are binding to non-const JSON literals with many properties doing non-trivial work processing their values, which is sometimes the case for ngClass or even ngOptions --- and you would expand on this pathological case by properly using the same comparison mechanism as the isolate scope (relying on angular.equals) --- doing this twice can potentially create model instability and end up calling the watchers multiple times, where they would be doing at least one, and possibly two angular.equals comparisons, after returning a value which would itself then be checked against the old value redundantly using angular.equals --- and it would do that not just for the isolate scope, but also for your service.

So basically what this does is it just increases the workload of the dirtychecker significantly.

Is the pathological case the most common? That depends on what kind of data is being used. You can obviously never have an assignable JSON literal, so model instability isn't going to happen. It's not as bad when only used for primitive values, but it's still extra work being done that really does not need to happen, increasing the dataset the dirtychecker deals with.

@thorn0
Copy link
Contributor Author

thorn0 commented May 30, 2014

Doesn't the same thing happen with the current implementation of bindings like scope: { prop: '=,,,' }?

@caitp
Copy link
Contributor

caitp commented May 30, 2014

@thorn0 yes, but setting up a redundant watch in a separate service compounds the issue, especially in the pathological case (which may or may not effect your application)

@thorn0
Copy link
Contributor Author

thorn0 commented May 30, 2014

This watcher might be redundant, but it's exactly what $compile does in the current implementation. My code of that service isn't really mine, it's basically a copy-paste from $compile, except that for the sake of code size I removed some logic that handles literal values. From the performance point of view, it just can't matter whether $watch is called from a custom service or from $compile.

@caitp
Copy link
Contributor

caitp commented May 30, 2014

@thorn0 well it's not what $compile does, it's a little bit cheaper because you aren't using the special case for JSON literals, so you have some slight gains there, but lose the ability to deal with certain cases.

@caitp
Copy link
Contributor

caitp commented May 30, 2014

Anyways, I don't want to have an argument about this. I think in most cases, it is not going to matter a whole lot to use the redundant watch, it's just that it can matter, is what I'm saying. It's not an ideal thing to have to do. If you have a lot of these, and they're doing real work, and digests are frequent, it can add up.

I don't think it would necessarily be the end of the world to add builtin support for what you're asking, so that you don't have to add a redundant watch. My issue with it though, is that the solutions I'm imagining for this will either:

  1. be a breaking change for anyone currently using an isolate scope + controllerAs (if putting stuff into the controller rather than scope becomes the de facto behaviour when controllerAs is used)
  2. add additional rules to the isolate scope micro-syntax, making the directive api marginally more complicated than it already is

N'either of these seem particularly good to me, but I am not totally opposed to fixing them this way (more likely via option 2 --- but in order for that to happen, we need to decide if it's worth "fixing", so I would like to hear from other people, which may not happen this week.

@thorn0
Copy link
Contributor Author

thorn0 commented May 30, 2014

So if I bring that code back. the performance will be the same as that of the standard '=' bindings? If so, how is it possible that this performance is acceptable for the current implementation and in the same time it might be used as an objection to my proposal? That's what I'm trying to say.

@thorn0
Copy link
Contributor Author

thorn0 commented May 30, 2014

OK, I hear you. Let me cite @RichardLitt from #5076

Am I right in thinking that if 'controller as' is used consistently, this isn't in fact an edge case, but a restructuring of how controllers are used in general?

Yes, it's an alternative way to use controllers. And if the framework supports it, this support should be consistent. That's my point.

@caitp
Copy link
Contributor

caitp commented May 30, 2014

Okay @thorn0, I've taken a shot at putting together a "fix" for this, but I'm not totally confident it will make it into the tree. We'll see.

@thorn0
Copy link
Contributor Author

thorn0 commented May 30, 2014

Thank you! It's more complicated than I expected. Trying to wrap my head around it...

As for syntax and the gross magic, what about something like this?

{
    controller: {
        constructor: 'SomeCtrl',
        as: 'someCtrl',
        bind: {
            "myData": "=someData",
            "myString": "@someInterpolation",
            "myExpr": "&someExpr"
        }
    },
    scope: {},
    ...
}

@btford btford added this to the 1.3.0 milestone Jun 9, 2014
@TrueWill
Copy link

I've run into exactly this issue, and I agree - it would be nice to be able to use controllerAs consistently.

@tonypee
Copy link

tonypee commented Aug 15, 2014

Im not sure if this has been resolved, but my first thought was that they current scope {...} definition could just resolve on the scope eg:
scope: {
'someCtrl.test': '=test'
'someCtrl.name': '=name'
}
this should mean that the performance stays the same no? it just resolves to a different variable

@btford btford removed the gh: issue label Aug 20, 2014
caitp added a commit to caitp/angular.js that referenced this issue Aug 21, 2014
…ntrollerAs

It is now possible to ask the $compiler's isolate scope property machinery to
reference properties by their isolate scope.

The current syntax is to prefix the scope name with a '@', like so:

    scope: {
        "myData": "=someData",
        "myString": "@someInterpolation",
        "myExpr": "&someExpr"
    },
    controllerAs: "someCtrl",
    bindtoController: true

The putting of properties within the context of the controller will only occur if
controllerAs is used for an isolate scope with the `bindToController` property of the
directive definition object set to `true`.

Closes angular#7635
Closes angular#7645
caitp added a commit to caitp/angular.js that referenced this issue Aug 21, 2014
…ntrollerAs

It is now possible to ask the $compiler's isolate scope property machinery to
reference properties by their isolate scope.

The current syntax is to prefix the scope name with a '@', like so:

    scope: {
        "myData": "=someData",
        "myString": "@someInterpolation",
        "myExpr": "&someExpr"
    },
    controllerAs: "someCtrl",
    bindtoController: true

The putting of properties within the context of the controller will only occur if
controllerAs is used for an isolate scope with the `bindToController` property of the
directive definition object set to `true`.

Closes angular#7635
Closes angular#7645
@caitp caitp closed this as completed in 787c5a7 Aug 21, 2014
caitp added a commit that referenced this issue Aug 29, 2014
It is now possible to ask the $compiler's isolate scope property machinery to bind isolate
scope properties to a controller rather than scope itself. This feature requires the use of
controllerAs, so that the controller-bound properties may still be referenced from binding
expressions in views.

The current syntax is to prefix the scope name with a '@', like so:

    scope: {
        "myData": "=someData",
        "myString": "@someInterpolation",
        "myExpr": "&someExpr"
    },
    controllerAs: "someCtrl",
    bindtoController: true

The putting of properties within the context of the controller will only occur if
controllerAs is used for an isolate scope with the `bindToController` property of the
directive definition object set to `true`.

Closes #7635
Closes #7645
@goleafs
Copy link

goleafs commented Sep 22, 2014

It's not clear to me from this thread - so I just wanted to ask before moving to 1.3 rc1 to benefit from this.

is the ControllerAs option supported on a directive and will it behave consistently with standard controllers? Is there a sample of the correct implementation?

We have invested a lot of refactoring into following the "controller as vm" pattern, would be nice to leverage it for directives as well.

@caitp
Copy link
Contributor

caitp commented Sep 22, 2014

@goleafs the only difference WRT compiler-instantiated controllers, is that you cannot return a value from the controller constructor.

EG:

// This will not work correctly, because the return value will be ignored...
function MyController($scope, $http) {
  return new SomeOtherConstructor($scope, $http);
}

The reason the return value is ignored, is because it's necessary to pre-allocate the object so that its isolate scope bindings can be populated before the constructor is invoked (sort of a convenience). Other than that, it is exactly the same as ng-controller="SomeController as someCtrl"

@shrekuu
Copy link

shrekuu commented Mar 25, 2015

@thorn0 I haven't learned much about angularjs. I use controllerAs syntax this way in directive, so the services injected in the controller are there. :)

angular.mudule('puppy').directive("faceDirective", [function() {
    return {
        scope: {},
        controller: "LongLongDogNameController",
        controllerAs: "mojo",
        templateUrl: "a-puppy-face-template-tpl",
        link: function(scope, elem, attrs) {

            var ctrl = scope.mojo;

            ctrl.viewService.showTeeth();
        }
    };
}]);

@jmls
Copy link

jmls commented Jul 13, 2015

@shrekuu - this worked for me. Thanks !

@yellow1912
Copy link

@shrekuu a bit late but I think link function always get the ctrl as the 4th parameter, there is no need to get it from scope.

Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Projects
None yet
Development

No branches or pull requests

9 participants