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

Commit 715efb8

Browse files
feat($compile): add more lifecycle hooks to directive controllers
This change adds in the following new lifecycle hooks, which map in some way to those in Angular 2: * `$onChanges(changesObj)` - Called whenever one-way bindings are updated. The `changesObj` is a hash whose keys are the names of the bound properties that have changed, and the values are an object of the form `{ currentValue: ..., previousValue: ... }`. Use this hook to trigger updates within a component such as cloning the bound value to prevent accidental mutation of the outer value. * `$onDestroy` - Called on a controller when its containing scope is destroyed. Use this hook for releasing external resources, watches and event handlers. * `$afterViewInit` - Called after this controller's element and its children been linked. Similar to the post-link function this hook can be used to set up DOM event handlers and do direct DOM manipulation. Note that child elements that contain `templateUrl` directives will not have been compiled and linked since they are waiting for their template to load asynchronously and their own compilation and linking has been suspended until that occurs. Closes #14127 Closes #14030 Closes #14020 Closes #13991 Closes #14302
1 parent e34ef23 commit 715efb8

File tree

3 files changed

+272
-31
lines changed

3 files changed

+272
-31
lines changed

docs/content/guide/component.ngdoc

+21
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,27 @@ components should follow a few simple conventions:
147147
}
148148
```
149149

150+
- **Components have a well-defined lifecycle
151+
Each component can implement "lifecycle hooks", which are methods that will be called at certain points in the life
152+
of the component. The following hook methods can be implemented:
153+
154+
* `$onInit()` - Called on each controller after all the controllers on an element have been constructed and
155+
had their bindings initialized (and before the pre & post linking functions for the directives on
156+
this element). This is a good place to put initialization code for your controller.
157+
* `$onChanges(changesObj)` - Called whenever one-way bindings are updated. The `changesObj` is a hash whose keys
158+
are the names of the bound properties that have changed, and the values are an object of the form
159+
`{ currentValue: ..., previousValue: ... }`. Use this hook to trigger updates within a component such as
160+
cloning the bound value to prevent accidental mutation of the outer value.
161+
* `$onDestroy` - Called on a controller when its containing scope is destroyed. Use this hook for releasing
162+
external resources, watches and event handlers.
163+
* `$afterViewInit` - Called after this controller's element and its children been linked. Similar to the post-link
164+
function this hook can be used to set up DOM event handlers and do direct DOM manipulation.
165+
Note that child elements that contain `templateUrl` directives will not have been compiled and linked since
166+
they are waiting for their template to load asynchronously and their own compilation and linking has been
167+
suspended until that occurs.
168+
169+
By implementing these methods, you component can take part in its lifecycle.
170+
150171
- **An application is a tree of components:**
151172
Ideally, the whole application should be a tree of components that implement clearly defined inputs
152173
and outputs, and minimize two-way data binding. That way, it's easier to predict when data changes and what the state

src/ng/compile.js

+55-5
Original file line numberDiff line numberDiff line change
@@ -293,9 +293,21 @@
293293
* `true` if the specified slot contains content (i.e. one or more DOM nodes).
294294
*
295295
* The controller can provide the following methods that act as life-cycle hooks:
296-
* * `$onInit` - Called on each controller after all the controllers on an element have been constructed and
296+
* * `$onInit()` - Called on each controller after all the controllers on an element have been constructed and
297297
* had their bindings initialized (and before the pre & post linking functions for the directives on
298298
* this element). This is a good place to put initialization code for your controller.
299+
* * `$onChanges(changesObj)` - Called whenever one-way (`<`) bindings are updated. The `changesObj` is a hash
300+
* whose keys are the names of the bound properties that have changed, and the values are an object of the form
301+
* `{ currentValue: ..., previousValue: ... }`. Use this hook to trigger updates within a component such as
302+
* cloning the bound value to prevent accidental mutation of the outer value.
303+
* * `$onDestroy` - Called on a controller when its containing scope is destroyed. Use this hook for releasing
304+
* external resources, watches and event handlers.
305+
* * `$afterViewInit` - Called after this controller's element and its children have been linked. Similar to the post-link
306+
* function this hook can be used to set up DOM event handlers and do direct DOM manipulation.
307+
* Note that child elements that contain `templateUrl` directives will not have been compiled and linked since
308+
* they are waiting for their template to load asynchronously and their own compilation and linking has been
309+
* suspended until that occurs.
310+
*
299311
*
300312
* #### `require`
301313
* Require another directive and inject its controller as the fourth argument to the linking function. The
@@ -474,7 +486,7 @@
474486
*
475487
* * `iElement` - instance element - The element where the directive is to be used. It is safe to
476488
* manipulate the children of the element only in `postLink` function since the children have
477-
* already been linked.
489+
* already have been linked.
478490
*
479491
* * `iAttrs` - instance attributes - Normalized list of attributes declared on this element shared
480492
* between all directive linking functions.
@@ -2360,10 +2372,16 @@ function $CompileProvider($provide, $$sanitizeUriProvider) {
23602372
}
23612373
});
23622374

2363-
// Trigger the `$onInit` method on all controllers that have one
2375+
// Handle the init and destroy lifecycle hooks on all controllers that have them
23642376
forEach(elementControllers, function(controller) {
2365-
if (isFunction(controller.instance.$onInit)) {
2366-
controller.instance.$onInit();
2377+
var controllerInstance = controller.instance;
2378+
if (isFunction(controllerInstance.$onInit)) {
2379+
controllerInstance.$onInit();
2380+
}
2381+
if (isFunction(controllerInstance.$onDestroy)) {
2382+
controllerScope.$on('$destroy', function callOnDestroyHook() {
2383+
controllerInstance.$onDestroy();
2384+
});
23672385
}
23682386
});
23692387

@@ -2400,6 +2418,14 @@ function $CompileProvider($provide, $$sanitizeUriProvider) {
24002418
);
24012419
}
24022420

2421+
// Trigger $afterViewInit lifecycle hooks
2422+
forEach(elementControllers, function(controller) {
2423+
var controllerInstance = controller.instance;
2424+
if (isFunction(controllerInstance.$afterViewInit)) {
2425+
controllerInstance.$afterViewInit();
2426+
}
2427+
});
2428+
24032429
// This is the function that is injected as `$transclude`.
24042430
// Note: all arguments are optional!
24052431
function controllersBoundTransclude(scope, cloneAttachFn, futureParentElement, slotName) {
@@ -2995,6 +3021,7 @@ function $CompileProvider($provide, $$sanitizeUriProvider) {
29953021
// only occurs for isolate scopes and new scopes with controllerAs.
29963022
function initializeDirectiveBindings(scope, attrs, destination, bindings, directive) {
29973023
var removeWatchCollection = [];
3024+
var changes;
29983025
forEach(bindings, function initializeBinding(definition, scopeName) {
29993026
var attrName = definition.attrName,
30003027
optional = definition.optional,
@@ -3081,6 +3108,8 @@ function $CompileProvider($provide, $$sanitizeUriProvider) {
30813108
destination[scopeName] = parentGet(scope);
30823109

30833110
removeWatch = scope.$watch(parentGet, function parentValueWatchAction(newParentValue) {
3111+
var oldValue = destination[scopeName];
3112+
recordChanges(scopeName, newParentValue, oldValue);
30843113
destination[scopeName] = newParentValue;
30853114
}, parentGet.literal);
30863115

@@ -3101,6 +3130,27 @@ function $CompileProvider($provide, $$sanitizeUriProvider) {
31013130
}
31023131
});
31033132

3133+
function recordChanges(key, currentValue, previousValue) {
3134+
if (isFunction(destination.$onChanges)) {
3135+
// If we have not already scheduled the onChanges hook then do so now
3136+
if (!changes) {
3137+
changes = {};
3138+
scope.$$postDigest(triggerOnChangesHook);
3139+
}
3140+
// Store this change
3141+
changes[key] = {previousValue: previousValue, currentValue: currentValue};
3142+
}
3143+
}
3144+
3145+
function triggerOnChangesHook() {
3146+
// We must run this hook in an apply since the $$postDigest runs outside apply
3147+
scope.$apply(function() {
3148+
destination.$onChanges(changes);
3149+
// Now clear the changes so that we schedule onChanges when more changes arrive
3150+
changes = undefined;
3151+
});
3152+
}
3153+
31043154
return removeWatchCollection.length && function removeWatches() {
31053155
for (var i = 0, ii = removeWatchCollection.length; i < ii; ++i) {
31063156
removeWatchCollection[i]();

test/ng/compileSpec.js

+196-26
Original file line numberDiff line numberDiff line change
@@ -3515,6 +3515,202 @@ describe('$compile', function() {
35153515
});
35163516
});
35173517

3518+
describe('controller lifecycle hooks', function() {
3519+
3520+
describe('$onInit', function() {
3521+
3522+
it('should call `$onInit`, if provided, after all the controllers on the element have been initialized', function() {
3523+
3524+
function check() {
3525+
/*jshint validthis:true */
3526+
expect(this.element.controller('d1').id).toEqual(1);
3527+
expect(this.element.controller('d2').id).toEqual(2);
3528+
}
3529+
3530+
function Controller1($element) { this.id = 1; this.element = $element; }
3531+
Controller1.prototype.$onInit = jasmine.createSpy('$onInit').and.callFake(check);
3532+
3533+
function Controller2($element) { this.id = 2; this.element = $element; }
3534+
Controller2.prototype.$onInit = jasmine.createSpy('$onInit').and.callFake(check);
3535+
3536+
angular.module('my', [])
3537+
.directive('d1', valueFn({ controller: Controller1 }))
3538+
.directive('d2', valueFn({ controller: Controller2 }));
3539+
3540+
module('my');
3541+
inject(function($compile, $rootScope) {
3542+
element = $compile('<div d1 d2></div>')($rootScope);
3543+
expect(Controller1.prototype.$onInit).toHaveBeenCalledOnce();
3544+
expect(Controller2.prototype.$onInit).toHaveBeenCalledOnce();
3545+
});
3546+
});
3547+
});
3548+
3549+
3550+
describe('$onDestroy', function() {
3551+
3552+
it('should call `$onDestroy`, if provided, on the controller when its scope is destroyed', function() {
3553+
3554+
function TestController() { this.count = 0; }
3555+
TestController.prototype.$onDestroy = function() { this.count++; };
3556+
3557+
angular.module('my', [])
3558+
.directive('d1', valueFn({ scope: true, controller: TestController }))
3559+
.directive('d2', valueFn({ scope: {}, controller: TestController }))
3560+
.directive('d3', valueFn({ controller: TestController }));
3561+
3562+
module('my');
3563+
inject(function($compile, $rootScope) {
3564+
3565+
element = $compile('<div><d1 ng-if="show[0]"></d1><d2 ng-if="show[1]"></d2><div ng-if="show[2]"><d3></d3></div></div>')($rootScope);
3566+
3567+
$rootScope.$apply('show = [true, true, true]');
3568+
var d1Controller = element.find('d1').controller('d1');
3569+
var d2Controller = element.find('d2').controller('d2');
3570+
var d3Controller = element.find('d3').controller('d3');
3571+
3572+
expect([d1Controller.count, d2Controller.count, d3Controller.count]).toEqual([0,0,0]);
3573+
$rootScope.$apply('show = [false, true, true]');
3574+
expect([d1Controller.count, d2Controller.count, d3Controller.count]).toEqual([1,0,0]);
3575+
$rootScope.$apply('show = [false, false, true]');
3576+
expect([d1Controller.count, d2Controller.count, d3Controller.count]).toEqual([1,1,0]);
3577+
$rootScope.$apply('show = [false, false, false]');
3578+
expect([d1Controller.count, d2Controller.count, d3Controller.count]).toEqual([1,1,1]);
3579+
});
3580+
});
3581+
3582+
3583+
it('should call `$onDestroy` top-down (the same as `scope.$broadcast`)', function() {
3584+
var log = [];
3585+
function ParentController() { log.push('parent created'); }
3586+
ParentController.prototype.$onDestroy = function() { log.push('parent destroyed'); };
3587+
function ChildController() { log.push('child created'); }
3588+
ChildController.prototype.$onDestroy = function() { log.push('child destroyed'); };
3589+
function GrandChildController() { log.push('grand child created'); }
3590+
GrandChildController.prototype.$onDestroy = function() { log.push('grand child destroyed'); };
3591+
3592+
angular.module('my', [])
3593+
.directive('parent', valueFn({ scope: true, controller: ParentController }))
3594+
.directive('child', valueFn({ scope: true, controller: ChildController }))
3595+
.directive('grandChild', valueFn({ scope: true, controller: GrandChildController }));
3596+
3597+
module('my');
3598+
inject(function($compile, $rootScope) {
3599+
3600+
element = $compile('<parent ng-if="show"><child><grand-child></grand-child></child></parent>')($rootScope);
3601+
$rootScope.$apply('show = true');
3602+
expect(log).toEqual(['parent created', 'child created', 'grand child created']);
3603+
log = [];
3604+
$rootScope.$apply('show = false');
3605+
expect(log).toEqual(['parent destroyed', 'child destroyed', 'grand child destroyed']);
3606+
});
3607+
});
3608+
});
3609+
3610+
3611+
describe('$afterViewInit', function() {
3612+
3613+
it('should call `$afterViewInit`, if provided, after the element has completed linking (i.e. post-link)', function() {
3614+
3615+
var log = [];
3616+
3617+
function Controller1() { }
3618+
Controller1.prototype.$afterViewInit = function() { log.push('d1 view init'); };
3619+
3620+
function Controller2() { }
3621+
Controller2.prototype.$afterViewInit = function() { log.push('d2 view init'); };
3622+
3623+
angular.module('my', [])
3624+
.directive('d1', valueFn({
3625+
controller: Controller1,
3626+
link: { pre: function(s, e) { log.push('d1 pre: ' + e.text()); }, post: function(s, e) { log.push('d1 post: ' + e.text()); } },
3627+
template: '<d2></d2>'
3628+
}))
3629+
.directive('d2', valueFn({
3630+
controller: Controller2,
3631+
link: { pre: function(s, e) { log.push('d2 pre: ' + e.text()); }, post: function(s, e) { log.push('d2 post: ' + e.text()); } },
3632+
template: 'loaded'
3633+
}));
3634+
3635+
module('my');
3636+
inject(function($compile, $rootScope) {
3637+
element = $compile('<d1></d1>')($rootScope);
3638+
expect(log).toEqual([
3639+
'd1 pre: loaded',
3640+
'd2 pre: loaded',
3641+
'd2 post: loaded',
3642+
'd2 view init',
3643+
'd1 post: loaded',
3644+
'd1 view init'
3645+
]);
3646+
});
3647+
});
3648+
});
3649+
3650+
3651+
describe('$onChanges', function() {
3652+
it('should call `$onChanges`, if provided, when a one-way (`<`) binding is updated', function() {
3653+
var log = [];
3654+
function TestController() { }
3655+
TestController.prototype.$onChanges = function(change) { log.push(change); };
3656+
3657+
angular.module('my', [])
3658+
.component('c1', {
3659+
controller: TestController,
3660+
bindings: { 'prop1': '<', 'prop2': '<', 'other': '=', 'attr': '@' }
3661+
});
3662+
3663+
module('my');
3664+
inject(function($compile, $rootScope) {
3665+
// Setup a watch to indicate some complicated updated logic
3666+
$rootScope.$watch('val', function(val, oldVal) { $rootScope.val2 = val * 2; });
3667+
// Setup the directive with two bindings
3668+
element = $compile('<c1 prop1="val" prop2="val2" other="val3" attr="{{val4}}"></c1>')($rootScope);
3669+
3670+
// There should be no changes initially
3671+
expect(log).toEqual([]);
3672+
3673+
// Update val to trigger the onChanges
3674+
$rootScope.$apply('val = 42');
3675+
// Now we should have a single changes entry in the log
3676+
expect(log).toEqual([
3677+
{
3678+
prop1: {previousValue: undefined, currentValue: 42},
3679+
prop2: {previousValue: undefined, currentValue: 84}
3680+
}
3681+
]);
3682+
3683+
// Clear the log
3684+
log = [];
3685+
3686+
// Update val to trigger the onChanges
3687+
$rootScope.$apply('val = 17');
3688+
// Now we should have a single changes entry in the log
3689+
expect(log).toEqual([
3690+
{
3691+
prop1: {previousValue: 42, currentValue: 17},
3692+
prop2: {previousValue: 84, currentValue: 34}
3693+
}
3694+
]);
3695+
3696+
// Clear the log
3697+
log = [];
3698+
3699+
// Update val3 to trigger the "other" two-way binding
3700+
$rootScope.$apply('val3 = 63');
3701+
// onChanges should not have been called
3702+
expect(log).toEqual([]);
3703+
3704+
// Update val4 to trigger the "attr" interpolation binding
3705+
$rootScope.$apply('val3 = 22');
3706+
// onChanges should not have been called
3707+
expect(log).toEqual([]);
3708+
3709+
});
3710+
});
3711+
});
3712+
});
3713+
35183714

35193715
describe('isolated locals', function() {
35203716
var componentScope, regularScope;
@@ -5324,32 +5520,6 @@ describe('$compile', function() {
53245520
});
53255521
});
53265522

5327-
it('should call `controller.$onInit`, if provided after all the controllers have been constructed', function() {
5328-
5329-
function check() {
5330-
/*jshint validthis:true */
5331-
expect(this.element.controller('d1').id).toEqual(1);
5332-
expect(this.element.controller('d2').id).toEqual(2);
5333-
}
5334-
5335-
function Controller1($element) { this.id = 1; this.element = $element; }
5336-
Controller1.prototype.$onInit = jasmine.createSpy('$onInit').and.callFake(check);
5337-
5338-
function Controller2($element) { this.id = 2; this.element = $element; }
5339-
Controller2.prototype.$onInit = jasmine.createSpy('$onInit').and.callFake(check);
5340-
5341-
angular.module('my', [])
5342-
.directive('d1', valueFn({ controller: Controller1 }))
5343-
.directive('d2', valueFn({ controller: Controller2 }));
5344-
5345-
module('my');
5346-
inject(function($compile, $rootScope) {
5347-
element = $compile('<div d1 d2></div>')($rootScope);
5348-
expect(Controller1.prototype.$onInit).toHaveBeenCalledOnce();
5349-
expect(Controller2.prototype.$onInit).toHaveBeenCalledOnce();
5350-
});
5351-
});
5352-
53535523
describe('should not overwrite @-bound property each digest when not present', function() {
53545524
it('when creating new scope', function() {
53555525
module(function($compileProvider) {

0 commit comments

Comments
 (0)