Google I/O 2016 プログレッシブ ウェブアプリの構築

アイオワ、自宅

まとめ

ウェブ コンポーネント、ポリマー、マテリアル デザインを使用してシングルページ アプリを作成し、Google.com で本番環境にリリースした方法をご覧ください。

結果

  • ネイティブ アプリよりもエンゲージメントが高い(モバイルウェブでは 4 分 06 秒、Android の場合は 2 分 40 秒)。
  • Service Worker のキャッシュ保存により、リピーターの First Paint が 450 ミリ秒高速化
  • 訪問者の 84% が Service Worker をサポート
  • ホーム画面への追加保存数は 2015 年と比較して 900% 増加。
  • 3.8% のユーザーがオフラインでも、引き続き 11,000 回のページビューを獲得
  • ログイン ユーザーの 50% が通知を有効にしています。
  • 53 万 6,000 件の通知がユーザーに送信されました(12% が再度ユーザーに通知しました)。
  • ユーザーのブラウザの 99% がウェブ コンポーネントのポリフィルをサポート

概要

今年は、Google I/O 2016 のプログレッシブ ウェブアプリ(愛称で「IOWA」)の開発に取り組みました。モバイル ファーストで、完全にオフラインで動作し、マテリアル デザインを多く取り入れています。

IOWA は、ウェブ コンポーネント、Polymer、Firebase を使用して構築されたシングルページ アプリケーション(SPA)であり、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>

これにはどのような意味があるでしょう。1 つ目の理由は、このコードが読みやすいことです。初めて閲覧する場合、アプリの各ページが何であるかは完全に明白です。2 つ目の理由は、ウェブ コンポーネントには SPA を構築するための優れたプロパティがあるからです。<template> 要素、カスタム要素Shadow DOM の固有の機能のおかげで、多くの一般的な不満(状態管理、ビューの有効化、スタイル スコープ設定)が解消されます。これらはブラウザに組み込まれているデベロッパー ツールです。ぜひご活用ください。

ページごとにカスタム要素を作成することで、多くのことを無料で実現できました。

  • ページのライフサイクル管理。
  • ページに固有の CSS/HTML のスコープを設定します。
  • ページに固有のすべての CSS/HTML/JS がバンドルされ、必要に応じて一緒に読み込まれます。
  • ビューは再利用できます。ページは DOM ノードなので、単にページを追加または削除するだけでも表示は変わります。
  • 今後のメンテナンス担当者は、マークアップを触るだけでアプリを理解できるようになります。
  • サーバーによってレンダリングされるマークアップは、ブラウザによって要素の定義が登録され、アップグレードされるにつれて段階的に拡張できます。
  • カスタム要素には継承モデルがあります。DRY コードは適切なコードです。
  • ...まだまだあります。

IOWA ではこれらの利点を最大限に活用しました。詳しく見ていきましょう。

ページの動的な有効化

<template> 要素は、再利用可能なマークアップを作成するブラウザの標準的な方法です。<template> には、SPA で活用できる特性が 2 つあります。まず、<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">)で <template> を拡張します。extendsどちらも追加機能を備えた <template> を拡張するカスタム要素です。ウェブ コンポーネントの宣言型という性質のおかげで、どちらも期待どおりに動作します。最初のコンポーネントは、条件に基づいてマークアップをスタンプします。2 つ目の方法は、リスト(データモデル)内のすべての項目に対してマークアップを繰り返します。

IOWA はこれらの型拡張要素をどのように使用していますか?

IOWA のすべてのページがウェブ コンポーネントです。しかし、最初の読み込みですべてのコンポーネントを宣言することは意味がありません。つまり、アプリが最初に読み込まれるときに、すべてのページのインスタンスを作成することになります。最初の読み込みのパフォーマンスが損なわれたくはありませんでした。特に、1、2 ページにしか移動しないユーザーもいるため、

私たちの解決策は、カンニングすることでした。IOWA では、各ページの要素を <template is="dom-if"> でラップして、初回起動時にそのコンテンツが読み込まれないようにします。テンプレートの name 属性が URL と一致すると、ページが有効になります。<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 Imports が一度に読み込まれます。明らかな改善としては、必要な場合にのみ要素定義を遅延読み込みできます。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 の「新しいビュー」に移動するには、1 つのノードを DOM に接続し、別のノードを削除するだけで済みます。

attachedCallback を使用して、セットアップ作業(初期化状態、イベント リスナーのアタッチ)を行いました。ユーザーが別のページに移動すると、detachedCallback がクリーンアップ(リスナーの削除、共有状態のリセット)を行います。また、ネイティブのライフサイクル コールバックを、次のような独自のコールバックで拡張しました。

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

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

これらは、処理を遅らせ、ページ遷移間のジャンクを最小限に抑えるのに便利な追加でした。詳しくは後で説明します。

ページ間で共通する機能を DRY する

継承はカスタム要素の強力な機能です。ウェブ向けの標準的な継承モデルを提供します。

残念ながら、この記事の執筆時点では、Polymer 1.0 には要素の継承がまだ実装されていません。当面は、Polymer の Behaviors 機能が役立ちました。動作は単なるミックスインです。

すべてのページで同じ 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 を一度定義して、アプリ内のさまざまな場所で再利用できます。Google にとって、「さまざまな場所」は異なるコンポーネントを意味しています。

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> は、「shared-app-styles という名前のモジュールにスタイルを含める」という Polymer の構文です。

アプリケーションの状態の共有

アプリ内のすべてのページがカスタム要素であることがおわかりいただけたと思います。もう何百万回も言ったのよ。それでも、すべてのページが自己完結型のウェブ コンポーネントである場合、アプリ全体で状態をどのように共有するかを考えているのではないでしょうか。

IOWA は、依存関係インジェクション(Angular)やリダックス(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 サブプロパティを読み取るだけで済みます。この手法自体は、アプリ間で状態を共有するのに便利ですが、もう 1 つのメリットは、最終的に単一のログイン要素を作成し、その結果をサイト全体で再利用することでした。メディアクエリについても同様です。すべてのページで重複ログインを行ったり、独自のメディアクエリ セットを作成したりすることは無駄になっていたでしょう。代わりに、アプリ全体の機能やデータを担当するコンポーネントはアプリレベルで存在します。

ページの切り替え

Google I/O ウェブアプリを操作すると、滑らかなページ遷移(マテリアル デザイン)が採用されています。

IOWA のページ トランジションの動作。
IOWA のページ遷移の実例

ユーザーが新しいページに移動すると、次の一連の流れが発生します。

  1. 上部のナビゲーションで、選択バーを新しいリンクにスライドします。
  2. ページの見出しがフェードアウトします。
  3. ページのコンテンツが下方向にスライドした後、フェードアウトします。
  4. アニメーションを反転させることで、新しいページの見出しとコンテンツが表示されます。
  5. (省略可)新しいページでは追加の初期化処理が行われます。

私たちの課題の一つは、パフォーマンスを犠牲にすることなくこの洗練されたトランジションを作成する方法を考え出すことでした。ダイナミックな作業が数多く行われ、パーティーではジャンクは歓迎されませんでした。私たちのソリューションは、Web Animations API と Promise を組み合わせることでした。この 2 つを組み合わせることで、汎用性、プラグアンドプレイのアニメーション システム、きめ細かい制御が可能になり、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));
    }

}

「物事を DRY に: ページ間で共通の機能」のセクションのことを思い出してください。各ページは page-transition-start および page-transition-done DOM イベントをリッスンしています。イベントが発生する場所が確認できます

runEnterAnimation/runExitAnimation ヘルパーの代わりに Web Animations API を使用しました。runExitAnimation の場合、いくつかの DOM ノード(マストヘッドとメイン コンテンツ エリア)を取得し、各アニメーションの開始と終了を宣言して、GroupEffect を作成してこの 2 つを並行して実行します。

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 には興味深い効果があります。1 つ目は、ユーザーをページの上部に戻すためのフローティング アクション ボタン(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 を構築するためのコンポーネント、ツール、テンプレートのコレクションです。簡単に導入して運用できます