AngularJS 1.5+ incontra i Components

AngularJS non molto tempo fa, con la versione 1.5, ha introdotto nativamente il concetto di WebComponent, “portando a casa” gran parte dell’esperienza fatta con Angular 2. Cominciare ad usare i Components con AngularJS oggi non solo significa scrivere codice molto più facilmente aggiornabile verso Angular 2, soprattutto se scritto in ES6, ma permette anche di modularizzare e riusare il codice in modo più semplice, in linea con lo stile di programmazione del frontend che sarà sempre più orientato ai componenti.

Perché i componenti? E le direttive?

Secondo la documentazione ufficiale, un component non è altro che una speciale direttiva… più semplice! Di fatto, le direttive di AngularJS potevano essere usate per isolare parti del DOM per creare componenti, ma non solo. Possono essere attributi di un tag, si possono comporre, possono manipolare il DOM o il modello e così via. Diciamoci la verità: il concetto di direttiva è troppo ampio da diventare ambiguo! Con l’introduzione dei componenti, si definisce un preciso perimetro di competenze tra ciò che un componente deve fare e cosa non deve. Per esempio, i componenti non vanno usati se si ha necessità di operare in fase di “pre-link” o “compile” (perché non ci sono!), possono essere solo dei tag (che ne aggregano altri) e mai attributi di un tag e hanno sempre uno scope isolato, riducendo al massimo l’accoppiamento parent-child (che è uno dei grandi “rischi” a cui ti portano delle direttive a mio avviso).

Di fatto quindi, adottare i componenti permette di scrivere codice più facilmente portabile verso Angular 2, introducendo una architettura modulare del DOM più manutenibile rispetto alle solite direttive. Nell’architettura a componenti, una applicazione diventa una struttura di tag ad albero nella quale ognuno ha ben definito i propri input e output (limitando quindi il two-way databinding come vedremo a breve) così da rendere più prevedibile il cambiamento di stato dell’applicazione e dei suoi componenti. In questa struttura, solitamente i tag più vicini alla radice sono definiti “smart components” perché sono quelli che gestiscono i dati, quelli più vicini alle foglie invece sono detti “dumb components” perché sono di pura interazione e sono altamente riusabili.
Per avvicinarsi ai componenti, un primo passo quindi può essere quello di convertire qualche direttiva in un componente: su DZone spiegano come!

A proposito di Angular 2, quando ne abbiamo parlato qua su CodingJam, abbiamo portato come esempio una applicazione che gestisce i TODO. Facciamo quindi una sorta di “downgrade” ad AngularJS (controcorrente si!!) per capire quanto poche siano le differenze, soprattutto se lavoriamo in ES6. Abbiamo già visto infatti nello scorso post come si lavora con AngularJS e ES6, quindi basta vedere adesso come scrivere i component.

Quick start

Meglio vedere un po’ di codice prima di tante chiacchiere. Secondo la documentazione ufficiale, per scrivere un componente basta:

function TodoListController() {

}

angular.module('todoApp').component('todoList', {
  templateUrl: 'todo-list.html',
  controller: TodoListController,
  bindings: {
    todos: '='
  }
});

Come forse atteso, un componente si dichiara tramite la funzione .component(), che si aspetta due argomenti:

  • il nome del componente (che corrisponderà al tag html todo-list: come avviene nelle direttive, si passa dal camel case al kebab case)
  • oggetto di configurazione (a differenza del solito, in questo caso non è prevista una factory function)

Il componente quindi può essere usato come se fosse un nuovo tag:

<div ng-controller="MainCtrl as ctrl">
  <todo-list todos="ctrl.todoList"></todo-list>
</div>

A prima vista non è quindi molto dissimile dalle direttive che già conosciamo (anzi, dal punto di vista dell’html è uguale). Vediamo quindi alcuni aspetti su come deve essere costruito l’oggetto di configurazione:

  • templateUrl: è il percorso del file che contiene il template html da applicare. E’ possibile usare anche template per indicare direttamente l’html in-line (in una stringa).
  • controller: ogni componente probabilmente avrà un controller associato. Normalmente vi si accede dal template tramite l’alias $ctrl, ma è possibile customizzarlo tramite controllerAs.
  • bindings: lo scope di un componente è sempre isolato (a differenza delle direttive). Tramite il bindings si definisce il legame tra il componente e il mondo esterno.

L’elenco completo è disponibile sulla documentazione ufficiale.

I componenti e lo scope

Le direttive, a differenza dei componenti, hanno molta (troppa a mio avviso) libertà, soprattutto per quanto riguarda lo scope delle variabili. E’ molto comodo infatti rendere dipendente una direttiva dal contesto in cui viene usata, ma così non è riusabile: spesso quindi è meglio isolarne lo scope, altrimenti al crescere dell’applicazione non si capisce più chi modifica cosa. Con i componenti invece si è obbligati: lo scope è sempre isolato! Questo permette di impostare una architettura a componenti dove si definisce in modo chiaro input e output del componente (come se fosse una sorta di API): i componenti quindi non manipolano né DOM, né dati che sono fuori dal proprio scope, ma ricevono degli input e producono degli output attraverso l’interazione della view che mettono a disposizione.

A differenza però di Angular 2, dove questa architettura input/output è l’unico modo per usare i componenti, in AngularJS non è così: i componenti infatti sono sostanzialmente delle direttive, per cui il binding:

bindings: {
   todos: '='
}

è bidirezionale, cioè che una modifica all’interno del componente di todos si ripercuote anche nel padre.

Il detto “il troppo stroppia” è applicabile anche in questo caso: troppo binding bidirezionale (cioè con il simbolo '=') fa male a tal punto da essere diventato una worst practice per i componenti! Meglio ricorrere al one-way binding come questo:

bindings: {
   todos: '<',   //INPUT
   caption: '@', //INPUT
   onDone: '&',  //OUTPUT
}

Che significano questi segni?

  • '@': binding di interpolazione (che funge da input) usato anche nelle direttive per quei valori provenienti dal DOM come le stringhe.
  • '<': binding monodirezionale (di input) introdotto in AngularJS 1.5, supportato anche dalle direttive. Usando questo simbolo, la variabile di scope non viene messa in watch, quindi se gli si assegna un nuovo valore, il padre non cambia. Attenzione però: la referenza è la stessa sia nel padre che ne figlio, quindi una modifica su ogni suo attributo si ripercuote nel padre. Questo è un motivo in più per impedire ad un componente di modificare i dati che riceve in ingresso! E’ bene gestire ogni modifica tramite gli eventi di output (ovvero chiamate alle funzioni che vengono passate al componente). La notazione '< ?' rende il parametro di input opzionale.
  • '&': come nelle direttive, questo simbolo serve per passare una funzione al componente in modo da essere usata come callback degli eventi (per generare un output) verso il componente chiamante.

Ciclo di vita di un componente

Come in Angular 2, anche i componenti di AngularJS sono scanditi da un proprio ciclo di vita, che è praticamente uguale a quello della versione 2, probabilmente per facilitarne l'upgrade. Dalla versione 1.5.3 infatti è possibile implementare dei "lifecycle hooks" sia per direttive che componenti: ciò significa dichiarare funzioni con un nome specifico nel controller del component che vengono richiamate nelle specifiche fasi di vita del componente stesso. Tra questi:

  • $onInit(): chiamato sul controller dopo che le variabili di binding sono disponibili. E' il posto giusto dove gestire anche la comunicazione tra componenti, dove è possibile iniettare nel controller figlio il controller del componente padre. La documentazione ufficiale ha un esempio a riguardo. Questo approccio a mio avviso può essere utile per instaurare una comunicazione "privata" tra figlio e padre, senza dover passare dagli eventi, che, come abbiamo detto, fanno parte dell'API pubblica del componente.
  • $onChanges(changesObj): chiamato quando si registra un cambiamento su un binding monodirezionale. Da notare che si riferisce al cambiamento di referenza sulla variabile di binding (per esempio una riassegnazione), non alla modifica di un suo attributo!
  • $onDestroy(): chiamato sul controller quando viene distrutto lo scope.
  • $postLink(): è il posto giusto dove manipolare il DOM del componente. Dall'introduzione dei "lifecycle hooks", questa affermazione ha lasciato diverse persone perplesse, compreso me. Una delle regole d'oro infatti è sempre stata quella di non manipolare il DOM nei controller: viene fuori adesso invece che l'affermazione è sempre vera, ad esclusione dei controller dei componenti (e delle direttive). Come si accede quindi al DOM? E' possibile iniettare nel controller l'elemento che rappresenta il componente tramite $element.

Ulteriori dettagli interessanti possono essere trovati sul blog di Thoughtram.

Esempio

Torniamo quindi al codice: su Github è presente un progetto di test che fa proprio il downgrade della Todo List creata in Angular2 qualche tempo fa.

Tutti gli esempio sono stati scritti in ES6, per somigliare il più possibile alla versione di Angular2 (addio $scope quindi!), con grande ispirazione tratta da AngularJS ES6 style guide.

Cominciamo quindi con il componente principale chiamato todos.

import angular from 'angular';
import {TodoFormComponent} from '../todo-form/todo-form.component';
import {TodoListComponent} from '../todo-list/todo-list.component';

const TodosComponent = {
    selector: 'todos',
    template: `
        <div class="row">
            <h1 class="text-center">{{$ctrl.title}}</h1>
            <todo-form on-new-element="$ctrl.addNewElement($event)"></todo-form>
            <hr/>
            <todo-list todos="$ctrl.todoList" 
                       on-done="$ctrl.markDone($event)" 
                       on-undone="$ctrl.markUndone($event)"></todo-list>
    	</div>
    `,
    controller: class TodosController {
        /* @ngInject */
        constructor($log, TodoListService) {
            ...
        }
        addNewElement(todoLabel) { ... }
        markDone(todo) { ... }
        markUndone(todo) { ... }
    }
}

Possiamo quindi considerare il nostro componente come un'oggetto (costante) fatto proprio come la sintassi di AngularJS si aspetta (cioè con l'attributo controller o template). Siamo liberi però di renderlo "migliore" dichiarando il controller come classe o aggiungendo l'attributo selector, in modo da sapere come si chiama il componente semplicemente guardando questo oggetto. Non dimentichiamoci poi di registrare il nuovo componente nel modulo che stiamo creando:

angular.module('todos-module', [])
    .component(TodosComponent.selector, TodosComponent);

Detto questo, date un'occhiata alle differenze tra TodosComponent in AngularJS e Angular2!!

Input e Output di un componente

Vale la pena insistere ancora un po' sul concetto di input e output di un componente, in alternativa al binding bidirezionale. Abbiamo visto le motivazioni che hanno portato a questo cambio di paradigma, ma come si realizza nel concreto?

L'idea è che:

  • gli input consistono nel passare la referenza di una variabile al componente
  • gli output consistono nel passare una funzione al componente, invocata da esso quando vuole restituire un valore al padre

Di fatto quindi è un approccio di design: niente vieta in AngularJS di modificare gli input ricevuti! E' solo quindi una questione di disciplina delegare la modifica di un dato ricevuto nuovamente al chiamante, tramite la callback fornita (con il binding '&'). In Angular2 invece gli output vengono restituiti al componente padre sempre tramite eventi: in questo caso non è possibile farlo e la soluzione adottata è quella delle callback, che per convenzione hanno sempre un nome che ricorda un evento (per esempio on-done).

Nel template di todos vediamo per esempio:

<todo-list todos="$ctrl.todoList" 
     on-done="$ctrl.markDone($event)" 
     on-undone="$ctrl.markUndone($event)"></todo-list>

La variabile todoList di TodosController viene passata in input al componente figlio e legata alla variabile todos del suo controller. Allo stesso modo, in output, le funzioni markDone e markUndone vengono associate rispettivamente agli eventi del componente figlio on-done e on-undone.

Da notare $event: avrebbe potuto chiamarsi in qualsiasi modo (ma questo è il nome che va usato in Angular2, quindi meglio mettersi avanti), ma l'importante è che sia definito se ci si aspetta che il componente figlio passi un valore come argomento alla funzione, altrimenti non viene propagato.

Il componente todo-list infatti definisce come binding:

bindings: {
   todos: '<',
   onDone: '&',
   onUndone: '&'
}

un attributo (input) e due funzioni (output - occhio al kebab-case che diventa camelCase come nelle direttive!!) che nel template, vengono usate così:

<button ng-click="$ctrl.onDone({$event: todo})" ng-show="!todo.done">Done</button>
<button ng-click="$ctrl.onUndone({$event: todo})" ng-show="todo.done">Redo</button>

Per passare quindi la variabile todo al componente padre, è necessario che sia "wrappata" in un oggetto che ha come chiave $event (o come è stato chiamato nel template del componente padre). Il componente padre riceve direttamente il valore:

markDone(todo) {
   todo.done = true;
}

Conclusioni

Con l'introduzione dei componenti in AngularJS, si avvicina il paradigma di programmazione a quello del web che sarà. Onestamente ho molti dubbi che in una applicazione mediamente complessa si riesca a fare l'upgrade ad Angular2 anche se si cominciano ad adottare i componenti. A prescindere da questo, rimane un ottimo approccio di sviluppo a mio avviso, soprattutto per la questione degli input/output che mettono ordine e rendono meno imprevedibile le propagazioni di cambiamenti di stato.

Andrea Como

Sono un software engineer focalizzato nella progettazione e sviluppo di applicazioni web in Java. Presso OmniaGroup ricopro il ruolo di Tech Leader sulle tecnologie legate alla piattaforma Java EE 5 (come WebSphere 7.0, EJB3, JPA 1 (EclipseLink), JSF 1.2 (Mojarra) e RichFaces 3) e Java EE 6 con JBoss AS 7, in particolare di CDI, JAX-RS, nonché di EJB 3.1, JPA2, JSF2 e RichFaces 4. Al momento mi occupo di ECM, in particolar modo sulla customizzazione di Alfresco 4 e sulla sua installazione con tecnologie da devops come Vagrant e Chef. In passato ho lavorato con la piattaforma alternativa alla enterprise per lo sviluppo web: Java SE 6, Tomcat 6, Hibernate 3 e Spring 2.5. Nei ritagli di tempo sviluppo siti web in PHP e ASP. Per maggiori informazioni consulta il mio curriculum pubblico. Follow me on Twitter - LinkedIn profile - Google+