PWA作ってみた

Programming

今回はついにPWAに手を出してみました。

若干遅い気はするけど、気にしない。

PWAはローカルで挙動を確認はできるが、公開する場合はhttps出ないといけないため、今回はfirebaseのhostingサービスを利用した!

firebaseについてもそこまで理解できているわけではないので、今後勉強していきたい。

早速作り方に入る前に簡単にPWAについてかいつまんで書いておくと

Webアプリなんやけど、オフラインでも動くし(キャッシュからデータを取ってきて動いているように見える?)、プッシュ通知できるし、モバイル端末にインストールできるって感じ。

普通、ネイティブアプリってアップルストアやGoogleプレイストアからダウンロードして使うんやけど、それと同じようにWebアプリを見せることができるってかんじ。

さらに、プッシュ通知ができるんで、Webアプリの運営者側が通知をユーザに出すことができる。(これすごい)

で、オフラインでも動くので(オンライン時にキャッシュにデータ持っておいて、オフラインになったらそっからデータを取ってきてる。で、オンラインになった時に再度サーバからデータ取ってくる)、インターネットに接続されていませんっていう画面が表示されるのを防げる。(これ出ると、ユーザは結構嫌)

この全てを可能にしているのが、ServiceWorkerっているJavaScriptの実行環境らしい。

クライアントとサーバの中間に位置していて、(あ、もちろん存在しているのはクライアントのブラウザ内)サーバとの接続がうまくいかなかった場合は、キャッシュからデータを取ってきてくれたり、サーバからの通知をクライアントに通知してくれたり、上記の機能を受け持ってくれる。

PWAとは、このようにプッシュ通知ができて、オフラインでも動いて、HTTPS通信ができるWebアプリのことを言うみたい。

ではでは、作っていきましょう!

Firebaseにアプリをデプロイするため、firebaseの設定から。

こっからコンソールへ移動をクリックし、ログインして、プロジェクトを作成する

ログイン - Google アカウント

プロジェクトを追加をクリック

プロジェクト名をつける

アナリティクスは今回は使わないので、「今は設定しない」を選択

すると、プロジェクトが作成される

作成が完了したら、続行というボタンが出てくるのでそれを押下すると次の画面が出てくる

で、今回はHostingサービスを利用するので、左のメニューからHostingを選択

上記画面の通り、firebaseCLIをインストールできてない人は、インストールしてください。

インストールが完了したら、firebase loginでまずログインします。

ログインの情報は、コンソールへ移動時に入力した情報です。

ログインが完了したら、firebase initを実行するとカレントディレクトリにfirebaseのプロジェクトができます。

できたディレクトリ内のpublicディレクトリが公開ディレクトリになっており、そこのindex.htmlにあるスクリプトを追記することで、firebase上でwebアプリを公開させることができます。

あとで実際にやってみた内容を載せるので、とりあえずブラウザ上でプロジェクトにWebアプリの登録を済ませちゃいましょう!Webアプリの名前を入力し登録を済ませましょう。

これで、Webアプリのプロジェクトがfirebase上に作成できました。

では、実際にfirebaseプロジェクトをローカルに作成し、実装していきましょう!

1:firebaseにログインし、任意のディレクトリ内でfirebase initを実行しよう

※ちなみに、ログインに成功したらこんな画面が出てきます。

firebase initを実行します

矢印でHostingにカーソルを合わせてスペースキーで選択します

今回はすでにfirebaseにプロジェクトを作成しているのでそれを指定する

アップロードするファイルを配置するディレクトリを選択(公開フォルダのイメージかな)する

今回はSPAを作成するので、yを入力

完了!これでローカルにfirebaseプロジェクトが完成しました。

ディレクトリはこんな感じ

2:firebase deployでデプロイして確認してみよう!

コンソール上に表示されるURLにアクセスしてみよう!

この画面が表示されれば成功!

3:PWAの画面「index.html」を修正する

firebaseプロジェクトを作成したてのindex.htmlはこんな感じ

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>Welcome to Firebase Hosting</title>

    <!-- update the version number as needed -->
    <script defer src="/__/firebase/6.6.0/firebase-app.js"></script>
    <!-- include only the Firebase features as you need -->
    <script defer src="/__/firebase/6.6.0/firebase-auth.js"></script>
    <script defer src="/__/firebase/6.6.0/firebase-database.js"></script>
    <script defer src="/__/firebase/6.6.0/firebase-messaging.js"></script>
    <script defer src="/__/firebase/6.6.0/firebase-storage.js"></script>
    <!-- initialize the SDK after all desired features are loaded -->
    <script defer src="/__/firebase/init.js"></script>

    <style media="screen">
      body { background: #ECEFF1; color: rgba(0,0,0,0.87); font-family: Roboto, Helvetica, Arial, sans-serif; margin: 0; padding: 0; }
      #message { background: white; max-width: 360px; margin: 100px auto 16px; padding: 32px 24px; border-radius: 3px; }
      #message h2 { color: #ffa100; font-weight: bold; font-size: 16px; margin: 0 0 8px; }
      #message h1 { font-size: 22px; font-weight: 300; color: rgba(0,0,0,0.6); margin: 0 0 16px;}
      #message p { line-height: 140%; margin: 16px 0 24px; font-size: 14px; }
      #message a { display: block; text-align: center; background: #039be5; text-transform: uppercase; text-decoration: none; color: white; padding: 16px; border-radius: 4px; }
      #message, #message a { box-shadow: 0 1px 3px rgba(0,0,0,0.12), 0 1px 2px rgba(0,0,0,0.24); }
      #load { color: rgba(0,0,0,0.4); text-align: center; font-size: 13px; }
      @media (max-width: 600px) {
        body, #message { margin-top: 0; background: white; box-shadow: none; }
        body { border-top: 16px solid #ffa100; }
      }
    </style>
  </head>
  <body>
    <div id="message">
      <h2>Welcome</h2>
      <h1>Firebase Hosting Setup Complete</h1>
      <p>You're seeing this because you've successfully setup Firebase Hosting. Now it's time to go build something extraordinary!</p>
      <a target="_blank" href="https://firebase.google.com/docs/hosting/">Open Hosting Documentation</a>
    </div>
    <p id="load">Firebase SDK Loading…</p>

    <script>
      document.addEventListener('DOMContentLoaded', function() {
        // // 🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥
        // // The Firebase SDK is initialized and available here!
        //
        // firebase.auth().onAuthStateChanged(user => { });
        // firebase.database().ref('/path/to/ref').on('value', snapshot => { });
        // firebase.messaging().requestPermission().then(() => { });
        // firebase.storage().ref('/path/to/ref').getDownloadURL().then(() => { });
        //
        // // 🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥🔥

        try {
          let app = firebase.app();
          let features = ['auth', 'database', 'messaging', 'storage'].filter(feature => typeof app[feature] === 'function');
          document.getElementById('load').innerHTML = `Firebase SDK loaded with ${features.join(', ')}`;
        } catch (e) {
          console.error(e);
          document.getElementById('load').innerHTML = 'Error loading the Firebase SDK, check the console.';
        }
      });
    </script>
  </body>
</html>

では、いらん部分を消してまおう!

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>Welcome to Firebase Hosting</title>

    <!-- update the version number as needed -->
    <script defer src="/__/firebase/6.6.0/firebase-app.js"></script>
    <!-- include only the Firebase features as you need -->
    <script defer src="/__/firebase/6.6.0/firebase-auth.js"></script>
    <script defer src="/__/firebase/6.6.0/firebase-database.js"></script>
    <script defer src="/__/firebase/6.6.0/firebase-messaging.js"></script>
    <script defer src="/__/firebase/6.6.0/firebase-storage.js"></script>
    <!-- initialize the SDK after all desired features are loaded -->
    <script defer src="/__/firebase/init.js"></script>
    <link rel="stylesheet" href="https://use.fontawesome.com/releases/v5.6.3/css/all.css">
  </head>
  <body>
    <div class="container">
      <div class="row my-5">
        <div class="col-12">
          <h1>Hello PWA App!</h1>
        </div>
      </div>
      <div class="row my-5">
        <div class="col-12">
          <button id="installapp">
            インストール!<i class="fas fa-caret-square-down"></i>
          </button>
        </div>
      </div>
    </div>
  </body>
</html>

※本ソースではBootstrapとfontawesomeを使ってますが、読み込み部分はソースの可読性のため削除してますので、この通りclassをしようする場合はbootstrap4をcdnで読み込むなりしてください。

4:manifest.jsonをpublicディレクトリ内に作成する

PWAアプリはスマホなどの端末にインストールして、ネイティブアプリのように使います。

manifest.jsonは、端末にインストールした時に表示するアイコンの画像や、アイコンをクリックした際に最初にアクセスするURL、そのアイコンに表示するアイコン名であったり、アイコンを起動してからサイトが表示されるまでに見せるスプラッシュ画像などの設定を持ったファイルです。

今回はこんな感じにします

{
    "short_name": "PWA-Solo",
    "name": "PWA App Soloware",
    "icons": [
      {
        "src": "favicon.ico",
        "sizes": "64x64 32x32 24x24 16x16",
        "type": "image/x-icon"
      },
      {
        "src": "logo192.png",
        "type": "image/png",
        "sizes": "192x192"
      },
      {
        "src": "logo512.png",
        "type": "image/png",
        "sizes": "512x512"
      }	    
    ],
    "start_url": ".",
    "display": "standalone",
    "theme_color": "#000000",
    "background_color": "#ffffff"
  }
 

ちなみに、faviconに始まり、アイコンの画像は、reactのものパクってきて使います笑

アイコンのサイズはなんか、色々用意しておいたほうがいいみたい。

short_nameがアイコンの名前になって、nameはインストールする際に表示されるポップアップ上に出てくる文字列です。

start_urlがアイコンタップ時にアクセスする先で

theme_colorが画面上部であったり、バックグラウンドからアプリを選択する際のアイコンの背景の色になる。

background_colorはアイコンの背景であったり、スプラッシュ画面の背景の色っぽい

5:service_worker.jsファイルを作成する

こいつが主役です。中身はこんな感じ(長いんで3パートに分けて説明します)

中身パート1

const CACHE_VERSION = 'v1';
const CACHE_NAME = `${registration.scope}!${CACHE_VERSION}`;

// キャッシュするファイルをセットする
const urlsToCache = [
  'index.html',
  'logo192.png',
  'logo512.png',
  'favicon.ico',
  'install.js'
];

上から順に説明すると、まずキャッシュのバージョンとキャッシュ名を定義しています。

これは、何に使うかと言うと、その下にあるキャッシュするファイルセットの内容が変わった時に、このキャッシュのバージョンを変える(キャッシュ名が変わる)ことで、タブが閉じたタイミングでServiceWorkerが自動でキャッシュの更新をしてくれます。

っている理解。笑

ここ、変えへんかったらいつまでたっても古いファイルをキャッシュに残して更新されないのかな?

この辺は、また暇な時に調べとこう。

で、このキャッシュするファイル群に指定したものは、オフラインでも自動で取得が可能なファイルたちになります。

ちなみに、ServiceWorkerが制御可能な範囲は、service_worker.jsが置かれたディレクトリ以下のファイル達です!なので、それより上層のファイルは制御できないので注意!

中身パート2

self.addEventListener('install', (event) => {
  event.waitUntil(
    // キャッシュを開く
    caches.open(CACHE_NAME)
    .then((cache) => {
      // 指定されたファイルをキャッシュに追加する
      return cache.addAll(urlsToCache);
    })
  );
});

self.addEventListener('activate', (event) => {
  event.waitUntil(
    caches.keys().then((cacheNames) => {
      return cacheNames.filter((cacheName) => {
        // このスコープに所属していて且つCACHE_NAMEではないキャッシュを探す
        return cacheName.startsWith(`${registration.scope}!`) &&
               cacheName !== CACHE_NAME;
      });
    }).then((cachesToDelete) => {
      return Promise.all(cachesToDelete.map((cacheName) => {
        // いらないキャッシュを削除する
        return caches.delete(cacheName);
      }));
    })
  );
});

self.addEventListnerでServiceWorkerのライフサイクルで発生するイベントごとに処理内容が記載されている

1:ますがinstall。ServiceWorkerをブラウザにインストールしている段階

このイベントの処理では、キャッシュするファイル群をサーバから取得して、キャッシュに追加している

2:次が、activate。ServiceWorkerを有効化している段階。

このイベントでは、キャッシュ名をキーにしキャッシュするファイル群を保持しているので、現在の最新のキャッシュ名と異なるものを取得し、削除している(要は、ファイル群に更新があった場合、キャッシュ名を変えることで、古いキャッシュを消して最新のものをキャッシュに残してくれるってこと)

なので、キャッシュするファイル群に変更があった場合は、キャッシュ名を変える必要があるっちゅうことやね。

最後に、中身パート3。ここでServiceWorkerの最後のイベントFetchイベントを紹介


self.addEventListener('fetch', (event) => {
  event.respondWith(
    caches.match(event.request)
    .then((response) => {
      // キャッシュ内に該当レスポンスがあれば、それを返す
      if (response) {
        return response;
      }

      // 重要:リクエストを clone する。リクエストは Stream なので
      // 一度しか処理できない。ここではキャッシュ用、fetch 用と2回
      // 必要なので、リクエストは clone しないといけない
      let fetchRequest = event.request.clone();

      return fetch(fetchRequest)
        .then((response) => {
          if (!response || response.status !== 200 || response.type !== 'basic') {
            // キャッシュする必要のないタイプのレスポンスならそのまま返す
            return response;
          }

          // 重要:レスポンスを clone する。レスポンスは Stream で
          // ブラウザ用とキャッシュ用の2回必要。なので clone して
          // 2つの Stream があるようにする
          let responseToCache = response.clone();

          caches.open(CACHE_NAME)
            .then((cache) => {
              cache.put(event.request, responseToCache);
            });

          return response;
        });
    })
  );
});

3:fetch。これは、ServiceWorkerがサーバからデータを取得している段階

サーバに問い合わせに行く前にキャッシュを確認し、キャッシュ内にリクエスト内容が存在すれば、それを返して終わる。

なければ、サーバに問い合わせに行く。問い合わせた結果が、エラーだったり何も応答がなかったりした場合は、何もしない。

何か応答が返ってきた場合、それをキャッシュに格納してレスポンスを返す。って感じ。

5:install.jsを作成する

このJSファイルに、Webアプリをデバイスにインストールさせる処理を実装していく

console.log('install.js was read')
let deferredInstallPrompt = null;
const installButton = document.getElementById('installapp');
installButton.addEventListener('click', installPWA);

window.addEventListener('beforeinstallprompt', saveBeforeInstallPromptEvent);

/**
 * Event handler for beforeinstallprompt event.
 *   Saves the event & shows install button.
 *
 * @param {Event} evt
 */
function saveBeforeInstallPromptEvent(evt) {
  evt.preventDefault();
  deferredInstallPrompt = evt;
  installButton.removeAttribute('hidden');

}


/**
 * Event handler for butInstall - Does the PWA installation.
 *
 * @param {Event} evt
 */
function installPWA(evt) {
  console.log(deferredInstallPrompt);
  deferredInstallPrompt.prompt();
  // Hide the install button, it can't be called twice.
  evt.srcElement.setAttribute('hidden', true);

  deferredInstallPrompt.userChoice
  .then((choice) => {
    if (choice.outcome === 'accepted') {
      console.log('User accepted the A2HS prompt', choice);
    } else {
      console.log('User dismissed the A2HS prompt', choice);
    }
    deferredInstallPrompt = null;
  });
}

window.addEventListener('appinstalled', logAppInstalled);

/**
 * Event handler for appinstalled event.
 *   Log the installation to analytics or save the event somehow.
 *
 * @param {Event} evt
 */
function logAppInstalled(evt) {
  console.log('Weather App was installed.', evt);

}

イベントリスナー系の設定だったり、変数の設定を最初の数行で行なっている。

重要なのは、このイベント「beforeinstallprompt」

こいつは、service workerがactivateされた後にWebサイトをデバイスインストールさせるために発火するイベントらしい。

これ、Webサイト開いた瞬間に出てきたら、大概キャンセルしません?笑

ってことで、任意のタイミングでインストールさせたいので、

preventDefault()で本来の挙動を無効にしてます。

で、prompt自体(ポップアップ)は変数に入れておいて、このイベント処理は終了

肝心の任意のタイミングってやつが、次のinstallPWA関数を発火させるものなんやが、

ただのボタンクリックです。笑

htmlでコーディングした、ボタンですね。あれ押したらインストール用のポップアップが出るってわけ。

ポップアップが出た後は、ユーザの選択によって処理が変わりますねもちろん。

なので、userChoiceってプロパティ見てそれがacceptedだと、この処理。そうじゃなかったらこの処理って感じで選択によって挙動を変えれるようになってます。

今回は、どっちもただログを出力するだけです。

とは言ったものの、

多分、acceptedになったら、windowオブジェクトのappinstalledイベントが発火するんじゃないかな。

おし。これで準備は整いました!

6:index.htmlへの組み込み

後は、service_workerやらmanifest.jsonやらをindex.htmlに組み込むのみ!

結果がこんな感じ

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>Welcome to Firebase Hosting</title>

    <!-- update the version number as needed -->
    <script defer src="/__/firebase/6.6.0/firebase-app.js"></script>
    <!-- include only the Firebase features as you need -->
    <script defer src="/__/firebase/6.6.0/firebase-auth.js"></script>
    <script defer src="/__/firebase/6.6.0/firebase-database.js"></script>
    <script defer src="/__/firebase/6.6.0/firebase-messaging.js"></script>
    <script defer src="/__/firebase/6.6.0/firebase-storage.js"></script>
    <!-- initialize the SDK after all desired features are loaded -->
    <script defer src="/__/firebase/init.js"></script>
    <link rel="stylesheet" href="https://use.fontawesome.com/releases/v5.6.3/css/all.css">
    
    <!-- ポイント1 -->
    <link rel="manifest" href="manifest.json" />

  </head>
  <body>
    <div class="container">
      <div class="row my-5">
        <div class="col-12">
          <h1>Hello PWA App!</h1>
        </div>
      </div>
      <div class="row my-5">
        <div class="col-12">
          <button id="installapp">
            インストール!<i class="fas fa-caret-square-down"></i>
          </button>
        </div>
      </div>
    </div>

    <!-- ポイント2 -->
    <script>
        if ('serviceWorker' in navigator) {
          navigator.serviceWorker.register('service_worker.js').then(function(registration) {
            // 登録成功
            console.log('ServiceWorker の登録に成功しました。スコープ: ', registration.scope);
          }).catch(function(err) {
            // 登録失敗
            console.log('ServiceWorker の登録に失敗しました。', err);
          });
        }
    </script>
    <script src="install.js"></script>
  </body>
</html>

ポイント1でmanifest.jsonを読み込んでます。

ポイント2で、ServiceWorkerの登録を実施しています。

これで完成です!

では、デプロイしてアクセスしてみよう!

インストールボタンを押すと

インストールしてみましょう!

こんな画面で表示されました!

ちなみにインストールされたアプリはこんな感じ

※ちなみに、ディベロッパーツール出したままだと、インストールってしてもポップアップが表示されないので注意!ここ結構はまりました。。

あ、それと一度インストールされるとブラウザで、もう一度サイトにアクセスしてインストールボタン押してもポップアップが表示されません。

表示させるには、chrome://appsをアドレスバーで検索してインストールしたアプリを削除する必要があります。

これも、ハマりポイントかと。。

以上で、PWAの記事は終わりでーす!BYE

参考サイト:

Service Worker を使ってオフラインでも閲覧できるウェブページを作る方法 – ラボラジアン
Progressive Web Apps
Websites that took all the right vitamins.

タイトルとURLをコピーしました