Componentizing directives using controllerAs encapsulation
One of the unusual conventions introduced in Angular 1 was the relationship between directives and the data they consumed. By default, directives used an inherited scope, which suited the needs of most developers just fine. While this was easy to use, it had the effect of introducing extra dependencies in the directives, and also the convention that directives often did not own the data they were consuming. Additionally, the data interpolated in the template was unclear in relation to where it was being assigned or owned.
Angular 2 utilizes components as the building blocks of the entire application. These components are class-based and are therefore in some ways at odds with the scope mechanisms of Angular 1. Transitioning to a controller-centric directive model is a large step towards compliance with the Angular 2 standards.
Note
The code, links, and a live example related to this recipe are available at http://ngcookbook.herokuapp.com/8194.
Getting ready
Suppose your application contains the following setup that involves the nested directives that share data using an isolate scope:
[index.html] <div ng-app="articleApp"> <article></article> </div> [app.js] angular.module('articleApp', []) .directive('article', function() { return { controller: function($scope) { $scope.articleData = { person: {firstName: 'Jake'}, title: 'Lesotho Yacht Club Membership Booms' }; }, template: ` <h1>{{articleData.title}}</h1> <attribution author="articleData.person.firstName"> </attribution> ` }; }) .directive('attribution', function() { return { scope: {author: '='}, template: `<p>Written by: {{author}}</p>` }; });
How to do it...
The goal is to refactor this setup so that templates can be explicit about where the data is coming from and so that the directives have ownership of this data:
[app.js] angular.module('articleApp', []) .directive('article', function() { return { controller: function() { this.person = {firstName: 'Jake'}; this.title = 'Lesotho Yacht Club Membership Booms'; }, controllerAs: 'articleCtrl', template: ` <h1>{{articleCtrl.title}}</h1> <attribution></attribution> ` }; }) .directive('attribution', function() { return { template: `<p>Written by: {{articleCtrl.author}}</p>` }; });
In this second implementation, anywhere you use the article data, you are certain of its origin. This is better, but the child directive is still referencing the parent controller, which isn't ideal since it is introducing an unneeded dependency. The attribution directive instance should be provided with the data, and it should instead interpolate from its own controller instance:
[app.js]
angular.module('articleApp', [])
.directive('article', function() {
return {
controller: function() {
this.person = {firstName: 'Jake'};
this.title = 'Lesotho Yacht Club Membership Booms';
},
controllerAs: 'articleCtrl',
template: `
<h1>{{articleCtrl.title}}</h1>
<attribution author="articleCtrl.person.firstName">
</attribution>
`
};
})
.directive('attribution', function() {
return {
controller: function() {},
controllerAs: 'attributionCtrl',
bindToController: {author: '='},
template: `<p>Written by: {{attributionCtrl.author}}</p>`
};
});
Much better! You provide the child directive with a stand-in controller and give it an alias in the attributionCtrl
template. It is implicitly bound to the controller instance via bindToController
in the same way you would accomplish a regular isolate scope; however, the binding is directly attributed to the controller object instead of the scope.
Now that you have introduced the notion of data ownership, suppose you want to modify your application data. What's more, you want different parts of your application to be able to modify it. A naïve implementation of this would be something as follows:
[app.js] angular.module('articleApp', []) .directive('attribution', function() { return { controller: function() { this.capitalize = function() { this.author = this.author.toUpperCase(); } }, controllerAs: 'attributionCtrl', bindToController: {author: '='}, template: ` <p ng-click="attributionCtrl.capitalize()"> Written by: {{attributionCtrl.author}} </p>` }; });
The desired behavior is for you to click on the author, and it will become capitalized. However, in this implementation, the article controller's data is modified in the attribution controller, which does not own it. It is preferable for the controller that owns the data to perform the actual modification and instead supply an interface that an outside entity—here, the attribution directive—could use:
[app.js] angular.module('articleApp', []) .directive('article', function() { return { controller: function() { this.person = {firstName: 'Jake'}; this.title = 'Lesotho Yacht Club Membership Booms'; this.capitalize = function() { this.person.firstName = this.person.firstName.toUpperCase(); }; }, controllerAs: 'articleCtrl', template: ` <h1>{{articleCtrl.title}}</h1> <attribution author="articleCtrl.person.firstName" upper-case-author="articleCtrl.capitalize()"> </attribution> ` }; }) .directive('attribution', function() { return { controller: function() {}, controllerAs: 'attributionCtrl', bindToController: { author: '=', upperCaseAuthor: '&' }, template: ` <p ng-click="attributionCtrl.upperCaseAuthor()"> Written by: {{attributionCtrl.author}} </p>` }; });
Vastly superior! You are still able to namespace within the click binding, but the owning directive controller is providing a method to outside entities instead of just giving them direct data access.
How it works...
When a controller is specified in the directive definition object, one will be explicitly instantiated for each directive instance that is created. Thus, it is natural for this controller object to encapsulate the data that it owns and for it to be delegated the responsibility of passing its data to the members that require it.
The final implementation accomplishes several things:
- Improved template namespacing: When you use the
$scope
properties that span multiple directives or nestings, you are creating a scenario where multiple entities can manipulate and read data without being able to concretely reason about where it is coming from or what is controlling it. - Improved testability: If you look at each of the directives in the final implementation, you'll find they are not too difficult to test. The attribution directive has no dependencies other than what are explicitly passed to it.
- Encapsulation: Introducing the notion of data ownership in your application affords you a much more robust structure, better reusability, and additional insight and control involving pieces of your application interacting.
- Angular 2 style: Angular 2 uses the
@Input
and@Output
annotations on component definitions. Mirroring this style will make the process of transitioning to an application easier.
There's more...
You will notice that $scope
has been made totally irrelevant in these examples. This is good as there is no notion of $scope
in Angular 2, which means you are heading towards having an upgradeable application. This is not to say that $scope
does not still have utility in an Angular 1 application, and surely, there are scenarios where this is unavoidable, like with $scope.$apply()
.
However, thinking about the application pieces in this component style will allow you to be more adequately prepared to adopt Angular 2 conventions.
See also
- Migrating an application to component directives demonstrates how to refactor Angular 1 to a component style
- Implementing a basic component in AngularJS 1.5 details how to write an Angular 1 component
- Normalizing service types gives instruction on how to align your Angular 1 service types for Angular 2 compatibility