打造 2016 年 Google I/O 大會漸進式網頁應用程式

愛荷華州首頁

摘要

瞭解我們如何運用網頁元件、Polymer 和 Material Design 打造單一頁面應用程式,並在 Google.com 上推出正式版產品。

研究結果

  • 參與度高於原生應用程式 (行動版網站 4 分 06 分鐘,Android 影片為 2 分 40 分鐘)。
  • 拜服務工作處理程序快取所賜,回訪者首次繪製所需時間加快 450 毫秒
  • 84% 的訪客支援 Service Worker
  • 與 2015 年相比,新增至主畫面的次數增加了 900% 以上。
  • 3.8% 的使用者離線後仍持續產生 11,100 次網頁瀏覽量!
  • 50% 的登入使用者啟用了通知功能。
  • 使用者收到 53.6 萬則通知 (12% 則回訪)。
  • 99% 使用者的瀏覽器支援 Web 元件 polyfill

總覽

今年,我很高興能開發 Google I/O 2016 漸進式網頁應用程式,這個應用程式很奇怪地名為「IOWA」。並優先支援行動裝置、在離線狀態下全面使用,大幅改善了質感設計的設計。

IOWA 是一種單頁應用程式 (SPA),它以網頁元件、Polymer 和 Firebase 建構,且具備以 App Engine (Go) 編寫的廣泛後端。它會使用「Service Worker」來預先快取內容、動態載入新頁面、在檢視畫面之間順暢轉換,以及重複使用內容。

在本個案研究中,我會說明幾個較有趣的前端架構決策。如果您對原始碼感興趣,請前往 GitHub 查看原始碼。

前往 GitHub 查看

使用網頁元件建立 SPA

將每個網頁當做元件

我們前端的其中一個核心面向,就是以網頁元件為中心。事實上,SPA 中的每個網頁都是網頁元件:

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

我們為什麼要這麼做?第一個原因是此程式碼易於讀取。身為首次讀取者,應用程式中的每個頁面都完全顯而易見。第二個原因是網頁元件具備建構 SPA 的絕佳屬性。多虧了 <template> 元素、自訂元素Shadow DOM 的內建功能,許多常見的困擾已不適用。這些是瀏覽器內建的開發人員工具。建議您善加利用,

為每個網頁建立自訂元素後,我們免費提供許多服務:

  • 頁面生命週期管理。
  • 針對該網頁特別設定 CSS/HTML。
  • 特定網頁的所有 CSS/HTML/JS 會視需要封裝並載入。
  • 檢視畫面可重複使用。由於頁面是 DOM 節點,因此只要新增或移除這些節點,就能變更檢視畫面。
  • 後續維護人員只需使用標記即可瞭解我們的應用程式。
  • 元素定義是由瀏覽器註冊及升級,因此伺服器轉譯的標記可以逐步強化。
  • 自訂元素具有繼承模型。DRY 程式碼是很好的程式碼。
  • ...還有很多東西。

我們在 IOWA 中充分利用了這些優勢。接著來深入說明

動態啟用網頁

<template> 元素是瀏覽器用來建立可重複使用標記的標準方式。<template> 有兩個特性,SPA 可善加利用。首先,<template> 中的所有項目都會插入,直到建立範本的例項為止。第二,瀏覽器會剖析標記,但無法透過主網頁存取內容。這是真實、可重複使用的標記區塊。例如:

<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 可透過一些類型擴充功能自訂元素 (即 <template is="dom-if"><template is="dom-repeat">) extends <template> 的類型。兩者都是自訂元素,可透過額外功能擴充 <template>。有鑑於網頁元件的宣告式特性,這兩種元件的表現恰好符合您的期望。依據條件建立的第一個元件戳記標記。第二個則會重複標記清單中的每個項目 (資料模型)。

IOWA 如何使用這些型別擴充功能元素?

如果還記得,IOWA 中的每個頁面就是一個網頁元件。不過,第一次載入時宣告每個元件會很無聊。也就是說,系統會在應用程式初次載入時,建立每個網頁的執行個體。我們不希望降低初始載入的效能,尤其是因為有些使用者只會瀏覽 1 或 2 個網頁。

我們的解決方案就是:在 IOWA 中,我們會將每個頁面的元素納入 <template is="dom-if"> 中,以免初次啟動時載入頁面的內容。接著,當範本的 name 屬性與網址相符時,系統就會啟用網頁。而 <lazy-pages> 網頁元件會代為處理所有邏輯。標記看起來像這樣:

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

我喜歡的地方在於,每個網頁都會經過剖析,因此可在網頁載入時立即運作,但網頁的 CSS/HTML/JS 僅可隨需執行 (父項 <template> 加上戳記時)。使用 FTW 網頁元件的動態 + 延遲檢視。

日後改進

網頁首次載入時,系統會一次載入每個網頁的所有 HTML 匯入。明顯的改善是只在必要時延遲載入元素定義。Polymer 也提供簡易的輔助程式,可進行非同步載入 HTML 匯入:

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

IOWA 沒做到這一點,因為 a) 我們非常延遲, b) 我們不清楚我們已經看到多少成效提升。我們的第一張畫作已經約 1 秒了。

頁面生命週期管理

Custom Elements API 會定義用於管理元件狀態的「生命週期回呼」。實作這些方法時,您可以在元件的生命週期內使用免費的掛鉤:

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

這些回呼很容易在 IOWA 中運用。提醒您,每個網頁都是獨立的 DOM 節點。在 SPA 中導覽至「new view」是一個節點,是將一個節點附加至 DOM 並移除另一個節點。

我們使用 attachedCallback 執行設定工作 (init 狀態、附加事件監聽器)。當使用者前往其他頁面時,detachedCallback 會執行清除作業 (移除事件監聽器、重設共用狀態)。我們也利用以下格式擴充原生生命週期回呼:

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

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

這些新增項目有助於延後工作,並盡量減少頁面轉換時的卡頓。稍後會再詳細討論。

在不同網頁上拖曳共通功能

繼承是自訂元素的強大功能。並提供網頁的標準繼承模式

遺憾的是,在撰寫本文時,Polymer 1.0 尚未實作元素繼承。在此期間,Polymer 的行為功能依然非常實用。行為只是混合的行為。

與其在所有頁面建立相同的 API 介面,建立共用組合是合理的方式,以 DRY 將程式碼集移除。例如,PageBehavior 會定義應用程式中的所有頁面所需的通用屬性/方法:

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

如您所見,PageBehavior 會在使用者造訪新頁面時執行一般工作。包括更新 document.title、重設捲動位置,以及設定捲動和子導覽效果的事件監聽器。

個別網頁會使用 PageBehavior,將其載入為依附元件並使用 behaviors。如有需要,也能覆寫基礎屬性/方法。例如,以下是首頁的「子類別」覆寫值:

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>

共用樣式

為了在應用程式中不同元件的樣式,我們使用了 Polymer 的共用樣式模組。樣式模組可讓您定義一段 CSS 區塊,並在應用程式的不同位置重複使用。就我們而言,「不同位置」代表不同的元件。

我們在 IOWA 中建立了 shared-app-styles,以便跨頁面和其他元件共用顏色、字體排版和版面配置類別。

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>

在本例中,<style include="shared-app-styles"></style> 是 Polymer 的語法,用於「在名為「shared-app-styles」的模組中納入樣式」。

分享應用程式狀態

現在,您知道應用程式中的每個頁面都是「自訂元素」。我已經說了一百萬次。不過,如果每個網頁都是獨立的網頁元件,你可能會詢問我們如何在應用程式中分享狀態。

IOWA 會使用類似於依附元件插入 (Angular) 或 Redux (React) 進行共用狀態的技術。我們建立全域 app 資源,並捨棄共用的子資源。app 會插入需要其資料的每個元件,藉此繞過我們的應用程式。只要使用 Polymer 的資料繫結功能,即可輕鬆進行接線作業,因為我們不必編寫任何程式碼:

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

使用者登入應用程式時,<google-signin> 元素會更新其 user 屬性。由於該屬性已繫結至 app.currentUser,因此任何想存取目前使用者的網頁只需要繫結至 app 並讀取 currentUser 子資源即可。這項技術本身很適合用來跨應用程式分享狀態,但還有一個好處,就是我們最終可以建立單一登入元素,並在整個網站上重複使用其結果。媒體查詢也是一樣。畢竟是避免每個網頁重複登入或建立自己的媒體查詢組合的麻煩。相反地,負責全應用程式功能/資料的元件會改為存在於應用程式層級。

頁面轉換

瀏覽 Google I/O 網頁應用程式時,您會注意到頁面轉換順暢 (à la Material Design)。

IOWA 的頁面轉換實際運作。
IOWA 的頁面轉換實際運作。

使用者瀏覽至新頁面時,系統會依序發生下列情形:

  1. 頂端的導覽欄會將選取列滑動至新連結。
  2. 網頁標題淡出。
  3. 網頁的內容向下滑動,然後淡出。
  4. 反轉這些動畫,便會顯示新頁面的標題和內容。
  5. (選用) 新頁面會執行其他初始化作業。

我們遇到的其中一項挑戰,是找出如何在不犧牲效能的情況下,打造流暢的轉場效果。有很多動態工作正進行,就不歡迎派對的「卡頓」。我們的解決方案是結合 Web Animation API 和 Promise,搭配使用這兩項工具後,我們能享有靈活性、插頭和播放動畫系統,以及精細的控制選項,盡可能減少 das 卡頓情形。

運作方式

當使用者點選 (或點選上一頁/下一頁) 時,我們的路由器的 runPageTransition() 會透過一系列的 Promise 執行神奇指令。使用 Promise 可讓我們謹慎編排動畫,並合理化 CSS 動畫和動態載入內容的「非同步」性。

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

}

喚回度:請參閱「確保一切保持原狀:各頁面的共同功能」一節的內容,網頁會監聽 page-transition-startpage-transition-done DOM 事件。您現在可以查看觸發這些事件的位置。

我們使用 Web Animation API,而不是 runEnterAnimation/runExitAnimation 輔助程式。以 runExitAnimation 為例,我們會擷取幾個 DOM 節點 (刊頭廣告和主要內容區域),宣告每個動畫的開始/結束,然後建立 GroupEffect 以平行執行這兩個節點:

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

只要修改陣列,就可以更詳細 (或更少) 視圖轉場效果!

捲動效果

當您捲動頁面時,IOWA 會有一些有趣的效果。第一個是懸浮動作按鈕 (FAB),可將使用者帶回頁面頂端:

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

流暢捲動是使用 Polymer 的應用程式版面配置元素實作。這些特效提供立即可用的捲動效果,例如固定式/返回頂端導覽列、投射陰影、色彩和背景轉場、視差效果,以及順暢捲動。

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

我們使用 <app-layout> 元素的另一個位置是固定式導覽面板。如影片所示,當使用者向下捲動頁面並返回時,頁面就會消失。

固定式捲動導覽
使用 做為固定式捲動導覽功能。

我們用了 <app-header> 元素的方式相當簡單,很容易在應用程式中出現,而且可以在應用程式中獲得精美的捲動效果。當然,我們可以自行實作,但把細節寫在可重複使用的元件中,可說是省時省力。

宣告元素。利用屬性進行自訂。這樣就完成本教學課程了!

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

結語

針對 I/O 漸進式網頁應用程式,我們得以在數週內打造出整個前端,這都要歸功於網頁元件和 Polymer 預先建立的質感設計小工具。原生 API 的功能 (自訂元素、Shadow DOM、<template>) 自然而然地融入 SPA 的動態效果。重複使用可省下大量時間。

如果您想自行建立漸進式網頁應用程式,請參考 App Toolbox。Polymer 的 App Toolbox 是一組元件、工具和範本,可用來透過 Polymer 建構 PWA。踏出第一步即可輕鬆體驗。