PWAアプリをLaravel + Vueでつくる方法(ダウンロード可)

こんにちは。フリーランス・コンサルタント&エンジニアの 九保すこひ です。

さてさて、前回記事「Laravel + Vue + GraphQLでデータ取得」では最近知った新しい技術をテーマにした内容をお届けしました。

そして、今回はその流れで「知ってはいたけど、なかなかチャレンジする機会がなかった」内容をお届けしたいと思います。

ズバリ、それは・・・

PWA(Progressive Web Application)

です。

PWAとは、簡単に言うと「ウェブの技術でスマホのアプリがつくれる」というもので、大部分のシェアを占める iPhone、Android 両方に対応しています。(厳密にはiOSの場合、インストール部分などで未整備の部分もあります)

そこで!

今回はLaravelを使って、PWAを実装してみたいと思います。
ぜひ楽しみながらやってみましょう❗

(最後に今回開発したソースコード一式をダウンロードできますよ😊)

「正直言うと、手こずりました😂」

開発環境: Laravel 7.x(サンプルは5.8)

やりたいこと

今回は日本の5大都市の天気予報(3時間毎)を表示するアプリを作ってみます。

👇 見た目はこんな感じです。(サンプルはこちら

では、実際に作業をしていきましょう❗

開発前の注意点

PWAをブラウザで実行するにはHTTPSでの接続が必須です。
しかも、自己証明によるHTTPSも不可です。

そのため、これを解決するには「接続環境のエラーについて」を参考にして環境をつくってみてください。

なお、ローカル環境にHTTPSを用意するには「コピペでOK!ローカル環境にHTTPSを導入する(nginx編)」が参考になるかと思います。

準備する

マニフェストを用意する

PWAに必要な、ウェブアプリケーション情報がわかる「マニフェスト」と呼ばれるファイルを作成します。

なお、マニフェストにはアイコン(512 x 512ピクセル)が必要になりますのでお好みで用意しておいてください。もし、適当なアイコンがない場合は以下のものを使ってください。

(この画像は、こちらのページからダウンロードしたもので、パブリックドメイン・ライセンスです。)

では、マニフェストを簡単にオンラインで作成できるWeb App Manifest Generatorにアクセスしてください。

すると次のようなフォームが表示されるので、

  • App Name: 天気予報テスト(アプリ名)
  • Short Name: 天気予報テスト(短いアプリ名)
  • Display Mode: Standalone
  • Start URL: /pwa_forecast

と入力します。

次に、先ほど用意した512 x 512ピクセルのアイコンを選択し、「GENERATE .ZIP」ボタンをクリックしてください。

すると、自動的にZIPファイルがダウンロードされますので、中身を展開してLaravelpublicフォルダに設置します。

OpenWeatherMapに登録する

天気予報の情報を取得するために、OpenWeatcherMap(無料プランあります)に登録してAPIキーを取得します。

登録ページにアクセスすると、次のページが表示されるので、

  • ユーザー名
  • メールアドレス
  • パスワード
  • パスワード(確認)
  • 「16歳以上です」のチェック
  • 利用規約同意チェック
  • 「ロボットではありません」のチェック

に入力をして「Create Account」ボタンをクリックしてください。

すると、次のような表示がでるので、Purpose(目的)を「Weather widget for web」にして「Save」ボタンをクリックします。

すると、入力したメールアドレスに次のようなメッセージが届きますので、「Verify your email」をクリックして本登録を完了させてください。

本登録が完了したら、(ログインして)ページ上部にある「API keys」をクリックして、APIキーを取得します。

APIキーを取得したら、後で管理しやすいようにLaravel.envに書き込んでおきます。

/.env

OPEN_WEATHER_MAP_API=(あなたのAPIキー)

PWAアプリをつくる

では、ここからが本番です。
PWAアプリ本体をつくっていきましょう❗

Service Workerをつくる

では、先にブラウザのバックグラウンドで動くことになる「Service Woker」と呼ばれるファイルを作っていきます。

/public/sw.js

const appKey = 'my-forecast-app';

self.addEventListener('install', e => { // 👈 インストールされたとき ・・・ ①

    // 👇 基本ファイルのキャッシュを保存
    caches.open(appKey)
        .then(cache => {

            cache.addAll([
                '/pwa_forecast',
                'https://stackpath.bootstrapcdn.com/bootstrap/4.4.1/css/bootstrap.min.css',
                'https://cdn.jsdelivr.net/npm/vue@2.6.11'
            ]);

        })

});

self.addEventListener('fetch', e => { // 👈 データ取得するとき ・・・ ②

    e.respondWith(getFetchResponse(e.request));

});

async function getFetchResponse(request) {

    const url = new URL(request.url);

    if(url.host === location.host) { // 自分のサイト内のURLの場合

        // キャッシュ優先
        const cachedResponse = await caches.match(request);

        if(cachedResponse) {

            return cachedResponse;

        }

        return fetch(request);

    } else {

        // ネットワーク優先
        const cache = await caches.open(appKey);

        try {

            const response = await fetch(request);
            await cache.put(request, response.clone());
            return response;

        } catch {

            return await cache.match(request);

        }

    }

}

この中では大きく2つの部分に分かれています。

① インストールされたとき

Service Workerがインストールされた時に実行されることになります。この部分では、メインのHTMLCSSJSファイルをキャッシュすることになります。

② データ取得するとき

この部分では、通信状況によって以下4つのパターンに対応しています。

  • 自分のサイトのURLで、通信OKの時
  • 自分のサイトのURLで、通信NGの時
  • 外部サイトのURLで、通信OKの時
  • 外部サイトのURLで、通信NGの時

つまり、今回の例で言うと、

  • 天気データは外部サイトから取得するので、できるだけ最新データを取得するけど、もし通信NGだったら直前にキャッシュされたデータを使う(ネットワーク優先)
  • 逆に、自分のサイトの場合はアイコンなど変化がないので、できるだけキャッシュから読み込む(キャッシュ優先)

となります。

ルートをつくる

今回はテストですので、コントローラーを省略して開発を進めます。

/routes/web.php

// 👇 追加
Route::get('pwa_forecast', function(){ return view('pwa_forecast'); });

ビューをつくる

次にビューをつくります。

/resources/views/pwa_forecast.blade.php

<html>
<head>
    <meta name="viewport" content="width=device-width,initial-scale=1.0,minimum-scale=1.0">
    <link rel="manifest" href="/manifest.json">
    <link href="https://stackpath.bootstrapcdn.com/bootstrap/4.4.1/css/bootstrap.min.css" rel="stylesheet">
</head>
<body>
    <div id="app" class="p-3">
        <div class="row">
            <div class="col-md-8">
                <h1>天気予報テスト</h1>
            </div>
            <div class="col-md-4 text-right mb-3">
                <button class="btn btn-primary" type="button" @click="onInstallationClick">ホーム画面へ登録</button>
            </div>
            <div class="col-md-12">
                <select
                    class="form-control mb-3"
                    v-model="currentCityId"
                    @change="getForecast">
                    <option
                        v-for="c in cities"
                        v-text="c.name"
                        :value="c.id">
                    </option>
                </select>
                <div class="card mb-2" v-for="f in forecasts">
                    <div class="card-body">
                        <img :src="forecastIcon(f.weather)">
                        <span v-text="f.dt_txt"></span>
                    </div>
                </div>
                <div class="pl-2" v-if="!forecasts.length">
                    データが見つかりません。
                </div>
            </div>
        </div>

    </div>

    <script src="https://cdn.jsdelivr.net/npm/vue@2.6.11"></script>
    <script>

        new Vue({
            el: '#app',
            data: {
                currentCityId: 1850144, // 東京
                appId: '{{ env('OPEN_WEATHER_MAP_API') }}',
                cities: [
                    { id: 2128295, name: '札幌' },
                    { id: 1850144, name: '東京' },
                    { id: 1856057, name: '名古屋' },
                    { id: 1853909, name: '大阪' },
                    { id: 1863967, name: '福岡' }
                ],
                forecasts: [],
                promptEvent: null,
            },
            methods: {
                async getForecast() {   // 天気データを取得

                    this.forecasts = [];

                    if(this.currentCityId > 0) {

                        const url = `https://api.openweathermap.org/data/2.5/forecast?id=${this.currentCityId}&appid=${this.appId}`;
                        const response = await fetch(url);

                        if(response.ok) {

                            const data = await response.json();
                            this.forecasts = data.list;

                        }

                    }

                },
                forecastIcon(weather) {

                    // OpenWeatherMapが提供するアイコンのURL
                    return `https://openweathermap.org/img/wn/${weather[0].icon}.png`;

                },
                onInstallationClick() { // 👈 PWAアプリをインストールする部分

                    this.promptEvent.prompt();
                    this.promptEvent.userChoice
                        .then(choice => {

                            if(choice.outcome === 'accepted') {

                                console.log('PWA: インストールを許可しました。');

                            } else {

                                console.log('PWA: インストールを拒否しました。');

                            }

                            this.promptEvent = null;

                        });

                }
            },
            computed: {
                showButton() {

                    return (this.promptEvent !== null);

                }
            },
            created() {

                window.addEventListener('beforeinstallprompt', e => {

                    e.preventDefault();
                    this.promptEvent = e;

                });

                // 👇 Service Workerをインストールする部分
                if('serviceWorker' in navigator) {

                    navigator.serviceWorker.register('sw.js')
                        .then(registration => {

                            if(registration.installing) {

                                console.log('Service Worker: インストール成功!');
                                location.reload(); // 👈 インストール直後はリロード

                            } else {

                                this.getForecast();

                            }

                        })
                        .catch(error => {

                            console.log('Service Worker: インストール失敗...');

                        });

                } else {

                    this.getForecast();

                }

            }
        });

    </script>
</body>
</html>

この中で重要なのは、created()内のService Workerをインストールする部分です。

Service Workerは、navigator.serviceWorker.register('***')でブラウザにインストールしますが、この直後にlocation.reload()でページを再読み込みしています。

これは、Service Workerがインストールされた直後は天気データなどを取得する時のfetchイベントが実行されないためです。(厳密には方法があるようですが、あまりおすすめしません)

そして、再読み込みされた時に初めてgetForecast()で天気データを取得することになり、このデータはService Workerでキャッシュされ、オフライン状態でも表示できるようになります。

テストしてみる

では、実際にAndroidでテストしてみましょう!

うまく天気情報が取得できています。
次に「ホーム画面へ登録」ボタンをクリックしてPWAアプリをインストールしてみましょう。

インストールが完了するとこうなりました。

そして、このアイコンをクリックすると次のような起動画面まで表示してくれます。

成功です😊✨

なお、天気データを取得後に一旦オフラインにして実行してみてもうまくキャッシュからデータ表示されました。

その他の実装

個人的に運営している「街角コレクション」というサイトで「pwa アプリ」を公開しています。

興味がある方は、ぜひインストールして試してみてください。😄

📝 街角コレクション:アプリ

ダウンロードする

今回実際に開発したソースコード一式を以下からダウンロードできます。

※ただし、アイコンやマニフェスト、APIキーはご自身で準備していただく必要があります。

PWAアプリをLaravel + Vueでつくる
開発のご依頼お待ちしております
開発のご依頼はこちらから: お問い合わせ
どうぞよろしくお願いいたします! by 九保すこひ

おわりに

ということで、今回はLaravelPWAアプリを作ってみました。

正直なことを言うと、記事の途中で書いた「Service Workerがインストールされた直後はfetchイベントが効かない」という点でハマってしまい、久しぶりに目薬を指すぐらい画面を凝視することになりました😂

なお、iPhoneについては「ホーム画面に追加」機能がサポートされていないので、今すぐPWAをモバイル・アプリとして使うには難しい部分があると思いますが、今後には期待が持てます。

というのも、PWAアプリをAndroidで実行してみましたがあまりウェブアプリという感じがせず、スピードも申し分がないものでした。

iPhoneにもAndroidにもアプリをつくりたいけど、予算があまりなかったり、そこまで難しい機能は必要ないという場合には有力な選択肢になるような気がしています。

ぜひ、みなさんもチャレンジしてみてくださいね。

ではでは〜❗

「長時間ハマったのに、
寝て起きたら5分で解決する
現象って名前ついてるのかな❓」

このエントリーをはてなブックマークに追加       follow us in feedly