Amélioration progressive avec Aurelia.io

Aurelia permet d'améliorer une page progressivement (progressive enhancement). C'est une vieille technique permettant d'avoir une page web avec du contenu indexable, donc rendue coté serveur et dont les fonctionnalités interactives sont ajoutées par javascript lors de l'exécution de la page avec pour principal but de fournir une page accessible et référençable même si le client ne dispose pas de javascript.
La plupart des Single Page Application (SPA) sont montées à partir d'une simple page web vide dans laquelle tout le DOM de l'application va être construit. Ainsi, si on désactive javascript ou qu'on crawle la page (googlebot), on ne trouve pas le moindre contenu. Ça n'est pas un problème dans le cas d'une application, mais quand le site doit fournir du contenu, c'est dommage. C'est à ça que sert la construction d'une page en progressive enhancement. Nous allons voir un cas pratique et découvrir comment le traiter avec Aurelia.

Exemple

J'utilise personnellement cette technique pour Reverb qui affiche des pages référençables : les profils. Mais ces profils sont aussi des pages de la SPA et contiennent donc des custom element. Ils contiennent aussi le même header et le même footer que les pages de la SPA.

Un header

Nous allons étudier le cas du header. Ce header contiendra le titre du site et un menu. Le titre devra être présent dans les deux phases : sans javascript et avec javascript. Le menu aussi, mais avec javascript, il sera modifié pour générer la version mobile à l'aide de materializecss.

Nous voudrions donc que notre serveur génère quelque chose de ce genre :

<reverb-header>  
  <nav
    role="navigation">
    <div
      class="nav-wrapper container">
      <h1>
        <a
          href="/"
          target="_top"
          class="brand-logo">
          <img
            src="/static/images/logo-header.png"
            class="logo"
            alt="reverb">
          Reverb
          <small>beta</small></a>
      </h1>
    </div>
  </nav>
</reverb-header>

Nous n'avons pour le moment que le titre du site. La balise h1 est bien présente sans javascript et Google peut indexer le titre du site sans problème. Ne reste qu'à expliquer à Aurelia qu'il doit instancier le custom element <reverb-header>.

Pour cela, nous allons modifier le bootstrap (généralement src/main.js).

export function configure(aurelia) {  
  aurelia.use
    .standardConfiguration()
    .developmentLogging()
    .globalResources('main/reverb-header');

  aurelia.start().then(a => a.enhance(document));
}

Ceci va remplacer le setRoot d'un projet Aurelia classique et ne va donc pas supprimer tout le dom présent dans la page. Il déclare aussi à l'aide de globalResources les custom elements globaux qui vont donc pouvoir s'instancier dans la page. Ici, nous avons chargé main/reverb-header. Voici sa déclaration :

export class ReverbHeader {}  

Et son template :

<template>  
  <content></content>
</template>  

Il ne fait rien pour le moment. Nous allons maintenant prendre en charge le menu et sa version mobile.

Commençons par modifier le markup pour avoir un menu :

<reverb-header>  
  <nav
    role="navigation">
    <div
      class="nav-wrapper container">
      <h1>
        <a
          href="/"
          target="_top"
          class="brand-logo">
          <img
            src="/static/images/logo-header.png"
            class="logo"
            alt="reverb">
          Reverb
          <small>beta</small></a>
      </h1>
      <ul
        class="right hide-on-med-and-down">
        <li>
          <a
            href="/auth/twitter">Login</a>
        </li>
      </ul>

      <a
        href="#"
        data-activates="nav-mobile"
        class="button-collapse"><i class="material-icons">menu</i></a>
    </div>
  </nav>
</reverb-header>

Materialize a besoin d'avoir deux fois le DOM du menu : une fois pour sa version desktop et une autre fois pour sa version mobile. Il propose ensuite une méthode pour permettre d'afficher/masquer le menu en cliquant sur un bouton. Nous allons donc modifier ReverbHeader en conséquence :

import {inject} from 'aurelia-framework';  
import $ from jQuery;

@inject(Element)
export class ReverbHeader {  
  constructor(element) {
    this.element = element;
  }

  attached() {
    const menu = this.element.querySelector('ul');
    const mobileMenu = menu.cloneNode(true);
    mobileMenu.id = 'nav-mobile';
    mobileMenu.className = 'side-nav';

    menu.parentNode.insertBefore(mobileMenu, menu.nextSibling);

    $(this.element.querySelector('.button-collapse')).sideNav();
  }
}

Et voilà, nous avons un menu généré par le serveur et amélioré grâce à un custom element.

Interpolation

Maintenant, ce qu'on aimerait, c'est pouvoir utiliser toute la puissance du langage de template de Aurelia dans le template généré par le serveur. Par exemple, afficher le nom de l'utilisateur à l'aide de ${user.name} et afficher/masquer les liens de connexion/déconnexion selon que l'utilisateur est connecté ou pas.

On pourrait simplement modifier le DOM de l'élément du custom element à l'aide de this.element et querySelector comme on l'a fait pour le menu mais c'est très vilain et ça peut devenir très compliqué. Moi ce que j'aimerais c'est :

<reverb-header>  
  <nav
    role="navigation">
    <div
      class="nav-wrapper container">
      <h1>
        <a
          href="/"
          target="_top"
          class="brand-logo">
          <img
            src="/static/images/logo-header.png"
            class="logo"
            alt="reverb">
          Reverb
          <small>beta</small></a>
      </h1>
      <ul
        class="right hide-on-med-and-down">
        <li
          if.bind="!user">
          <a
            href="/auth/twitter">Login</a>
        </li>
        <li
          if.bind="user">
          Welcome ${user.name}
        </li>
        <li
          if.bind="user">
          <a
            href="/auth/logout"
            target="_top">
            <i
              class="material-icons">power_settings_new</i>
          </a>
        </li>
      </ul>

      <a
        href="#"
        data-activates="nav-mobile"
        class="button-collapse"><i class="material-icons">menu</i></a>
    </div>
  </nav>
</reverb-header>

Mais il s'avère que c'est un peu plus compliqué que prévu car ce qui est placé dans la balise <content> dispose d'un viewModel différent de celui du custom element.

Amélioration personnalisée

Aurelia permet d'initialiser l'enhancement de la page en passant un objet viewModel. Il devient alors possible de passer un objet qui sera un service injectable, puis, dans le custom element, injecter ce service, et modifier ses propriétés. Cela donnera alors ceci :

export const GlobalData = {};  
import {GlobalData} from './global-data';

export function configure(aurelia) {  
  const globalData = aurelia.container.get(GlobalData);
  aurelia.use
    .standardConfiguration()
    .developmentLogging()
    .globalResources('main/reverb-header');

  aurelia.start().then(a => a.enhance(globalData, document));
}
import {inject} from 'aurelia-framework';  
import $ from jQuery;  
import {GlobalData} from './global-data';

@inject(Element, GlobalData)
export class ReverbHeader {  
  constructor(element, globalData) {
    this.element = element;
    this.globalData = globalData;
  }

  attached() {
    const menu = this.element.querySelector('ul');
    const mobileMenu = menu.cloneNode(true);
    mobileMenu.id = 'nav-mobile';
    mobileMenu.className = 'side-nav';

    menu.parentNode.insertBefore(mobileMenu, menu.nextSibling);

    $(this.element.querySelector('.button-collapse')).sideNav();
    this.globalData.user = {name: 'Maurice'};
  }
}

Ça fonctionne, mais c'est vilain. Je préfèrerais que les données de mon custom element restent uniquement dans mon custom element. Imaginons que je me retrouve avec deux custom element qui utilisent chacun le même nom de variable pour une donnée différente : soit je dois gérer deux services et appeler la méthode enhance en ciblant précisément les éléments correspondant, soit je dois toujours vérifier que le nom de la variable n'est pas déjà pris. Un autre problème est qu'il est toujours impossible de require un custom element. Bref, cherchons une autre solution.

Modifier le template à la compilation

Cette solution est définitivement la meilleure. Nous allons faire usage du décorateur processContent pour remplacer le template du custom element par le dom de la page générée par le serveur. En gros, tout ce qui est entre la balise d'ouverture et de fermeture du custom element devient son template et donc l'interpolation, les custom-attribute, les require de custom element fonctionnent.
Notre classe va devenir ainsi :

import {inject, processContent, noView} from 'aurelia-framework';  
import $ from jQuery;  
import {GlobalData} from './global-data';

@inject(Element, GlobalData)
@processContent(function(viewCompiler, viewResources, element, instruction) {
  instruction.viewFactory = viewCompiler.compile(`<template>${element.innerHTML}</template>`, viewResources, instruction);
  element.innerHTML = '';
  return false;
})
@noView
export class ReverbHeader {  
// …
}

Deux choses à retenir : processContent retourne false. Ça veut dire que le template n'aura pas à être compilé. En effet, nous le faisons à la main juste avant. Le second point est l'usage de noView qui permet de se passer d'un template. En effet, puisque nous écrivons nous même le template, il n'est plus utile d'avoir recours au workaround du template qui ne contient qu'une balise <content>.

Cependant, il peut devenir intéressant d'avoir recours malgré tout à un vrai template et une balise <content>. Modifions donc le template reverb-header.html :

<template>  
  <div
    class="header">
    <content></content>
  </div>
</template>  

Il suffira alors de modifier la procédure de construction du template en conséquence :

@processContent(function(viewCompiler, viewResources, element, instruction) {

  const template = instruction.type.viewFactory.template,
    content = template.querySelector('content'),
    innerTemplate = document.createElement('div');

  innerTemplate.innerHTML = element.innerHTML;
  content.parentNode.replaceChild(innerTemplate, content);
  instruction.viewFactory = viewCompiler.compile(template, viewResources, instruction);
  element.innerHTML = '';
  return false;
})

Ici, nous prenons le template et au lieu de l'écraser, nous remplaçons la balise <content> trouvée par le dom de l'élément. On peut aller encore plus loin en gérant les selector.

Content selector

Nous pourrions aussi vouloir faire usage de la fonctionnalité des sélections de contenu : l'attribut select de la balise content. Elle permet de ne récupérer qu'un élément particulier au lieu du DOM complet. Dans notre exemple, le serveur génère donc le titre et le menu. Mais finalement, nous ne voulons garder que le titre. Le template de notre custom element sera alors :

<template>  
  <content select="h1"></content>
</template>  

On pourrait aussi vouloir plutôt réorganiser le DOM. On pourrait par exemple inverser l'ordre des éléments titre et menu :

<template>  
  <div
    class="header">
    <content select="ul"></content>
    <content select="h1"></content>
  </div>
</template>  

Les possibilités sont tellement grandes qu'il serait dommage de s'en passer. Modifions donc notre processContent en conséquence :

@processContent(function(viewCompiler, viewResources, element, instruction){

  let template;

  if (instruction.type.viewFactory) {
    template = instruction.type.viewFactory.template;
    const contents = template.querySelectorAll('content');

    for (let content of contents) {
      const select = content.getAttribute('select');
      let innerTemplate;
      if (select) {
        innerTemplate = element.querySelector(select);
      } else {
        innerTemplate = document.createElement('div');
        innerTemplate.innerHTML = element.innerHTML;
      }
      content.parentNode.replaceChild(innerTemplate, content);
    }
  } else {
    template = `<template>${element.innerHTML}</template>`;
  }

  instruction.viewFactory = viewCompiler.compile(template, viewResources, instruction);
  element.innerHTML = '';
  return false;
})

On peut enfin créer un décorateur réutilisable dans n'importe quelle classe.

JSPM

Vous pouvez finalement utiliser directement le decorateur grâce à jspm :

$ jspm install github:hadrienl/aurelia-enhanced-template

Puis dans votre classe :

import {enhancedTemplate} from 'hadrienl/aurelia-enhanced-template';

@enhancedTemplate()
export class MyCustomElement {}  

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