
Podsumowanie
Dowiedz się, jak zbudowaliśmy jednostronicową aplikację z użyciem 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 szybsze pierwsze wyrenderowanie dla powracających użytkowników dzięki pamięci podręcznej dla service workera
- 84% użytkowników obsługiwało Service Worker
- Liczba zapisanych filmów wzrosła o 900% w porównaniu z 2015 r.
- 3,8% użytkowników wyszło offline, ale nadal wygenerowało 11 tys. wyświetleń strony.
- 50% zalogowanych użytkowników włączyło powiadomienia.
- Do użytkowników wysłano 536 tys. powiadomień (12% z nich zostało przywróconych).
- 99% przeglądarek użytkowników obsługuje polyfille komponentów internetowych
Omówienie
W tym roku miałem przyjemność pracować nad aplikacją internetową Progressive Web na Google I/O 2016, która nosi nazwę „IOWA”. Jest zoptymalizowany pod kątem urządzeń mobilnych, działa całkowicie offline i jest mocno inspirowany material designem.
IOWA to aplikacja jednostronicowa, która została stworzona za pomocą komponentów internetowych, Polymera i Firebase. Ma rozbudowany 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, przejścia między widokami są płynne, 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.
Tworzenie aplikacji SPA za pomocą komponentów internetowych
każda strona jako komponent;
Jednym z podstawowych aspektów naszej strony frontendowej jest to, że koncentruje się ona na komponentach internetowych. W istocie 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ą kilka przydatnych właściwości 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?
Dzięki utworzeniu elementu niestandardowego na każdej stronie uzyskaliśmy wiele bezpłatnych korzyści:
- Zarządzanie cyklem życia strony.
- CSS/HTML ograniczony do konkretnej strony.
- Wszystkie pliki CSS/HTML/JS związane z daną stroną są grupowane i ładowane razem w miarę potrzeby.
- Widoki 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. Kod DRY to dobry kod.
- …i wiele innych rzeczy.
W ramach IOWA w pełni skorzystaliśmy z tych korzyści. Przyjrzyjmy się bliżej kilku szczegółom.
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, wszystko, co znajduje się wewnątrz <template>
, jest nieaktywne, dopóki nie zostanie utworzona instancja szablonu. Po drugie, przeglądarka parsuje znaczniki, ale zawartość nie jest dostępna ze strony 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 <template>
o kilka elementów własnych rozszerzenia typu, a mianowicie <template is="dom-if">
i <template is="dom-repeat">
. Oba są elementami niestandardowymi, które rozszerzają <template>
o dodatkowe funkcje. 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 powtarza znaczniki dla każdego elementu na liście (model danych).
Jak IOWA używa tych elementów rozszerzenia typu?
Jak pamiętasz, każda strona w IOWA jest komponentem internetowym. Nie ma jednak sensu deklarować wszystkich komponentów 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.
Nasze rozwiązanie polegało na oszustwie. 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ę. Oznacznik 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 ulepszeniem byłoby opóźnione wczytywanie definicji elementów tylko wtedy, gdy są one 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, 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 „funkcje wywołujące w cyklu życia” służące do zarządzania stanem komponentu. Po implementowaniu tych metod możesz bezpłatnie korzystać z tych elementów 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 można było łatwo wykorzystać te funkcje zwracania wartości. Pamiętaj, że każda strona jest samodzielnym 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.
}
Te dodatki były przydatne do opóźniania pracy i minimalizowania zacięć podczas przełączania się między stronami. Więcej informacji na ten temat znajdziesz 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 moduły. Na przykład PageBehavior
definiuje właściwości i metody wspólne dla wszystkich stron 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ędnego menu nawigacyjnego.
Poszczególne strony używają PageBehavior
, wczytując go jako element zależny i korzystając z elementu behaviors
.
W razie potrzeby mogą też zastąpić jego właściwości lub 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 ramach projektu 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
Jak już wiesz, 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 do udostępniania stanu używa techniki podobnej do wstrzykiwania zależności (Angular) lub redux (React). Utworzyliśmy globalną usługę app
i podrzędne usługi udostępnione. app
jest przekazywany w naszej aplikacji przez wstrzyknięcie go do każdego komponentu, który potrzebuje jego danych. Używanie funkcji powią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 wykorzystać jego wyniki w całej witrynie. To samo dotyczy zapytań o multimedia. Duplikatowe logowanie na każdej stronie lub tworzenie osobnych zestawów zapytań dotyczących multimediów byłoby nieefektywne. Zamiast tego komponenty odpowiedzialne za funkcje lub dane na poziomie aplikacji znajdują się na poziomie aplikacji.
Przejścia między stronami
Podczas korzystania z aplikacji internetowej Google I/O zauważysz płynne przejścia między stronami (w stylu Material Design).

Gdy użytkownik przechodzi na nową stronę, następuje taka sekwencja działań:
- Górne menu przesuwa pasek wyboru na nowy link.
- Nagłówek strony znika.
- Treści strony przesuwają się w dół, a potem znikają.
- Gdy animacje są odtwarzane w odwrotnej kolejności, pojawiają się nagłówek i treści nowej strony.
- (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żytkownicy klikają nową stronę (lub klikają wstecz/w przód), nasz router runPageTransition()
wykonuje swoje zadanie, wykonując serię obietnic. Dzięki użyciu obietnic mogliśmy starannie zsynchronizować animacje i usystematyzować asynchroniczne działanie animacji CSS oraz wczytywanie dynamiczne 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 wspomnieliśmy w sekcji Utrzymywanie spójności kodu: wspólne funkcje na stronach, strony nasłuchują zdarzeń DOM page-transition-start
i page-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. Pierwszym jest pływający przycisk polecenia (FAB), 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. Dostępne są 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 ona, gdy użytkownicy przewijają stronę w dół, i powraca, gdy przewijają w górę.

.
Element <app-header>
został przez nas wykorzystany w postaci niemal niezmienionej. Łatwo było go wstawić i uzyskać efektowne efekty przewijania w aplikacji. Oczywiście moglibyśmy je zaimplementować samodzielnie, ale posiadanie szczegółów już skodyfikowanych w komponencie wielokrotnego użytku pozwoliło nam zaoszczędzić mnóstwo czasu.
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 na potrzeby konferencji I/O udało nam się zbudować cały interfejs w kilka tygodni dzięki komponentom internetowym i gotowym widżetom w technologii Material Design w 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 tworzyć własne progresywne aplikacje internetowe, 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.