Directives man! While the they're literally the entry point into Angular development (every application contains a call to ng-app), many people starting out with Angular are hesitant to write their own because of the complexity associated with them.
And directives are complex. Even a simple directive requires understanding complicated concepts like restrict
, scope
and link
. Just figure out prototypical inheritance! Just figure out when Angular will run this link
function (with its magic signature). But it turns out these hurdles can be cleared and that the value of a directive as a reusable component becomes indispensable.
But what about when a directive isn't self contained? What if a complicated component comes along that that is naturally modeled by multiple directives? This group of directives, as a whole, form a single self contained component but none of directives in the group can stand alone. They only make sense when grouped together. They collaborate. They are aware of each other and need to communicate with each other. Now there are more scary concepts to learn about, like restrict
and controller
.
This post will discuss best practices for managing communication among collaborating directives and illustrate these practices with an example.
Imagine a scenario like this:
<parent-component>
<child-component></child-component>
<another-child-component></another-child-component>
</parent-component>
Let's assume that when something changes in child-component
that parent-component
and another-child-component
need to know about it. How should these directives communicate? There is an answer tucked away in the Angular documentation, but before looking at it let's survey a set of approaches that could be taken with the tools at hand.
It's tempting to communicate via scopes. parent-component
could expose an API by exporting functions and data on its scope. This API is available to both child-component
and another-child-component
, assuming neither uses an isolate scope. Now both directives can communicate directly with their parent via the API on scope. However, parent-component
cannot communicate directly with child-component
or another-child-component
because scopes represent a hierarchical relationship between parent and child. This relationship is implemented using prototypical inheritance, so children have access to data placed on the scope by the parent but not vice versa. parent-component
has to find another way to communicate with its children.
Similarly, child-component
and another-child-component
will have trouble communicating because they are siblings and scopes do not provide a method for sibling communication.
This approach does not appear to be ideal. Communication is limited, but there are a few other problems:
- Scopes are generally used to provide data to templates and coordinate events generated by user interaction with the view. Using them for inter-directive communication concerns overloads the responsibilities of scope. This is working against the framework.
- Isolated scopes are not available since data and communication is being routed through scopes.
- As
parent-component
grows, so too will the surface area of scope. It will become hard to maintain and reason about the implementation. Data placed on the scope byparent-component
may accidentally be masked by data placed on the scope bychild-component
oranother-child-component
.
If scopes aren't your jam, check out this article on our blog for a more detailed discussion on scopes in angular.
Because communication between parent and child or between sibling directives is limited via scopes, another option is Angular's eventing system. Because this approach does not rely on scope (directly at least) for communication none of the problems outlined above will present themselves. But the design of the eventing system presents its own challenges.
Events in Angular are directed. They either travel up the scope hierarchy via $scope.$emit
or down it via $scope.$broadcast
. This means that the location of a directive in the document is important when it comes to sending the event. While this can be circumvented by injecting $rootScope
and only sending events down the chain using $rootScope.$broadcast
, the extra dependency is a bit of a bummer. An increasing dependency list size is usually an indication that something is amiss.
The biggest problem with this approach, conceptually at least, is that the collaborating directives are carrying on a private conversation in public. By using $broadcast
, the event is delivered across the entire system and any directive can react to it. This can lead to unintended consequences, e.g. a generically named event triggers an unintended event handler. This problem can be solved by naming conventions, but this is a work around attempting to better target the event. $broadcast
is best suited to situations where the type and number of listeners for an event is unknown by the origin of the event. This differs from the situation of collaborating directives; the consumers are well known.
So how should collaborating directives communicate? The answer is in the documentation for directives (tucked away at the bottom of the page):
Best Practice: use controller when you want to expose an API to other directives. Otherwise use link.
Obvious, right? Ok, maybe not. Taking a look at how a directive is defined will shed some light. Consider, again, this example template:
<parent-component>
<child-component></child-component>
<another-child-component></another-child-component>
</parent-component>
And this directive definition for parent-component
:
module.directive('parentComponent', function() {
function ParentComponentController(scope) {
// initialize scope
}
ParentComponentController.prototype.doSomething = function() {
// ironically does nothing
}
return {
restrict: 'E',
controller: ['$scope', ParentComponentController],
scope: {}
};
});
The parent-component
directive specifies it provides a controller ParentComponentController
. The controller is a class and is instantiated each time the directive is encountered. It is instantiated before the directive's template is compiled so that the controller can initialize the scope if needed.
Now look at the directive definition for child-component
:
module.directive('childComponent', function() {
function link(scope, element, attrs, controller) {
controller.doSomething();
}
return {
restrict: 'E',
require: '^parentComponent',
link: link,
scope: {}
}
});
There are two things at play here. child-component
specifies via the require
property that it needs the controller provided by parent-component
. Controllers are referenced not by the name of the controller class, but by the name of the directive as registered with the dependency injector. The '^' indicates that the controller is provided by a parent directive. As a result of requiring the parent-component
controller, it is passed to child-component
's link function as the fourth argument.
Let's put this understanding of how to manage communication between collaborating directives to use with an example. Consider an image annotation tool like this:
-- embed directive --
It should behave like this:
- By default, annotations are not shown.
- The total number of annotations is shown
- There is a control to show annotations.
- Pressing the "show annotations" control overlays the annotations on the image.
- The "show annotations" control is replaced by a "hide annotations" control after it is pressed.
- Annotations can be selected by clicking on their visual representation in the image.
- Selecting an annotation will show the text and author of the annotation.
- Pressing "hide annotations" control returns the component to its original state.
As a starting point, consider what the template for this component might look like:
<div class="annotations-control">
<span ng-click="showAnnotations()" ng-hide="viewing">Show</span>
<span ng-click="hideAnnotations()" ng-show="viewing">Hide</span>
<span ng-click="showAnnotations()">{{ annotations.length }} Annotations</span>
</div>
<canvas></canvas>
<div class="annotation-content" ng-if="viewing">
<div ng-if="annotation">
<span>"{{ annotation.text }}"</span> - <span>{{ annotation.author }}</span>
</div>
</div>
The canvas
tag will be where the image and the annotations are displayed. The div
with class annotations-control
represents the controls area for showing and hiding. The div
with class annotation-content
is the text of the currently selected annotation. Unfortunately, the template does not specify where the image source and the annotations are coming from. This means anybody using this code will also have to look in the javascript to fully know how this component is wired up.
The canvas
and the two div
s represent distinct visual components and make good candidates for directives. Let's update the template to only show the higher level directives:
<annotated-image-controls annotations="annotations"></annotated-image-controls>
<annotated-image-viewer src="image" annotations="annotations"></annotated-image-viewer>
<annotated-image-current></annotated-image-current>
This looks better. Before it was unclear where the image source and annotations came from, now it is expressed in the template. But, this is a lot of boilerplate to use this component. Taking that as a hint, let's expose a single directive to coordinate the whole thing:
<annotated-image configuration="config"></annotated-image>
The definition of annotated-image
looks like this:
angular.module('annotated-image').directive('annotatedImage', function()
{
function AnnotatedImageController(scope) {}
return {
{
restrict: 'E',
template: [
'<annotated-image-controls annotations="configuration.annotations"></annotated-image-controls>',
'<annotated-image-viewer src="configuration.image" annotations="configuration.annotations"></annotated-image-viewer>',
'<annotated-image-current></annotated-image-current>'
].join('\n'),
controller: ['$scope', AnnotatedImageController],
scope: {
configuration: '='
}
}
};
});
The main responsibility of annotated-image
is to coordinate annotated-image-viewer
, annotated-image-controls
and annotated-image-current
. It provides a template which includes them and a controller to facilitate communication between them. The controller will be built up as the other three directives are developed.
Let's focus on the task of showing annotations. Remember that clicking the "show annotations" control should cause changes in all three directives. Here are their definitions:
angular.module('annotated-image').directive('annotatedImageControls', function() {
function link(scope, el, attrs, controller) {
scope.showAnnotations = function() {
controller.showAnnotations();
};
controller.onShowAnnotations(function() {
scope.viewing = true;
});
}
return {
restrict: 'E',
require: '^annotatedImage',
template: [
'<div>',
'<span span[data-role="show annotations"] ng-click="showAnnotations()" ng-hide="viewing">Show</span>',
'<span span[data-role="hide annotations"] ng-click="hideAnnotations()" ng-show="viewing">Hide</span>',
'<span ng-click="showAnnotations()">{{ annotations.length }} Annotations</span>',
'</div>'
].join('\n'),
link: link,
scope: {
annotations: '='
}
};
});
angular.module('annotated-image').directive('annotatedImageViewer', function() {
function link(scope, el, attrs, controller) {
var canvas = el.find('canvas');
var viewManager = new AnnotatedImage.ViewManager(canvas[0], scope.src);
controller.onShowAnnotations(function() {
viewManager.showAnnotations(scope.annotations);
});
}
return {
restrict: 'E',
require: '^annotatedImage',
template: '<canvas></canvas>',
link: link,
scope: {
src: '=',
annotations: '='
}
};
});
angular.module('annotated-image').directive('annotatedImageCurrent', function() {
function link(scope, el, attrs, controller) {
controller.onShowAnnotations(function() {
scope.viewing = true;
});
}
return {
restrict: 'E',
require: '^annotatedImage',
link: link,
template: [
'<div class="annotation-content" ng-if="viewing">',
'<div ng-if="annotation">',
'<span>"{{ annotation.text }}"</span> - <span>{{ annotation.author }}</span>',
'</div>',
'</div>'
].join('\n'),
scope: { }
};
};
Notice annotated-image-controls
, annotated-image-viewer
and annotated-image-current
require the controller provided by annotated-image
. Also notice that the fourth argument to the link
function is the required controller. Remember that the '^' at the beginning of the require property means the controller will be provided by a parent directive. This effectively means that these directives cannot be used outside of annotated-image
because Angular will throw an error when it cannot locate the required controller.
annotated-image-controls
exports a function called showAnnotations
which is bound via ng-click
to span[data-role="show annotations"]
. The showAnnotations
function simply delegates to AnnotatedImageController
.
When AnnotatedImageController#showAnnotations
is called it notifies interested listeners of the event. This is implemented using a simple observer pattern for flexibility:
function AnnotatedImageController(scope) {
this.handlers = {
showAnnotations: []
}
}
AnnotatedImageController.prototype.showAnnotations = function() {
this.handlers.showAnnotations.forEach(function(handler) {
handler();
});
}
AnnotatedImageController.prototype.onShowAnnotations = function(handler)
{
this.handlers.showAnnotations.push(handler);
}
All three directives register themselves with AnnotatedImageController#onShowAnnotations
because all three must take action when a request to show annotations is made. annotated-image-controls
toggles it's state. annotated-image-current
reveals itself. annotated-image-viewer
redraws the image with the annotations overlaid. The "hide annotations" functionality can be implemented in much the same way. The source is available here.
Directives are complicated. Directives that need to collaborate are even more complicated. Having an understanding of the tools Angular provides in this scenario can help you manage and contain this complexity within your application.