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

Commit 2afc7f3

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
1 parent 7452bc4 commit 2afc7f3

File tree

3 files changed

+260
-30
lines changed

3 files changed

+260
-30
lines changed

docs/content/guide/component.ngdoc

Lines changed: 21 additions & 0 deletions
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

Lines changed: 54 additions & 4 deletions
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 whose keys
300+
* 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 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
@@ -2349,10 +2361,16 @@ function $CompileProvider($provide, $$sanitizeUriProvider) {
23492361
}
23502362
});
23512363

2352-
// Trigger the `$onInit` method on all controllers that have one
2364+
// Handle the init and destroy lifecycle hooks on all controllers that have them
23532365
forEach(elementControllers, function(controller) {
2354-
if (isFunction(controller.instance.$onInit)) {
2355-
controller.instance.$onInit();
2366+
var controllerInstance = controller.instance;
2367+
if (isFunction(controllerInstance.$onInit)) {
2368+
controllerInstance.$onInit();
2369+
}
2370+
if (isFunction(controllerInstance.$onDestroy)) {
2371+
controllerScope.$on('$destroy', function callOnDestroyHook() {
2372+
controllerInstance.$onDestroy();
2373+
});
23562374
}
23572375
});
23582376

@@ -2389,6 +2407,14 @@ function $CompileProvider($provide, $$sanitizeUriProvider) {
23892407
);
23902408
}
23912409

2410+
// Trigger $afterViewInit lifecycle hooks
2411+
forEach(elementControllers, function(controller) {
2412+
var controllerInstance = controller.instance;
2413+
if (isFunction(controllerInstance.$afterViewInit)) {
2414+
controllerInstance.$afterViewInit();
2415+
}
2416+
});
2417+
23922418
// This is the function that is injected as `$transclude`.
23932419
// Note: all arguments are optional!
23942420
function controllersBoundTransclude(scope, cloneAttachFn, futureParentElement, slotName) {
@@ -2984,6 +3010,7 @@ function $CompileProvider($provide, $$sanitizeUriProvider) {
29843010
// only occurs for isolate scopes and new scopes with controllerAs.
29853011
function initializeDirectiveBindings(scope, attrs, destination, bindings, directive) {
29863012
var removeWatchCollection = [];
3013+
var changes;
29873014
forEach(bindings, function initializeBinding(definition, scopeName) {
29883015
var attrName = definition.attrName,
29893016
optional = definition.optional,
@@ -3070,6 +3097,8 @@ function $CompileProvider($provide, $$sanitizeUriProvider) {
30703097
destination[scopeName] = parentGet(scope);
30713098

30723099
removeWatch = scope.$watch(parentGet, function parentValueWatchAction(newParentValue) {
3100+
var oldValue = destination[scopeName];
3101+
recordChanges(scopeName, newParentValue, oldValue);
30733102
destination[scopeName] = newParentValue;
30743103
}, parentGet.literal);
30753104

@@ -3090,6 +3119,27 @@ function $CompileProvider($provide, $$sanitizeUriProvider) {
30903119
}
30913120
});
30923121

3122+
function recordChanges(key, currentValue, previousValue) {
3123+
if (isFunction(destination.$onChanges)) {
3124+
// If we have not already schedule the onChanges hook then do so now
3125+
if (!changes) {
3126+
changes = {};
3127+
scope.$$postDigest(triggerOnChangesHook);
3128+
}
3129+
// Store this change
3130+
changes[key] = {previousValue: previousValue, currentValue: currentValue};
3131+
}
3132+
}
3133+
3134+
function triggerOnChangesHook() {
3135+
// We must run this hook in an apply since the $$postDigest runs outside apply
3136+
scope.$apply(function() {
3137+
destination.$onChanges(changes);
3138+
// Now clear the changes so that we schedule onChanges when more changes arrive
3139+
changes = undefined;
3140+
});
3141+
}
3142+
30933143
return removeWatchCollection.length && function removeWatches() {
30943144
for (var i = 0, ii = removeWatchCollection.length; i < ii; ++i) {
30953145
removeWatchCollection[i]();

test/ng/compileSpec.js

Lines changed: 185 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -3515,6 +3515,191 @@ 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': '<' }
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+
3668+
// Setup the directive with two bindings
3669+
element = $compile('<c1 prop1="val" prop2="val2"></c1>')($rootScope);
3670+
3671+
// There should be no changes initially
3672+
expect(log).toEqual([]);
3673+
3674+
// Update val to trigger the onChanges
3675+
$rootScope.$apply('val = 42');
3676+
3677+
// Now we should have a single changes entry in the log
3678+
expect(log).toEqual([
3679+
{
3680+
prop1: {previousValue: undefined, currentValue: 42},
3681+
prop2: {previousValue: undefined, currentValue: 84}
3682+
}
3683+
]);
3684+
3685+
// Clear the log
3686+
log = [];
3687+
3688+
// Update val to trigger the onChanges
3689+
$rootScope.$apply('val = 17');
3690+
3691+
// Now we should have a single changes entry in the log
3692+
expect(log).toEqual([
3693+
{
3694+
prop1: {previousValue: 42, currentValue: 17},
3695+
prop2: {previousValue: 84, currentValue: 34}
3696+
}
3697+
]);
3698+
});
3699+
});
3700+
});
3701+
});
3702+
35183703

35193704
describe('isolated locals', function() {
35203705
var componentScope, regularScope;
@@ -5324,32 +5509,6 @@ describe('$compile', function() {
53245509
});
53255510
});
53265511

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-
53535512
describe('should not overwrite @-bound property each digest when not present', function() {
53545513
it('when creating new scope', function() {
53555514
module(function($compileProvider) {

0 commit comments

Comments
 (0)