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". Il est mobile first, fonctionne entièrement hors connexion et est fortement inspiré du material design.
IOWA est une application monopage créée à l'aide de composants Web, de Polymer et de Firebase. Elle dispose d'un backend étendu écrit en 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.
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 nombreux problèmes courants (gestion de l'état, activation de la vue, champ d'application des styles) sont résolus grâce aux fonctionnalités inhérentes de l'élément <template>
, des éléments personnalisés et du 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. Étant donné que les pages sont des nœuds DOM, leur ajout ou leur suppression modifie la vue.
- Les futurs responsables de maintenance pourront comprendre notre application simplement en analysant 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. Voyons quelques 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. Deuxièmement, le navigateur analyse le balisage, mais les contenus ne sont pas accessibles 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 la balise pour chaque élément d'une liste (modèle de données).
Comment 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 aux performances de chargement initial, d'autant plus que certains utilisateurs ne consultent qu'une ou deux pages.
Notre solution a été de tricher. Dans IOWA, nous encapsulent chaque élément de la page dans un <template is="dom-if">
afin que son contenu ne soit pas chargé 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, nous chargeons toutes les importations HTML pour chaque page 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 outil d'assistance pratique 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 s.
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 sans frais 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'attacher un nœud au DOM et d'en supprimer un autre.
Nous avons utilisé attachedCallback
pour effectuer la configuration (état d'initialisation, attachement d'é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 étaient utiles pour retarder le travail et réduire 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 navigation secondaire.
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 notre "sous-classe " de 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 un extrait de CSS une fois et de le réutiliser à différents endroits dans une application. Pour nous, "différents endroits" 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 tâche, 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 sur chaque page ou de créer son propre ensemble de requêtes multimédias. À la place, les composants responsables des fonctionnalités/données à l'échelle de l'application existent au niveau de l'application.
Transitions de page
Lorsque vous naviguez dans l'application Web Google I/O, vous remarquerez ses transitions de page fluides (à la Material Design).
Lorsqu'un utilisateur accède à une nouvelle page, une séquence d'événements se produit:
- La barre de navigation supérieure fait glisser une barre de sélection vers le nouveau lien.
- L'en-tête de la page disparaît.
- Le contenu de la page glisse vers le bas, puis disparaît.
- En inversant ces animations, l'en-tête et le contenu de la nouvelle page s'affichent.
- (Facultatif) La nouvelle page effectue une initialisation supplémentaire.
L'un de nos défis consistait à trouver comment créer cette transition fluide sans sacrifier les performances. Nous avons beaucoup travaillé de manière dynamique, et le junk n'était pas le bienvenu. Notre solution combinait l'API Web Animations et les promesses. L'association de ces deux éléments nous a permis de bénéficier d'une polyvalence, d'un système d'animation prêt à l'emploi et d'un contrôle précis pour minimiser les à-coups 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 l'asynchronicité 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));
}
}
Rappelez-vous de la section "Rester DRY: fonctionnalité commune entre les 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 rendre les transitions de vue plus (ou moins) élaborées.
Effets de défilement
IOWA propose quelques effets intéressants lorsque vous faites défiler la page. Le premier 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 d'application de Polymer. Ils offrent des effets de défilement prêts à l'emploi, comme les barres de navigation en haut de l'écran persistantes/récupérables, les ombres portées, les transitions de couleur et d'arrière-plan, les effets de parallaxe et le 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.
Nous avons utilisé l'élément <app-header>
à peu près tel quel. Il était facile de l'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 que les détails soient déjà codés dans un composant réutilisable nous a fait gagner beaucoup 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 l'application Web progressive I/O, nous avons pu créer un frontend complet en quelques 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éutilisation vous fait gagner beaucoup 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.