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

アイオワ(自宅)

概要

ウェブ コンポーネント、Polymer、マテリアル デザインを使用してシングルページ アプリを構築し、Google.com で本番環境にリリースした方法を学びます。

結果

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

概要

今年は、愛称「IOWA」の Google I/O 2016 プログレッシブ ウェブアプリ開発を担当しました。モバイル ファーストで、完全にオフラインで動作し、マテリアル デザインを強く意識したデザインになっています。

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/JavaScript がすべてバンドルされ、必要に応じて読み込まれます。
  • ビューは再利用可能です。ページは 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"></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> を拡張します。どちらも、追加機能を備えた <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 インポートが一度に読み込まれます。要素定義は、必要な場合にのみ遅延読み込みするのが明らかな改善策です。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 機能は非常に有用でした。動作は単なる mixin です。

すべてのページに同じ API サーフェスを作成するのではなく、共有の mixin を作成してコードベースを 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 を使用して PageBehavior を使用します。また、必要に応じてベースのプロパティやメソッドをオーバーライドすることもできます。たとえば、ホームページのサブクラスのオーバーライドは次のようになります。

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 のチャンクを 1 回定義して、アプリ内のさまざまな場所で再利用できます。ここで言う「さまざまな場所」とは、さまざまなコンポーネントを指します。

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)や 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 ウェブアプリを操作すると、マテリアル デザインを彷彿とさせるスムーズなページ遷移が確認できます。

IOWA のページ遷移の動作。
IOWA のページ遷移の動作。

ユーザーが新しいページに移動すると、次の処理が行われます。

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

パフォーマンスを犠牲にすることなく、このスムーズな遷移を実現する方法を見つけることが課題の一つでした。さまざまな動的作業が行われるため、ジャンクは歓迎されませんでした。Google のソリューションは、Web Animations 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));
    }

}

「DRY を保つ: ページ間の共通機能」セクションで説明したように、ページは page-transition-startpage-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 の Progressive Web App では、ウェブ コンポーネントと Polymer の既製のマテリアル デザイン ウィジェットのおかげで、フロントエンドを数週間で構築できました。ネイティブ API の機能(カスタム要素、Shadow DOM、<template>)は、SPA のダイナミズムに自然に適しています。再利用することで、時間を大幅に節約できます。

独自のプログレッシブ ウェブアプリの作成に関心をお持ちの場合は、App Toolbox をご覧ください。Polymer の App Toolbox は、Polymer で PWA を構築するためのコンポーネント、ツール、テンプレートのコレクションです。簡単にご利用を開始できます