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

愛荷華州住家

摘要

瞭解我們如何使用網路元件、Polymer 和 Material Design 建構單頁應用程式,並在 Google.com 上正式推出。

結果

  • 參與度比原生應用程式還高 (行動版網站的載入時間是 4 分 40 秒,Android 則為 2 分 40 秒)。
  • 藉助 Service Worker 快取功能,對回訪者首次繪製的速度加快 450 毫秒
  • 84% 的訪客支持 Service Worker
  • 與 2015 年相比,新增至主畫面的儲存量增加了 900%。
  • 3.8% 的使用者離線,但仍持續產生 11,000 次網頁瀏覽!
  • 50% 的已登入使用者啟用了通知功能。
  • 已向使用者發送 53.6 萬則通知 (12% 的使用者回訪)。
  • 99% 的使用者瀏覽器支援網頁元件 polyfill

總覽

今年,我很榮幸參與 Google I/O 2016 漸進式網頁應用程式的開發工作,這項應用程式暱稱為「IOWA」。這項服務以行動裝置為優先,可完全離線運作,並大量受到Material Design 的啟發。

IOWA 是單頁應用程式 (SPA),使用網路元件、Polymer 和 Firebase 建構而成,且具有以 App Engine (Go) 編寫的廣泛後端。這項服務會使用服務工作處理程序預先快取內容,動態載入新頁面、在檢視畫面之間流暢轉換,並在首次載入後重複使用內容。

在本研究案例中,我將介紹我們為前端做出的一些更有趣的架構決策。如果您對原始碼感興趣,請前往 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"></video> <!-- doesn't load/start -->
    <script>alert("Not run until the template is stamped");</script>
</template>

Polymer 會擴充 <template>,並提供一些類型擴充自訂元素,例如 <template is="dom-if"><template is="dom-repeat">。這兩者都是自訂元素,可透過額外功能擴充 <template>。而且,由於網頁元件具有宣告性質,因此兩者都能確切執行您預期的操作。第一個元件會根據條件設定標記標記。第二個步驟會針對清單中的每個項目 (資料模型) 重複標記。

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

如果您還記得,IOWA 中的每個網頁都是網頁元件。不過,在第一次載入時宣告每個元件是不明智的做法。這表示系統會在應用程式首次載入時,建立一個頁面的執行個體。我們不希望妨礙初始載入效能,特別是某些使用者只會瀏覽一或兩個網頁。

我們的解決方案是作弊。在 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> 加上時間戳記的情況下,才會視需要執行。使用網頁元件建立動態 + 延遲檢視畫面,可說是完美結局。

未來改善項目

網頁首次載入時,我們會一次載入每個網頁的所有 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 中前往「新檢視畫面」只需將一個節點附加至 DOM 並移除另一個節點即可。

我們使用 attachedCallback 執行設定工作 (初始化狀態、附加事件監聽器)。當使用者前往其他頁面時,detachedCallback 會執行清理作業 (移除事件監聽器、重設共用狀態)。我們也擴充了原生生命週期回呼,並加入了幾個自訂回呼:

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

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

這些額外功能有助於延後工作,以及盡量減少頁面轉換之間的卡頓。稍後會再詳細討論。

將各個頁面的常見功能放在一起

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

很抱歉,Polymer 1.0 在撰寫本文時尚未實作元素繼承功能。在此同時,Polymer 的「行為」功能也同樣實用。行為只是混合物。

與其在所有頁面上建立相同的 API 介面,不如建立共用的組合,進而使程式碼集拖曳至原始位置,更合理。例如,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 網頁應用程式時,您會發現其流暢的頁面轉場效果 (類似於 Material Design)。

IOWA 的頁面轉場效果。
IOWA 的頁面轉換實際運作。

使用者前往新頁面時,會發生以下一連串事件:

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

我們面臨的挑戰之一,就是瞭解如何在不犧牲效能的情況下,打造流暢的轉場效果。我們需要進行大量動態作業,而「jank」不受歡迎。我們的解決方案是 Web Animations API 和 Promise 組合而成。兩者結合後,我們就能擁有多功能、隨插即播放動畫系統,以及精細的控制選項,盡可能減少「達標」das卡頓情形。

運作方式

當使用者點選新頁面 (或按下「返回」/「前進」) 時,我們的路由器 runPageTransition() 會執行一系列承諾,發揮神奇功效。使用 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));
    }

}

回想保持 DRY 原則:跨頁面的常用功能」一節,頁面會監聽 page-transition-startpage-transition-done DOM 事件。您現在可以看到這些事件的觸發位置。

我們使用的是 Web Animations 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 的 app-layout 元素實作平順捲動功能。這些元素提供即用型捲動效果,例如固定/返回頂端導覽列、投射陰影、顏色和背景轉場、視差效果和流暢捲動。

    // 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 預先建立的 Material Design 小工具,在短短幾週內就打造出完整的前端。原生 API 的功能 (自訂元素、陰影 DOM、<template>) 與 SPA 的構成直接作用相仿。可重複使用,可節省大量時間。

如果您有興趣自行建立漸進式網頁應用程式,請參閱應用程式工具箱。Polymer 的 App Toolbox 提供一系列元件、工具和範本,可讓您透過 Polymer 建構 PWA。這麼做可以輕鬆啟用服務。