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

Domicile de l'Iowa

Résumé

Découvrez comment nous avons créé une application monopage basée sur des composants Web, Polymer et Material Design, avant de l'avoir lancée sur Google.com.

Résultats

  • Plus d'engagement que l'application native (4 min 06 s pour le Web mobile contre 2 min 40 min pour Android).
  • Réduction de 450 ms du chargement de la première image pour les utilisateurs connus grâce à la mise en cache des service workers
  • 84% des visiteurs soutiennent les service workers
  • Les enregistrements sur l'écran d'accueil ont augmenté de 900% par rapport à 2015.
  • 3,8% des utilisateurs se sont déconnectés, mais ont continué à générer 11 000 pages vues !
  • 50% des utilisateurs connectés ont activé les notifications.
  • 536 000 notifications ont été envoyées aux utilisateurs (12% les ont fait revenir).
  • 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 la progressive web app Google I/O 2016, affectueusement nommée "IOWA". Conçue en priorité pour les mobiles, elle fonctionne entièrement hors connexion et s'inspire fortement du Material Design.

IOWA est une application monopage (SPA) conçue à l'aide de composants Web, Polymer et Firebase, et dotée d'un backend complet écrit dans App Engine (Go). Elle met en cache le contenu à l'aide d'un service worker, charge dynamiquement les nouvelles pages, passe d'une vue à l'autre de façon optimale et réutilise le contenu après le premier chargement.

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

Afficher sur GitHub

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

Chaque page en tant que composant

L'un des aspects essentiels de notre interface est qu'elle est centrée sur les composants Web. En fait, chaque page de notre application monopage 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 qu'il est lisible. Pour les nouveaux lecteurs, il est tout à fait évident d'identifier les pages de notre application. La deuxième raison est que les composants Web ont des propriétés intéressantes pour créer une SPA. De nombreuses frustrations courantes (gestion de l'état, activation des vues, portée des styles) 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, vous bénéficiez de nombreux avantages sans frais:

  • Gestion du cycle de vie des pages
  • Il s'agit du code CSS/HTML associé à la page.
  • Tout le code CSS/HTML/JS spécifique à une page est regroupé et chargé selon les besoins.
  • 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 pourront comprendre notre application simplement en explorant son balisage.
  • Le balisage généré par le serveur peut être amélioré progressivement à mesure que les définitions des éléments sont enregistrées et mises à jour par le navigateur.
  • Les éléments personnalisés ont un modèle d'héritage. Le code DRY est un bon code.
  • ... et bien plus encore.

Nous avons pleinement profité de ces avantages dans IOWA. Entrons dans le détail.

Activation dynamique des pages

L'élément <template> est la méthode standard utilisée par le navigateur pour créer un balisage réutilisable. <template> présente deux caractéristiques dont les applications monopages peuvent exploiter. Tout d'abord, tout élément à l'intérieur de <template> reste 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 à partir de la page principale. Il s'agit d'un véritable fragment 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 extends les <template> avec quelques éléments personnalisés d'extension de type, à savoir <template is="dom-if"> et <template is="dom-repeat">. Tous deux sont des é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 fonctionnent exactement comme prévu. Le premier composant ajoute 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 ?

Pour rappel, chaque page de l'IOWA est un composant Web. Cependant, il serait absurde de déclarer tous les composants 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 aux performances de chargement initial, d'autant plus que certains utilisateurs ne naviguent qu'à une ou deux pages.

Notre solution était de tricher. Dans 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 qui me plaît, c'est que chaque page est analysée et prête à l'emploi au chargement de la page, mais que son code CSS/HTML/JS n'est exécuté qu'à la demande (lorsque son parent <template> est estampillé). Vues dynamiques et différées à l'aide de composants Web FTW.

Améliorations futures

Lorsque la page se charge pour la première fois, nous chargeons toutes les importations HTML de chaque page en une seule fois. L'amélioration évidente consiste à charger les définitions d'élément de manière différée, uniquement lorsqu'elles sont nécessaires. Polymer propose également un assistant utile pour les importations HTML à chargement asynchrone:

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

L'IOWA ne le fait pas parce que a) nous avons été paresseux et b) l'amélioration des performances que nous aurions pu obtenir n'est pas claire. 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 rappels de cycle de vie pour gérer l'état d'un composant. Lorsque vous implémentez ces méthodes, vous bénéficiez de hooks sans frais dans la 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.
}

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

Nous avons utilisé attachedCallback pour effectuer le travail de configuration (état d'initialisation, association des écouteurs d'événements). Lorsque les utilisateurs accèdent à une autre page, le detachedCallback effectue un nettoyage (suppression des écouteurs, réinitialisation de l'état partagé). Nous avons également développé 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.
}

Il s'agissait d'ajouts utiles pour retarder le travail et réduire les à-coups entre les transitions de page. Nous reviendrons sur ce point.

Sécuriser des fonctionnalités communes sur plusieurs pages

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

Malheureusement, Polymer 1.0 n'a pas encore mis en œuvre l'héritage des éléments au moment de la rédaction de ce document. Pendant ce temps, la fonctionnalité Comportements de Polymer s'est avérée tout aussi utile. Les comportements ne sont que des mélanges.

Plutôt que de créer la même surface d'API sur toutes les pages, il était judicieux de DRY le codebase en créant des mixins partagés. Par exemple, PageBehavior définit les propriétés/méthodes courantes 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 voir, PageBehavior effectue des tâches courantes qui s'exécutent lorsqu'une nouvelle page est consultée. Vous pouvez par exemple mettre à jour document.title, réinitialiser la position de défilement et configurer des écouteurs d'événements pour les effets de navigation secondaire et de défilement.

Des pages individuelles utilisent PageBehavior en le chargeant en tant que dépendance et en utilisant behaviors. Ils sont également libres de 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 des styles

Pour partager des styles entre 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 partie du code CSS une seule fois et de le réutiliser à différents endroits dans une application. Pour nous, "différents endroits" signifiait différents composants.

Dans l'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> correspond à la syntaxe de Polymer pour "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 nous partageons l'état dans l'application.

Pour partager l'état, IOWA utilise une technique semblable à l'injection de dépendances (Angular) ou redux (React). Nous avons créé une propriété app globale et y avons ajouté des sous-propriétés partagées. app est transmis à 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 s'associer à app et lire la sous-propriété currentUser. En soi, cette technique est utile pour partager l'état dans l'application. Cependant, un autre avantage a été que nous avons fini par créer un élément d'authentification unique et que nous avons réutilisé ses résultats sur l'ensemble du site. Il en va de même pour les requêtes média. Il aurait été inutile que chaque page duplique la connexion ou crée son propre ensemble de requêtes média. Au lieu de cela, 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 pages fluides (à la Material Design).

Transitions de page de l&#39;IOWA en action
Transitions de page de l'IOWA en action

Lorsque les utilisateurs accèdent à une nouvelle page, une séquence d'éléments se produit:

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

L'un de nos défis était de trouver comment créer cette transition fluide sans sacrifier les performances. Il y a beaucoup de travail dynamique, et les à-coups n'étaient pas les bienvenus à notre fête. Notre solution était une combinaison de l'API Web Animations et de promesses. En combinant les deux, nous avons pu plus de polyvalence, d'un système d'animation prêt à l'emploi et d'un contrôle précis pour réduire les à-coups das.

Fonctionnement

Lorsque les utilisateurs cliquent pour accéder à une nouvelle page (ou cliquent sur "Précédent" ou "Suivant"), le runPageTransition() de notre routeur effectue toute sa magie en exécutant une série de promesses. L'utilisation des promesses nous a permis d'orchestrer soigneusement les animations et de rationaliser le caractère asynchrone des animations CSS et du 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));
    }

}

Pour rappel de la section "Keeping things DRY: common features across pages", les pages écoutent les événements DOM page-transition-start et page-transition-done. Vous voyez maintenant 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 principal), déclarons le début et la fin de chaque animation, puis 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 rendre les transitions d'affichage plus (ou moins) élaborées.

Effets de défilement

IOWA a quelques effets intéressants lorsque vous faites défiler la page. Le premier est le bouton d'action flottant qui redirige les utilisateurs vers le 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 app-layout de Polymer. Ils offrent des effets de défilement prêts à l'emploi, tels que des barres de navigation persistantes supérieures, des ombres projetées, des transitions de couleur et d'arrière-plan, des effets de parallaxe 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 navigation persistante. Comme vous pouvez le voir dans la vidéo, elle 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 persistantes à défilement
Navigations persistantes à l'aide de .

Nous avons utilisé l'élément <app-header> tel quel. Il était facile d'y intégrer 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 déjà codifié les détails dans un composant réutilisable nous a fait gagner énormément de temps.

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 quelques semaines grâce aux composants Web et aux widgets Material Design prédéfinis de Polymer. Les fonctionnalités des API natives (Custom Elements, Shadow DOM, <template>) se prêtent naturellement au dynamisme d'une application monopage. La réutilisabilité fait gagner un temps considérable.

Si vous souhaitez créer votre propre progressive web app, consultez la Boîte à outils pour les applications. La boîte à outils d'application 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 d'être rapidement opérationnel.