Membuat Progressive Web App Google I/O 2016

Beranda Iowa

Ringkasan

Pelajari cara kami membuat aplikasi satu halaman menggunakan komponen web, Polymer, dan desain material, serta meluncurkannya ke tahap produksi di Google.com.

Hasil

  • Lebih banyak interaksi daripada aplikasi native (web seluler 4:06 menit vs. Android 2:40 menit).
  • Cat pertama 450 md lebih cepat untuk pengguna yang kembali berkat caching pekerja layanan
  • 84% pengunjung mendukung Service Worker
  • Jumlah penghematan di layar utama naik +900% dibandingkan tahun 2015.
  • 3,8% pengguna beralih ke offline, tetapi terus menghasilkan 11 ribu kunjungan halaman.
  • 50% pengguna yang login mengaktifkan notifikasi.
  • 536 ribu notifikasi dikirim ke pengguna (12% membawa mereka kembali).
  • 99% browser pengguna mendukung polyfill komponen web

Ringkasan

Tahun ini, saya merasa senang dapat mengerjakan progressive web app Google I/O 2016, yang diberi nama "IOWA". Game ini mengutamakan perangkat seluler, berfungsi sepenuhnya secara offline, dan sangat terinspirasi oleh desain material.

IOWA adalah aplikasi web satu halaman (SPA) yang dibuat menggunakan komponen web, Polymer, dan Firebase, serta memiliki backend ekstensif yang ditulis di App Engine (Go). Layanan ini melakukan pra-cache konten menggunakan service worker, memuat halaman baru secara dinamis, melakukan transisi dengan lancar antar-tampilan, dan menggunakan kembali konten setelah pemuatan pertama.

Dalam studi kasus ini, saya akan membahas beberapa keputusan arsitektur yang lebih menarik yang kami buat untuk frontend. Jika Anda tertarik dengan kode sumbernya, lihat di GitHub.

Lihat di GitHub

Membuat SPA menggunakan komponen web

Setiap halaman sebagai komponen

Salah satu aspek inti tentang frontend kami adalah fokus pada komponen web. Faktanya, setiap halaman di SPA kami adalah komponen 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>

Mengapa kita melakukannya? Alasan pertama adalah agar kode ini dapat dibaca. Sebagai pembaca pemula, Anda dapat memahami setiap halaman di aplikasi dengan jelas. Alasan kedua adalah komponen web memiliki beberapa properti yang bagus untuk membuat SPA. Banyak masalah umum (pengelolaan status, aktivasi tampilan, cakupan gaya) hilang berkat fitur inheren elemen <template>, Elemen Kustom, dan Shadow DOM. Ini adalah alat developer yang dibangun ke dalam browser. Mengapa tidak memanfaatkannya saja?

Dengan membuat Elemen Khusus untuk setiap halaman, kami punya banyak hal gratis:

  • Pengelolaan siklus proses halaman.
  • CSS/HTML dengan cakupan khusus untuk halaman.
  • Semua CSS/HTML/JS khusus untuk halaman dipaketkan dan dimuat bersama sesuai kebutuhan.
  • Tampilan dapat digunakan kembali. Karena halaman adalah simpul DOM, cukup dengan menambahkan atau menghapusnya akan mengubah tampilan.
  • Pengelola mendatang dapat memahami aplikasi kita hanya dengan membaca markup.
  • Markup yang dirender server dapat ditingkatkan secara progresif saat definisi elemen didaftarkan dan diupgrade oleh browser.
  • Elemen Kustom memiliki model pewarisan. Kode DRY adalah kode yang baik.
  • ...lebih banyak lagi.

Kami mendapatkan manfaat penuh dari manfaat tersebut di IOWA. Mari pelajari detailnya.

Mengaktifkan halaman secara dinamis

Elemen <template> adalah cara standar browser untuk membuat markup yang dapat digunakan kembali. <template> memiliki dua karakteristik yang dapat dimanfaatkan SPA. Pertama, semua yang ada di dalam <template> tidak aktif hingga instance template dibuat. Kedua, browser mengurai markup, tetapi kontennya tidak dapat dijangkau dari halaman utama. Ini adalah potongan markup yang benar dan dapat digunakan kembali. Contoh:

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

Polimer extends <template> dengan beberapa elemen kustom ekstensi jenis, yaitu <template is="dom-if"> dan <template is="dom-repeat">. Keduanya adalah elemen kustom yang memperluas <template> dengan kemampuan tambahan. Dan berkat sifat deklaratif dari komponen web, keduanya melakukan apa yang diharapkan. Markup stempel komponen pertama berdasarkan kondisional. Yang kedua mengulangi markup untuk setiap item dalam daftar (model data).

Bagaimana IOWA menggunakan elemen ekstensi jenis ini?

Jika Anda ingat, setiap halaman di IOWA adalah komponen web. Namun, akan konyol jika mendeklarasikan setiap komponen pada pemuatan pertama. Itu berarti membuat instance dari setiap halaman saat aplikasi pertama kali dimuat. Kami tidak ingin menurunkan performa pemuatan awal kami, terutama karena beberapa pengguna hanya akan menavigasi ke 1 atau 2 halaman.

Solusi kami adalah menyontek. Di IOWA, kita menggabungkan setiap elemen halaman dalam <template is="dom-if"> sehingga kontennya tidak dimuat saat booting pertama. Kemudian, kami akan mengaktifkan halaman saat atribut name template cocok dengan URL. Komponen web <lazy-pages> menangani semua logika ini untuk kita. Markup akan terlihat seperti ini:

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

Yang saya sukai dari hal ini adalah setiap halaman diuraikan dan siap ditampilkan saat halaman dimuat, tetapi CSS/HTML/JS-nya hanya dijalankan sesuai permintaan (saat induknya <template> distempel). Tampilan dinamis + lambat menggunakan FTW komponen web.

Peningkatan mendatang

Saat halaman pertama kali dimuat, kita akan memuat semua Impor HTML untuk setiap halaman sekaligus. Peningkatan yang nyata adalah menjalankan lazy load definisi elemen hanya saat diperlukan. Polymer juga memiliki bantuan yang bagus untuk memuat Impor HTML secara asinkron:

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

IOWA tidak melakukan ini karena a) kita lambat dan b) tidak jelas seberapa besar peningkatan performa yang akan kita dapatkan. Cat pertama kami sudah mencapai ~1s.

Pengelolaan siklus proses halaman

Custom Elements API menentukan "callback siklus proses" untuk mengelola status komponen. Saat menerapkan metode ini, Anda mendapatkan hook gratis ke masa pakai komponen:

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

Sangat mudah untuk memanfaatkan callback ini di IOWA. Ingat, setiap halaman adalah simpul DOM mandiri. Menavigasi ke "tampilan baru" di SPA kita berarti melampirkan satu simpul ke DOM dan menghapus yang lain.

Kita menggunakan attachedCallback untuk melakukan pekerjaan penyiapan (memulai status, melampirkan pemroses peristiwa). Saat pengguna membuka halaman lain, detachedCallback akan melakukan pembersihan (menghapus pemroses, mereset status bersama). Kita juga memperluas callback siklus proses native dengan beberapa callback sendiri:

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

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

Ini adalah tambahan yang berguna untuk menunda pekerjaan dan meminimalkan jank di antara transisi halaman. Selengkapnya akan dibahas nanti.

MENYELEKSI fungsi umum di seluruh halaman

Pewarisan adalah fitur canggih dari Elemen Kustom. Solusi ini menyediakan model pewarisan standar untuk web.

Sayangnya, Polymer 1.0 belum menerapkan pewarisan elemen pada saat penulisan ini. Sementara itu, fitur Behaviors Polymer juga sama bergunanya. Perilaku hanyalah mixin.

Daripada membuat platform API yang sama di semua halaman, sebaiknya LIHAT codebase dengan membuat mixin bersama. Misalnya, PageBehavior menentukan properti/metode umum yang diperlukan semua halaman di aplikasi kita:

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

Seperti yang dapat Anda lihat, PageBehavior melakukan tugas umum yang berjalan saat halaman baru dikunjungi. Hal-hal seperti memperbarui document.title, mereset posisi scroll, dan menyiapkan pemroses peristiwa untuk efek scroll dan subnavigasi.

Setiap halaman menggunakan PageBehavior dengan memuatnya sebagai dependensi dan menggunakan behaviors. Mereka juga bebas mengganti properti/metode dasarnya jika diperlukan. Sebagai contoh, berikut adalah yang diganti dengan "subclass" halaman beranda:

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>

Gaya berbagi

Untuk membagikan gaya di berbagai komponen dalam aplikasi, kita menggunakan modul gaya bersama Polymer. Modul gaya memungkinkan Anda menentukan potongan CSS sekali dan menggunakannya kembali di berbagai tempat di seluruh aplikasi. Bagi kami, "tempat yang berbeda" berarti komponen yang berbeda.

Di IOWA, kami membuat shared-app-styles untuk membagikan class warna, tipografi, dan tata letak di seluruh halaman dan komponen lain yang kami buat.

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>

Di sini, <style include="shared-app-styles"></style> adalah sintaksis Polymer untuk menyatakan "sertakan gaya dalam modul bernama "shared-app-styles".

Berbagi status aplikasi

Sekarang Anda tahu bahwa setiap laman di aplikasi kita adalah Elemen Khusus. Saya sudah mengatakannya jutaan kali. Oke, tetapi jika setiap halaman adalah komponen web mandiri, Anda mungkin bertanya pada diri sendiri bagaimana kami berbagi status di seluruh aplikasi.

IOWA menggunakan teknik yang mirip dengan injeksi dependensi (Angular) atau redux (React) untuk status berbagi. Kami membuat properti app global dan menggantungkan sub-properti bersama di dalamnya. app diteruskan di aplikasi dengan memasukkannya ke setiap komponen yang memerlukan datanya. Penggunaan fitur data binding Polymer memudahkan hal ini karena kita dapat melakukan pemasangan kabel tanpa menulis kode apa pun:

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

Elemen <google-signin> memperbarui properti user-nya saat pengguna login ke aplikasi. Karena properti tersebut terikat dengan app.currentUser, setiap halaman yang ingin mengakses pengguna saat ini hanya perlu mengikat ke app dan membaca sub-properti currentUser. Teknik ini sendiri berguna untuk membagikan status ke seluruh aplikasi. Namun, manfaat lainnya adalah kami akhirnya membuat elemen login tunggal dan menggunakan kembali hasilnya di seluruh situs. Begitu juga untuk kueri media. Akan sia-sia bagi setiap halaman jika menduplikasi login atau membuat kumpulan kueri medianya sendiri. Sebagai gantinya, komponen yang bertanggung jawab atas fungsi/data tingkat aplikasi ada di tingkat aplikasi.

Transisi halaman

Saat membuka aplikasi web Google I/O, Anda akan melihat transisi halamannya yang apik (à la desain material).

Transisi halaman IOWA sedang diterapkan.
Transisi halaman IOWA sedang berlangsung.

Saat pengguna menavigasi ke halaman baru, urutan hal akan terjadi:

  1. Navigasi atas akan menggeser panel pilihan ke link baru.
  2. Judul halaman akan memudar.
  3. Konten halaman bergeser ke bawah, lalu memudar.
  4. Dengan membalik animasi tersebut, judul dan konten halaman baru akan muncul.
  5. (Opsional) Halaman baru melakukan pekerjaan inisialisasi tambahan.

Salah satu tantangan kami adalah mencari tahu cara membuat transisi yang apik ini tanpa mengorbankan performa. Ada banyak pekerjaan dinamis yang berlangsung, dan jank tidak disambut di pesta kita. Solusi kami adalah kombinasi dari Web Animations API dan Promise. Menggunakan keduanya secara bersamaan memberi kami fleksibilitas, sistem animasi plug and play, dan kontrol terperinci untuk meminimalkan jank das.

Cara kerjanya

Saat pengguna mengklik ke halaman baru (atau menekan mundur/maju), runPageTransition() router kita melakukan keajaibannya dengan menjalankan serangkaian Promise. Dengan menggunakan Promise, kami dapat mengorkestrasi animasi dengan hati-hati dan membantu merasionalisasi "asinkron" Animasi CSS dan memuat konten secara dinamis.

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

}

Perolehan dari bagian "Menjaga berbagai hal tetap DRY: fungsi umum di seluruh halaman", halaman memproses peristiwa DOM page-transition-start dan page-transition-done. Sekarang Anda melihat di mana peristiwa itu diaktifkan.

Kami menggunakan Web Animations API, bukan helper runEnterAnimation/runExitAnimation. Dalam kasus runExitAnimation, kita mengambil beberapa node DOM (masthead dan area konten utama), mendeklarasikan awal/akhir setiap animasi, dan membuat GroupEffect untuk menjalankan keduanya secara paralel:

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

Cukup ubah array untuk membuat transisi tampilan lebih (atau kurang) rumit.

Efek scroll

IOWA memiliki beberapa efek menarik saat Anda men-scroll halaman. Yang pertama adalah tombol tindakan mengambang (FAB) yang membawa pengguna kembali ke bagian atas halaman:

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

Scroll yang lancar diimplementasikan menggunakan elemen tata letak aplikasi Polymer. Mereka memberikan efek scroll luar biasa seperti navigasi atas yang melekat/kembali, drop shadow, transisi warna dan latar belakang, efek paralaks, dan scroll yang mulus.

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

Tempat lain kami menggunakan elemen <app-layout> adalah untuk navigasi melekat. Seperti yang dapat Anda lihat di video, elemen ini akan menghilang saat pengguna men-scroll halaman ke bawah dan kembali saat men-scroll kembali ke atas.

Navigasi scroll melekat
Navigasi scroll melekat menggunakan .

Kami menggunakan elemen <app-header> apa adanya. Mudah untuk menambahkan dan mendapatkan efek scroll mewah di aplikasi. Tentu, kami dapat menerapkannya sendiri, tetapi memiliki detailnya yang sudah dikodifikasi dalam komponen yang dapat digunakan kembali akan sangat menghemat waktu.

Deklarasikan elemen. Sesuaikan dengan atribut. Selesai!

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

Kesimpulan

Untuk progressive web app I/O, kami dapat membangun seluruh frontend dalam beberapa minggu berkat komponen web dan widget desain material Polymer yang sudah jadi. Fitur API native (Elemen Kustom, Shadow DOM, <template>) secara alami menyesuaikan dengan dinamisme SPA. Penggunaan kembali menghemat banyak waktu.

Jika Anda tertarik untuk membuat progressive web app sendiri, lihat Toolbox App. App Toolbox Polymer adalah kumpulan komponen, alat, dan template untuk membuat PWA dengan Polymer. Ini adalah cara yang mudah untuk mengaktifkan dan menjalankan.