Membuat Progressive Web App Google I/O 2016

Beranda Iowa

Ringkasan

Pelajari cara kami mem-build aplikasi web satu halaman menggunakan komponen web, Polymer, dan desain material, lalu meluncurkannya ke produksi di Google.com.

Hasil

  • Lebih banyak engagement daripada aplikasi native (web seluler menit 4:06 vs. menit Android 2:40).
  • Sorotan pertama 450 md lebih cepat untuk pengguna yang kembali berkat penyimpanan cache pekerja layanan
  • 84% pengunjung mendukung Pekerja Layanan
  • Penambahan ke layar utama meningkat sebesar +900% dibandingkan tahun 2015.
  • 3,8% pengguna offline, tetapi terus menghasilkan 11 ribu kunjungan halaman.
  • 50% pengguna yang login mengaktifkan notifikasi.
  • 536 rb notifikasi dikirim ke pengguna (12% mengembalikan mereka).
  • 99% browser pengguna mendukung polyfill komponen web

Ringkasan

Tahun ini, saya dengan senang hati mengerjakan aplikasi web progresif Google I/O 2016, yang diberi nama "IOWA". Aplikasi 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 yang luas yang ditulis di App Engine (Go). Fitur ini melakukan pra-cache konten menggunakan pekerja layanan, memuat halaman baru secara dinamis, melakukan transisi yang lancar di antara 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 sumber, lihat di GitHub.

Lihat di GitHub

Mem-build SPA menggunakan komponen web

Setiap halaman sebagai komponen

Salah satu aspek inti tentang frontend kami adalah berfokus pada komponen web. Faktanya, setiap halaman di SPA kita 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 kode ini dapat dibaca. Sebagai pembaca pertama, Anda akan langsung mengetahui setiap halaman di aplikasi kita. Alasan kedua adalah komponen web memiliki beberapa properti bagus untuk membuat SPA. Banyak masalah umum (pengelolaan status, aktivasi tampilan, cakupan gaya) hilang berkat fitur bawaan elemen <template>, Custom Elements, dan Shadow DOM. Ini adalah alat developer yang terintegrasi dalam browser. Mengapa tidak memanfaatkannya?

Dengan membuat Elemen Kustom untuk setiap halaman, kita mendapatkan banyak hal secara gratis:

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

Kami mendapatkan manfaat penuh dari IOWA ini. Mari kita pelajari beberapa 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, apa pun di dalam <template> tidak aktif hingga instance template dibuat. Kedua, browser menguraikan markup, tetapi kontennya tidak dapat dijangkau dari halaman utama. Ini adalah potongan markup yang sebenarnya 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 memperluas <template> dengan beberapa elemen khusus 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 komponen web, keduanya melakukan persis seperti yang Anda harapkan. Komponen pertama menandai markup 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 sangat bodoh jika mendeklarasikan setiap komponen pada pemuatan pertama. Artinya, Anda harus membuat instance setiap halaman saat aplikasi pertama kali dimuat. Kami tidak ingin menurunkan performa pemuatan awal, terutama karena beberapa pengguna hanya akan membuka 1 atau 2 halaman.

Solusi kami adalah dengan curang. Di IOWA, kita menggabungkan setiap elemen halaman dalam <template is="dom-if"> sehingga kontennya tidak dimuat saat booting pertama. Kemudian, kita 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 digunakan saat halaman dimuat, tetapi CSS/HTML/JS-nya hanya dijalankan sesuai permintaan (saat <template> induknya dicap). Tampilan dinamis + lambat menggunakan komponen web FTW.

Peningkatan pada masa mendatang

Saat halaman pertama kali dimuat, kita memuat semua Impor HTML untuk setiap halaman sekaligus. Peningkatan yang jelas adalah memuat lambat definisi elemen hanya saat diperlukan. Polymer juga memiliki bantuan yang bagus untuk pemuatan asinkron Import HTML Imports:

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

IOWA tidak melakukannya karena a) kita malas dan b) tidak jelas seberapa besar peningkatan performa yang akan kita lihat. Proses rendering pertama kami sudah ~1 detik.

Pengelolaan siklus proses halaman

Custom Elements API menentukan "callback siklus proses" untuk mengelola status komponen. Saat menerapkan metode ini, Anda mendapatkan hook gratis ke dalam siklus proses 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.
}

Tidak sulit untuk memanfaatkan callback ini di IOWA. Ingat, setiap halaman adalah node DOM mandiri. Membuka "tampilan baru" di SPA kita adalah masalah melampirkan satu node ke DOM dan menghapus node lainnya.

Kita menggunakan attachedCallback untuk melakukan tugas penyiapan (memulai status, melampirkan pemroses peristiwa). Saat pengguna membuka halaman lain, detachedCallback akan melakukan pembersihan (menghapus pemroses, mereset status bersama). Kami juga memperluas callback siklus proses native dengan beberapa callback kami 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.

Melakukan DRY pada fungsi umum di seluruh halaman

Pewarisan adalah fitur yang efektif dari Elemen Kustom. Library ini menyediakan model pewarisan standar untuk web.

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

Daripada membuat platform API yang sama di semua halaman, sebaiknya DRY-up 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 mengupdate document.title, mereset posisi scroll, dan menyiapkan pemroses peristiwa untuk efek scroll dan sub nav.

Setiap halaman menggunakan PageBehavior dengan memuat sebagai dependensi dan menggunakan behaviors. Pengguna juga bebas mengganti properti/metode dasarnya jika diperlukan. Misalnya, berikut adalah penggantian "subclass" halaman beranda kami:

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>

Berbagi gaya

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

Dalam IOWA, kita membuat shared-app-styles untuk berbagi class warna, tipografi, dan tata letak di seluruh halaman dan komponen lain yang kita 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 mengatakan "sertakan gaya dalam modul bernama "shared-app-styles".

Berbagi status aplikasi

Sekarang Anda tahu bahwa setiap halaman di aplikasi kita adalah Elemen Kustom. Saya sudah mengatakannya jutaan kali. Oke, tetapi jika setiap halaman adalah komponen web mandiri, Anda mungkin bertanya-tanya bagaimana kita berbagi status di seluruh aplikasi.

IOWA menggunakan teknik yang mirip dengan injeksi dependensi (Angular) atau redux (React) untuk berbagi status. Kita telah membuat properti app global dan menggantung sub-properti bersama dari properti tersebut. app diteruskan ke seluruh aplikasi dengan memasukkannya ke setiap komponen yang memerlukan datanya. Menggunakan fitur data binding Polymer akan mempermudahnya karena kita dapat melakukan penyambungan 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 kita. Karena properti tersebut terikat dengan app.currentUser, halaman apa pun yang ingin mengakses pengguna saat ini hanya perlu terikat dengan app dan membaca sub-properti currentUser. Teknik ini sendiri berguna untuk berbagi status di seluruh aplikasi. Namun, manfaat lainnya adalah kita akhirnya membuat elemen login sekali dan menggunakan kembali hasilnya di seluruh situs. Begitu juga untuk kueri media. Akan sangat sia-sia jika setiap halaman menduplikasi login atau membuat kumpulan kueri medianya sendiri. Sebagai gantinya, komponen yang bertanggung jawab atas fungsi/data di seluruh aplikasi terdapat di tingkat aplikasi.

Transisi halaman

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

Transisi halaman IOWA sedang berlangsung.
Cara kerja transisi halaman IOWA.

Saat pengguna membuka halaman baru, akan terjadi urutan hal-hal berikut:

  1. Navigasi atas menggeser panel pilihan ke link baru.
  2. Judul halaman akan memudar.
  3. Konten halaman bergeser ke bawah, lalu memudar.
  4. Dengan membalikkan 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 cerdas ini tanpa mengorbankan performa. Ada banyak pekerjaan dinamis yang dilakukan, dan jank tidak diterima di pesta kami. Solusi kami adalah kombinasi dari Web Animations API dan Promises. Menggunakan keduanya bersama-sama memberi kita fleksibilitas, sistem animasi plug and play, dan kontrol terperinci untuk meminimalkan jank das.

Cara kerjanya

Ketika pengguna mengklik ke halaman baru (atau menekan back/forward), runPageTransition() router akan melakukan keajaibannya dengan menjalankan serangkaian Promise. Penggunaan Promise memungkinkan kami untuk 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 agar semua tetap DRY: fungsionalitas umum di seluruh halaman", halaman akan memproses peristiwa DOM page-transition-start dan page-transition-done. Sekarang Anda melihat tempat peristiwa tersebut diaktifkan.

Kami menggunakan Web Animations API, bukan helper runEnterAnimation/runExitAnimation. Dalam kasus runExitAnimation, kita mengambil beberapa node DOM (header 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 diterapkan menggunakan elemen tata letak aplikasi Polymer. Komponen ini memberikan efek scroll siap pakai seperti navigasi atas yang melekat/kembali, drop shadow, transisi warna dan latar belakang, efek paralaks, dan scroll yang lancar.

    // 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 yang kami gunakan untuk elemen <app-layout> adalah untuk navigasi melekat. Seperti yang dapat Anda lihat dalam video, iklan akan hilang saat pengguna men-scroll halaman ke bawah dan muncul kembali saat men-scroll kembali ke atas.

Navigasi scroll melekat
Navigasi scroll melekat menggunakan .

Kita menggunakan elemen <app-header> hampir apa adanya. Sangat mudah untuk memasukkan dan mendapatkan efek scroll yang menarik di aplikasi. Tentu saja, kita dapat menerapkannya sendiri, tetapi memiliki detail yang sudah dikodifikasi dalam komponen yang dapat digunakan kembali adalah penghemat waktu yang besar.

Deklarasikan elemen. Sesuaikan dengan atribut. Selesai!

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

Kesimpulan

Untuk aplikasi web progresif I/O, kami dapat mem-build seluruh frontend dalam beberapa minggu berkat komponen web dan widget desain material siap pakai dari Polymer. Fitur API native (Elemen Kustom, Shadow DOM, <template>) cocok secara alami dengan dinamisme SPA. Kemampuan penggunaan kembali menghemat banyak waktu.

Jika Anda tertarik untuk membuat progressive web app Anda sendiri, lihat App Toolbox. App Toolbox Polymer adalah kumpulan komponen, alat, dan template untuk mem-build PWA dengan Polymer. Ini adalah cara mudah untuk menyiapkan dan menjalankannya.