神刀安全网

Comprehensive dive into Angular 1.5 lifecycle hooks

Lifecycle hooks are simply functions that get called at specific points of a component’s life in our Angular apps. They landed in Angular 1.5 and are to be used alongside the.component() method, and have slowly evolved over the last few versions to include some more powerful (and Angular 2 inspired) hooks. Let’s explore in-depth how we can actually use them, the roles they play and why you should use them – this is especially important with a component architecture based app.

I’ve spent some time polyfilling the .component() method and all the lifecycle hooks back to Angular v1.3.0+, so it’s been a massive insight as to how we actually use these hooks and their roles in components. Let’s dive in.

Table of contents

$onInit

What is $onInit ? First off, it’s a property exposed to us on component controllers, it’s predefined by Angular, and we can attach a function to it:

var myComponent = {   bindings: {},   controller: function () {     this.$onInit = function() {      };   } };  angular   .module('app')   .component('myComponent', myComponent);

Using $onInit

The $onInit lifecycle hook is for all initialisation code for a Controller. Here’s what we used to do:

var myComponent = {   ...   controller: function () {     this.foo = 'bar';     this.bar = 'foo';     this.fooBar = function () {      };   } };

Note how we create all these properties inside the controller, they are kind of just “floating” around. Now, let’s be sensible and use $onInit :

var myComponent = {   ...   controller: function () {     this.$onInit = function () {       this.foo = 'bar';       this.bar = 'foo';     };     this.fooBar = function () {       console.log(this.foo); // 'bar'     };   } };

These pieces of data are obviously hard-coded, but in reality we would likely be passed data from the bindings: {} Object and pass it into our component. Things we want to use for initialisation purposes, no more “floating” variables around, almost think of $onInit as some kind of constructor for the controller , contain that information.

So what about the this.fooBar function? Don’t sweat, functions are completely fine outside of $onInit , if you were to call this.fooBar , it would log out this.foo with the corresponding value of 'bar' – so all your data is bound correctly to the controller this context.

$onInit + “require”

Because the lifecycles are well-defined (and well-timed in execution order) we can also inherit behaviour from other components, and they’re readily available inside the $onInit hook.

First what we’d have to do though, is use require , I’ve written a more in-depth post on using$onInit and require, but let’s cover some basics here too and provide a real world example after.

Let’s take our myComponent example and use require in it’s new Object form (only for .component() , when using require with .directive() you can still use an Array or String syntax for requiring controllers):

var myComponent = {   ...   require: {     parent: '^^anotherComponent'   },   controller: function () {     this.$onInit = function () {       this.foo = 'bar';       this.bar = 'foo';     };     this.fooBar = function () {       console.log(this.foo); // 'bar'     };   } };

Now the require is setup to use ^^anotherComponent , which searches only the parent component for a controller ( ^anotherComponent would also search the local component and then the parent if it doesn’t exist) we can use any methods bound to the parent component inside $onInit :

var myComponent = {   ...   require: {     parent: '^^anotherComponent'   },   controller: function () {     this.$onInit = function () {       this.foo = 'bar';       this.bar = 'foo';       this.parent.sayHi();     };     this.fooBar = function () {       console.log(this.foo); // 'bar'     };   } };

Real world $onInit + require

Let’s create a tabs component that uses $onInit and require . First up, the markup, this is what we want:

<tabs>   <tab label="Tab 1">     Tab 1 contents!    </tab>    <tab label="Tab 2">     Tab 2 contents!    </tab>    <tab label="Tab 3">     Tab 3 contents!    </tab> </tabs>

This means we need two components, tab and tabs . We’ll transclude all the tabs children (which are <tab> elements into the tab template) and we’ll fetch the label through the bindings Object.

First up, the component definition for each component with necessary properties we’ll need on each component:

var tab = {   bindings: {},   require: {},   transclude: true,   template: ``,   controller: function () {} };  var tabs = {   transclude: true,   template: ``,   controller: function () {} };  angular   .module('app', [])   .component('tab', tab)   .component('tabs', tabs);

The tab component will take some bindings , we’ll also use require , transclude , a template and finally a controller .

The tabs component will transclude our <tab> elements into its template , and we’ll manage the tabs through the controller .

Let’s get the template out the way for each component:

var tab = {   ...   template: `     <div class="tabs__content" ng-if="$ctrl.tab.selected">       <div ng-transclude></div>     </div>   `,   ... };

For tab , we’ll only show that specific tab if $ctrl.tab.selected property is true , so we’ll need some controller logic to handle this. Then we’ll transclude the contents of <tab> into itself (this will be the text we place between the tabs).

var tabs = {   ...   template: `     <div class="tabs">       <ul class="tabs__list">         <li ng-repeat="tab in $ctrl.tabs">           <a href=""             ng-bind="tab.label"             ng-click="$ctrl.selectTab($index);"></a>         </li>       </ul>       <div class="tabs__content" ng-transclude></div>     </div>   `,   ... };

Then for tabs , we’ll create an Array to display as $ctrl.tabs , bind a click to $ctrl.selectTab() which passes in the current $index and transclude the childNodes (each <tab> element) into .tabs__content container.

Let’s next setup the tab component’s controller, we’ll create a this.tab property, and of course initialise that property inside $onInit :

var tab = {   bindings: {     label: '@'   },   ...   template: `     <div class="tabs__content" ng-if="$ctrl.tab.selected">       <div ng-transclude></div>     </div>   `,   controller: function () {     this.$onInit = function () {       this.tab = {         label: this.label,         selected: false       };     };   }   ... };

You can see I’ve used this.label inside the controller too, and have added bindings: { label: '@' } which gives me the value as a String so I can simply map it into each tab component.

Now onto the controller logic for tabs , this is a little more complex:

var tabs = {   ...   template: `     <div class="tabs">       <ul class="tabs__list">         <li ng-repeat="tab in $ctrl.tabs">           <a href=""             ng-bind="tab.label"             ng-click="$ctrl.selectTab($index);"></a>         </li>       </ul>       <div class="tabs__content" ng-transclude></div>     </div>   `,   controller: function () {     this.$onInit = function () {       this.tabs = [];     };     this.addTab = function addTab(tab) {       this.tabs.push(tab);     };     this.selectTab = function selectTab(index) {       for (var i = 0; i < this.tabs.length; i++) {         this.tabs[i].selected = false;       }       this.tabs[index].selected = true;     };   },   ... };

We setup this.tabs = []; inside $onInit , we know this bit already for initialisation props, then define two functions, addTab and selectTab . The addTab function is the one we’ll be delegating down into each child <tab> component, so it can tell the parent it exists so we can hold a reference to it in the ng-repeat to iterate over and select the tab (with selectTab ) once clicked.

Next we’ll delegate the addTab method to the component using require on tab :

var tab = {   ...   require: {     tabs: '^^tabs'   },   ... };

As we learned at the beginning of this chapter covering $onInit and require , we’ll be using ^^tabs syntax over ^tabs (single ^ ), as we only require the logic from the parent and not the local component itself. Ready for the easy part? Looking at the { tabs: '^^tabs' } hash, we have a property name called { tabs: ... } so all we need to do is this.tabs inside $onInit and simply call the parent method:

var tab = {   ...   require: {     tabs: '^^tabs'   },   controller: function () {     this.$onInit = function () {       this.tab = {         label: this.label,         selected: false       };       // this.tabs === require: { tabs: '^^tabs' }       this.tabs.addTab(this.tab);     };   }   ... };

Putting this altogether:

var tab = {   bindings: {     label: '@'   },   require: {     tabs: '^^tabs'   },   transclude: true,   template: `     <div class="tabs__content" ng-if="$ctrl.tab.selected">       <div ng-transclude></div>     </div>   `,   controller: function () {     this.$onInit = function () {       this.tab = {         label: this.label,         selected: false       };       this.tabs.addTab(this.tab);     };   } };  var tabs = {   transclude: true,   controller: function () {     this.$onInit = function () {       this.tabs = [];     };     this.addTab = function addTab(tab) {       this.tabs.push(tab);     };     this.selectTab = function selectTab(index) {       for (var i = 0; i < this.tabs.length; i++) {         this.tabs[i].selected = false;       }       this.tabs[index].selected = true;     };   },   template: `     <div class="tabs">       <ul class="tabs__list">         <li ng-repeat="tab in $ctrl.tabs">           <a href=""             ng-bind="tab.label"             ng-click="$ctrl.selectTab($index);"></a>         </li>       </ul>       <div class="tabs__content" ng-transclude></div>     </div>   ` };

Click on a tab and the contents are revealed. But wait, why don’t we setup an initial tab to be selected? This is where we dive into $postLink .

$postLink

We know directives by now, we have the compile function which returns the pre and post “link” functions. It looks a little something like this:

function myDirective() {   restrict: 'E',   scope: { foo: '=' },   compile: function compile($element, $attrs) {     return {       pre: function preLink($scope, $element, $attrs) {         // access to child elements that are NOT linked       },       post: function postLink($scope, $element, $attrs) {         // access to child elements that are linked       }     };   } }

Though you may know it more by this:

function myDirective() {   restrict: 'E',   scope: { foo: '=' },   link: function postLink($scope, $element, $attrs) {     // access to child elements that are linked   } }

The two are the same if we only want to use the postLink function. Take note here of post: function() {...} – this is our guy. I’ve added a note here saying that “access to child elements that are linked”, which means the child elements of the template are compiled and linked to the current directive. It can be uncommon to use compile and pre linking functions, so we have a lifecycle hook that enables us to hook into the final phase of the compile process – where we know for sure our child elements are compiled and linked.

Using $postLink

The $postLink lifecycle hook gives us the behaviour as described above, but in a new, non-hacky-looking way inside our controllers:

var myComponent = {   ...   controller: function () {     this.$postLink = function () {       // fire away...     };   } };

We are essentially notified by the hook once all child elements are linked and ready to go. Let’s look at how we can implement it alongside our tabs component.

Real world $postLink

We can actually use the $postLink function to set an initial tab value. First, let’s adjust our template:

<tabs selected="0">   <tab label="Tab 1">...</tab>   <tab label="Tab 2">...</tab>   <tab label="Tab 3">...</tab> </tabs>

Now we can grab the selected attribute’s value through bindings to set an initial value:

var tabs = {   bindings: {     selected: '@'   },   ...   controller: function () {     this.$onInit = function () {       this.tabs = [];     };     this.addTab = function addTab(tab) {       this.tabs.push(tab);     };     this.selectTab = function selectTab(index) {       for (var i = 0; i < this.tabs.length; i++) {         this.tabs[i].selected = false;       }       this.tabs[index].selected = true;     };     this.$postLink = function () {       // use `this.selected` passed down from bindings: {}       // a safer option would be to parseInt(this.selected, 10)       // to coerce to a Number to lookup the Array index, however       // this works just fine for the demo :)       this.selectTab(this.selected || 0);     };   },   ... };

And now we have a live example that uses the selected property to pre-select a template, this example uses selected="2" to set the initial index to the third item in the Array:

What $postLink is not

Using $postLink is not a complete replacement for DOM manipulation. Directives that extend HTML/template behaviour through native event bindings and “outside of the Angular ecosystem” stuff should still be left as Directives. Do not just rewrite Directives (that do not have templates) into components – this is bad and not the intended use for .component() .

So why does it exist? You might need to use some DOM manipulation or custom events inside the $postLink , which makes perfect sense in your use case with a template directive (now known as a component). Use it wisely, and as always – post a comment below am happy to answer on your direction.

$onChanges

This is a big one (also the most important), and aligns with how we use component architecture and one-way dataflow with Angular 1.5.x. The golden rule to remember is, $onChanges is called in the local component controller from changes that occurred in the parent controller. Changes that occur from the parent which are inputted into a component using bindings: {} is the secret sauce here.

What calls $onChanges?

The $onChanges lifecycle hook gets called for a few reasons. The first, is on component initialisation, it passes down that initial changes Object at runtime, so we can grab our data straight away. The second reason it gets called is only when changes occur to '<' (one-way databinding) and '@' (for evaluated DOM attribute values) that are being bound to from the parent component. Once $onChanges gets called, you’ll get a special changes Object back that you can hook into, which we’ll explore in the upcoming sections.

Using $onChanges

Using $onChanges is really easy, and it’s often incorrectly used and talked about, so let’s clear it up in the upcoming sections. First up, this is declare it inside childComponent :

var childComponent = {   bindings: { user: '<' },   controller: function () {     this.$onChanges = function (changes) {       // `changes` is a special instance of a constructor Object,       // it contains a hash of a change Object and       // also contains a function called `isFirstChange()`       // it's implemented in the source code using a constructor Object       // and prototype method to create the function `isFirstChange()`     };   } };  angular   .module('app')   .component('childComponent', childComponent);

Note here how bindings contains a property user with the value of '<' , this signifies one-way dataflow, whichI previously covered, which is one of two property bindings that force an $onChanges hook trigger.

But, as mentioned before, we need a parentComponent to complete the picture:

var parentComponent = {   template: `     <div>       <child-component></child-component>     </div>   ` };  angular   .module('app')   .component('parentComponent', parentComponent);

The important thing to note here is that <child-component></child-component> is being rendered inside our <parent-component></parent-component> . This is where we can setup a controller with some data, and bind the data to the childComponent :

var parentComponent = {   template: `     <div>       <a href="" ng-click="$ctrl.changeUser();">         Change user (this will call $onChanges in child)       </a>       <child-component        user="$ctrl.user">       </child-component>     </div>   `,   controller: function () {     this.$onInit = function () {      this.user = {        name: 'Todd Motto',         location: 'England, UK'       };     };     this.changeUser = function () {      this.user = {        name: 'Tom Delonge',         location: 'California, USA'       };     };   } };

Again, we use $onInit to define some local data, then setup this.user and assign it an Object. We also have a this.changeUser function, which when called updates this.user . This change coming from the parent will trigger $onChanges in the child . Parent changes results in child components getting told about it. That’s it.

Now, onto the childComponent :

var childComponent = {   bindings: {     user: '<'   },   template: `     <div>       <pre>{{ $ctrl.user | json }}</pre>     </div>   `,   controller: function () {     this.$onChanges = function (changes) {       this.user = changes;     };   } };

Here, we’re using bindings: { user: '<' } , which means we receive the data through one-way bindings under the alias of user , which means we do this.user inside the template to show the changes (I’m using a | json filter to show the whole Object).

Press the button and watch the childComponent propagate those changes down from $onChanges :

“I don’t get it”. Neither would I at this point, because we’re assigning the entire changes Object back to the this.user property, so let’s change it to this:

var childComponent = {   ...   controller: function () {     this.$onChanges = function (changes) {       this.user = changes.user.currentValue;     };   } };

Here we’re using the user property that gets passed down to us, and then referencing the currentValue property to get the… current value of the change hash, obviously. Try the updated code:

Cloning “change” hashes for “immutable” bindings

Now we’ve got data into the component using one-way bindings, we can be a little smarter. Data passed down through one-way bindings are not $watch -ed by Angular, however they are passed by reference . This means any changes we make to Objects (specifically, primitives are not bound by reference) actually affect the parent, which almost acts as a two-way binding without meaning to. This is however, by design. We can be a little smarter and clone the data initially bound to treat the data being passed down as (almost) “immutable”, which means it cannot be changed from inside the child component.

Here’s a fiddle demonstrating this (note the user | json ) filter has now moved up to the parent (notice as you type, the parent Object is updated too):

Switching things up, we can use angular.copy() to clone the Object(s) passed down to us, which breaks the “binding by reference” that happens with JavaScript Objects:

var childComponent = {   ...   controller: function () {     this.$onChanges = function (changes) {       this.user = angular.copy(changes.user.currentValue);     };   } };

Better yet, we’ll add an if statement to check that the property exists first, which is a good practice:

var childComponent = {   ...   controller: function () {     this.$onChanges = function (changes) {       if (changes.user) {         this.user = angular.copy(changes.user.currentValue);       }     };   } };

Or even better , because changes from the parent are passed immediately to this.user , which we then take a clone of the changes.user.currentValue Object, the two are identical . Which means these are doing the exact same thing:

this.$onChanges = function (changes) {   if (changes.user) {     this.user = angular.copy(this.user);     this.user = angular.copy(changes.user.currentValue);   } };

This is my preferred approach (using angular.copy(this.user) ).

Try it now, a copied Object from the bindings , passed from the parent, then cloned and reassigned:

Feels good right? Now we have a cloned Object that we can fully mutate without two-way binding (which is not really recommend anymore, sorry). So what do we do once we’ve updated data? We send it back via an event. This isn’t specifically part of any lifecycle hook, but it’s what $onChanges was designed for. Inputs and Outputs (Input = data, Output = events). Let’s do it.

One-way dataflow + events

What we’ve done so far by using bindings and $onChanges covers the one-way dataflow part, so we’ll expand on it with adding some events in.

To get data back up to our parentComponent , we need to delegate a function to be used as an event callback, let’s add a function called updateUser , which expects an event back as an argument. Bare with me, this will make more sense once we’re done:

var parentComponent = {   ...   controller: function () {     ...     this.updateUser = function (event) {       this.user = event.user;     };   } };

From the design here, we can see we’re expecting event to be an Object, with a property called user , which we’ll pass back up from the child component. First, we need to delegate the event into the component:

var parentComponent = {   template: `     <div>       ...       <child-component         user="$ctrl.user"         on-update="$ctrl.updateUser($event);">       </child-component>     </div>   `,   controller: function () {     ...     this.updateUser = function (event) {       this.user = event.user;     };   } };

Notice I’m creating a property called on-update , with an on-* prefix, this is good practice for determining that your bindings are in fact, events (think onclick/onblur).

Now we’ve passed the function into <child-component> , we need to use bindings to gain access to it:

var childComponent = {   bindings: {     user: '<',     onUpdate: '&' // magic ingredients   },   ...   controller: function () {     this.$onChanges = function (changes) {       if (changes.user) {         this.user = angular.copy(this.user);       }     };     // now we can access this.onUpdate();   } };

Using & allows us to delegate functions, so we’re literally passing this.updateUser from parentComponent down to childComponent , mutating the data inside childComponent (which we clone from bindings inside $onChanges ) and then passing it back up via the delegated function. Simple flow of data. Data down and events up.

Next, we need to expand our template to allow the user to update the cloned data:

var childComponent = {   ...   template: `     <div>       <input type="text" ng-model="$ctrl.user.name">       <a href="" ng-click="$ctrl.saveUser();">Update</a>     </div>   `,   ... };

This means we need this.saveUser inside the controller, so let’s add it:

var childComponent = {   ...   template: `     <div>       <input type="text" ng-model="$ctrl.user.name">       <a href="" ng-click="$ctrl.saveUser();">Update</a>     </div>   `,   controller: function () {     ...     this.saveUser = function () {      };   } };

Though, this time once we “save” in the local component, it’s really just a wrapper to update the parent, so let’s call that parent method this.updateUser :

var childComponent = {   ...   controller: function () {     ...     this.saveUser = function () {      this.updateUser();     };   } };

Okay, bear with me, we’re in the final phase. This is where things get… interesting. Instead of just passig back this.user into the function, we’re going to fake an $event Object, which complies with how Angular 2 does this (using EventEmitter ), and also provides global consistency between your templates to fetch data back through the $ctrl.updateUser($event); call we delegate down into the child component. The $event argument is a real thing in Angular, you can use it with ng-submit and so on. If you remember this function:

this.updateUser = function (event) {   this.user = event.user; };

We’re expecting an event back with a user property. So let’s add that inside our child component’s saveUser function:

var childComponent = {   ...   controller: function () {     ...     this.saveUser = function () {       this.updateUser({         $event: {           user: this.user         }       });     };   } };

“This looks weird”. Maybe a little, but it’s consistent and once you’ve used it ten times, you’ll never stop using it. Essentially what this does is create this.saveUser in the child, then call this.updateUser which is the delegate function from bindings , then we pass it the event Object, which passes the mutated data back up to the parent.

Try it:

Is two-way binding through “=” syntax dead?

Yes. One-way binding is established as the best way to approach data flow, React, Angular 2 and others all use it. Now it’s Angular 1’s turn, a little late to the party, but hey, this is insanely powerful and changes the way new Angular 1.x apps are created.

Comprehensive dive into Angular 1.5 lifecycle hooks

Using isFirstChange()

One more feature from $onChanges , is inside the changes hash we’re given, it’s actually an instance of a simple constructor called SimpleChange that contains an isFirstChange method on the prototype:

function SimpleChange(previous, current) {   this.previousValue = previous;   this.currentValue = current; } SimpleChange.prototype.isFirstChange = function () {   // don't worry what _UNINITIALIZED_VALUE is :)   return this.previousValue === _UNINITIALIZED_VALUE; };

This is how those change hashes are created (with new ) against each binding (I had to polyfill all this, was enjoyable… kinda).

Why would you want to use it? We mentioned above that $onChanges gets called at runtime, not only when parent Objects change, so we can essentially skip an initial call if it’s the first time the $onChange function is called by checking isFirstChange against a property (not the whole Object):

this.$onChanges = function (changes) {   if (changes.user.isFirstChange()) {     console.log('First change...', changes);     return; // Maybe? Do what you like.   }   if (changes.user) {     this.user = angular.copy(this.user);   } };

Here’s a JSFiddle if you want to check the console .

$onDestroy

We’ll finish with a nice easy one. $onDestroy is basically this:

function SomeController($scope) {   $scope.$on('$destroy', function () {     // destroy event   }); }

Using $onDestroy

You can probably guess what this one looks like:

var childComponent = {   bindings: {     user: '<'   },   controller: function () {     this.$onDestroy = function () {       // component scope is destroyed     };   } };  angular   .module('app')   .component('childComponent', childComponent);

If you’re using $postLink to set DOM event listeners or any non-native Angular logic, this is the place to clean up.

Conclusion

Angular 1.x app developer just changed forever with one-way dataflow, events and these lifecycle hooks. More posts on component architecture coming soon.

转载本站任何文章请注明:转载至神刀安全网,谢谢神刀安全网 » Comprehensive dive into Angular 1.5 lifecycle hooks

分享到:更多 ()

评论 抢沙发

  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址