Tworzenie progresywnej aplikacji internetowej Google I/O 2016

Iowa – dom

Podsumowanie

Dowiedz się, jak zbudowaliśmy jednostronicową aplikację, korzystając z komponentów internetowych, technologii Polymer i projektu Material Design, a następnie wdrożyliśmy ją w produkcji na stronie Google.com.

Wyniki

  • Większe zaangażowanie niż w przypadku natywnej aplikacji (4:06 min w witrynie mobilnej w porównaniu z 2:40 min na Androidzie).
  • 450 ms szybszego pierwszego wyrenderowania dla powracających użytkowników dzięki pamięci podręcznej skryptu service worker
  • 84% użytkowników obsługiwało Service Worker
  • Liczba zapisanych filmów dodanych do ekranu głównego wzrosła o 900% w porównaniu z 2015 r.
  • 3,8% użytkowników przeszło do trybu offline, ale nadal osiągnęło 11 tys. wyświetleń strony.
  • 50% zalogowanych użytkowników włączyło powiadomienia.
  • Wysłano 536 tys. powiadomień do użytkowników (12% wróciło do nich).
  • 99% przeglądarek użytkowników obsługiwało kod polyfill komponentów internetowych

Omówienie

W tym roku miałem przyjemność pracować nad progresywną aplikacją internetową Google I/O 2016 o nazwie „IOWA”. Są zoptymalizowane pod kątem urządzeń mobilnych i działają w pełni offline, a ich wygląd mocno wiąże się z technologią Material Design.

IOWA to aplikacja jednostronicowa (SPA) wykorzystująca komponenty internetowe, Polymer i Firebase, która ma obszerny backend napisany w App Engine (Go). Zawartość jest wcześniej umieszczana w pamięci podręcznej za pomocą skryptu service worker, nowe strony są wczytywane dynamicznie, płynnie przechodzi się między widokami, a zawartość jest ponownie używana po pierwszym wczytaniu.

W tym studium przypadku omówię niektóre z bardziej interesujących decyzji dotyczących architektury, które podjęliśmy w przypadku interfejsu. Jeśli interesuje Cię kod źródłowy, pobierz go z GitHuba.

Wyświetl w GitHubie

Tworzenie SPA przy użyciu komponentów sieciowych

Każda strona jako komponent

Jednym z podstawowych aspektów naszej strony frontendowej jest to, że koncentruje się ona na komponentach internetowych. W fakcie każda strona w naszej aplikacji SPA jest komponentem internetowym:

    <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>

Dlaczego to zrobiliśmy? Po pierwsze, kod jest czytelny. Dla osób, które po raz pierwszy czytają ten artykuł, jest całkowicie oczywiste, czym jest każda strona w naszej aplikacji. Drugim powodem jest to, że komponenty internetowe mają pewne właściwości, które są przydatne przy tworzeniu SPA. Wiele typowych problemów (zarządzanie stanem, aktywacja widoku, ograniczanie zakresu stylów) znika dzięki wbudowanym funkcjom elementu <template>, elementów niestandardowych i modelu Shadow DOM. To narzędzia dla programistów wbudowane w przeglądarkę. Dlaczego nie skorzystać z tych możliwości?

Tworząc element niestandardowy dla każdej strony, zyskujesz wiele bezpłatnych korzyści:

  • zarządzanie cyklem życia strony;
  • Ograniczony zakres CSS/HTML dla strony.
  • Wszystkie pliki CSS/HTML/JS specyficzne dla strony są grupowane i ładowane razem w miarę potrzeby.
  • Widoków można używać wielokrotnie. Strony są węzłami DOM, więc ich dodanie lub usunięcie zmienia widok.
  • Przyszli administratorzy mogą zrozumieć naszą aplikację, po prostu analizując znaczniki.
  • Oznakowanie renderowane na serwerze może być stopniowo ulepszane, gdy przeglądarka rejestruje i ulepsza definicje elementów.
  • Elementy niestandardowe mają model dziedziczenia. DRY to dobry kod.
  • ...i wiele innych rzeczy.

W pełni skorzystaliśmy z tych zalet IOWA. Przejdźmy do szczegółów.

Dynamiczne aktywowanie stron

Element <template> to standardowy sposób tworzenia znaczników do wielokrotnego użytku w przeglądarce. <template> ma 2 cechy, z których mogą korzystać usługi SPA. Po pierwsze, wszystkie elementy wewnątrz obiektu <template> są bezwładne, dopóki nie zostanie utworzona instancja szablonu. Po drugie, przeglądarka analizuje znaczniki, ale nie ma dostępu do treści na stronie głównej. To prawdziwy element znaczników, który można wielokrotnie wykorzystywać. Na przykład:

<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 rozszerza komponenty <template> o kilka niestandardowych elementów rozszerzenia typu, takich jak <template is="dom-if"> i <template is="dom-repeat">. Oba są elementami niestandardowymi, które rozszerzają <template> o dodatkowe możliwości. Dzięki deklaratywnej naturze komponentów internetowych oba te elementy działają dokładnie tak, jak oczekujesz. Pierwszy komponent umieszcza znaczniki na podstawie warunku. Drugi to znacznik dla każdego elementu na liście (modelu danych).

Jak IOWA używa tych elementów rozszerzenia typu?

Każda strona w IOWA to komponent internetowy. Jednak głupota byłoby zadeklarować każdy komponent przy pierwszym wczytaniu. Oznaczałoby to utworzenie instancji każdej strony podczas pierwszego wczytywania aplikacji. Nie chcemy obniżać wydajności wczytywania, zwłaszcza że niektórzy użytkownicy będą przechodzić tylko na 1 lub 2 strony.

Naszym rozwiązaniem było oszukiwanie. W IOWA każdy element strony jest ujęty w element <template is="dom-if">, aby jego zawartość nie była wczytywana przy pierwszym uruchomieniu. Następnie aktywujemy strony, gdy atrybut name szablonu pasuje do adresu URL. Komponent internetowy <lazy-pages> obsługuje całą tę logikę. Oznakowanie wygląda mniej więcej tak:

<!-- 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>

Podoba mi się to, że każda strona jest analizowana i gotowa do użycia, gdy się wczytuje, ale jej elementy CSS/HTML/JS są wykonywane tylko na żądanie (gdy zostanie oznaczona jako rodzic <template>). Widoki dynamiczne i opóźnione korzystające z komponentów sieciowych.

Ulepszenia w przyszłości

Podczas pierwszego wczytywania strony wczytujemy wszystkie importy HTML dla każdej strony naraz. Oczywistym usprawnieniem byłoby leniwe ładowanie definicji elementów tylko wtedy, gdy są potrzebne. Polymer ma też przydatny pomocnik do asynchronicznego wczytywania importów HTML:

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

IOWA tego nie robi, ponieważ: a) jesteśmy leniwsi i b) nie wiemy, jak bardzo zwiększyłaby się wydajność. Pierwsze wyrenderowanie zajęło około 1 s.

Zarządzanie cyklem życia strony

Interfejs API elementów niestandardowych definiuje „wywołania zwrotne cyklu życia” służące do zarządzania stanem komponentu. Przy wdrażaniu tych metod możesz łatwo zobaczyć, jak działa komponent:

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.
}

W IOWA można było łatwo wykorzystać te funkcje zwracania wartości. Pamiętaj, że każda strona jest niezależnym węzłem DOM. Przejście do „nowego widoku” w naszym SPA polega na dołączeniu jednego węzła do DOM i usunięciu innego.

Użyliśmy attachedCallback do wykonania czynności konfiguracyjnych (inicjalizacja stanu, dołączanie odbiorników zdarzeń). Gdy użytkownicy przechodzą na inną stronę, detachedCallback wykonuje czyszczenie (usuwanie słuchaczy, resetowanie współdzielonego stanu). Rozszerzyliśmy też natywne wywołania zwrotne cyklu życia o kilka własnych:

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

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

Były to przydatne dodatki, które skracają czas pracy i minimalizują zakłócenia między przejściami między stronami. Więcej informacji na ten temat później.

DRYing up common functionality across pages

Dziedziczenie to przydatna funkcja elementów niestandardowych. Zapewnia on standardowy model dziedziczenia w internecie.

W momencie pisania tego artykułu w ramach Polymer 1.0 nie wdrożone dziedziczenie elementów. Tymczasem funkcja Behaviors w Polymer była równie przydatna. Zachowania to tylko mieszanki.

Zamiast tworzyć tę samą stronę interfejsu API na wszystkich stronach, postanowiliśmy wykorzystać kod źródłowy, tworząc wspólne mixiny. Na przykład PageBehavior określa typowe właściwości/metody, których potrzebują wszystkie strony w aplikacji:

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};

Jak widać, PageBehavior wykonuje typowe czynności, które są wykonywane po otwarciu nowej strony. Na przykład: aktualizowanie document.title, resetowanie pozycji przewijania i konfigurowanie detektorów zdarzeń dla efektów przewijania i podrzędnej nawigacji.

Poszczególne strony korzystają z tagu PageBehavior, ponieważ wczytują go jako zależność i używa behaviors. W razie potrzeby mogą też zastępować podstawowe właściwości/metody. Oto przykład zastąpienia „podklasy” na stronie głównej:

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>

Udostępnianie stylów

Aby udostępniać style w różnych komponentach w naszej aplikacji, użyliśmy modułów wspólnych stylów w Polymer. Moduły stylów umożliwiają zdefiniowanie fragmentu kodu CSS tylko raz i jego ponowne użycie w różnych miejscach w aplikacji. W naszym przypadku „różne miejsca” oznaczały różne komponenty.

W IOWA stworzyliśmy shared-app-styles, aby udostępniać kolory, typografię i klasy układu na stronach i w innych komponentach.

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>

Tutaj <style include="shared-app-styles"></style> to składnia Polymer oznaczająca „uwzględnij style w module o nazwie „shared-app-styles”.

Udostępnianie stanu aplikacji

Wiesz już, że każda strona w naszej aplikacji jest elementem niestandardowym. Mówiłam to już milion razy. Jeśli jednak każda strona jest samodzielnym komponentem internetowym, możesz się zastanawiać, jak udostępniamy stan w aplikacji.

IOWA używa techniki podobnej do wstrzykiwania zależności (Angular) lub redux (React) w przypadku stanu udostępniania. Utworzyliśmy globalną usługę app i podpiętych do niej udostępnionych usług podrzędnych. appjest przekazywany w naszej aplikacji przez wstrzyknięcie go do każdego komponentu, który potrzebuje jego danych. Używanie funkcji wiązania danych w Polymerze ułatwia to, ponieważ możemy wykonać połączenia bez pisania kodu:

<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>

Element <google-signin> aktualizuje swoją właściwość user, gdy użytkownicy logują się w naszej aplikacji. Ponieważ ta właściwość jest powiązana z elementem app.currentUser, każda strona, która chce uzyskać dostęp do bieżącego użytkownika, musi po prostu powiązać się z elementem app i odczytać podelement currentUser. Ta technika jest przydatna do udostępniania stanu w aplikacji. Jednak jej dodatkową zaletą było to, że udało nam się utworzyć element logowania jednokrotnego i wykorzystywać jego wyniki w całej witrynie. To samo dotyczy zapytań o multimedia. Szkoda, by każda strona powtarzała logowanie się lub tworzyła własny zestaw zapytań o media. Zamiast tego komponenty odpowiedzialne za funkcje lub dane na poziomie aplikacji znajdują się na poziomie aplikacji.

Przejścia stron

Podczas korzystania z aplikacji internetowej Google I/O zauważysz płynne przejścia między stronami (w stylu Material Design).

Trwa przenoszenie stron w witrynie IOWA.
Przejścia między stronami w IOWA

Gdy użytkownik przechodzi na nową stronę, następuje taka sekwencja działań:

  1. Górny element nawigacyjny przesuwa pasek wyboru do nowego połączenia.
  2. Nagłówek strony znika.
  3. Treści strony przesuwają się w dół, a potem znikają.
  4. Gdy animacje są odtwarzane w odwrotnej kolejności, pojawiają się nagłówek i treści nowej strony.
  5. (Opcjonalnie) Nowa strona wykonuje dodatkowe czynności inicjujące.

Jednym z wyzwań było opracowanie płynnego przejścia bez uszczerbku na wydajności. Trwają intensywne prace, a jank nie został zaproszony na naszą imprezę. Nasze rozwiązanie było kombinacją interfejsu Web Animations API i obietnic. Dzięki połączeniu tych dwóch technologii uzyskaliśmy wszechstronność, system animacji typu „plug and play” oraz szczegółową kontrolę, która pozwala zminimalizować das jank.

Jak to działa

Gdy użytkownik klika nową stronę (lub klika wstecz/dalej), runPageTransition() na naszym routerze działa niewiarygodnie przez realizację szeregu obietnic. Dzięki użyciu obietnic mogliśmy starannie zsynchronizować animacje i usystematyzować asynchroniczne działanie animacji CSS oraz wczytywanie dynamicznych treści.

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));
    }

}

Przypomnienie: jak już wiesz ze sekcji „Utrzymywanie spójności kodu: wspólne funkcje na stronach”, strony nasłuchują zdarzeń DOM page-transition-startpage-transition-done. Teraz widzisz, gdzie są wywoływane te zdarzenia.

Zamiast pomocniczych interfejsów runEnterAnimation/runExitAnimation użyliśmy interfejsu Web Animations API. W przypadku runExitAnimation pobieramy kilka węzłów DOM (nagłówek i główna treść), deklarujemy początek i koniec każdej animacji oraz tworzymy GroupEffect, aby uruchamiać je równolegle:

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)
    ]);
}

Wystarczy zmodyfikować tablicę, aby przejścia między widokami były bardziej lub mniej rozbudowane.

Efekty przewijania

IOWA ma kilka ciekawych efektów, które pojawiają się podczas przewijania strony. Pierwszy z nich to nasz pływający przycisk polecenia, który przenosi użytkowników z powrotem na górę strony:

    <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>

Płynne przewijanie jest implementowane za pomocą elementów układu aplikacji w Polymerze. Dodają one gotowe efekty przewijania, takie jak przyklejone lub powracające menu górne, cienie, przejścia kolorów i tła, efekty paralaksy i płynne przewijanie.

    // 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.
    }

Elementy <app-layout> wykorzystaliśmy też w przypadku nawigacji na stałe. Jak widać na filmie, znika, gdy użytkownik przewinie stronę w dół, i wraca, gdy przewijasz z powrotem w górę.

Nawigacja przyklejona do przewijania
Przyklejone menu nawigacyjne z użyciem .

Użyliśmy elementu <app-header> w niezmienionej formie. Łatwo było je dodawać i zyskać efekt przewijania w aplikacji. Oczywiście, moglibyśmy to sami wdrożyć, ale skopiowanie szczegółów w komponencie wielokrotnego użytku pozwoliło nam zaoszczędzić czas.

Zadeklaruj element. Dostosuj go za pomocą atrybutów. To już wszystko.

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

Podsumowanie

W przypadku progresywnej aplikacji internetowej I/O udało nam się zbudować cały frontend w kilka tygodni dzięki komponentom internetowym i wstępnie utworzonym widżetom Material Design dostępnym w narzędziu Polymer. Funkcje natywnych interfejsów API (elementy niestandardowe, Shadow DOM, <template>) doskonale nadają się do dynamizmu SPA. Wielokrotne wykorzystanie oszczędza mnóstwo czasu.

Jeśli chcesz samodzielnie utworzyć progresywną aplikację internetową, skorzystaj z Narzędzi dla deweloperów aplikacji. Zestaw narzędzi do tworzenia aplikacji w Polymer to kolekcja komponentów, narzędzi i szablonów do tworzenia aplikacji PWA za pomocą Polymer. Jest to łatwy sposób na rozpoczęcie pracy.