Tworzenie progresywnej aplikacji internetowej Google I/O 2016

Strona główna Iowa

Podsumowanie

Dowiedz się, jak za pomocą komponentów internetowych, Polymer i Material Design aplikację stworzyliśmy z jedną stroną, a następnie udostępniliśmy ją w wersji produkcyjnej na Google.com.

Wyniki

  • Większe zaangażowanie niż w przypadku aplikacji natywnej (przeglądarka mobilna: 4:06 min, a czas na Androida – 2:40 min).
  • Szybsze pierwsze renderowanie o 450 ms w przypadku powracających użytkowników dzięki buforowaniu skryptu service worker
  • 84% użytkowników obsługiwało skrypt Service Worker
  • Liczba przypadków dodania do ekranu głównego wzrosła o 900% w porównaniu z 2015 rokiem.
  • 3,8% użytkowników przeszło do trybu offline, ale nadal generuje 11 tys. wyświetleń stron.
  • 50% zalogowanych użytkowników włączyło powiadomienia.
  • Wysłano 536 tys. powiadomień (12% zwróciło ich uwagę).
  • 99% przeglądarek użytkowników obsługiwało komponenty internetowe polyfill

Przegląd

W tym roku miałem przyjemność pracować nad progresywną aplikacją internetową Google I/O 2016 pod nazwą „IOWA”. Ten model powstał z myślą o urządzeniach mobilnych, działa w trybie offline i jest w dużym stopniu inspirowany stylem Material Design.

IOWA to aplikacja z jedną stroną (SPA), która została stworzona z wykorzystaniem komponentów internetowych, Polymer i Firebase, i ma rozbudowany backend napisany w App Engine (Go). Wstępnie zapisuje treści w pamięci podręcznej za pomocą skryptu service worker, dynamicznie wczytuje nowe strony, płynnie przełącza się między widokami i wykorzystuje je po pierwszym wczytaniu.

W tym studium przypadku omówię niektóre z ciekawszych decyzji architektonicznych, jakie podjęliśmy w przypadku frontendu. Jeśli chcesz dowiedzieć się więcej o kodzie źródłowym, sprawdź go w GitHubie.

Wyświetl w GitHub

Tworzenie SPA za pomocą komponentów sieciowych

Każda strona jako komponent

Jedną z najważniejszych cech naszego frontendu jest to, że jest on skoncentrowany na komponentach sieciowych. Każda strona w naszym SPA to komponent internetowy:

    <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 tak się stało? Po pierwsze, kod jest czytelny. Gdy czytasz nas po raz pierwszy, od razu wiesz, czym jest każda strona w naszej aplikacji. Po drugie, komponenty sieciowe mają właściwości przydatne do budowania SPA. Wiele typowych problemów (zarządzanie stanem, aktywacja widoku, określanie zakresu) nie jest już możliwe 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 warto ich wykorzystać?

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

  • Zarządzanie cyklem życia strony.
  • Ograniczony zakres CSS/HTML do strony.
  • Cała zawartość CSS/HTML/JS specyficzna dla danej strony jest łączona i wczytywana razem.
  • Widoków można używać wielokrotnie. Ponieważ strony to węzły DOM, samo ich dodanie lub usunięcie zmienia widok.
  • Przyszli opiekunowie będą mogli zrozumieć działanie aplikacji, po prostu robiąc znaczniki na swoich znacznikach.
  • Znaczniki renderowane przez serwer można stopniowo ulepszać w miarę rejestrowania i uaktualniania definicji elementów przez przeglądarkę.
  • Elementy niestandardowe mają model dziedziczenia. Dobry kod to DRY.
  • ...wiele innych rzeczy.

W pełni wykorzystaliśmy te możliwości w ramach inicjatywy IOWA. Przejdźmy do szczegółów.

Dynamiczna aktywacja stron

Element <template> to standardowy sposób tworzenia znaczników wielokrotnego użytku w przeglądarce. <template> ma 2 cechy, które są korzystne dla aplikacji SPA. Po pierwsze, dopóki nie zostanie utworzone wystąpienie szablonu, wszystkie elementy wewnątrz obiektu <template> będą bezczynne. Po drugie, przeglądarka analizuje znaczniki, ale ich zawartość jest nieosiągalna na stronie głównej. To prawdziwy fragment znacznika do wielokrotnego użytku. 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" autoplay></video> <!-- doesn't load/start -->
    <script>alert("Not run until the template is stamped");</script>
</template>

Polymer extends obiekty <template> o kilka niestandardowych elementów rozszerzenia typu, np. <template is="dom-if"> i <template is="dom-repeat">. Oba to elementy niestandardowe, które rozszerzają <template> o dodatkowe możliwości. Dzięki deklaratywnemu charakterowi komponentów sieciowych oba działają dokładnie tak, jak można by oczekiwać. Pierwszy komponent oznacza znaczniki na podstawie warunku warunkowego. Drugie powtarza znaczniki każdego elementu na liście (modelu danych).

W jaki sposób IOWA korzysta z elementów rozszerzeń tego typu?

Każda strona w IOWA jest komponentem internetowym. Jednak byłoby głupio deklarować każdy komponent przy pierwszym wczytaniu. Oznacza to utworzenie wystąpienia każdej strony przy pierwszym wczytaniu aplikacji. Nie chcieliśmy pogarszać początkowego wczytywania, zwłaszcza że niektórzy użytkownicy poruszają się po 1 lub 2 stronach.

Naszym rozwiązaniem było oszustwo. W architekturze IOWA pakujemy każdy element strony za pomocą tagu <template is="dom-if">, aby jej 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 za nas całą tę logikę. Znaczniki wyglądają 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 wyświetlenia po wczytaniu, ale jej kod CSS/HTML/JS jest wykonywany tylko na żądanie (gdy jej element nadrzędny <template> jest opatrzony pieczęcią). Widoki dynamiczne i leniwe z użyciem komponentów sieciowych FTW.

Przyszłe ulepszenia

Podczas pierwszego wczytywania strony wczytujemy wszystkie importy HTML dla każdej strony jednocześnie. Oczywistym ulepszeniem jest leniwe ładowanie definicji elementów tylko wtedy, gdy są potrzebne. Polymer ma też przydatne narzędzie do asynchronicznego wczytywania importów HTML:

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

IOWA tego nie robi, ponieważ a) jesteśmy leniwi i b) nie wiemy, jaki wzrost wydajności możemy osiągnąć. Pierwsze renderowanie trwało ok. 1 s.

Zarządzanie cyklem życia strony

Interfejs Custom Elements API definiuje „wywołania zwrotne cyklu życia” służące do zarządzania stanem komponentu. Implementacja tych metod daje bezpłatne punkty zaczepienia związane z życiem komponentu:

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 bez trudu można było wykorzystać te wywołania. Pamiętaj, że każda strona jest autonomicznym węzłem DOM. Przejście do „nowego widoku” w naszej SPA polega na dołączeniu jednego węzła do DOM i usunięciu innego.

Użyliśmy polecenia attachedCallback do wykonania czynności konfiguracyjnych (stanu początkowego, dołączenia detektorów zdarzeń). Gdy użytkownicy przechodzą na inną stronę, detachedCallback czyści je (usuwa detektory i resetuje stan udostępnienia). Rozszerzyliśmy też natywne wywołania zwrotne cyklu życia, dodając kilka własnych:

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

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

Były one przydatne w przypadku opóźnień w pracy i minimalizowania zakłóceń między przenoszeniem stron. Więcej na ten temat znajdziesz później.

Osuszanie wspólnych funkcji na różnych stronach

Dziedziczenie to potężna funkcja elementów niestandardowych. Stanowi standardowy model dziedziczenia w internecie.

Niestety w Polymer 1.0 w chwili tworzenia tego języka wdrożyliśmy jeszcze dziedziczenie elementów. W międzyczasie przydatna była funkcja zachowań dostępnych w Polymer. Zachowania to po prostu kombinacje.

Zamiast tworzyć tę samą platformę interfejsu API na wszystkich stronach, warto było DOSTOSOWAĆ bazę kodu, tworząc wspólne składanki. Na przykład PageBehavior definiuje wspólne 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 zadania wykonywane podczas odwiedzania nowej strony. Obejmuje to aktualizację elementu document.title, zresetowanie pozycji przewijania oraz skonfigurowanie detektorów zdarzeń przewijania i efektów nawigacji podrzędnej.

Na poszczególnych stronach używana jest zasada PageBehavior, która wczytuje się jako zależność i używa behaviors. W razie potrzeby mogą też zastąpić podstawowe właściwości/metody. Oto przykładowa klasa „podklasa” 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>

Style udostępniania

Aby udostępnić style różnych komponentów w naszej aplikacji, użyliśmy modułów wspólnych stylów Polymer. Moduły stylów umożliwiają jednorazowe zdefiniowanie fragmentu kodu CSS i wykorzystywanie go w różnych miejscach w aplikacji. Dla nas „różne miejsca” oznaczały różne komponenty.

W serwisie IOWA stworzyliśmy shared-app-styles, aby udostępnić informacje o kolorach, typografii i klasie układu na różnych stronach i w innych naszych 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>

W tym miejscu <style include="shared-app-styles"></style> to składnia Polymer, która brzmi: „uwzględnij style w module o nazwie „shared-app-styles”.

Stan aplikacji udostępniania

Jak wiesz, każda strona w naszej aplikacji jest elementem niestandardowym. Powiedziałam to milion razy. Jeśli jednak każda strona jest samodzielnym komponentem internetowym, możesz zastanawiać się, w jaki sposób udostępniamy informacje o stanie aplikacji.

IOWA stosuje technikę podobną do wstrzykiwania zależności (Angular) lub redux (React) do udostępniania stanu. Utworzyliśmy globalną usługę app i zawiesiliśmy udostępniane usługi podrzędne. app przechodzi do naszej aplikacji, wstrzykując ją do każdego komponentu, który wymaga swoich danych. Funkcje wiązania danych w Polymer znacznie to ułatwiają, bo możemy okablować bez konieczności 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. Usługa jest powiązana z usługą app.currentUser, dlatego każda strona, która chce uzyskać dostęp do bieżącego użytkownika, musi po prostu utworzyć powiązanie z usługą app i przeczytać usługę podrzędną currentUser. Sama ta technika jest przydatna przy udostępnianiu informacji o stanie w całej aplikacji. Kolejną zaletą było to, że w końcu utworzyliśmy element logowania jednokrotnego i wykorzystaliśmy jego wyniki w całej witrynie. To samo dotyczy zapytań o media. Szkodliwe byłoby duplikowanie logowania na każdą stronę lub tworzenie własnego zestawu zapytań o multimedia. Zamiast tego komponenty, które odpowiadają za funkcje i dane dotyczące całej aplikacji, istnieją na poziomie aplikacji.

Przejścia między stronami

W aplikacji internetowej Google I/O można zauważyć płynne przejścia między stronami (à la Material Design).

Trwa przechodzenie między stronami IOWA.
Przenoszenie stron firmy IOWA w praktyce.

Gdy użytkownicy przechodzą na nową stronę, następuje kilka zdarzeń:

  1. Górny panel nawigacyjny przesuwa pasek wyboru do nowego linku.
  2. Nagłówek strony znika.
  3. Zawartość strony przesuwa się w dół, a następnie znika.
  4. Gdy odwracasz te animacje, pojawi się nagłówek i zawartość nowej strony.
  5. (Opcjonalnie) Nowa strona wykonuje dodatkowe operacje inicjowania.

Jednym z naszych wyzwań było znalezienie sposobu na uzyskanie płynnego przejścia bez negatywnego wpływu na wydajność. Czeka na nas dużo dynamicznej pracy, a dreszczyk nie był mile widziany na naszej imprezie. Nasze rozwiązanie stanowiło połączenie interfejsu API Web Animations i Promises. Połączenie tych dwóch rozwiązań zapewniło nam wszechstronność, system animacji typu „plug and play” oraz szczegółową kontrolę, która pozwala zminimalizować das zacinanie.

Jak to działa

Gdy użytkownik kliknie nową stronę (lub kliknie przycisk Wstecz/Dalej), runPageTransition() naszego routera wykonuje swoje magiczne działanie, wyświetlając serię obietnic. Wykorzystanie obietnic pomogło nam starannie administrować animacjami i pomóc racjonalizować „asynchronizację” animacji CSS i dynamicznie wczytywać 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));
    }

}

Ciemność z sekcji „Utrzymywanie suchości: wspólne funkcje na różnych stronach” – strony nasłuchują zdarzeń page-transition-start i page-transition-done DOM. Teraz widzisz, gdzie są wywoływane te zdarzenia.

Użyliśmy interfejsu Web Animations API zamiast runEnterAnimation/runExitAnimation pomocników. W przypadku obiektu runExitAnimation bierzemy kilka węzłów DOM (masthead i obszar treści głównej), deklarujemy początek i koniec każdej animacji, a następnie tworzymy GroupEffect, aby wyświetlać 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)
    ]);
}

Zmodyfikuj tablicę, aby przejścia widoku były bardziej (lub mniej) skomplikowane.

Efekty przewijania

IOWA ma kilka ciekawych efektów, gdy przewijasz stronę. Pierwszy z nich to 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 zaimplementowane za pomocą elementów układu aplikacji Polymer. Oferują gotowe efekty przewijania, takie jak przyklejone/powracające górne elementy nawigacyjne, cienie, przejścia kolorów i tła, efekty paralaksy oraz 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> były też używane w przyklejonej nawigacji. Jak widać na filmie, znika, gdy użytkownik przewinie stronę w dół, a potem wraca, gdy przewija się z powrotem w górę.

Nawigacje z przyklejonym przewijaniem
Przyklejone elementy nawigacyjne w interfejsie .

Użyliśmy elementu <app-header> w niezmienionej formie. Łatwo było dodać go do aplikacji i uzyskać w niej wymyślne efekty przewijania. Oczywiście moglibyśmy je zaimplementować sami, ale utworzenie szczegółów w komponencie wielokrotnego użytku pozwoliło nam zaoszczędzić dużo czasu.

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

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

Podsumowanie

Dzięki komponentom internetowym i gotowym widżetom interfejsu Material Design firmy Polymer udało nam się w ciągu kilku tygodni zbudować cały frontend progresywnej aplikacji internetowej I/O. Cechy natywnych interfejsów API (Custom Elements, Shadow DOM, <template>) w naturalny sposób odpowiadają za dynamikę SPA. Możliwość wielokrotnego użytku pozwala zaoszczędzić sporo czasu.

Jeśli chcesz utworzyć własną progresywną aplikację internetową, skorzystaj z Zestawu narzędzi. Zestaw narzędzi aplikacji Polymer to zbiór komponentów, narzędzi i szablonów do tworzenia aplikacji PWA za pomocą Polymer. To prosty sposób, by zacząć z niego korzystać.