In Angular, a Component is a special kind of directive that uses a simpler configuration which is suitable for a component-based application structure.
This makes it easier to write an app in a way that's similar to using Web Components or using Angular 2's style of application architecture.
Advantages of Components:
When not to use Components:
Components can be registered using the .component()
method of an Angular module (returned by angular.module()
). The method takes two arguments:
.directive()
method, this method does not take a factory function.
angular.module('heroApp', []).controller('mainCtrl', function() {
this.hero = {
name: 'Spawn'
};
});
function HeroDetailController() {
}
angular.module('heroApp').component('heroDetail', {
templateUrl: 'heroDetail.html',
controller: HeroDetailController,
bindings: {
hero: '='
}
});
<!-- components match only elements -->
<div ng-controller="mainCtrl as ctrl">
<b>Hero</b><br>
<hero-detail hero="ctrl.hero"></hero-detail>
</div>
<span>Name: {{$ctrl.hero.name}}</span>
It's also possible to add components via $compileProvider
in a module's config phase.
Directive | Component | |
---|---|---|
bindings | No | Yes (binds to controller) |
bindToController | Yes (default: false) | No (use bindings instead) |
compile function | Yes | No |
controller | Yes | Yes (default function() {} ) |
controllerAs | Yes (default: false) | Yes (default: $ctrl ) |
link functions | Yes | No |
multiElement | Yes | No |
priority | Yes | No |
require | Yes | Yes |
restrict | Yes | No (restricted to elements only) |
scope | Yes (default: false) | No (scope is always isolate) |
template | Yes | Yes, injectable |
templateNamespace | Yes | No |
templateUrl | Yes | Yes, injectable |
terminal | Yes | No |
transclude | Yes (default: false) | Yes (default: false) |
As already mentioned, the component helper makes it easier to structure your application with a component-based architecture. But what makes a component beyond the options that the component helper has?
Components only control their own View and Data: Components should never modify any data or DOM that is out of their own scope. Normally, in Angular it is possible to modify data anywhere in the application through scope inheritance and watches. This is practical, but can also lead to problems when it is not clear which part of the application is responsible for modifying the data. That is why component directives use an isolate scope, so a whole class of scope manipulation is not possible.
Components have a well-defined public API - Inputs and Outputs:
However, scope isolation only goes so far, because Angular uses two-way binding. So if you pass
an object to a component like this - bindings: {item: '='}
, and modify one of its properties, the
change will be reflected in the parent component. For components however, only the component that owns
the data should modify it, to make it easy to reason about what data is changed, and when. For that reason,
components should follow a few simple conventions:
@
and =
bindingsbindings: {
hero: `=`,
}
&
bindings, which function as callbacks to component eventsbindings: {
onDelete: '&',
onUpdate: '&'
}
hero
itself, but sends it back to
the owner component via the correct event.<button ng-click="$ctrl.onDelete({hero: hero})">Delete</button>
ctrl.deleteHero(hero) {
$http.delete(...).then(function() {
var idx = ctrl.list.indexOf(hero);
if (idx >= 0) {
ctrl.list.splice(idx, 1);
}
});
}
An application is a tree of components: Ideally, the whole application should be a tree of components that implement clearly defined inputs and outputs, and minimize two-way data binding. That way, it's easier to predict when data changes and what the state of a component is.
The following example expands on the simple component example and incorporates the concepts we introduced above:
Instead of an ngController, we now have a heroList component that holds the data of different heroes, and creates a heroDetail for each of them.
The heroDetail component now contains the following new functionality:
var mode = angular.module('heroApp', []);
function HeroListController($scope, $element, $attrs) {
var ctrl = this;
// This would be loaded by $http etc.
ctrl.list = [
{
name: 'Superman',
location: ''
},
{
name: 'Batman',
location: 'Wayne Manor'
}
];
ctrl.updateHero = function(hero, prop, value) {
hero[prop] = value;
};
ctrl.deleteHero = function(hero) {
var idx = ctrl.list.indexOf(hero);
if (idx >= 0) {
ctrl.list.splice(idx, 1);
}
};
}
angular.module('heroApp').component('heroList', {
templateUrl: 'heroList.html',
controller: HeroListController
});
function HeroDetailController($scope, $element, $attrs) {
var ctrl = this;
ctrl.update = function(prop, value) {
ctrl.onUpdate({hero: ctrl.hero, prop: prop, value: value});
};
}
angular.module('heroApp').component('heroDetail', {
templateUrl: 'heroDetail.html',
controller: HeroDetailController,
bindings: {
hero: '=',
onDelete: '&',
onUpdate: '&'
}
});
function EditableFieldController($scope, $element, $attrs) {
var ctrl = this;
this.editMode = false;
this.handleModeChange = function() {
if (ctrl.editMode) {
ctrl.onUpdate({value: ctrl.fieldValue});
ctrl.fieldValueCopy = ctrl.fieldValue;
}
ctrl.editMode = !ctrl.editMode;
};
this.reset = function() {
ctrl.fieldValue = ctrl.fieldValueCopy;
};
this.$onInit = function() {
// Make a copy of the initial value to be able to reset it later
this.fieldValueCopy = this.fieldValue;
// Set a default fieldType
if (!this.fieldType) {
this.fieldType = 'text';
}
};
}
angular.module('heroApp').component('editableField', {
templateUrl: 'editableField.html',
controller: EditableFieldController,
bindings: {
fieldValue: '@',
fieldType: '@',
onUpdate: '&'
}
});
<hero-list></hero-list>
<b>Heroes</b><br>
<hero-detail ng-repeat="hero in $ctrl.list" hero="hero" on-delete="$ctrl.deleteHero(hero)" on-update="$ctrl.updateHero(hero, prop, value)"></hero-detail>
<hr>
<div>
Name: {{$ctrl.hero.name}}<br>
Location: <editable-field field-value="{{$ctrl.hero.location}}" field-type="text" on-update="$ctrl.update('location', value)"></editable-field><br>
<button ng-click="$ctrl.onDelete({hero: $ctrl.hero})">Delete</button>
</div>
<span ng-switch="$ctrl.editMode">
<input ng-switch-when="true" type="text" ng-model="$ctrl.fieldValue">
<span ng-switch-default>{{$ctrl.fieldValue}}</span>
</span>
<button ng-click="$ctrl.handleModeChange()">{{$ctrl.editMode ? 'Save' : 'Edit'}}</button>
<button ng-if="$ctrl.editMode" ng-click="$ctrl.reset()">Reset</button>
Components are also useful as route templates (e.g. when using ngRoute). In a component-based application, every view is a component:
var myMod = angular.module('myMod', ['ngRoute']);
myMod.component('home', {
template: '<h1>Home</h1><p>Hello, {{ $ctrl.user.name }} !</p>',
controller: function() {
this.user = {name: 'world'};
}
});
myMod.config(function($routeProvider) {
$routeProvider.when('/', {
template: '<home></home>'
});
});
When using $routeProvider, you can often avoid some
boilerplate, by assigning the resolved dependencies directly to the route scope:
var myMod = angular.module('myMod', ['ngRoute']);
myMod.component('home', {
template: '<h1>Home</h1><p>Hello, {{ $ctrl.user.name }} !</p>',
bindings: {user: '='}
});
myMod.config(function($routeProvider) {
$routeProvider.when('/', {
template: '<home user="$resolve.user"></home>',
resolve: {user: function($http) { return $http.get('...'); }}
});
});
Directives can require the controllers of other directives to enable communication
between each other. This can be achieved in a component by providing an
object mapping for the require
property. Here is a tab pane example built
from components:
angular.module('docsTabsExample', [])
.component('myTabs', {
transclude: true,
controller: function() {
var panes = this.panes = [];
this.select = function(pane) {
angular.forEach(panes, function(pane) {
pane.selected = false;
});
pane.selected = true;
};
this.addPane = function(pane) {
if (panes.length === 0) {
this.select(pane);
}
panes.push(pane);
};
},
templateUrl: 'my-tabs.html'
})
.component('myPane', {
transclude: true,
require: {tabsCtrl: '^myTabs'},
bindings: {
title: '@'
},
controller: function() {
this.$onInit = function() {
this.tabsCtrl.addPane(this);
console.log(this);
};
},
templateUrl: 'my-pane.html'
});
<my-tabs>
<my-pane title="Hello">
<h4>Hello</h4>
<p>Lorem ipsum dolor sit amet</p>
</my-pane>
<my-pane title="World">
<h4>World</h4>
<em>Mauris elementum elementum enim at suscipit.</em>
<p><a href ng-click="i = i + 1">counter: {{i || 0}}</a></p>
</my-pane>
</my-tabs>
<div class="tabbable">
<ul class="nav nav-tabs">
<li ng-repeat="pane in $ctrl.panes" ng-class="{active:pane.selected}">
<a href="" ng-click="$ctrl.select(pane)">{{pane.title}}</a>
</li>
</ul>
<div class="tab-content" ng-transclude></div>
</div>
<div class="tab-pane" ng-show="$ctrl.selected" ng-transclude></div>
The easiest way to unit-test a component controller is by using the $componentController
that is included in ngMock
. The advantage of this method is that you do not have
to create any DOM elements. The following example shows how to do this for the heroDetail
component
from above.
The examples use the Jasmine testing framework.
Controller Test:
describe('component: heroDetail', function() {
var component, scope, hero;
beforeEach(module('simpleComponent'));
beforeEach(inject(function($rootScope, $componentController) {
scope = $rootScope.$new();
hero = {name: 'Wolverine'};
}));
it('should set the default values of the hero', function() {
// It's necessary to always pass the scope in the locals, so that the controller instance can be bound to it
component = $componentController('heroDetail', {$scope: scope});
expect(component.hero).toEqual({
name: undefined,
location: 'unknown'
});
});
it('should assign the name bindings to the hero object', function() {
// Here we are passing actual bindings to the component
component = $componentController('heroDetail',
{$scope: scope},
{hero: hero}
);
expect(component.hero.name).toBe('Wolverine');
});
it('should call the onDelete binding when a hero is deleted', function() {
component = $componentController('heroDetail',
{$scope: scope},
{hero: hero, onDelete: jasmine.createSpy('deleteSpy')}
);
component.onDelete({hero: component.hero});
expect(spy('deleteSpy')).toHaveBeenCalledWith(component.hero);
});
});