Here's Today Adventure in Enjoyable AngularJS...

I ran into some code from a project that looked give or take like this...

namespace.directive('caNavigation', function () {
    "use strict";

    return {
        restrict: 'E',
        scope: {
            inLinks: "@",
            collapseNav: "="
        },
        // ...
       
        controller:
            ["$scope", /* more jive */],
            function ($scope /* more jive */)
        {
            $scope.$watch('inLinks', function (value) {
                // omgwtfbbq?
                // ...


Well, that's @ and = stuff is pretty cryptic, especially since I hadn't used a directive in AngularJS before (Angular 2+, sure, but still learning ye olde AngularJS -- going backwards, I know. Don't forget I had my first run-in with Perl in 2014 [sic]).

Let's see what ye olde docs say:

[Having to have a separate controller to change direct scopes] is clearly not a great solution.
What we want to be able to do is separate the scope inside a directive from the scope outside, and then map the outer scope to a directive's inner scope. We can do this by creating what we call an isolate scope. To do this, we can use a directive's scope option:
...

Let's take a closer look at the scope option:
//...
scope: {
customerInfo: '=info'
},
//...
The scope option is an object that contains a property for each isolate scope binding. In this case it has just one property:
  • Its name (customerInfo) corresponds to the directive's isolate scope property, customerInfo.
  • Its value (=info) tells $compile to bind to the info attribute.

Good heavens, AngularJS. Not the most discoverable. Seems like in AngularJS in general there's lots of magic stringing. Perhaps I shouldn't be surprised, but that doesn't mean I like it. (Here I mean largely inline array notation, which is too hipster by half. I mean, I get that you don't what to lose information when you minimize, but then how about pass real references, not refer to objects by their original names? Sheesh.* )

But let's go a little deeper so that we know what the @ symbol means too.
The 'isolate' scope object hash defines a set of local scope properties derived from attributes on the directive's element. These local properties are useful for aliasing values for templates. The keys in the object hash map to the name of the property on the isolate scope; the values define how the property is bound to the parent scope, via matching attributes on the directive's element:
  • @ or @attr - bind a local scope property to the value of DOM attribute. The result is always a string since DOM attributes are strings. If no attr name is specified then the attribute name is assumed to be the same as the local name. Given <my-component my-attr="hello {{name}}"> and the isolate scope definition scope: { localName:'@myAttr' }, the directive's scope property localName will reflect the interpolated value of hello {{name}}. As the name attribute changes so will the localName property on the directive's scope. The name is read from the parent scope (not the directive's scope).
  • = or =attr - set up a bidirectional binding between a local scope property and an expression passed via the attribute attr. The expression is evaluated in the context of the parent scope. If no attr name is specified then the attribute name is assumed to be the same as the local name. Given <my-component my-attr="parentModel"> and the isolate scope definition scope: { localModel: '=myAttr' }, the property localModel on the directive's scope will reflect the value of parentModel on the parent scope. Changes to parentModel will be reflected in localModel and vice versa. If the binding expression is non-assignable, or if the attribute isn't optional and doesn't exist, an exception ($compile:nonassign) will be thrown upon discovering changes to the local value, since it will be impossible to sync them back to the parent scope.
    By default, the $watch method is used for tracking changes, and the equality check is based on object identity. However, if an object literal or an array literal is passed as the binding expression, the equality check is done by value (using the angular.equals function). It's also possible to watch the evaluated value shallowly with $watchCollection: use =* or =*attr
If you've used Angular 2+ (what a failed naming scheme, btw. Why not "Angular.IO" after the website or just NGular or something so that googling this jive would be easier?), you can already see our steady progression to banana boxes.

But for now, what a mess.
  1. If a scope property is a string that starts with @, then we'll initialize that scope property with the directive's DOM attribute that matches what comes after the @.
    • That is, this is a one-way process. Props down, and set up events if changes need to go back up.
  2. If the prop starts with an =, we're doing the equivalent of Angular.IO's banana boxing, it appears.
    • So if we change whatever's in here, the parent is changing too.
Back to our original example now... We had a cryptic (to me) $scope.$watch('inLinks', function (value)..., and now we know what that means. @ means we've got a prop that $watch can check for changes, which, in this case, means:

  • The listener is called only when the value from the current watchExpression and the previous call to watchExpression are not equal (with the exception of the initial run, see below). Inequality is determined according to reference inequality, strict comparison via the !== Javascript operator, unless objectEquality == true (see next point)
  • When objectEquality == true, inequality of the watchExpression is determined according to the angular.equals function. To save the value of the object for later comparison, the angular.copy function is used. This therefore means that watching complex objects will have adverse memory and performance implications.

Guess that works. Let's ignore the =* shallow comparison jive for now. All we've really got is one prop called inLinks that will be passed... um... "in" that we'll update whenever it changes, probably after the page finishes loading and making some resource calls. And sure enough...

<li ng-repeat="subLink in link.SubLinks|filter: {ShowInNav: true}" ...

That means once we've loaded the correct navigational links for this context, we'll start pushing them into the DOM.

And our code makes sense. Much rejoicing.



* I actually kinda like AngularJS. It's one clean step away from the inefficient, overly-engineered land of enterprise development Bjarnason discusses and that at least the social convention surrounding Angular 2+ seems to require. But there are a few stupid* conventions like this one that really do AngularJS in conceptually.

* Sorry, I've tried to come up with a good synonym, but "stupid" fits here. My earlier use of "hipster" was my kind attept at an alternative, but this stuff really feels a little too much like a first-pass solution to be in a nice, mature templating library.

Labels: , ,