Custom element Aurelia vs Angular 1.x

Je continue à tester Aurelia pour savoir si on doit lâcher Angular et je découvre donc de superbes choses. Cette fois, c'est à propos des CustomElement. Je vais vous faire une comparaison d'une directive un peu poussée que j'ai d'abord écrite pour Angular et que j'ai ensuite portée sur Aurelia. J'ai un peu galéré car j'ai perdu beaucoup de temps à chercher à faire des choses trop complexes, alors qu'au final, sur Aurelia, c'est d'une facilité déconcertante.

La directive en question propose d'intégrer un bouton et un panel afin que ce bouton affiche ce panel dans un autre élément du DOM qui fait office de menu en overlay qui va donc s'afficher et se masquer. Voici le markup qu'on aura au plus haut niveau :

<!-- Angular -->  
<responsive-menu>  
  <button
    trigger>
    X
  </button>
  <section
    menu>
    <p>Ceci est un exemple</p>
    <p
      ng-click="$close()">Close</p>
  </section>
</responsive-menu>  
<!-- Aurelia -->  
<template>  
  <require from="../ui/responsive-menu"></require>
  <responsive-menu>
    <button
      trigger>
      X
    </button>
    <section
      menu>
      <p>Ceci est un exemple</p>
      <p
        close>Close</p>
    </section>
  </responsive-menu>
</template>  

C'est kif kif. Aurelia nécessite d'importer le custom element dans le template, tandis qu'Angular dispose de sa directive dans tous les contexte de l'application.
Allons donc voir la directive responsive-menu d'Angular :

import {directive, inject} from '../../config/decorators';

@directive({
  restrict: 'E'
})
@inject('$element')
export class ResponsiveMenu {  
  @inject MainUi = null;
  @inject $timeout = null;

  constructor($element) {
    let trigger = $element[0].querySelector('[trigger]'),
      menu = $element[0].querySelector('[menu]');

    // This timeout is used to avoid breaking digest cycle
    this.$timeout(() => angular.element(menu).remove());

    angular.element(trigger).on('click', e => {
      e.stopPropagation();
      if (this.MainUi.menu.opened) {
        this.MainUi.menu.close();
      } else {
        this.MainUi.menu.open({
          template: menu.cloneNode(true),
          scope: $element.scope()
        });
      }
    });
  }
}

Le principe est le suivant, je trouve l'élément qui servira à ouvrir et fermer le menu grâce à son attribut trigger, je trouve l'élément du menu à l'aide de l'attribut menu. J'enlève le menu du DOM et le garde en mémoire. J'ajoute un listener sur le trigger pour qu'au click on appelle la méthode open() ou close() du service MainUi. On passe à open un template qui est en fait la copie du menu. Je me suis inspiré de angular-ui pour cette interface.

Coté Aurelia voici le custom element :

import {bindable, inject} from 'aurelia-framework';  
import {MainUi} from '../main/main-ui';

@inject(MainUi)
export class ResponsiveMenu {  
  constructor(MainUi) {
    this.MainUi = MainUi;
  }

  attached() {
    this.trigger.addEventListener('click', e => this.toggle(e));
    this.trigger.addEventListener('touchend', e => this.toggle(e));
    this.menu.parentNode.removeChild(this.menu);
    Array.prototype.forEach.call(this.menu.querySelectorAll('[close]'), el => {
      el.addEventListener('click', e => this.close());
    });
  }

  toggle (e) {
    e.stopPropagation();
    e.preventDefault();
    this.MainUi.menu.toggle({
      menu: this.menu
    });
  }

  close () {
    this.MainUi.menu.close();
  }
}

Le principe est le même mais la forme change un peu. Notamment, la partie où je cherche tous les éléments qui ont un attribut close. Il s'agit d'un workaround qui n'est peut être pas la meilleure façon de faire pour pouvoir définir une fonction close() au niveau du custom element. Pour Angular, c'est plus facile puisqu'on crée un nouveau scope qu'on peut modifier à volonté. On va voir par la suite que c'est MainUi qui va ajouter cette méthode close() pour angular, mais qu'on ne peut pas le faire de la même façon pour aurelia.

Passons donc au service MainUi, sur Angular (la classe Menu est instanciée dans MainUi.menu) :

class Menu {  
  @inject $document = null;
  @inject $compile = null;
  @inject $rootScope = null;

  constructor () {
    this.$document.on('click', e => {
      if (!this.opened) {
        return;
      }
      if (!this.menu[0].contains(e.target)) {
        this.close();
      }
    });
  }

  open (opts) {
    this.$document[0].body.classList.add('menu-view-displayed');
    this.createMenu(opts);
  }
  close () {
    this.$document[0].body.classList.remove('menu-view-displayed');
  }
  toggle () {
    if (this.opened) {
      this.close();
    } else {
      this.open();
    }
  }
  get opened () {
    return this.$document[0].body.classList.contains('menu-view-displayed');
  }

  get menu () {
    return angular.element(this.$document[0].querySelector('.menu-view'));
  }

  createMenu (opts = {}) {
    let view,
      template,
      scope;

    if (opts.template) {
      template = opts.template;
    }
    if (opts.scope) {
      scope = opts.scope;
    } else {
      scope = this.$rootScope.$new();
    }

    scope.$close = () => this.close('menu');

    view = this.$compile(template)(scope);

    this.menu.html('');
    this.menu.append(view);
  }
}

C'est la méthode createMenu qui nous intéresse ici. Cette méthode qui permet donc de générer la vue du menu à partir du template qui a été intégré tout en haut. Pour y parvenir, nous sommes obligé de faire appel à $compile, générer un nouveau scope, puis insérer la vue dans le menu. Un avantage tout de même, comme dit précédemment, on peut modifier le scope et ainsi ajouter notre méthode $close().

Voici la version Aurelia :

class Menu {  
  constructor () {
    let listener = e => {
      if (!this.opened) {
        return;
      }
      if (!this.menu.contains(e.target)) {
        this.close();
      }
    };
    document.addEventListener('click', listener);
    document.addEventListener('touchend', listener);
  }

  open (opts) {
    document.body.classList.add('menu-view-displayed');
    this.createMenu(opts);
  }
  close () {
    document.body.classList.remove('menu-view-displayed');
  }
  toggle (opts) {
    if (this.opened) {
      this.close();
    } else {
      this.open(opts);
    }
  }
  get opened () {
    return document.body.classList.contains('menu-view-displayed');
  }

  get menu () {
    return document.querySelector('.menu-view');
  }

  createMenu (opts) {
    this.menu.innerHTML = '';
    this.menu.appendChild(opts.menu);
  }
}

C'est ici qu'on découvre toute la simplicité d'Aurelia en lisant la méthode createMenu : pas besoin de compiler quoi que ce soit !

Grâce à cette comparaison, on peut réaliser deux choses. La première c'est qu'Aurelia est bien plus simple et compréhensible que Angular. En effet, quand on veut créer un custom element avec angular, il faut créer une directive et correctement la configurer en faisant attention aux cycles de digest et ne pas oublier de la charger dans l'un des modules de l'app. Pour Aurelia, il suffit simplement de créer une classe, et l'importer dans le template. Pour en savoir plus sur les Custom Element avec Aurelia, je vous invite à lire cet article de Rob Eisenberg, son auteur.
La seconde chose, c'est que porter une app Angular vers Aurelia est relativement simple et rapide. On sent vraiment que Aurelia a été largement inspiré par Angular et qu'il en a repris le meilleur. La migration n'en sera que facilitée si votre application Angular est écrite en ES6 à la base.

Hadrien

Hi, I'm a french Javascript Lead Developer, Web Architect from Toulouse, France. I've worked for 12 years for many projects with YUI, AngularJS, Aurelia.io and now React and React native.

Toulouse, France https://hadrien.eu