RAKUS Developers Blog | ラクス エンジニアブログ

株式会社ラクスのITエンジニアによる技術ブログです。

PWA(Progressive Web Apps)の理解と実践

f:id:masaka13:20210121091508p:plain

こんにちは、masakaです。
前の会社でPWAについて少し調べていたのですが、特に発表することもなかったので
今回はその内容をブログにしてみました。
ざっくりとした説明と、簡単な実装をやってみたいと思います。

PWAとは

ネイティブアプリみたいなWebアプリ

PWAは「progressive web apps」のことを指します。2015年に発表され、話題に上がってきました。
PWAに対応した代表的なサイトとして、Twitterや国内だとSUUMOなどがよく例に挙げられます。

PWAというワードで検索すると、「ネイティブアプリみたいな」Webアプリである、という抽象的な内容が散見されます。
この表現は間違っていないですが、何をもって「ネイティブアプリみたい」というのか、いまいちはっきりしません。
もう少し詳しく理解してみることにします。

PWAの代表的な機能

機能的な面で調べると、PWAで出来ることとして代表的なものには以下があります。

  • ホーム画面への追加:アプリの様にインストールし、ホーム画面から起動できる
  • オフライン起動:ネットワークに接続していなくても使用できる
  • バックグラウンド同期:オフライン時に操作した内容を、ネットワーク接続されたタイミングでバックグラウンド送信する
  • プッシュ通知:アプリからの通知

これらの機能が実装されることがPWAなのでしょうか?

Webとネイティブアプリ、それぞれの特徴とPWAが求めるもの

Google によるPWAの説明には、ネイティブアプリとWeb アプリの特徴を次のように書いてあります。


  • ネイティブアプリの特徴 - 機能性・信頼性に優れる
    • ホーム画面、タスクバーなどに存在しネットワークに影響されず使用できる
    • 機能が豊富
  • Web アプリの特徴 - 到達性に優れる
    • 単一コードベース
    • バイスを問わず、誰でもどこでもアクセスできる

その上で、PWAはWebアプリに双方の長所を持たせることを目指しているようで、以下のような記載がされています。

  • PWAはあくまでWeb アプリである
    • モダンなAPIで構築されている
    • バイスやブラウザを問わない到達性

PWAを支える三つの柱

加えてネイティブアプリのように感じさせるための三つの柱についても書かれています。

  • 1.Capable (機能)
    • Webは様々なことが出来るように機能拡張し、成長し続ける
      • これらを取り入れることによりネイティブアプリでしか出来なかったことがWeb で可能になっていく
  • 2.Reliable (信頼)
    • ネットワークに影響されずに使用できる
      • ネットワーク接続が出来ない、あるいは低速なネットワークでも使用できる
  • 3.Installable (インストール可能)
    • ブラウザの中で動くのではなく独立したアプリとして動く

1.はともかく、2.と 3.は具体的ですね。

ちなみに、以前はGoogleデベロッパーページでは、

  • Reliable
  • Fast
  • Engaging

という特徴を書いていたようなのですが、少し変わっているようです。

結局のところ、PWAとは

これまでの内容で、つまるところPWAとは

ネイティブアプリ的なユーザー体験を得られるWebアプリ

であり、

  • ネットワーク状態に影響されず、
  • 独立したアプリのように起動でき、
  • 新しいWebAPIを採用し、機能的にネイティブアプリに大きく劣らないようにする

といった要素を満たしておくと、結果的にそうなるという感じでしょうか。

PWAに求められる、より具体的な内容はPWAのチェックリストを見ると分かります。
よりよいユーザー体験を提供しましょう的な内容が多い印象です。

PWAの対応状況

PWAはあくまでWebアプリなので、ブラウザによって対応状況が変わってきてしまいます。

What Web Can Do Todayというサイトでは、アクセスされたブラウザでどの機能が使えるか表示してくれます。

  • Googleが推進しているだけあって、Android Chromeは早くからPWA対応
  • iOS Safari は一部の機能未対応
    • セキュリティ的な思惑もあり、すぐに対応は難しそう

iOSでもプッシュ通知くらいは使えてほしいですが、iOS14でもまだ未対応です・・。

デスクトップ環境もPWAの対象に

PWAは元々モバイル環境をターゲットにしていたようですが、ChromeとEdgeはデスクトップ環境でもPWAに対応しています。
Chromeversion67でPWAについての記載があります。

モバイルでPWA対応していたものは、特にデスクトップ用にコードを変更する必要はなく、そのままデスクトップPWAとして使えます。
PCでもPWAを利用できる状況になってきています。


[実践編] PWA化への第一歩

ここからは実践編です。今回は手始めに、PWAっぽくするために

  • インストール可能にする
  • オフラインで使用できる

をやってみます。

デスクトップPWAとしての使用をターゲットにします。

PWA化のために必要なこと - [Web App Manifest]と[Service Worker]

PWA化において欠かせない要素は、

の二つがあります。

manifest

manifestファイルはウェブアプリについての情報や、挙動についての設定が記したファイルです。
ダウンロードし、アプリとして動かすために必要な情報が記載され、中身はJSONです。
ユーザーが取得できるよう、Web上に配置します。

Service Worker

Service Workerはブラウザのメインスクリプト処理とは別に、バックグラウンドとして実行されるスクリプトです。
PWAの肝となる部分かと思います。

ServiceWorkerはWebアプリ(ブラウザ)とネットワークの間で動作します。
必要に応じてキャッシュからデータを取得するか、ネットワークへリクエストするかを制御できます。

f:id:masaka13:20210120184411p:plain
sw

オフライン時や、通信環境が悪い状況での実行に密接に影響しそうな機能ですね。

Webサイトからインストール出来るようにする - A2HS

アプリとしてホーム画面などに追加する機能を「A2HS」と呼びます。(Add-to-Home-Screen の略)
早速、これをやってみましょう。

サンプルアプリとWebサーバの準備

まず、何かWebアプリが必要です。
htmlとJavaScriptでごく簡単なクリック連打ゲームを作成しました。 f:id:masaka13:20210121090555p:plain

これで試してみましょう。

GitHubにもあります。

クリック連打ゲームのソース

index.html

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>button mashing</title>
    <link rel="stylesheet" type="text/css" href="app.css" />
  </head>
  <body>
    <div class="wrapper">
      <div>目標クリック連打数:<span class="js-target-count">10</span></div>
      <div class="timer">
        <span class="js-timer-label">経過時間:</span>
        <span class="js-spend-time"></span>
      </div>

      <div class="click-btn-wrapper">
        <button class="js-click-target btn-click-target">
          <span>Click!</span><br />
          <span class="js-counter"></span><br />
        </button>
      </div>

      <div>
        <button class="js-reset btn-reset">リセット</button>
      </div>

      <div class="change-count-wrapper">
        クリック数を変更する
        <ul>
          <li>
            <button class="js-change-count btn-change-count" data-count="10">
              クリック数:10回
            </button>
          </li>
          <li>
            <button class="js-change-count btn-change-count" data-count="20">
              クリック数:20回
            </button>
          </li>
          <li>
            <button class="js-change-count btn-change-count" data-count="30">
              クリック数:30回
            </button>
          </li>
        </ul>
      </div>
    </div>

    <script src="app.js"></script>
  </body>
</html>

app.css

.wrapper {
  padding: 10px;
  max-width: 400px;
  margin: 0 auto;
  text-align: center;
  font-size: 14px;
}

.click-btn-wrapper {
  margin: 10px auto;
}

.btn-click-target {
  display: inline-block;
  text-decoration: none;
  color: #668ad8;
  background-color: #fff;
  width: 80px;
  height: 80px;
  border-radius: 50%;
  border: solid 2px #668ad8;
  text-align: center;
  overflow: hidden;
  font-weight: bold;
  transition: 0.4s;
}

.btn-click-target:hover {
  background: #b3e1ff;
}

.change-count-wrapper {
  max-width: 240px;
  margin: 20px auto 0px auto;
}

.btn-change-count {
  position: relative;
  display: inline-block;
  font-weight: bold;
  padding: 0.25em 0.5em;
  text-decoration: none;
  color: #00bcd4;
  background: #dbebf8;
  border: none;
  transition: 0.4s;
}

.btn-change-count:hover {
  background: #00bcd4;
  color: white;
}

.header-change-count {
  border-bottom: solid 3px black;
}

ul, ol {
  padding: 0;
  text-align: center;
  margin: 3px 0px;
}

ul li {
  position: relative;
  list-style-type: none!important;
  padding: 0.3em 0.3em 0.3em 0.3em;
  margin-bottom: 1px;
  vertical-align: middle;
}

.btn-reset {
  position: relative;
  display: inline-block;
  font-weight: bold;
  padding: 0.25em 0.5em;
  text-decoration: none;
  color: #00BCD4;
  background: #ECECEC;
  border: none;
  border-radius: 0;
  transition: .4s;
}

.btn-reset:hover {
  background: #636363;
}

app.js

let clickCount = 0;
let playing = false;
let targetCount = 10;
let spendTime = 0;
let timerId = 0;
const clickTarget = document.querySelector(".js-click-target");
const resetBtn = document.querySelector(".js-reset");
const counterText = document.querySelector(".js-counter");
const targetCountText = document.querySelector(".js-target-count");
const spendTimeText = document.querySelector(".js-spend-time");
const timerLabelText = document.querySelector(".js-timer-label");

const countChanger = document.getElementsByClassName("js-change-count");
Array.prototype.forEach.call(countChanger, (btn) => {
  btn.addEventListener("click", (e) => {
    targetCount = btn.getAttribute("data-count");
    targetCountText.innerHTML = targetCount;
  });
});

const start = () => {
  clickCount = 0;
  time = 0;
  playing = true;
  timerLabelText.innerHTML = "経過時間:";
  counterText.innerText = clickCount;
  spendTimeText.innerHTML = time / 1000;

  timerId = setInterval(() => {
    time += 10;
    spendTimeText.innerHTML = (time / 1000).toFixed(2);
  }, 10);
};

const complete = () => {
  playing = false;
  timerLabelText.innerHTML = "連打終了! 記録:";
  clearInterval(timerId);
  clickTarget.disabled = true;
  setTimeout(() => (clickTarget.disabled = false), 2000);
};

const reset = () => {
  playing = false;
  clickCount = 0;
  counterText.innerText = clickCount;
  spendTimeText.innerHTML = 0;
  timerLabelText.innerHTML = "経過時間:";
  clickTarget.disabled = false;
  clearInterval(timerId);
};

// 初回クリックでスタート
clickTarget.addEventListener("click", () => {
  if (playing) return;

  start();
  playing = true;
});

// クリック時のカウントアップ
clickTarget.addEventListener("click", () => {
  if (!playing) return;

  counterText.innerText = ++clickCount;
  if (clickCount >= targetCount) {
    complete();
  }
});

// リセットボタン
resetBtn.addEventListener("click", () => reset());

これらhtml, css, jsファイルを配置したらローカルでwebサーバを起動してアクセスできるようにします。
(ここではnode.jsのhttp-serverを使いますが、なんでも構いません)

npm install -g http-server
ファイルを配置した場所で
http-server -p 8001

これでhttp://localhost:8001/index.html`にアクセスできるようにします。
※ちなみに、GitHub Pagesを使うとHTTPSでアクセスできるので楽です。

A2HSを実装する

やることはそう多くはありません。以下を実行すれば、インストールが可能になります。

  • 1.manifest(Web App Manifest)を作成する
    • htmlのヘッダにmanifest.jsonへのlinkを記述する
  • 2.ServiceWorkerを登録し、fetchイベントをハンドリングさせる

この他にHTTPS経由で提供される必要があるようですが、localhostの場合はHTTPでもOKです。


1.manifestの作成

まずmanifestを作成していきます。
中身はJSONですが、拡張子は.webmanifestにするべきなようです。が、.jsonでも動きます。
ここではmanifest.webmanifestとして作成します。

manifest.webmanifest

{
  "name": "Button mashing",
  "short_name": "Bm",
  "description": "クリック連打",
  "icons": [
    {
      "src": "./icons/icon-192.png",
      "sizes": "192x192",
      "type": "image/png"
    },
    {
      "src": "./icons/icon-512.png",
      "sizes": "512x512",
      "type": "image/png"
    }
  ],
  "start_url": "./index.html",
  "display": "standalone",
  "background_color": "#FFFFFF",
  "theme_color": "#FFFFFF"
}

ざっと項目の説明です。

  • name: アプリの名称。
  • icons: ホーム画面やデスクトップに表示するアイコン。192192と512512があると良いらしいです。(とりあえず適当に作るか拾う)
  • start_url: インストールして実行するファイルのパス。
  • display: 表示方法。アプリっぽく動かすには、standalonefullscreenを指定。fullscreenだとステータスバーなどのUIも表示されない。

パス系はmanifestファイルの場所からの相対パスです。

作成したら、index.htmlにこのmanifestへのリンクを記載します。

index.html

  <head>
    <meta charset="utf-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>button mashing</title>
    <link rel="stylesheet" type="text/css" href="app.css" />
+   <link rel="manifest" href="manifest.webmanifest" />
    </script>
  </head>

2.Service Workerの登録

SeriveWorkerの実態はJavaScriptです。
sw.jsにSeriveWorkerの本体を記載し、それをアプリ内から登録します。

sw.js

self.addEventListener('fetch', (e) => {})

インストールに必要なのは、このfetchイベントをハンドリングさせるだけです。コールバックは空で構いません。 (ServiceWorker内では、selfにServiceWorkerGlobalScopeがbindされています。)

これをアプリケーション内で登録します。
index.html

  <head>
    <meta charset="utf-8" />
    <meta http-equiv="X-UA-Compatible" content="IE=edge" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>button mashing</title>
    <link rel="stylesheet" type="text/css" href="app.css" />
    <link rel="manifest" href="manifest.webmanifest" />
+    <!-- ServiceWorker -->
+    <script>
+      if ("serviceWorker" in navigator) {
+        window.addEventListener("load", function () {
+          navigator.serviceWorker.register("./sw.js").then(
+            function (registration) {
+              // Registration was successful
+              console.log(
+                "ServiceWorker registration successful with scope: ",
+                registration.scope
+              );
+            },
+            function (err) {
+              // registration failed :(
+              console.log("ServiceWorker registration failed: ", err);
+            }
+          );
+        });
+      }
+    </script>
  </head>

ここまで変更したら、localhost:8001/index.htmlにアクセスします。
アドレスバーの右端に、+のアイコンが追加されました。

f:id:masaka13:20210120173602p:plain
PWAのインストールボタン・・・わかりづらい

クリックしてインストールしてみます。デスクトップにアイコンが追加され、起動してみると・・

f:id:masaka13:20210121020335p:plain
デスクトップから起動したアプリ

起動できました。これでデスクトップに追加することができるようになりました。

オフラインでも実行できるようにする

アプリとして起動できるようにはなりましたが、このままではオフラインでは使用できません。

コンテンツをオフラインで使用できるようにするため、キャッシュ処理をService Workerに書かなければならないのですが、workboxを使うと簡単に試せます。

sw.jsにworkboxを利用したファイルのキャッシュ処理を書いてみましょう。

sw.jsに以下を追加します。

importScripts('https://storage.googleapis.com/workbox-cdn/releases/6.0.2/workbox-sw.js');

workbox.precaching.precacheAndRoute([
  '/index.html',
  '/app.css',
  '/app.js'
]);

pre-cacheを使用しています。(インストール時にキャッシュされる機能)
本来はファイルにリビジョンを指定して、バージョン管理を行うことも必要ですが今回は割愛します。

キャッシュ処理を追加したところで、再度アプリをインストールし直してみます。(アンインストールはアプリ右上のメニューからできます)
http-serverを停止したり、オフライン状態でもインストールしたゲームを起動できるようになっています。
どういう仕組みになっているのか軽く確認してみましょう。

f:id:masaka13:20210121113043p:plain 開発者ツールを見るとキャッシュされたファイルはService Workerから取得していることがわかります


f:id:masaka13:20210121113137p:plain cache storageに保存されています


これで、オフライン実行が有効になります。
PWA化への第一歩を踏み出せました。

おわりに

PWAとは何かの把握と実装の第一歩をやってみました。
機会があったらプッシュ通知あたりもやってみたいと思います。

PWA自体は対応するサイトも増えてきているようですが、普及に関してはiOSの対応が未だ不透明である点がネックですね。
とはいえiOSも徐々に対応機能を増やしてきてはいるので、今後も発展が見込めそうな技術かと思います。


  • エンジニア中途採用サイト
    ラクスでは、エンジニア・デザイナーの中途採用を積極的に行っております!
    ご興味ありましたら是非ご確認をお願いします。 career-recruit.rakus.co.jp

  • カジュアル面談お申込みフォーム
    どの職種に応募すれば良いかわからないという方は、カジュアル面談も随時行っております。
    以下フォームよりお申込みください。
    forms.gle

  • イベント情報
    会社の雰囲気を知りたい方は、毎週開催しているイベントにご参加ください! rakus.connpass.com

Copyright © RAKUS Co., Ltd. All rights reserved.