もっと身近にやってたPWA

2020/05/20

PWA night #16

jiyuujin

  • 複雑なUIを作るのが好きです
    • Vue/Nuxt/React/Next/Scala/Swift
  • v-kansai/kansai.ts/..
  • Profile site by Gatsby.js since 2020/03
    • https://yuukit.me
  • Tech blog by Nuxt.js since 2018/10
    • https://webneko.dev

PWA、やってますか?

結果的にPWAを意識してない🤭

色々やった中でも具体例を3つ程

技術スタック

  • Vue.jsを使うことを前提に、細かなデザインの微調整を除いてフル自作したコンポーネント
  • ネイティブのDateライブラリに加え、dayjsも使用
  • rangeのみならず、single / timeにも対応

力を入れた箇所は?

入りきらない程の props

import Vue from 'vue'

export default Vue.extend({
  props: {
    id: {
      type: String,
      default: ''
    },
    minDate: {
      type: Date,
      default() {
        return null
      }
    },
    maxDate: {
      type: Date,
      default() {
        return null
      }
    },
    linkedCalendars: {
      type: Boolean,
      default: true
    },
    singleDatePicker: {
      type: Boolean,
      default: false
    },
    showDropdown: {
      type: Boolean,
      default: false
    },
    autoApply: {
      type: Boolean,
      default: false
    },
    dateRange: {
      type: Object,
      required: true
    },
    ranges: {
      type: Object,
      default() {
        let today = new Date()
        today.setHours(0, 0, 0, 0)

        let yesterday = new Date()
        yesterday.setDate(today.getDate() - 1)
        yesterday.setHours(0, 0, 0, 0)

        let thisMonthStart = new Date(today.getFullYear(), today.getMonth(), 1)
        let thisMonthEnd = new Date(
          today.getFullYear(),
          today.getMonth() + 1,
          0
        )

        let result = {}
        result['今日'] = [today, today]
        result['昨日'] = [yesterday, yesterday]
        result['今月'] = [thisMonthStart, thisMonthEnd]
        result['先月'] = [
          new Date(today.getFullYear(), today.getMonth() - 1, 1),
          new Date(today.getFullYear(), today.getMonth(), 0)
        ]
        result['今年'] = [
          new Date(today.getFullYear(), 0, 1),
          new Date(today.getFullYear(), 11, 31)
        ]
        result['昨年'] = [
          new Date(today.getFullYear() - 1, 0, 1),
          new Date(today.getFullYear() - 1, 11, 31)
        ]
        return result
      }
    },
    localeData: {
      type: Object,
      default() {
        return {}
      }
    },
    opens: {
      type: String,
      default: 'center'
    },
    dateFormat: {
      type: Function,
      required: true
    },
    clear: {
      type: Boolean,
      default: false
    },
    timePicker: {
      type: Boolean,
      default: false
    },
    timePickerIncrement: {
      type: Number,
      default: 5
    },
    timePicker24Hour: {
      type: Boolean,
      default: true
    },
    timePickerSeconds: {
      type: Boolean,
      default: false
    }
  },
})

全てを紹介すると時間が足らない😅

  • ranges (Object)
  • singleDatePicker (Boolean)
  • timePicker (Boolean)

適宜変数など準備いただければ

<v-range-picker
    ref="range-picker"
    :single-date-picker="singleDatePicker"
    :auto-apply="autoApply"
    :linked-calendars="linkedCalendars"
    :date-range="dateRange"
    opens="right"
    :date-format="dateFormat"
    @update="updateValues"
    @toggle="checkOpen"
>
    <div slot="input" slot-scope="picker" style="min-width: 350px;">
        {{ picker.startDate }} - {{ picker.endDate }}
    </div>
</v-range-picker>

npm i v-range-picker

お知らせ通知機能

こういう経緯から、

  • リリースのタイミングで、ユーザに対して知らせる情報を、HTMLで静的に書く
  • けどその通知が無く、それを解決しよう

互いに全く違うプロジェクトでしたが

  • 新規案件 (Laravel/SPA, Play/SPA) のアプリ内ブラウザでリリースするタイミングを通知する機能を実装
  • 既存案件 (CakePHP/SPA) の会員制向け管理画面で、工数をかけずリリースするタイミングを通知する機能を実装

前者の例

もともと既に、簡単なPWAを入れてた

const PRECACHE = 'precache-v1';
const RUNTIME = 'runtime';

// キャッシュのバージョン管理の自動化
const VERSION = '<%= hash %>';
const STATIC_CACHE_KEY = 'static-' + VERSION;

const PRECACHE_URLS = [
    'index.php',
    './', // Alias for index.html
    './css/app.css',
    './js/app.js',
    './js/manifest.js',
    './js/vendor.js'
];

const CACHE_VERSION = 1;
let CURRENT_CACHES = {
    offline: 'offline-v' + CACHE_VERSION
};

const OFFLINE_URL = 'offline.html';

self.addEventListener('install', event => {
    //console.log('Installing...');
    if (typeof self.skipWaiting === 'function') {
        //console.log('self.skipWaiting() is supported.');
        event.waitUntil(
            caches.open(PRECACHE)
                .then(cache => cache.addAll(PRECACHE_URLS))
                .then(self.skipWaiting())
        );
    } else {
        console.log('self.skipWaiting() is not supported');
    }
});

ガチャ画像など適切に指定してはキャッシュ

さらに serviceWorker を利用しよう💡

push通知対応しているか否か

isSupported() {
  return ('serviceWorker' in navigator
    && 'PushManager' in window)
}

SWインストール時に、pushする情報をLambdaにPOSTする

AWS SNSにアクセスする

const AWS = require('aws-sdk')

exports.handler = async (event) => {
  const params = {
    Message: 'ここに入力します。',
    TopicArn: ''
  }

  await new AWS.SNS().publish(params).promise().then((data) => {
    console.log('MessageID is ' + data.MessageId)
  }).catch((err) => {
    console.error(err, err.stack)
  })
}

serviceWorker.js で受け取る準備をして、

// 通知内容を表示する
self.addEventListener('push', function (event) {
  let name = ((((event || {}).data || {}).json() || {}).data || {}).name;
  event.waitUntil(
    self.registration.showNotification(`${name}さんから`, {
      'body': 'v1.0.0 がリリースされました。',
    })
  );
});

// クリックした時に、次にとるアクションを表示する
self.addEventListener('notificationClick', function (event) {
  event.notification.close();
  focusWindow(event);
});

得られた成果

  • 決められた時間に設定できる
  • Aurora、もしくはRDBと連携して、柔軟にゲームステータスに応じてターゲットや時間を設定できる

一方、後者の例

工数を取れない制約が..🙅‍♀️

せめてログイン直後には通知する💡

特定のDOMに対して、クッキーを設定

const version = '';
const newestTopicId = `Topic_${version}`;

// クッキーを設定する
docCookies.setItem(
  `COOKIE_TOPIC_${version}`,
  newEstTopicId,
  new Date()
);

if (docCookies.getItem(`COOKIE_TOPIC_${version}`)) {
  // 存在した場合、ポップアップを表示する
}

得られる成果

  • 初回ログイン時にこのクッキーが存在した場合、新たにポップアップを表示する
  • 一方で存在しなかった場合、ポップアップを表示しない

微々たる方策でしたが、

やらないよりは少しの解決を✍️

思い返すと色々やっていた

現在のお仕事は

  • Next.js + AppSync のサーバレス環境で、新しいしごと情報を管理する両サイトの制作を進めてます
    • 実際のおしごとで使う用の本番アプリ
    • 本番で使うため、事前に作ったデモアプリ (趣味用)
  • Vue CLI ベースのMPA構成を構築、コンポーネント単位でリプレースを進めてます

最近やっていることでPWA..🤔

Auth0を使ったマルチデバイス化

Next.jsを使った会員限定サイト

@auth0/auth0-nextjs

Auth0を楽に始める

[Single Page Application] からプロジェクトを作る

consoleで設定

  • Domain
  • Client ID
  • Client Secret
  • Callback URL
  • Logout URLs
  • Web Origins

configを読み込む

import { initAuth0 } from '@auth0/nextjs-auth0';

export default initAuth0({
  clientId: process.env.AUTH0_CLIENT_ID,
  clientSecret: process.env.AUTH0_CLIENT_SECRET,
  scope: process.env.AUTH0_SCOPE,
  domain: process.env.AUTH0_DOMAIN,
  redirectUri: process.env.REDIRECT_URI,
  postLogoutRedirectUri: process.env.POST_LOGOUT_REDIRECT_URI,
  session: {
    cookieSecret: process.env.SESSION_COOKIE_SECRET,
    cookieLifetime: process.env.SESSION_COOKIE_LIFETIME,
    storeIdToken: false,
    storeRefreshToken: false,
    storeAccessToken: false
  }
});

1個でも欠けると認証に失敗..😇

ログイン周りの導線を作る

@auth0/nextjs-auth0

export interface ISignInWithAuth0 {
    handleLogin: (
      req: IncomingMessage,
      res: ServerResponse,
      options?: LoginOptions
    ) => Promise<void>;
}

pages/api/login.tsx

import auth0 from '../../lib/auth0';

export default async function login(req, res) {
  try {
    await auth0.handleLogin(req, res);
  } catch (error) {
    console.error(error);
    res.status(error.status || 500).end(error.message);
  }
}

@auth0/nextjs-auth0

export interface ISignInWithAuth0 {
  handleLogout: (
    req: IncomingMessage,
    res: ServerResponse
  ) => Promise<void>;
}

pages/api/logout.tsx

import auth0 from '../../lib/auth0';

export default async function logout(req, res) {
  try {
    await auth0.handleLogout(req, res);
  } catch (error) {
    console.error(error);
    res.status(error.status || 500).end(error.message);
  }
}

これでログイン、ログアウト完

認証するためのフックを作る

let userState;

export const fetchUser = async () => {
  if (userState !== undefined) {
    return userState;
  }

  const res = await fetch('/api/me');
  userState = res.ok ? await res.json() : null;
  return userState;
};

使いたい場所で呼び出す

const IndexPage: NextPage<IndexPageProps> = () => {
    const { user, loading } = useFetchUser()

    return (
        <>
            {!loading && !user && <SWHeader isAuth={false} />}

            {user && (
                <>
                    <SWHeader isAuth={true} />
                    <!--
                        ログインに成功したら表示するページ
                    -->
                </>
            )}
        </>
    )
}

さらにこれをやった認証は、

  • 全画面同じ処理を書くのは格好悪いので、middlewareとしてラッパーを持たせた
  • ログインした際にlocalStorageで保持させた

ルールを設定する

今後(も)進めたいこと

解決したい課題など

  • もっとUX (ユーザ・エクスペリエンス) 向上させられるよう、フロントエンド・インフラを理解したい
  • 登壇駆動開発として今回登壇させていただきましたが、黄金比率、のような一体何が正解かを知りたい

最後に自分なりのPWAを簡単にまとめ

  • 求められる仕様に、自然と入ってくることが多い
    • Range Pickerを始めとしたSP対応
    • ユーザを逃さない施策をきっかけに始めたpush通知
    • Auth0を使って簡単にマルチデバイス対応
  • これまで(これからも?)意識しないことは変わらない(だろうと私感を最後にぶっ込んで)

Thank you..🙇‍♀️