Créer la progressive web app Google I/O 2016

Accueil Iowa

Résumé

Découvrez comment nous avons créé une application monopage à l'aide de composants Web, de Polymer et de Material Design, et comment nous l'avons mise en production sur Google.com.

Résultats

  • Plus d'engagement que l'application native (4:06 min sur le Web mobile contre 2:40 min sur Android).
  • Première peinture 450 ms plus rapide pour les utilisateurs connus grâce à la mise en cache du service worker
  • 84 % des visiteurs ont accepté le service worker
  • Les enregistrements "Ajouter à l'écran d'accueil" ont augmenté de 900 % par rapport à 2015.
  • 3,8 % des utilisateurs sont passés hors connexion, mais ont continué à générer 11 000 vues de page.
  • 50 % des utilisateurs connectés ont activé les notifications.
  • 536 000 notifications ont été envoyées aux utilisateurs (12 % d'entre eux ont récupéré leur appareil).
  • 99 % des navigateurs des utilisateurs étaient compatibles avec les polyfills des composants Web.

Présentation

Cette année, j'ai eu le plaisir de travailler sur l'application Web progressive Google I/O 2016, affectueusement appelée "IOWA". Cette application est avant tout mobile, fonctionne hors connexion et s'inspire largement du Material Design.

IOWA est une application monopage (SPA) créée à l'aide de composants Web, de Polymer et de Firebase, et dotée d'un backend complet écrit dans App Engine (Go). Il pré-met en cache le contenu à l'aide d'un service worker, charge de manière dynamique de nouvelles pages, effectue des transitions fluides entre les vues et réutilise le contenu après le premier chargement.

Dans cette étude de cas, je vais passer en revue certaines des décisions d'architecture les plus intéressantes que nous avons prises pour le frontend. Si le code source vous intéresse, consultez-le sur GitHub.

Afficher sur GitHub

Créer une application SPA à l'aide de composants Web

Chaque page en tant que composant

L'un des aspects clés de notre interface utilisateur est qu'elle est centrée sur les composants Web. En fait, chaque page de notre SPA est un composant Web:

    <io-home-page date="2016-05-18T17:00:00Z" app="[[app]]"></io-home-page>
    <io-schedule-page date="2016-05-18T17:00:00Z" app="{ % templatetag openvariable % }app}}"></io-schedule-page>
    <io-attend-page></io-attend-page>
    <io-extended-page></io-extended-page>
    <io-faq-page></io-faq-page>

Pourquoi avons-nous fait cela ? La première raison est que ce code est lisible. En tant que lecteur novice, il est tout à fait évident que chaque page de notre application est un composant Web. La deuxième raison est que les composants Web présentent des propriétés intéressantes pour créer une SPA. De nombreuses frustrations courantes (gestion de l'état, activation des vues, portée du style) disparaissent grâce aux fonctionnalités inhérentes à l'élément <template>, aux éléments personnalisés et au Shadow DOM. Il s'agit d'outils pour les développeurs intégrés au navigateur. Pourquoi ne pas en profiter ?

En créant un élément personnalisé pour chaque page, nous avons obtenu beaucoup de choses sans frais :

  • Gestion du cycle de vie des pages.
  • CSS/HTML ciblé propre à la page.
  • Tous les fichiers CSS/HTML/JS spécifiques à une page sont regroupés et chargés ensemble si nécessaire.
  • Les vues sont réutilisables. Les pages étant des nœuds DOM, il suffit de les ajouter ou de les supprimer pour modifier la vue.
  • Les futurs responsables de maintenance pourront comprendre notre application simplement en comprenant le balisage.
  • Le balisage rendu par le serveur peut être progressivement amélioré à mesure que les définitions d'éléments sont enregistrées et mises à niveau par le navigateur.
  • Les éléments personnalisés ont un modèle d'héritage. Le code DRY est un bon code.
  • …et bien d'autres choses.

Nous avons pleinement profité de ces avantages dans l'Iowa. Examinons certains détails.

Activation dynamique des pages

L'élément <template> est le moyen standard du navigateur de créer du balisage réutilisable. <template> présente deux caractéristiques que les SPA peuvent exploiter. Tout d'abord, tout ce qui se trouve dans <template> est inerte jusqu'à ce qu'une instance du modèle soit créée. Ensuite, le navigateur analyse le balisage, mais son contenu n'est pas accessible depuis la page principale. Il s'agit d'un véritable bloc de balisage réutilisable. Exemple :

<template id="t">
    <div>This markup is inert and not part of the main page's DOM.</div>
    <img src="profile.png"> <!-- not loaded by the browser -->
    <video id="vid" src="vid.mp4"></video> <!-- doesn't load/start -->
    <script>alert("Not run until the template is stamped");</script>
</template>

Polymer étend les <template> avec quelques éléments personnalisés d'extension de type, à savoir <template is="dom-if"> et <template is="dom-repeat">. Il s'agit de deux éléments personnalisés qui étendent <template> avec des fonctionnalités supplémentaires. Et grâce à la nature déclarative des composants Web, les deux font exactement ce que vous attendez. Le premier composant appose un balisage en fonction d'une condition. La seconde répète le balisage pour chaque élément d'une liste (modèle de données).

Comment l'IOWA utilise-t-il ces éléments d'extension de type ?

Si vous vous souvenez, chaque page d'IOWA est un composant Web. Toutefois, il serait absurde de déclarer chaque composant lors du premier chargement. Cela impliquerait de créer une instance de chaque page lors du premier chargement de l'application. Nous ne voulions pas nuire à nos performances de chargement initial, d'autant plus que certains utilisateurs ne naviguent que vers une ou deux pages.

Notre solution était de tricher. Dans l'IOWA, nous encapsulons l'élément de chaque page dans une <template is="dom-if"> afin que son contenu ne se charge pas au premier démarrage. Nous activons ensuite les pages lorsque l'attribut name du modèle correspond à l'URL. Le composant Web <lazy-pages> gère toute cette logique pour nous. Le balisage ressemble à ceci :

<!-- Lazy pages manages the template stamping. It watches for route changes
        and sets `template.if = true` on the appropriate template. -->
<lazy-pages>
    <template is="dom-if" name="home">
    <io-home-page date="2016-05-18T17:00:00Z"></io-home-page>
    </template>

    <template is="dom-if" name="schedule">
    <io-schedule-page date="2016-05-18T17:00:00Z"></io-schedule-page>
    </template>

    <template is="dom-if" name="attend">
    <io-attend-page></io-attend-page>
    </template>
</lazy-pages>

Ce que j'aime dans cette approche, c'est que chaque page est analysée et prête à l'emploi lors du chargement, mais que son CSS/HTML/JS n'est exécuté que sur demande (lorsque son <template> parent est estampillé). Affichage dynamique et paresseux à l'aide de composants Web : une bonne solution.

Améliorations futures

Lors du premier chargement de la page, toutes les importations HTML de chaque page sont chargées en même temps. Une amélioration évidente consisterait à ne charger les définitions des éléments que lorsqu'elles sont nécessaires. Polymer propose également un bon outil d'assistance pour le chargement asynchrone des importations HTML :

Polymer.Base.importHref('io-home-page.html', (e) => { ... });

IOWA ne le fait pas, car a) nous avons été paresseux et b) nous ne savons pas exactement dans quelle mesure nous aurions pu améliorer les performances. Notre première peinture était déjà d'environ 1 seconde.

Gestion du cycle de vie des pages

L'API Custom Elements définit des appels de rappel de cycle de vie pour gérer l'état d'un composant. Lorsque vous implémentez ces méthodes, vous obtenez des crochets gratuits dans la durée de vie d'un composant :

createdCallback() {
    // automatically called when an instance of the element is created.
}

attachedCallback() {
    // automatically called when the element is attached to the DOM.
}

detachedCallback() {
    // automatically called when the element is removed from the DOM.
}

attributeChangedCallback() {
    // automatically called when an HTML attribute changes.
}

Il a été facile d'exploiter ces rappels dans IOWA. N'oubliez pas que chaque page est un nœud DOM autonome. Pour accéder à une "nouvelle vue" dans notre SPA, il suffit d'associer un nœud au DOM et d'en supprimer un autre.

Nous avons utilisé attachedCallback pour effectuer la configuration (état d'initialisation, associer des écouteurs d'événements). Lorsque les utilisateurs accèdent à une autre page, detachedCallback effectue un nettoyage (suppression des écouteurs, réinitialisation de l'état partagé). Nous avons également étendu les rappels de cycle de vie natifs avec plusieurs de nos propres rappels :

onPageTransitionDone() {
    // page transition animations are complete.
},

onSubpageTransitionDone() {
    // sub nav/tab page transitions are complete.
}

Ces ajouts se sont révélés utiles pour retarder le travail et minimiser les à-coups entre les transitions de page. Nous reviendrons sur ce point.

Simplifier les fonctionnalités courantes sur les pages

L'héritage est une fonctionnalité puissante des éléments personnalisés. Il fournit un modèle d'héritage standard pour le Web.

Malheureusement, l'héritage d'éléments n'a pas encore été implémenté dans Polymer 1.0 au moment de la rédaction de cet article. En attendant, la fonctionnalité Behaviors (Comportements) de Polymer était tout aussi utile. Les comportements ne sont que des mixins.

Plutôt que de créer la même surface d'API sur toutes les pages, il était logique de réduire la taille du codebase en créant des mixins partagés. Par exemple, PageBehavior définit des propriétés/méthodes communes dont toutes les pages de notre application ont besoin :

PageBehavior.html

let PageBehavior = {

    // Common properties all pages need.
    properties: {
    name: { type: String }, // Slug name of the page.
    ...
    },

    attached() {
    // If the page defines a `onPageTransitionDone`, call it when the router
    // fires 'page-transition-done'.
    if (this.onPageTransitionDone) {
        this.listen(document.body, 'page-transition-done', 'onPageTransitionDone');
    }

    // Update page meta data when new page is navigated to.
    document.body.id = `page-${this.name}`;
    document.title = this.title || 'Google I/O 2016';

    // Scroll to top of new page.
    if (IOWA.Elements.Scroller) {
        IOWA.Elements.Scroller.scrollTop = 0;
    }

    this.setupSubnavEffects();
    },

    detached() {
    this.unlisten(document.body, 'page-transition-done', 'onPageTransitionDone');
    this.teardownSubnavEffects();
    }
};

IOWA.IOBehaviors = IOWA.IOBehaviors || {PageBehavior: PageBehavior};

Comme vous pouvez le constater, PageBehavior effectue des tâches courantes qui s'exécutent lorsqu'une nouvelle page est consultée. Par exemple, mettre à jour document.title, réinitialiser la position de défilement et configurer des écouteurs d'événements pour les effets de défilement et de sous-navigation.

Les pages individuelles utilisent PageBehavior en le chargeant en tant que dépendance et en utilisant behaviors. Ils peuvent également remplacer ses propriétés/méthodes de base si nécessaire. Par exemple, voici ce que la "sous-classe" de notre page d'accueil remplace:

io-home-page.html

<link rel="import" href="../bower_components/polymer/polymer.html">
<link rel="import" href="PageBehavior.html">
<!-- rest of the import dependencies used by the page. -->

<dom-module id="io-home-page">
    <template>
    <!-- PAGE'S MARKUP -->
    </template>
    <script>
    Polymer({
        is: 'io-home-page',

        behaviors: [IOBehaviors.PageBehavior], // All pages have common functionality.

        // Pages define their own title and slug for the router.
        title: 'Schedule - Google I/O 2016',
        name: 'home',

        // The home page has custom setup work when it's added navigated to.
        // Note: PageBehavior's attached also gets called.
        attached() {
        if (this.app.isPhoneSize) {
            this.listen(IOWA.Elements.ScrollContainer, 'scroll', '_onPageScroll');
        }
        },

        // The home page does its own cleanup when a new page is navigated to.
        // Note: PageBehavior's detached also gets called.
        detached() {
        this.unlisten(IOWA.Elements.ScrollContainer, 'scroll', '_onPageScroll');
        },

        // The home page can define onPageTransitionDone to do extra work
        // when page transitions are done, and thus preventing janky animations.
        onPageTransitionDone() {
        ...
        }
    });
    </script>
</dom-module>

Partager les styles

Pour partager des styles entre les différents composants de notre application, nous avons utilisé les modules de style partagés de Polymer. Les modules de style vous permettent de définir une seule fois un fragment de code CSS et de le réutiliser à différents endroits d'une application. Pour nous, "différents lieux" signifie différents composants.

Dans IOWA, nous avons créé shared-app-styles pour partager les couleurs, la typographie et les classes de mise en page entre les pages et les autres composants que nous avons créés.

shared-app-styles.html

<link rel="import" href="../bower_components/polymer/polymer.html">
<link rel="import" href="../bower_components/iron-flex-layout/iron-flex-layout.html">
<link rel="import" href="../bower_components/paper-styles/color.html">

<dom-module id="shared-app-styles">
    <template>
    <style>
        [layout] {
        @apply(--layout);
        }
        [layout][horizontal] {
        @apply(--layout-horizontal);
        }
        .scrollable {
        @apply(--layout-scroll);
        }
        .noscroll {
        overflow: hidden;
        }
        /* Style radio buttons and tabs the same throughout the app */
        paper-tabs {
        --paper-tabs-selection-bar-color: currentcolor;
        }
        paper-radio-button {
        --paper-radio-button-checked-color: var(--paper-cyan-600);
        --paper-radio-button-checked-ink-color: var(--paper-cyan-600);
        }
        ...
    </style>
    </template>
</dom-module>

io-home-page.html

<link rel="import" href="shared-app-styles.html">
<!-- Rest of import dependencies used by the page. -->

<dom-module id="io-home-page">
    <template>
    <style include="shared-app-styles">
        :host { display: block} /* Other element styles can go here. */
    </style>
    <!-- PAGE'S MARKUP -->
    </template>
    <script>Polymer({...});</script>
</dom-module>

Ici, <style include="shared-app-styles"></style> est la syntaxe de Polymer pour indiquer "inclure les styles dans le module nommé "shared-app-styles".

Partager l'état de l'application

Vous savez maintenant que chaque page de notre application est un élément personnalisé. Je l'ai dit un million de fois. D\'accord, mais si chaque page est un composant Web autonome, vous vous demandez peut-être comment partager l\'état dans l\'application.

IOWA utilise une technique semblable à l'injection de dépendances (Angular) ou à redux (React) pour partager l'état. Nous avons créé une propriété app globale et y avons suspendu des sous-propriétés partagées. app est transmis dans notre application en l'injectant dans chaque composant qui a besoin de ses données. L'utilisation des fonctionnalités de liaison de données de Polymer facilite cette opération, car nous pouvons effectuer le câblage sans écrire de code:

<lazy-pages>
    <template is="dom-if" name="home">
    <io-home-page date="2016-05-18T17:00:00Z" app="[[app]]"></io-home-page>
    </template>

    <template is="dom-if" name="schedule">
    <io-schedule-page date="2016-05-18T17:00:00Z" app="{ % templatetag openvariable % }app}}"></io-schedule-page>
    </template>
    ...
</lazy-pages>

<google-signin client-id="..." scopes="profile email"
                            user="{ % templatetag openvariable % }app.currentUser}}"></google-signin>

<iron-media-query query="(min-width:320px) and (max-width:768px)"
                                query-matches="{ % templatetag openvariable % }app.isPhoneSize}}"></iron-media-query>

L'élément <google-signin> met à jour sa propriété user lorsque les utilisateurs se connectent à notre application. Étant donné que cette propriété est liée à app.currentUser, toute page qui souhaite accéder à l'utilisateur actuel doit simplement se lier à app et lire la sous-propriété currentUser. Cette technique est utile pour partager l'état dans l'application. Toutefois, nous avons également pu créer un élément de connexion unique et réutiliser ses résultats sur l'ensemble du site. Il en va de même pour les requêtes multimédias. Il aurait été inutile de dupliquer la connexion ou de créer son propre ensemble de requêtes multimédias sur chaque page. À la place, les composants responsables des fonctionnalités/données à l'échelle de l'application existent au niveau de l'application.

Transitions de page

En naviguant dans l'application Web Google I/O, vous remarquerez ses transitions de page fluides (à la Material Design).

Transitions de page de l&#39;IOWA en action.
Les transitions de page d'IOWA en action.

Lorsqu'un utilisateur accède à une nouvelle page, une séquence d'événements se produit :

  1. La barre de navigation supérieure fait glisser une barre de sélection vers le nouveau lien.
  2. L'en-tête de la page disparaît.
  3. Le contenu de la page glisse vers le bas, puis disparaît.
  4. En inversant ces animations, l'en-tête et le contenu de la nouvelle page s'affichent.
  5. (Facultatif) La nouvelle page effectue un travail d'initialisation supplémentaire.

L'un de nos défis consistait à trouver comment créer cette transition fluide sans sacrifier les performances. Le travail est très dynamique, et les à-coups n'étaient pas les bienvenus à notre fête. Notre solution combinait l'API Web Animations et les promesses. En combinant les deux, nous avons profité de la polyvalence, d'un système d'animation "plug-and-play" et d'un contrôle précis pour réduire au maximum les das.

Fonctionnement

Lorsque les utilisateurs cliquent sur une nouvelle page (ou appuient sur "Retour"/"Avant"), le runPageTransition() de notre routeur opère sa magie en exécutant une série de promesses. L'utilisation de promesses nous a permis d'orchestrer soigneusement les animations et de rationaliser le caractère asynchrone des animations CSS et le chargement dynamique du contenu.

class Router {

    init() {
    window.addEventListener('popstate', e => this.runPageTransition());
    }

    runPageTransition() {
    let endPage = this.state.end.page;

    this.fire('page-transition-start');              // 1. Let current page know it's starting.

    IOWA.PageAnimation.runExitAnimation()            // 2. Play exist animation sequence.
        .then(() => {
        IOWA.Elements.LazyPages.selected = endPage;  // 3. Activate new page in <lazy-pages>.
        this.state.current = this.parseUrl(this.state.end.href);
        })
        .then(() => IOWA.PageAnimation.runEnterAnimation())  // 4. Play entry animation sequence.
        .then(() => this.fire('page-transition-done')) // 5. Tell new page transitions are done.
        .catch(e => IOWA.Util.reportError(e));
    }

}

Dans le rappel de la section "Keeping Things DRY: common feature across pages", les pages écoutent les événements DOM page-transition-start et page-transition-done. Vous pouvez maintenant voir où ces événements sont déclenchés.

Nous avons utilisé l'API Web Animations au lieu des assistants runEnterAnimation/runExitAnimation. Dans le cas de runExitAnimation, nous récupérons quelques nœuds DOM (le masthead et la zone de contenu principale), déclarons le début/la fin de chaque animation et créons un GroupEffect pour exécuter les deux en parallèle :

function runExitAnimation(section) {
    let main = section.querySelector('.slide-up');
    let masthead = section.querySelector('.masthead');

    let start = {transform: 'translate(0,0)', opacity: 1};
    let end = {transform: 'translate(0,-100px)', opacity: 0};
    let opts = {duration: 400, easing: 'cubic-bezier(.4, 0, .2, 1)'};
    let opts_delay = {duration: 400, delay: 200};

    return new GroupEffect([
    new KeyframeEffect(masthead, [start, end], opts),
    new KeyframeEffect(main, [{opacity: 1}, {opacity: 0}], opts_delay)
    ]);
}

Il vous suffit de modifier le tableau pour élaborer des transitions de vue plus (ou moins) élaborées.

Effets de défilement

L'IOWA a quelques effets intéressants lorsque vous faites défiler la page. La première est notre bouton d'action flottant qui ramène les utilisateurs en haut de la page:

    <a href="#" tabindex="-1" aria-hidden="true" aria-label="back to top" onclick="backToTop">
      <paper-fab icon="io:expand-less" noink tabindex="-1"></paper-fab>
    </a>

Le défilement fluide est implémenté à l'aide des éléments de mise en page de l'application Polymer. Ils fournissent des effets de défilement prêts à l'emploi, tels que des navigations supérieures persistantes/rappelées, des ombres projetées, des transitions de couleur et d'arrière-plan, des effets parallaxes et un défilement fluide.

    // Smooth scrolling the back to top FAB.
    function backToTop(e) {
      e.preventDefault();

      Polymer.AppLayout.scroll({top: 0, behavior: 'smooth',
                                target: document.documentElement});

      e.target.blur();  // Kick focus back to the page so user starts from the top of the doc.
    }

Nous avons également utilisé les éléments <app-layout> pour la barre de navigation persistante. Comme vous pouvez le voir dans la vidéo, il disparaît lorsque les utilisateurs font défiler la page vers le bas et réapparaît lorsqu'ils font défiler la page vers le haut.

Navigations à défilement persistant
Navigations à défilement persistant avec .

Nous avons utilisé l'élément <app-header> à peu près tel quel. Il a été facile d'intégrer l'élément et d'obtenir des effets de défilement sophistiqués dans l'application. Bien sûr, nous aurions pu les implémenter nous-mêmes, mais le fait d'avoir les informations déjà codifiées dans un composant réutilisable nous a fait gagner un temps considérable.

Déclarez l'élément. Personnalisez-le avec des attributs. Vous avez terminé !

    <app-header reveals condenses effects="fade-background waterfall"></app-header>

Conclusion

Pour la progressive web app I/O, nous avons pu créer une interface complète en plusieurs semaines grâce aux composants Web et aux widgets Material Design prédéfinis de Polymer. Les fonctionnalités des API natives (éléments personnalisés, Shadow DOM, <template>) se prêtent naturellement au dynamisme d'une SPA. La réutilisabilité permet de gagner énormément de temps.

Si vous souhaitez créer votre propre progressive web app, consultez la boîte à outils pour les applications. La boîte à outils d'applications de Polymer est un ensemble de composants, d'outils et de modèles permettant de créer des PWA avec Polymer. C'est un moyen simple de vous lancer.