20201206のvue.jsに関する記事は17件です。

WebSocketを利用したチャット画面を作ってみる(Vuetify)

概要

WebSocketのサーバを用意したので、今度はクライアントを作ってみることに。
Vuetifyを使って、簡単なチャット画面を作ってみる。

サーバについては↓の記事を参照のこと。
https://qiita.com/donraku/items/d4071fcb1802600bda67

結論

WebSocketを利用したチャット画面を作って、リアルタイムのやり取りができることを確認できた。

画面を3つ出して動かしたイメージは↓。

20201206.gif

詳細

  • チャット用のWebSocketのサーバが稼働していること
  • Vue.js + Vuetify の環境が用意済みであること

が動かす前提になるけど、その辺の詳細は省略。
vue-router で下記の画面を表示するように設定。

画面レイアウト部

上部にチャットのログ、下部に名前とメッセージの入力を配置。
送信ボタン、または、Enterでメッセージを送信することが可能とする。

画面イメージは上記の通り。

ChatSample.vue
<template>
  <v-app>
    <v-main>
      <v-container>
        <v-row class="text-center">
          <v-col cols="12">
            <v-card
              class="mx-auto my-12"
              elevation="2"
              max-width="500"
              color="grey lighten-5"
            >
              <v-card-title>Chat Sample</v-card-title>
              <v-divider></v-divider>
              <v-card-text
                ><v-row>
                  <v-col cols="12">
                    <v-container
                      ref="scrollTarget"
                      style="height: 450px"
                      class="overflow-y-auto"
                    >
                      <v-row v-for="(msg, i) in messages" :key="i" dense>
                        <v-col v-if="msg.ws_key != ws_key">
                          <div class="balloon_l">
                            <div class="face_icon">
                              <v-avatar :color="msg.avatar_color">
                                <span class="white--text">
                                  {{ msg.name }}
                                </span>
                              </v-avatar>
                            </div>
                            <p class="says">
                              {{ msg.message }}
                            </p>
                          </div>
                        </v-col>
                        <v-col v-else>
                          <div class="balloon_r">
                            <div class="face_icon">
                              <v-avatar :color="msg.avatar_color">
                                <span class="white--text">
                                  {{ msg.name }}
                                </span>
                              </v-avatar>
                            </div>
                            <p class="says">
                              {{ msg.message }}
                            </p>
                          </div>
                        </v-col>
                      </v-row>
                    </v-container>
                  </v-col>
                </v-row>
              </v-card-text>
              <v-divider></v-divider>
              <v-card-text>
                <v-row>
                  <v-col cols="3">
                    <v-text-field
                      label="名前"
                      v-model="name"
                      clearable
                    ></v-text-field>
                  </v-col>
                  <v-col>
                    <v-text-field
                      autofocus
                      label="メッセージ ※Enterでも送信できるよ"
                      v-model="message"
                      clearable
                      @keyup.enter="send_onClick"
                    ></v-text-field>
                  </v-col>
                </v-row>
                <v-btn class="info" small @click="send_onClick">
                  <v-icon>mdi-play</v-icon>送信
                </v-btn>
              </v-card-text>
            </v-card>
          </v-col>
        </v-row>
      </v-container>
    </v-main>
  </v-app>
</template>

スクリプト部

初期表示(created)で、WebSocketのサーバに接続。
オープンしたら、初回メッセージを送って、1回通信を行うようにしている。

メッセージ送信ではjson形式で情報をまとめて送信する。
メッセージ受信時、自分のメッセージなら右側に表示して、他人のメッセージなら左側に表示。

ChatSample.vue
<script>
// 初回メッセージ
const MSG_START = "###start###";

export default {
  name: "App",

  components: {},

  data: () => ({
    // UI items
    name: "名無し",
    message: "おはようございます。",

    // Vars
    connection: null,
    messages: [],
    ws_key: null,
    avatar_color: "",
  }),

  created: function () {
    // websocket 接続
    this.connection = new WebSocket("ws://localhost:8002/ws");

    // アバターの色をランダムに決める
    let random_color = "#";
    for (var i = 0; i < 6; i++) {
      random_color += "0123456789abcdef"[(16 * Math.random()) | 0];
    }
    this.avatar_color = random_color;

    const vm = this;

    // websocket オープン
    this.connection.onopen = function () {
      // 初回メッセージを送信する
      vm.sendMessageData(vm.avatar_color, MSG_START, vm.name);
    };

    // websocket メッセージ受信
    this.connection.onmessage = function (event) {
      // sample: {"key": "lu0x0uw5sEQvehHYOMBfIA==", "message_data": "{\"avatar_color\":\"#3370a8\",\"message\":\"###start###\"}"}
      const data_json = JSON.parse(event.data);
      const ws_key = data_json.key;
      const msg_json = JSON.parse(data_json.message_data);

      // 初回メッセージの場合はサーバで発行したkeyを保持しておく
      if (msg_json.message == MSG_START) {
        if (vm.ws_key == null && vm.avatar_color == msg_json.avatar_color)
          vm.ws_key = ws_key;
        return;
      }
      // 初回以外は、jsonをリストに追加する
      vm.messages.push({
        ws_key,
        avatar_color: msg_json.avatar_color,
        message: msg_json.message,
        name: msg_json.name,
      });
    };
  },

  updated: function () {
    this.scrollToEnd();
  },

  methods: {
    // メッセージ送信ボタンクリック
    send_onClick: function () {
      if (this.message == "") return;
      this.sendMessageData(this.avatar_color, this.message, this.name);
      this.message = "";
    },

    // メッセージ情報を送信
    sendMessageData: function (avatar_color, message, name) {
      const msg_json = {
        avatar_color,
        message,
        name,
      };
      this.connection.send(JSON.stringify(msg_json));
    },

    // チャットログを一番下までスクロール
    scrollToEnd() {
      this.$nextTick(() => {
        const chatLog = this.$refs.scrollTarget;
        if (!chatLog) return;
        chatLog.scrollTop = chatLog.scrollHeight;
      });
    },
  },
};
</script>

CSS部

チャットのログ部分は、ちょうど良いコンポーネントが見当たらなかったので、
いろいろ探してCSSで表現することに。
そのCSSが↓。

ChatSample.vue
<style scoped>
.balloon_l,
.balloon_r {
  margin: 10px 0;
  display: flex;
  justify-content: flex-start;
  align-items: flex-start;
}
.balloon_r {
  justify-content: flex-end;
}
.face_icon img {
  width: 80px;
  height: auto;
}
.balloon_r .face_icon {
  margin-left: 25px;
}
.balloon_l .face_icon {
  margin-right: 25px;
}
.balloon_r .face_icon {
  order: 2 !important;
}
.says {
  max-width: 300px;
  display: flex;
  flex-wrap: wrap;
  position: relative;
  padding: 10px;
  border-radius: 12px;
  background: #8ee7b6;
  box-sizing: border-box;
  margin: 0 !important;
  line-height: 1.5;
  /*   align-items: center; */
}
.says p {
  margin: 8px 0 0 !important;
}
.says p:first-child {
  margin-top: 0 !important;
}
.says:after {
  content: "";
  position: absolute;
  border: 10px solid transparent;
  margin-top: -3px;
}
.balloon_l .says:after {
  left: -26px;
  border-right: 22px solid #8ee7b6;
}
.balloon_r .says:after {
  right: -26px;
  border-left: 22px solid #8ee7b6;
}
</style>
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

laravelでゲストログイン機能を作成

今やポートフォリオには必須の機能とされているゲストログイン機能を作成しました

環境
PHP 7.3.11
Laravel Framework 7.29.3

今回はLoginControllerをいじっていきます

LoginController.php
//省略//

public function guestLogin() {
        $name = 'ゲスト';
        $password = 'guestpass';

        if(Auth::attempt(['name' => $name, 'password' => $password])) {
            return redirect('/home');
        }

        return redirect('/');
    }

Auth::attemptは引数に指定したレコードがDB内にあればtrue,そうでなければfalseを返す

今回は予めゲストログイン用のユーザーアカウントを作成しておき名前とパスワードをname,passwordに代入、
Auth::attemptメソッドの引数に指定すればもちろんtrueが帰ってくるので認証が成功し
/homeにリダイレクトされるといった感じです。

web.php
Route::get('/login/guest', 'Auth\LoginController@guestLogin');

/login/guestを踏めばguestLoginメソッドが実行されます

guestLoginComponent.vue
<template>
  <div>
    <a class="nav-link" @click="openModal">ゲストログイン</a>
    <div class="overlay" @click="closeModal" v-if="showContent">
      <div class="dialog" @click="stopEvent">
        <p class="borderbottom pt-2 pb-4">ゲストユーザーとしてこのサイトをお試しになれます</p>
        <p class="text-right pr-2">
          <a @click="closeModal" class="mr-4 black">キャンセル</a>
          <a href="/login/guest" class="ml-auto">ログイン</a>
        </p>
      </div>
    </div>
  </div>
</template>

<script>
   export default {
     data() {
       return {
         showContent: false
       }
     },

     methods:{
      stopEvent(){
        event.stopPropagation()
      },
      openModal(){
        this.showContent = true
      },
      closeModal(){
        this.showContent = false
      }
    }
  }
</script>


今回はゲストログインボタンを押したらダイアログが表示され「ログイン」をクリックしたらメソッドが発火するというデザインにしてみました。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【 Firebase + Vue + SpringBoot 】 Firebase の uid を MySQL で作ったテーブルにインサートしてみた

はじめに

皆さんおはこんばにちは。
エンジニア歴1年目、現場未経験の弱小自称 Web エンジニアです。

本記事では、題名通りの処理の流れについてのみ記載していきます。

プロジェクトの作成方法や単語の意味(axios、JPA、… )などわからない事がありましたら
今回はこちらの記事を参考におおまかなシステムを構築しているので
下記のリンクからご参照ください↓↓↓
SpringBoot+Vue.js+ElementUI+Firebaseでマスタ管理アプリ入門

やりたいこと

処理の流れ:
① Firebase にあるログインユーザー ID(uid)を Vue で取得する。
② axios を用いて uid を含んだユーザー情報を SpringBoot のREST API に POST する。
③ MySQL で作ったユーザーのテーブルに、その情報を INSERT する。
image.png
ログイン認証は Firebase + Vue で行いたい。
ユーザーのテーブルは他のテーブルと結合させる予定なので、MySQL側でも作っておきたい。

それらのワガママを実現させるために、上記の方法を選択しました。

Firebase から uid を取得

今回は Firebase のメール / パスワード認証を用いて
ログイン認証を行っています。

ログイン済みのユーザーが入力した情報と共に uid をサーバーに送信したいので
ログイン画面を構築する View の後に遷移する View(今回だとユーザー情報入力画面)で
uid を取得する処理を書いていきます。

image.png

※ UI ライブラリは Vuetify を使用。

ProfileInput.vue
. . .

<v-text-field
    type="text"
    label="名前"
    v-model="userRequest.name"
    outlined
></v-text-field>
<v-textarea
    type="text"
    label="自己紹介文"
    v-model="userRequest.profile"
    outlined
></v-textarea>

. . .

<v-btn dark depressed @click="onSubmit" color="info">入力</v-btn>

. . .

<script>
import firebase from 'firebase'

export default {
    data() {
        return {
            userRequest: {
                name: undefined,
                profile: undefined,
                firebaseId: undefined,
            },
        }
    },

    methods: {
        onSubmit: async function () {
            await firebase.auth().onAuthStateChanged((user) => {
                this.userRequest.firebaseId = user.uid
            })
        }
    },
}
</script>

firebase.auth().onAuthStateChanged(user => /* ... */)を用いて現在ログインしているユーザーのデータを取得し、自前で定義した userRequest オブジェクトの変数に uid を代入しています。

axios で POST する

サーバーサイドの REST API にデータを追加する処理にaxiosを使用します。

ProfileInput.vue
<script>
import firebase from 'firebase'
import axios from 'axios' //axiosをインポート

export default {
    data() {
        return {
            userRequest: {
                name: undefined,
                profile: undefined,
                firebaseId: undefined,
            },
        }
    },

    methods: {
        onSubmit: async function () {
            await firebase.auth().onAuthStateChanged((user) => {
                this.userRequest.firebaseId = user.uid
                axios.post('http://localhost:8080/addUser', this.userRequest) //userRequestオブジェクトをPOST
            })
        }
    },
}
</script>

Spring Data JPA によるマッピング

Vue 側でデータを追加する処理を終えたので
サーバーサイドの処理を書いていきます。

MySQL 側で作ったテーブルと Java のクラスのマッピングを行うために
Spring Data JPAを使用しています。

Entity

UserEntity.java
package com.example.jpamysql.domain;

import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import javax.persistence.Table;

import lombok.Getter;
import lombok.Setter;

@Entity
@Getter
@Setter
@Table(name = "users")
public class UserEntity {

    /** 自動採番ID */
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    /** 名前 */
    @Column(name = "name", columnDefinition = "VARCHAR(45)")
    private String name;

    /** 自己紹介文 */
    @Column(name = "profile", columnDefinition = "VARCHAR(45)")
    private String profile;

    /** Firebase の uid */
    @Column(name = "firebase_id", columnDefinition = "VARCHAR(45)")
    private String firebaseId;
}

Firebase の uid は文字列になっているので、String 型で受け取ります。

Repository

UserRepository.java
package com.example.jpamysql.repository;

import com.example.jpamysql.domain.UserEntity;

import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;

@Repository
public interface UserRepository extends JpaRepository<UserEntity, Long> {

}

Service

UserService.java
package com.example.jpamysql.service;

import com.example.jpamysql.domain.UserEntity;
import com.example.jpamysql.repository.UserRepository;

import org.springframework.stereotype.Service;

import lombok.RequiredArgsConstructor;

@Service
@RequiredArgsConstructor
public class UserService {

    private final UserRepository userRepository;

    public void save (UserEntity user) {
        userRepository.save(user);
    }
}

RestController

Vue から送られてきたデータを受け取るためのクラスを先に定義しておきます。
Entity クラスで受け取っても問題なく処理されますが、保守性を高めるためです。

UserRequest.java
package com.example.jpamysql.request;

import lombok.Getter;
import lombok.Setter;

@Getter
@Setter
public class UserRequest {

    private String name;
    private String profile;
    private String firebaseId;
}
UserRestController.java
package com.example.jpamysql.controller;

import com.example.jpamysql.domain.UserEntity;
import com.example.jpamysql.request.UserRequest;
import com.example.jpamysql.service.UserService;

import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RestController;

import lombok.RequiredArgsConstructor;

@RestController
@RequiredArgsConstructor
public class UserRestController {

    private final UserService userService;

    @RequestMapping(value = "/addUser", method = RequestMethod.POST)
    public void addUser(@RequestBody UserRequest userRequest) {
        UserEntity user = new UserEntity();
        user.setName(userRequest.getName());
        user.setProfile(userRequest.getProfile());
        user.setFirebaseId(userRequest.getFirebaseId());
        userService.save(user);
    }
}

YAML

接続情報の定義も忘れずに。

application.yml
spring:
  datasource:
    url: jdbc:mysql://localhost:3306/jpa_db?serverTimezone=JST
    username: qiita
    password: qiita
    driver-class-name: com.mysql.cj.jdbc.Driver

これで Enitity クラスに対応するテーブルを MySQL 側で作っていれば無事に INSERT されるはず。

まとめ

  • firebase.auth().onAuthStateChanged(user => /* ... */)を使用すればログインユーザーのデータを取得できる

  • axios でデータを送信する場合は、フロント側の変数名とサーバー側の変数名を一致させておく必要がある

  • Firebase の uid は文字列なので、Java 側では String 型で受け取る必要がある
    (てっきり Spring プロジェクトにFirebase Admin SDKなんかを追加して、Firebase 独自のオブジェクトで受け取らなきゃいけないのかと思ったけど、その必要はなかった!!)
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【SpringBoot + Vue + Firebase】 Firebase の uid を MySQL で作ったテーブルにインサートしてみた

はじめに

皆さんおはこんばにちは。
エンジニア歴1年目、現場未経験の弱小自称 Web エンジニアです。

本記事では、題名通りの処理の流れについてのみ記載していきます。

プロジェクトの作成方法や単語の意味(axios、JPA、… )などわからない事がありましたら
今回はこちらの記事を参考におおまかなシステムを構築しているので
下記のリンクからご参照ください↓↓↓
SpringBoot+Vue.js+ElementUI+Firebaseでマスタ管理アプリ入門

やりたいこと

処理の流れ:
① Firebase にあるログインユーザー ID(uid)を Vue で取得する。
② axios を用いて uid を含んだユーザー情報を SpringBoot のREST API に POST する。
③ MySQL で作ったユーザーのテーブルに、その情報を INSERT する。
image.png
ログイン認証は Firebase + Vue で行いたい。
ユーザーのテーブルは他のテーブルと結合させる予定なので、MySQL側でも作っておきたい。

それらのワガママを実現させるために、上記の方法を選択しました。

Firebase から uid を取得

今回は Firebase のメール / パスワード認証を用いて
ログイン認証を行っています。

ログイン済みのユーザーが入力した情報と共に uid をサーバーに送信したいので
ログイン画面を構築する View の後に遷移する View(今回だとユーザー情報入力画面)で
uid を取得する処理を書いていきます。

image.png

※UI ライブラリは Vuetify を使用。

ProfileInput.vue
. . .

<v-text-field
    type="text"
    label="名前"
    v-model="userRequest.name"
    outlined
></v-text-field>
<v-textarea
    type="text"
    label="自己紹介文"
    v-model="userRequest.profile"
    outlined
></v-textarea>

. . .

<v-btn dark depressed @click="onSubmit" color="info">入力</v-btn>

. . .

<script>
import firebase from 'firebase'

export default {
    data() {
        return {
            userRequest: {
                name: undefined,
                profile: undefined,
                firebaseId: undefined,
            },
        }
    },

    methods: {
        onSubmit: async function () {
            await firebase.auth().onAuthStateChanged((user) => {
                this.userRequest.firebaseId = user.uid
            })
        }
    },
}
</script>

firebase.auth().onAuthStateChanged(user => /* ... */)を用いて現在ログインしているユーザーのデータを取得し、自前で定義した userRequest オブジェクトの変数に uid を代入しています。

axios で POST する

サーバーサイドの REST API にデータを追加する処理にaxiosを使用します。

ProfileInput.vue
<script>
import firebase from 'firebase'
import axios from 'axios' //axiosをインポート

export default {
    data() {
        return {
            userRequest: {
                name: undefined,
                profile: undefined,
                firebaseId: undefined,
            },
        }
    },

    methods: {
        onSubmit: async function () {
            await firebase.auth().onAuthStateChanged((user) => {
                this.userRequest.firebaseId = user.uid
                axios.post('http://localhost:8080/addUser', this.userRequest) //userRequestオブジェクトをPOST
            })
        }
    },
}
</script>

Spring Data JPA によるマッピング

Vue 側でデータを追加する処理を終えたので
サーバーサイドの処理を書いていきます。

MySQL 側で作ったテーブルと Java のクラスのマッピングを行うために
Spring Data JPAを使用しています。

Entity

UserEntity.java
package com.example.jpamysql.domain;

import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import javax.persistence.Table;

import lombok.Getter;
import lombok.Setter;

@Entity
@Getter
@Setter
@Table(name = "users")
public class UserEntity {

    /** 自動採番ID */
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    /** 名前 */
    @Column(name = "name", columnDefinition = "VARCHAR(45)")
    private String name;

    /** 自己紹介文 */
    @Column(name = "profile", columnDefinition = "VARCHAR(45)")
    private String profile;

    /** Firebase の uid */
    @Column(name = "firebase_id", columnDefinition = "VARCHAR(45)")
    private String firebaseId;
}

Firebase の uid は文字列になっているので、String 型で受け取ります。

Repository

UserRepository.java
package com.example.jpamysql.repository;

import com.example.jpamysql.domain.UserEntity;

import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;

@Repository
public interface UserRepository extends JpaRepository<UserEntity, Long> {

}

Service

UserService.java
package com.example.jpamysql.service;

import com.example.jpamysql.domain.UserEntity;
import com.example.jpamysql.repository.UserRepository;

import org.springframework.stereotype.Service;

import lombok.RequiredArgsConstructor;

@Service
@RequiredArgsConstructor
public class UserService {

    private final UserRepository userRepository;

    public void save (UserEntity user) {
        userRepository.save(user);
    }
}

RestController

Vue から送られてきたデータを受け取るためのクラスを先に定義しておきます。
Entity クラスで受け取っても問題なく処理されますが、保守性を高めるためです。

UserRequest.java
package com.example.jpamysql.request;

import lombok.Getter;
import lombok.Setter;

@Getter
@Setter
public class UserRequest {

    private String name;
    private String profile;
    private String firebaseId;
}
UserRestController.java
package com.example.jpamysql.controller;

import com.example.jpamysql.domain.UserEntity;
import com.example.jpamysql.request.UserRequest;
import com.example.jpamysql.service.UserService;

import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RestController;

import lombok.RequiredArgsConstructor;

@RestController
@RequiredArgsConstructor
public class UserRestController {

    private final UserService userService;

    @RequestMapping(value = "/addUser", method = RequestMethod.POST)
    public void addUser(@RequestBody UserRequest userRequest) {
        UserEntity user = new UserEntity();
        user.setName(userRequest.getName());
        user.setProfile(userRequest.getProfile());
        user.setFirebaseId(userRequest.getFirebaseId());
        userService.save(user);
    }
}

YAML

接続情報の定義も忘れずに。

application.yml
spring:
  datasource:
    url: jdbc:mysql://localhost:3306/jpa_db?serverTimezone=JST
    username: qiita
    password: qiita
    driver-class-name: com.mysql.cj.jdbc.Driver

これで Enitity クラスに対応するテーブルを MySQL 側で作っていれば無事に INSERT されるはず。

まとめ

  • firebase.auth().onAuthStateChanged(user => /* ... */)を使用すればログインユーザーのデータを取得できる

  • axios でデータを送信する場合は、フロント側の変数名とサーバー側の変数名を一致させておく必要がある

  • Firebase の uid は文字列なので、Java 側では String 型で受け取る必要がある
    (てっきり Spring プロジェクトにFirebase Admin SDKなんかを追加して、Firebase 独自のオブジェクトで受け取らなきゃいけないのかと思ったけど、その必要はなかった!!)
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

moment.jsで「〜時間前」を表示させる

#はじめに moment.jsを使用して、指定時刻から計算して「〜日前」「〜時間前」を表示させる。 #moment().fromNow() ###import import Moment from 'moment'; 指定した時刻から現在の時刻までの差を計算する。 Moment('2020-01-01T10:10:30.900Z').fromNow(); #言語を設定する このままでは「〜hour ago」など英語で表示されるので、言語を設定する。 Moment.locale( 'ja' ); #時差をなくす このままでは、UTC(世界標準時間)が適応され、日本からは+9時間の時刻が表示される。 moment-timezoneを使用する。 ###install npm install moment-timezone ###import import MomentTimezone from 'moment-timezone'; ###UTCの時刻を取得 let dateTimeUtc = MomentTimezone.tz('2020-01-01T10:10:30.900Z', 'UTC'); ###デバイス(ブラウザ)で設定されているタイムゾーンを取得 海外でも使用する可能性があればデバイス(ブラウザ)で設定されているタイムゾーンを取得する。 日本のみで使用の場合は不要。 let deviceTz = Intl.DateTimeFormat().resolvedOptions().timeZone; ###指定したタイムゾーンに変換 デバイス(ブラウザ)と同じタイムゾーンに変換。 let upDate = MomentTimezone(dateTimeUtc).tz(deviceTz).format('YYYY-MM-DDTHH:mm:ss.sssZ'); 日本のみで使用の場合は'Asia/Tokyo'を直接入力。 let upDate = MomentTimezone(dateTimeUtc).tz('Asia/Tokyo').format('YYYY-MM-DDTHH:mm:ss.sssZ'); ###moment().fromNow()に入れる let lastUpDate = Moment(upDate).fromNow(); #まとめ import Moment from 'moment'; import MomentTimezone from 'moment-timezone'; // 言語を日本語に設定 Moment.locale( 'ja' ); // デバイスのタイムゾーンを取得 let deviceTz = Intl.DateTimeFormat().resolvedOptions().timeZone; // UTCの時刻を取得 let dateTimeUtc = MomentTimezone.tz('2020-01-01T10:10:30.900Z', 'UTC'); // 指定したタイムゾーンに変換 let upDate = MomentTimezone(dateTimeUtc).tz(deviceTz).format('YYYY-MM-DDTHH:mm:ss.sssZ'); // 「〜日前」「〜時間前」を表示 let lastUpDate = Moment(upDate).fromNow(); #参考サイト https://stackoverflow.com/questions/53017772/moment-js-parse-date-time-ago
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Vue.js + moment.jsで、時刻をリアルタイム更新する方法

#はじめに Vue.jsとmoment.jsを使用して、時刻をリアルタイムで更新する時計を作る。 本文ではTypeScriptを使用。 #moment.jsをインストール npm install moment #開発コード インポートする。 import Moment from 'moment'; ###Moment().format()を使用 表示形式は自由に選択できる。 time = Moment().format("YYYY-MM-DD HH:mm:ssZ"); ###setIntervalを使用 createdの中にsetIntervalを入れる。1秒毎に新しい時刻を取得。 created() { setInterval(() => { this.time = Moment().format("YYYY-MM-DD HH:mm:ssZ"); }, 1000) } #参考サイト
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

element ui el-popover を繰り返す

表題の通り。

elementui の el-popoverを繰り返したい。

hoge.vue
<div v-for="(v,key) in list">

        <el-popover
                placement="top"
                width="160"
                v-model="visible[key]">
            <div>
                <el-button @click="visible[key] = false" size="mini" type="text" style="color: #666;">既読にする</el-button>
            </div>

            <el-button slot="reference" size="mini" type="text" style="color: #666;" @click="visible[key] = true"><i class="fa fa-ellipsis-h" aria-hidden="true"></i></el-button>
        </el-popover>
</div>


//略

data () {
    return {
        visible:[]//[]にするのが味噌

こんな感じ。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

Vueソーシャルシェアリングのバージョン指定

vue-social-sharingのバージョン指定

ターミナル
npm i vue-social-sharing@2.4.6

@2.4.6の部分を好きなバージョンに指定するだけ!

ver.3だとSNSアイコンが表示されない事が多いのでこちらのバージョンを指定するといけました!

以上!

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

nginxでローカルサーバー起動と同時にBASIC認証付き外部URLを発行する方法【Nuxt / vue】

記事の内容

使用方法をよく理解するため、作業を三段階に分けた。

Stap1
nginxサービスで外部URLを発行し、
Nuxt+vueプロジェクトのローカル環境を外部からも見られるようにする。

Stap2
nginxサービスアカウントとローカルの開発環境をAuthtokenで紐付けて
BASIC認証をかけた状態で外部URLを発行しするできるようにする。

Stap3
nginxを「yarn run dev:ngrok」で実行できるようnuxtのbuild処理に組み込む。

記事を書くまでの経緯

2020 Vue/Vuetify WEB開発 TestCafeを使ったE2Eテスト 基礎編

上記記事で、TestCafeを使ったE2Eテストを作成した。
TestCafeはBrowserstackと連携して
複数の端末やOS、ブラウザでE2Eテストを実行することができる。

BrowserstackはローカルホストのURLは指定できないため
httpで外部公開し、そのURLをターゲットに
BrowserstackでE2Eテストを実行したい。

この記事ではまずnginxサービスの利用方法についてまとめる。

環境

  • MacBook Pro (Retina, 13-inch, Early 2015)
  • OS:macOS Mojava 10.14.6
  • サーバサイドJavaScript:node v12.14.1
  • パッケージマネージャー: yarn v1.22.4
  • フレームワーク: nuxt v2.0.0
  • JavaScriptライブラリ: Vue v2.6.11
  • UIライブラリー: @nuxtjs/vuetify v1.11.2

サンプル環境

Gitにこの記事の項目3−3の状態のサンプルプロジェクトがあります
(3-2で設定する.envファイルを除く)

https://github.com/shiho-hoshino/e2e-sample/tree/nginx_test

記事を確認しながら動かしてみたい場合
ダウンロードしpackage.jsonをインストールしてください

// ※プロジェクトディレクトリに移動しpackage.jsonをインストール
$ yarn install

Stap1

目的
Nuxt+Vueプロジェクトのローカル環境を、
nginxサービスで外部URLを発行しする。

手順

  1. ngrokをダウンロード
  2. ローカル環境を起動
  3. ngrokを実行

[1-1]ngrokをダウンロード

  • 公式サイトからダウンロード
  • 展開したファイルをプロジェクトルートに配置

download.png

ダウンロード されるファイルngrok-stable-darwin-amd64.zip を展開して
Unix実行ファイルの「ngrok」をプロジェクトルートに配置する。

[1-2]ローカル環境を起動

サンプルのpackage.jsonを yarn install 済み前提
プロジェクトのフォルダで以下を実行

$ yarn run dev

nuxt.config.jsでポート3000を設定しているので
http://localhost:3000
でサイトを確認することができます。

nuxt.config.jsでポートを設定していない場合は以下のように指定して起動

  server: {
    port: 3000,
    host: '0.0.0.0',
  },

[1-3]ngrokを実行

[1-1]でルートディレクトリに配置した
「ngrok」ファイルを実行する
「http ポート番号」コマンドで、ドメインが発行される

今回はポート3000と指定してるので以下のコマンドを実行する

$ ./ngrok http 3000

実行するとこのような画面に切り替わる
「Forwarding」に書いてあるURLが「http://localhost:3000」の公開URLとなる

htto_port.png

nginxを停止したい場合は「Ctrl + C」でコンソールを止めてください。

Stap2

目的
nginxサービスアカウントとローカルの開発環境をAuthtokenで紐付けて
BASIC認証をかけた状態で外部URLを発行しするできるようにする。

目的1では完全にローカルが外部に晒された状態になるため
BASIC認証をかけて保護したい

前提
nginxでは、高度な機能を利用する場合
まずAuthtokenを使い、開発環境の認証(紐付け)を行う必要がある
参考:https://ngrok.com/docs

BASIC認証をかけURLを発行することは、
この「高度な機能」に分類されるため、Authtokenの認証が必要となる。

手順

  1. nginxサービスのアカウントを作る
  2. nginxでAuthtokenを取得する
  3. Authtokenでローカル環境を認証させる
  4. BASIC認証コマンドをつけてngrokを実行

[2-1]nginxサービスのアカウントを作る

https://ngrok.com/

ngrok.comでアカウントを作成
2020年時点での公式サイトはこのようになっている
ngrok.com.png

「Siginup」でアカウントを作成するとダッシュボードに飛ぶ
dashboard.ngrok.com.png

[2-2]nginxでAuthtokenを取得する

Authentication > Your Authtoken ページに記載があることを確認。

authtoken.png

[2-3]Authtokenでローカル環境を認証させる

ngrokのコマンドでと、先ほど確認したAuthtokenで簡単に認証が可能

$ ./ngrok authtoken [Authtoken]

MACOSの場合は以下のファイルが作成される

$ Authtoken saved to configuration file: /Users/[username]/.ngrok2/ngrok.yml

[2-4]BASIC認証コマンドをつけてngrokを実行

Authtokenで認証することで、「-auth」コマンドが利用できるようになっている
なおAuthtoken認証を行わない場合はエラーとなる

※username:password は好きなユーザー名とパスワードを設定してください

$ ./ngrok http -auth="username:password" 3000

上記コマンドで実行し、発行されたURLへ飛ぶと認証を求められるようになっている
BASICbasic_auth.png

Stap3

目的
nginxを「yarn run dev:ngrok」で実行できるようnuxtのbuild処理に組み込む。

手順

  1. 「ngrok」ファイルを@nuxtjs/ngrokプラグインに置き換える
  2. 「.env」ファイルの作成
  3. nuxt.config.jsに設定を追加する

[3-1]「ngrok」ファイルを@nuxtjs/ngrokプラグインに置き換える

[1-1]でダウンロードしたngrokは不要になるので削除
以下のコマンドで@nuxtjs/ngrokプラグインを追加

$ yarn add -D @nuxtjs/ngrok

@nuxtjs/ngrok
https://www.npmjs.com/package/@nuxtjs/ngrok

[3-2]「.env」ファイルの作成

[2-2]で取得したトークンと
[2-4]で設定したユーザー名:パスワードを変数に設定する

NGROK_AUTHTOKEN=ここにトークン
NGROK_AUTH=username:password

[3-3]nuxt.config.jsに設定を追加する

nuxtでプラグインを使用するためbuildModulesで読み込む

通常の「yarn run dev」とは処理を分けたいので
package.jsonのscriptsでngrokを使用するかどうか判定する変数を設定する

package.json

"scripts": {
    "dev": "nuxt",
    "dev:ngrok": "USE_NGROK=TRUE nuxt",

nuxt.config.js

const buildModules = [
  '@nuxtjs/eslint-module',
  '@nuxtjs/stylelint-module',
  '@nuxtjs/vuetify',
];

if (process.env.USE_NGROK) {
  buildModules.push('@nuxtjs/ngrok');
}

export default {
  buildModules: buildModules,

//~ 以下省略

この状態で「yarn run dev:ngrok」すると
nuxtローカルサーバーが起動すると同時に
BASIC認証無しの状態の外部URLが発行される

Step2で設定したようにBASIC認証ありで発行したいので
以下の設定もnuxt.config.jsに追記する

authtokenとauthは、3−1で設定したenvファイルの変数を読み込む

  ngrok: {
    authtoken: process.env.NGROK_AUTHTOKEN,
    auth: process.env.NGROK_AUTH,
    region: 'jp',
    addr: 3000,
    proto: 'http',
  },

改めて「yarn run dev:ngrok」すると
BASIC認証ありで外部URLが発行されるようになる

参考

ngrok Documentation
https://ngrok.com/docs

Vue.jsの開発環境とngrokを連携する方法
https://qiita.com/idani/items/aecd90a9e949c8dbf4a8

Vue.js / Nuxt.jsのアプリをnginxで動かす(サブディレクトリ対応)
https://qiita.com/ktkiyoshi/items/c6b7decfa368cd7ce579

【3分で出来る】ngrokでデプロイをしてみよう!
https://qiita.com/derorian3/items/5ab961c1d5cff5f50141

ngrokを使ってローカル開発中のVueアプリをHTTPSで公開する
https://dev.classmethod.jp/articles/ngrok-vue/

まとめと課題

buildModulesの切り替えはもう少しスムーズに行う方法があるのだろうか?

外部URLがあればBrowserstackを利用できるようになる
次回はTestCafeとBrowserstackの連携方法についてまとめる

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

ngrokでローカルサーバー起動と同時にBASIC認証付き外部URLを発行する方法【Nuxt / vue】

記事の内容

使用方法をよく理解するため、作業を三段階に分けた。

Stap1
ngrokサービスで外部URLを発行し、
Nuxt+vueプロジェクトのローカル環境を外部からも見られるようにする。

Stap2
ngrokサービスアカウントとローカルの開発環境をAuthtokenで紐付けて
BASIC認証をかけた状態で外部URLを発行しするできるようにする。

Stap3
ngrokを「yarn run dev:ngrok」で実行できるようnuxtのbuild処理に組み込む。

記事を書くまでの経緯

2020 Vue/Vuetify WEB開発 TestCafeを使ったE2Eテスト 基礎編

上記記事で、TestCafeを使ったE2Eテストを作成した。
TestCafeはBrowserstackと連携して
複数の端末やOS、ブラウザでE2Eテストを実行することができる。

BrowserstackはローカルホストのURLは指定できないため
httpで外部公開し、そのURLをターゲットに
BrowserstackでE2Eテストを実行したい。

この記事ではまずngrokサービスの利用方法についてまとめる。

環境

  • MacBook Pro (Retina, 13-inch, Early 2015)
  • OS:macOS Mojava 10.14.6
  • サーバサイドJavaScript:node v12.14.1
  • パッケージマネージャー: yarn v1.22.4
  • フレームワーク: nuxt v2.0.0
  • JavaScriptライブラリ: Vue v2.6.11
  • UIライブラリー: vuetify v2.2.27

サンプル環境

Gitにこの記事の項目3−3の状態のサンプルプロジェクトがあります
(3-2で設定する.envファイルを除く)

https://github.com/shiho-hoshino/e2e-sample/tree/ngrok_test

記事を確認しながら動かしてみたい場合
ダウンロードしpackage.jsonをインストールしてください

// ※プロジェクトディレクトリに移動しpackage.jsonをインストール
$ yarn install

Stap1

目的
Nuxt+Vueプロジェクトのローカル環境を、
ngrokサービスで外部URLを発行しする。

手順

  1. ngrokをダウンロード
  2. ローカル環境を起動
  3. ngrokを実行

[1-1]ngrokをダウンロード

  • 公式サイトからダウンロード
  • 展開したファイルをプロジェクトルートに配置

download.png

ダウンロード されるファイルngrok-stable-darwin-amd64.zip を展開して
Unix実行ファイルの「ngrok」をプロジェクトルートに配置する。

[1-2]ローカル環境を起動

サンプルのpackage.jsonを yarn install 済み前提
プロジェクトのフォルダで以下を実行

$ yarn run dev

nuxt.config.jsでポート3000を設定しているので
http://localhost:3000
でサイトを確認することができます。

nuxt.config.jsでポートを設定していない場合は以下のように指定して起動

  server: {
    port: 3000,
    host: '0.0.0.0',
  },

[1-3]ngrokを実行

[1-1]でルートディレクトリに配置した
「ngrok」ファイルを実行する
「http ポート番号」コマンドで、ドメインが発行される

今回はポート3000と指定してるので以下のコマンドを実行する

$ ./ngrok http 3000

実行するとこのような画面に切り替わる
「Forwarding」に書いてあるURLが「http://localhost:3000」の公開URLとなる

htto_port.png

ngrokを停止したい場合は「Ctrl + C」でコンソールを止めてください。

Stap2

目的
ngrokサービスアカウントとローカルの開発環境をAuthtokenで紐付けて
BASIC認証をかけた状態で外部URLを発行しするできるようにする。

目的1では完全にローカルが外部に晒された状態になるため
BASIC認証をかけて保護したい

前提
ngrokでは、高度な機能を利用する場合
まずAuthtokenを使い、開発環境の認証(紐付け)を行う必要がある
参考:https://ngrok.com/docs

BASIC認証をかけURLを発行することは、
この「高度な機能」に分類されるため、Authtokenの認証が必要となる。

手順

  1. ngrokサービスのアカウントを作る
  2. ngrokでAuthtokenを取得する
  3. Authtokenでローカル環境を認証させる
  4. BASIC認証コマンドをつけてngrokを実行

[2-1]ngrokサービスのアカウントを作る

https://ngrok.com/

ngrok.comでアカウントを作成
2020年時点での公式サイトはこのようになっている
ngrok.com.png

「Siginup」でアカウントを作成するとダッシュボードに飛ぶ
dashboard.ngrok.com.png

[2-2]ngrokでAuthtokenを取得する

Authentication > Your Authtoken ページに記載があることを確認。

authtoken.png

[2-3]Authtokenでローカル環境を認証させる

ngrokのコマンドでと、先ほど確認したAuthtokenで簡単に認証が可能

$ ./ngrok authtoken [Authtoken]

MACOSの場合は以下のファイルが作成される

$ Authtoken saved to configuration file: /Users/[username]/.ngrok2/ngrok.yml

[2-4]BASIC認証コマンドをつけてngrokを実行

Authtokenで認証することで、「-auth」コマンドが利用できるようになっている
なおAuthtoken認証を行わない場合はエラーとなる

※username:password は好きなユーザー名とパスワードを設定してください

$ ./ngrok http -auth="username:password" 3000

上記コマンドで実行し、発行されたURLへ飛ぶと認証を求められるようになっている
BASICbasic_auth.png

Stap3

目的
ngrokを「yarn run dev:ngrok」で実行できるようnuxtのbuild処理に組み込む。

手順

  1. 「ngrok」ファイルを@nuxtjs/ngrokプラグインに置き換える
  2. 「.env」ファイルの作成
  3. nuxt.config.jsに設定を追加する

[3-1]「ngrok」ファイルを@nuxtjs/ngrokプラグインに置き換える

[1-1]でダウンロードしたngrokは不要になるので削除
以下のコマンドで@nuxtjs/ngrokプラグインを追加

$ yarn add -D @nuxtjs/ngrok

@nuxtjs/ngrok
https://www.npmjs.com/package/@nuxtjs/ngrok

[3-2]「.env」ファイルの作成

[2-2]で取得したトークンと
[2-4]で設定したユーザー名:パスワードを変数に設定する

NGROK_AUTHTOKEN=ここにトークン
NGROK_AUTH=username:password

[3-3]nuxt.config.jsに設定を追加する

nuxtでプラグインを使用するためbuildModulesで読み込む

通常の「yarn run dev」とは処理を分けたいので
package.jsonのscriptsでngrokを使用するかどうか判定する変数を設定する

package.json

"scripts": {
    "dev": "nuxt",
    "dev:ngrok": "USE_NGROK=TRUE nuxt",

nuxt.config.js

const buildModules = [
  '@nuxtjs/eslint-module',
  '@nuxtjs/stylelint-module',
  '@nuxtjs/vuetify',
];

if (process.env.USE_NGROK) {
  buildModules.push('@nuxtjs/ngrok');
}

export default {
  buildModules: buildModules,

//~ 以下省略

この状態で「yarn run dev:ngrok」すると
nuxtローカルサーバーが起動すると同時に
BASIC認証無しの状態の外部URLが発行される

Step2で設定したようにBASIC認証ありで発行したいので
以下の設定もnuxt.config.jsに追記する

authtokenとauthは、3−1で設定したenvファイルの変数を読み込む

  ngrok: {
    authtoken: process.env.NGROK_AUTHTOKEN,
    auth: process.env.NGROK_AUTH,
    region: 'jp',
    addr: 3000,
    proto: 'http',
  },

改めて「yarn run dev:ngrok」すると
BASIC認証ありで外部URLが発行されるようになる

参考

ngrok Documentation
https://ngrok.com/docs

Vue.jsの開発環境とngrokを連携する方法
https://qiita.com/idani/items/aecd90a9e949c8dbf4a8

Vue.js / Nuxt.jsのアプリをngrokで動かす(サブディレクトリ対応)
https://qiita.com/ktkiyoshi/items/c6b7decfa368cd7ce579

【3分で出来る】ngrokでデプロイをしてみよう!
https://qiita.com/derorian3/items/5ab961c1d5cff5f50141

ngrokを使ってローカル開発中のVueアプリをHTTPSで公開する
https://dev.classmethod.jp/articles/ngrok-vue/

まとめと課題

buildModulesの切り替えはもう少しスムーズに行う方法があるのだろうか?

外部URLがあればBrowserstackを利用できるようになる
次回はTestCafeとBrowserstackの連携方法についてまとめる

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【個人開発】FGOの計算アプリを作った

今年の7月にFGOの計算アプリをリリースしました。
初めて作った個人アプリで、エンジニアになってからも時間を見つけて運用してるのでその話をしたいと思います。

アプリ紹介

まずアプリを簡単に紹介します。イラストが超可愛いです

FGO Calculator - Wオルタと計算 -

スクリーンショット 2020-12-06 13.04.32.png
・アプリURL → https://fgo-damage-calculation.web.app/
・使用技術 → Nuxt.js × Firebase

スマートフォン向けアプリ「Fate/Grand Order」(略称は「FGO」)で使える計算アプリを作りました。
FGOがどんなゲームなのかざっくり説明すると、プレイヤーが主人公となって、英霊と呼ばれる「サーヴァント」を召喚して戦い、地球の未来を救っていく物語です。

アプリデモ

◯宝具ダメージ計算(必殺技のダメージ計算)

demo
このページではキャラクターの必殺技のダメージ計算ができます。
FGOではダメージに乱数調整が入るので、最小 平均 最大の三種類の計算結果を用意しています。
Vue.jsのcomputedを利用して計算結果を即反映と、計算結果によってキャラクターのセリフを変化させてます。セリフは30種類以上!!!セリフを見てFGOユーザーに楽しんでもらえたら最高です。

アプリ全体で工夫した点

UI UXに力を入れてます。主に以下3つです。
・キャラクターのイラストを登場させる → FGOユーザーにインパクトを残せる。あと可愛い
・UIフレームワークVuetifyを使用して見た目を整える。→ UIを簡単に整えられる
・PWA対応でアプリをスマホのホーム画面に追加可能にした。 → ユーザーとの接触機会UP、フルスクリーンで表示可能なのでアプリ感UP


スマホのデザインも素人なりに頑張りました。
スクロールしなくても計算結果が見れるように固定フッターを用意してるのがポイントです。

Google アナリティクス

ここからは運用の話をします。
ここ3ヶ月のユーザー数はこんな感じです。

1500人!!!!!めっちゃ嬉しい!!!

開発中は「もし使ってくれる人がいなくても自分は必ず使う」という考え方でしたので、こうしてたくさんの方に使って貰えるのは本当に嬉しいです。

セッション継続時間も計算アプリにしては上々なのかな?
ただここは個人的にはあまり意識してないです。ユーザーがアプリに滞在する時間は短い方が良いのかなと思ってます。


約3割の方がアプリに2回以上訪問してるのも嬉しいです。
僕はこのアプリを愛してるのでユーザーさんからも好きになってもらえたら最高です!

運用する上で意識してること

・ 新キャラが出たら即追加

『Fate/Grand Order』では新しいサーヴァント(キャラクター)が頻繁に追加されます。
「新キャラで計算したいのにできない。」という状況を防ぐために翌日までにはデータの追加をしてます。

・フィードバックを貰ったら即対応

フィードバックを貰えることがあります。
これはユーザーさんの生の声なのでデータの追加、修正の要望であれば即日、もしくは翌日までに対応してます。

こんな感じにアップデート情報はお知らせしてます。

・運用は難しい

スクリーンショット 2020-12-06 10.35.56.png
この画像は先程ユーザー数を紹介したものと同じものですが、見て分かる通り数字が落ち着き気味です。(150には戻せると思う。200超えが難しい。)


僕のアプリは4番目です。実は一時期1番を取っていたのですが落ちました。素直に悔しい!!
1.2番を取ってる大手攻略サイトのAppMediaさん強しです...!

いやー運用って本当に難しいです。やはりOrganic Searchが一番多いのでSEOの重要さがわかります。

このアプリの目標

「FGOがサービス終了するまで使ってもらえるアプリであること」

超人気ゲーム(AppStoreのセルラン1位~2位)なのでいつ終わるかは全くわかりませんが、とにかく運用し続けることが最大の目標です。そのためにもUI UXの向上SEOの改善など頑張ります。
あとネイティブアプリ版も作りたいです。FGOの計算サイトはwebは激戦区だけどAppStoreではチャンスを感じるので。FlutterかReactn Naitive勉強せねば...!

終わりに

技術選定の話は全くしなかったですが、色々楽そうだなという理由でNuxt.jsFirebaseで作りました。SSRやSSGをあまり調べず軽い気持ちでSPAを選択したことを反省してます。(開発前はSEOを全く考えてなかった。)

最近SEOが落ちてしまって悔しいです!SPAでのSEO改善などでアドバイスあれば教えてください。m(_ _)m

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

【個人開発】3ヶ月で1500人が使ったFGOの計算アプリを作った話

はじめに

今年の7月にFGOの計算アプリをリリースしました。
個人アプリをリリースしたのは初めてでしたが、ありがたいことにたくさんの方に使ってもらえたので紹介したいです。

アプリ紹介

まずアプリを簡単に紹介します。イラストが超可愛いです

FGO Calculator - Wオルタと計算 -

スクリーンショット 2020-12-06 13.04.32.png
・アプリURL → https://fgo-damage-calculation.web.app/
・GitHub → https://github.com/ShotaroHirose59/FGO-Calculator
・使用技術 → Nuxt.js × Firebase

スマートフォン向けアプリ「Fate/Grand Order」(略称は「FGO」)で使える計算アプリを作りました。
FGOがどんなゲームなのかざっくり説明すると、プレイヤーが主人公となって、英霊と呼ばれる「サーヴァント」を召喚して戦い、地球の未来を救っていく物語です。

アプリデモ

◯宝具ダメージ計算 (必殺技のダメージ計算)

demo
このページではキャラクターの必殺技のダメージ計算ができます。
FGOではダメージに乱数調整が入るので、最小 平均 最大の三種類の計算結果を用意しています。
Vue.jsのcomputedを利用して計算結果の即反映と、計算結果によってキャラクターのセリフを変化させてます。セリフは30種類以上!!!セリフを見てFGOユーザーに楽しんでもらえたら最高です。

アプリ全体で工夫した点

UI UXに力を入れてます。主に以下3つです。
・キャラクターのイラストを登場させる → FGOユーザーにインパクトを残せる。可愛い
・UIフレームワークVuetifyを使用して見た目を整える。→ UIを簡単に整えられる
・PWA対応でアプリをスマホのホーム画面に追加可能にした。 → ユーザーとの接触機会UP、フルスクリーンで表示可能なのでアプリ感UP


スマホのデザインも素人なりに力を入れました。
スクロールしなくても計算結果が見れるように固定フッターを用意してるのがポイントです。

Google アナリティクス

ここからは運用の話をします。
ここ3ヶ月のユーザー数はこんな感じです。

1500人!!!!!めっちゃ嬉しい!!!

開発中は「もし使ってくれる人がいなくても自分は必ず使う」という考え方でしたので、こうしてたくさんの方に使って貰えるのは本当に嬉しいです。

セッション継続時間も計算アプリにしては上々なのかな?
ただここは個人的にはあまり意識してないです。ユーザーがアプリに滞在する時間は短い方が良いのかなと思ってます。


約3割の方がアプリに2回以上訪問してるのも嬉しいです。
僕はこのアプリを愛してるのでユーザーさんからも好きになってもらえたら最高です!

運用する上で意識してること

・ 新キャラが出たら即追加

『Fate/Grand Order』では新しいサーヴァント(キャラクター)が頻繁に追加されます。
「新キャラで計算したいのにできない。」という状況を防ぐために翌日までにはデータの追加をしてます。

・フィードバックを貰ったら即対応

フィードバックを貰えることがあります。
これはユーザーさんの生の声なのでデータの追加、修正の要望であれば即日、もしくは翌日までに対応してます。

こんな感じにアップデート情報はお知らせしてます。

・運用は難しい

スクリーンショット 2020-12-06 10.35.56.png
この画像は先程ユーザー数を紹介したものと同じものですが、見て分かる通り数字が落ち着き気味です。(150には戻せると思う。200超えが難しい。)


僕のアプリは4番目です。実は一時期1番を取っていたのですが落ちました。素直に悔しい!!
1.2番を取ってる大手攻略サイトのAppMediaさん強しです...!

いやー運用って本当に難しいです。やはりOrganic Searchが一番多いのでSEOの重要さがわかります。

このアプリの目標

「FGOがサービス終了するまで使ってもらえるアプリであること」

FGOは超人気ゲーム(AppStoreのセルラン1位~2位)なのでいつ終わるかは全くわかりませんが、とにかく運用し続けることが最大の目標です。そのためにもUI UXの向上SEOの改善など頑張ります!
あとネイティブアプリ版も作りたいです。FGOの計算サイトはwebは激戦区だけどAppStoreではチャンスを感じるので。FlutterかReact Native勉強せねば...!

終わりに

技術選定の話は全くしなかったですが、色々楽そうだなという理由でNuxt.jsFirebaseで作りました。SSRやSSGをあまり調べず軽い気持ちでSPAを選択したことを反省してます。(開発前はSEOを全く考えてなかった。)

最近SEOが落ちてしまって悔しいです!SPAでのSEO改善などでアドバイスあれば教えてください。m(_ _)m
FGOユーザーの方はこのアプリを是非使ってみてほしいです!!!

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

あの人はどんなことが好きなんだろう・・と気になる人と遊んでみよう!ウェブサービス「10の質問」を公開しました。

はじめに

本日(2020-12-6)、個人サービスとして「10の質問(β版)」を公開しました。

開発期間は約2週間、平日もお仕事が終わってから深夜までカチャカチャ作ってました。

コロナで人と会わなくなって、人との交流が少なくなって、でもその分人への興味が湧いてきて、そんな気持ちでスタートしたプロジェクトです。

そんな感じで作ったサービスについて、サービス概要やら使った技術やら書いていこうと思います。

よろしくお願いします。

「10の質問(β版)」

https://tenq.kai-lab.com/

一言で言うとどういうサービス?

  • リアルタイム相性診断サービス

こんな人に遊んで欲しい

  • どんなことが好きなんだろう、と気になる人がいる
  • いつも遊んでいる仲間のことをもっと知りたい
  • 久しく会っていない友達と話す話題が欲しい

遊び方

  • 代表者が「ルーム」を作ります
  • 代表者は招待URLを一緒に遊びたい人に送ります
  • 一緒に遊びたい人がルームに集まったら診断スタートです
  • 10個の質問が順に出てきます
  • 10問終わったら結果が出ます
  • 結果を見てみんなでわちゃわちゃしてください
注意:
  • 一人で遊ぶ用のサービスではありません
  • 遊ぶには 2人以上のユーザがルームにいる必要があります

制限

  • ルームは10人まで入ることができます
  • 1問5秒の制限時間があります
  • ルームは未使用のままだと10分で削除されます

どうして作ったの?

  • コロナ禍で人と会う機会が減りました
  • いつも会ってた人はどうしてるかな、と思うことが増えました
  • そう言えばあの人はどんなことが好きなんだっけ?と思うことが増えました
  • みんなでリアルタイムに遊べて、かつみんなのことがわかるサービスがあったらいいなと思いました
  • 何か話題のネタになるようなサービスを作ろうと思いました

という感じです。

こだわったところ

リアルタイム制

リアルタイム制にはこだわりました。一緒に楽しむ、がテーマの一つだったので、同じ時間に同じ物を見て、答えて、一緒に結果を見る、をリアルタイムに行うところは一番のこだわりです。

そのために今まであまり使ってこなかったWebSocketを使ってリアルタイム処理を実現させています。

募集

  • 質問は現在100個用意されています
  • 質問を作っていると、結構主観的な物が多くなってくるなと思いました
  • いろんな人から質問を募集したいという思いがあります
  • ということで、こんな質問をサービスに入れて欲しい、というのがありましたらこちらの記事のコメントかTwitterのこちらのアカウントまでジャンジャンお寄せください

使った技術

フロント

  • VueJS
  • Vuex
  • Vuetify

バックエンド

  • Python
  • Django
  • Django-Channels(これでDjangoでWebSocketを使えるようになる)
  • MySQL
  • Redis

インフラ

  • AWS EC2
  • docker-compose
  • Firebase

今後こうしていきたい

  • サーバ処理をAWSに寄せたい
    • 今はDjangoで作っているが最終的にはLambdaに集約したい
    • サーバの管理コストを減らしていきたい
  • 質問の数をどんどん増やしていきたい
    • 現時点で100個質問を用意しています
    • とりあえずの目標は1000個まで増やしたい
  • レイアウト改善
    • デザインが苦手なエンジニアです。。
    • もっとサイトデザイン、レイアウトを綺麗にしていきたい

以上です。

最後までお読みいただきありがとうございました。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

[Vue warn]: Avoid mutating a prop directly警告の解消の仕方

[Vue warn]: Avoid mutating a prop directly since the value will be overwritten whenever the parent component re-renders.

vueの警告についての説明とその対処法についてまとめる。
親コンポーネントから子コンポーネントに値を渡して以下のような実装をすると警告が出た。(クリックをするたびに警告が出ていることがわかる。)

App.vue
<template>
  <div>
    <Prop :number="number"></Prop>
  </div>
</template>

<script>
import Prop from './components/Prop'

export default {
  data: function() {
    return {
      number: 0,
    }
  },
  components: {
    Prop,
  }
}
</script>
Prop.vue
<template>
    <div>
        <button @click="increment">+1</button>
        <p>{{ number }}</p>
    </div>
</template>

<script>
export default {
    props: ["number"],
    methods: {
        increment() {
            this.number += 1;
        }
    }
}
</script>

スクリーンショット 2020-12-06 11.38.14.png

親コンポーネントからnumberを取ってきてクリックをするたびに値を+1するというコードになっている。一見このコードには問題がないように見えるが、、

スクリーンショット 2020-12-06 11.39.02.png

クリックをするたびに警告が出ていることがわかる。警告の内容を見ると、Avoid mutating a prop directly,,,と書かれている。mutateは「変化する」という意味で解釈すると、「直接プロップを変更しないで」という意味にとることができる。

今回、親コンポーネントからnumberを取ってきて、それをクリックするたびに+1,つまり値を変更している。ここに問題があったということがわかる。

解決策

直接値を変更しなければいいのだから、子コンポーネントに渡ってきたnumberを新しくなんらかの変数に入れて定義しなおせばいい。

Prop.vue
<script>
export default {
    props: ["number"],
    data: function() {
        return {
            num: this.number
        }
    },
    methods: {
        increment() {
            this.num += 1;
        }
    }
}
</script>

このように渡ってきた値を子コンポーネントの方で新しく変数に入れて定義すると、警告がなくなる。

スクリーンショット 2020-12-06 11.46.17.png

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

オレオレオを積み上げるアプリ 〜GitHub ActionsでGitHub Pagesに公開する〜

みなさん、お菓子のオレオは好きですか?
オレオは「オ」と「レ」に分割して、自分が好きなように積み上げることができます。
参考:https://twitter.com/773O3/status/1079573992254537733

1度ぐらいは自分が思い描いたオレオタワーを作ってみたいです
そんな願いをかなえるべく、しょーもないものを作ってみました

作ったもの

「オ」か「レ」の入力に応じてオレオが積み上がっていくク◯アプリ
https://tomoshiozawa.github.io/oreoreo/
oreoreo.gif

ソースはこちら

やったこと

Vueで作ってGitHub Pagesにホストすることにしました。

まずはアプリを作る

Vue 3(vue-cliを使いました)で作成することにして、
UIにはPrimeVueというのを利用しました。

まずはオレオの「オ」から作ります。
CSSでそれっぽくします。
また、「レ」は色を白くするだけです。

See the Pen LYRNWEO by shiotomo (@tomoshiozawa) on CodePen.

オレオのCSSはできので、あとはうまいこと表示させるだけです。
下記のようなオレオ表示用のコンポーネントを作って、
そのコンポーネントに「オ」と「レ」のリストを渡して表示させるようにしています。

ポイントはstyleを動的に設定するようにしている部分です。
オレオパーツが少しずつずれて表示されるようにして、積み上がっている感をだします。

<template>
  <div class="oreo-stack">
    <div
      v-for="(item, index) in oreoStack"
      :key="index"
      class="p-text-center"
      :class="{ o: item == 'オ', re: item == 'レ' }"
      :style="style(index)"
    />
  </div>
</template>

<script>
export default {
  name: 'OreoStack',
  props: {
    oreoStack: {
      type: Array,
      required: true,
      default() {
        return [];
      },
    },
  },
  methods: {
    style(index) {
      return {
        top: `${index * 5}px`,
        'z-index': this.oreoStack.length - index,
      };
    },
  },
};
</script>
<style scoped>
.oreo-stack {
  position: relative;
}

.o {
  width: 120px;
  height: 60px;
  left: 50%;
  background: #343028;
  border-radius: 50%;
  box-shadow: 0px 2px 3px;
  position: absolute;
}

.re {
  width: 120px;
  height: 60px;
  left: 50%;
  background: #eef2f0;
  border-radius: 50%;
  box-shadow: 0px 2px 3px;
  position: absolute;
}
</style>

他の部分は特に大したことはしていないので説明しませんが、ソースを追えばわかるかと思います
ソースはこちら

vue.config.jsを作成

GitHub Pagesにホストする時のために、vue.config.jsを作成します。
publicPathを設定しておく必要があります。
pagesは任意です。

vue.config.js
module.exports = {
  publicPath: '/oreoreo/',
  pages: {
    index: {
      title: 'Oreoreo',
      entry: './src/main.js',
    },
  },
};

GitHub Actionsでデプロイする

実装ができたらデプロイします
mainブランチへのpushをトリガにして、ビルドしてGitHub Pagesにデプロイするようなアクションを作成します。

actions/cache@v2を使って、モジュールをキャッシュします。
利用しているモジュールに変更がなければ、yarn installをせずにビルドを開始するようにします(node_modulesのディレクトリをキャッシュしてます)

GitHub Pagesへのデプロイにはpeaceiris/actions-gh-pagesを使います。
ビルドされたソースをGitHub Pages用のブランチにpushしてくれます

この辺りは各アクションのドキュメントが参考になります
https://github.com/actions/cache
https://github.com/peaceiris/actions-gh-pages

.github/workflows/deploy2GitHubPages.yml
name: deploy to github pages

on:
  push:
    branches:
      - main

jobs:
  deploy:
    runs-on: ubuntu-18.04
    steps:
      - uses: actions/checkout@v2

      - name: Setup Node
        uses: actions/setup-node@v2.1.2
        with:
          node-version: '12.x'

      - name: Cache dependencies
        uses: actions/cache@v2
        id: yarn-cache
        with:
          path: '**/node_modules'
          key: ${{ runner.os }}-modules-${{ hashFiles('**/yarn.lock') }}
          restore-keys: |
            ${{ runner.os }}-modules-

      - name: Install dependencies
        if: steps.yarn-cache.outputs.cache-hit != 'true'
        run: yarn install

      - name: Build
        run: yarn build

      - name: Deploy
        uses: peaceiris/actions-gh-pages@v3
        with:
          github_token: ${{ secrets.GITHUB_TOKEN }}
          publish_dir: ./dist

これでmainブランチにpushすることで、自動でビルドされたものがgh-pagesというブランチによしなにpushされるようになります。
あとはGitHub Pagesの設定をして完了です。

終わりに

書くネタがなくてこんな記事になっちゃいました
「オ」と「レ」以外の文字が入力された時の挙動とかは何も考えていないので、気が向いたら更新しようと思います

参考

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

FirebaseとVue.jsでデータ登録取得機能を構築してみた



この記事は、「Firebase Advent Calendar 2020」の6日目の記事です。

FirebaseとVue.jsでデータ登録取得機能を構築してみました :tada:


事前準備


バックエンド

まずは、バックエンドを構築していきます。
以前の記事で、認証機能までを追加したので今回はCloud Firestoreを設定します。

画像
画像
画像
画像
画像

これだけでバックエンドの構築は完了になります :thumbsup:


フロントエンド

次に、フロントエンドを構築していきます。

実行環境

  • node v12.7.0
  • npm v6.13.4


src/views

Main.vue

<template>
    <div class='main'>
        <!--メニュー-->
        <Menu></Menu>
        <b-container>
            <b-row>
                <b-col sm='12' class='mb-3'>
                    <h3>ログイン済</h3>
                    <hr>
                </b-col>
                <b-col sm='3' class='mx-auto mb-3'>
                    <b-form-input v-model='id' placeholder='id'></b-form-input>
                    <b-form-input v-model='name' placeholder='name'></b-form-input>
                </b-col>
                <b-col sm='12' class='mb-5'>
                    <b-button variant='primary' v-on:click='postData'>登録</b-button>
                </b-col>
                <b-col sm='3' class='mx-auto mb-3'>
                    <b-table striped hover :items='items'></b-table>
                    <b-form-input v-model='text' placeholder='id'></b-form-input>
                </b-col>
                <b-col sm='12' class='mb-5'>
                    <b-button variant='success' v-on:click='getData'>表示</b-button>
                    <hr>
                </b-col>
            </b-row>
        </b-container>
    </div>
</template>

<script>
    import Menu from '@/components/Menu.vue'
    // Firebase読み込み
    import firebase from 'firebase'

    export default {
        name: 'Main',
        components: {
            Menu
        },
        data() {
            return {
                id: '',
                name: '',
                text: '',
                items: []
            }
        },
        methods: {
            getData: function () {
                const  db = firebase.firestore();
                // データ取得
                db.collection('users').where('id', '==', Number(this.text)).get(
                ).then((querySnapshot) => {
                    querySnapshot.forEach((doc) => {
                        // テーブル表示
                        this.items = [doc.data()];
                    });
                }).catch((error) => {
                    console.log(error);
                    // テーブルリセット
                    this.items = [];
                });
            },
            postData: function () {
                const db = firebase.firestore();
                // データ登録
                db.collection('users').add({
                    id: Number(this.id),
                    name: String(this.name)
                }).then((response) => {
                    console.log(response);
                }).catch((error) => {
                    console.log(error);
                });
            }
        }
    }
</script>

<style scoped>
    h3 {
        margin-top: 50px;
        text-align: center;
    }
</style>


Firebaseを読み込みます。

// Firebase読み込み
import firebase from 'firebase'


データ取得用の関数を設定します。

getData: function () {
    const  db = firebase.firestore();
    // データ取得
    db.collection('users').where('id', '==', Number(this.text)).get(
    ).then((querySnapshot) => {
        querySnapshot.forEach((doc) => {
            // テーブル表示
            this.items = [doc.data()];
        });
    }).catch((error) => {
        console.log(error);
        // テーブルリセット
        this.items = [];
    });
},


データ登録用の関数を設定します。

postData: function () {
    const db = firebase.firestore();
    // データ登録
    db.collection('users').add({
        id: Number(this.id),
        name: String(this.name)
    }).then((response) => {
        console.log(response);
    }).catch((error) => {
        console.log(error);
    });
}


簡易ローカルサーバーで確認してみます。

npm run serve


ローカルサーバーを立ち上げて、ログインしてみます。データの登録と取得ができるようになっています :bulb:

画像

Firebaseのコンソールで登録できているか確認してみます。

画像


FirebaseとVue.jsでデータ登録取得機能の構築ができました :thumbsup:

Firebaseは、Cloud Firestoreを利用することで細かい設定はまだまだあったりはしますが、手軽にデータの登録取得機能も構築できるので、AWS Amplifyと同じくサーバーレスアプリケーションを構築する時にとても便利です :bulb:

AWS Amplifyでのデータ登録取得機能の記事、「AWS AmplifyとVue.jsでデータ登録取得機能を構築してみた」とも比べてみて頂ければと思います :grinning:


Vue.js・Firebaseについて、他にも記事を書いています。よろしければぜひ :bow:
tags - Vue.js
tags - Firebase

やってみたシリーズ :grinning:
tags - Try




book

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

ドラッグ可能な複数のポップアップウィンドウをPortalVue(Teleport)で制御する話

この記事は「弁護士ドットコム Advent Calendar 2020」の 7 日目の記事です。
昨日は@matsuyoshi30git switch からはじめる CLI ツール作成 でした。

はじめに

弁護士向けのアプリケーション(SPA)のクライアント側をVueで開発しています。
今回、PortalVueというライブラリを用いて複数のドラッグ可能なポップアップウィンドウを制御する開発を行ったので、考えたことや得た知見を書きます。
(※余談ですが、PortalVueはVue3でteleportという名称でおおよそ同じ機能がビルトインされています。)

一応デモを用意したのでだいたいこんな感じのものを作る解説記事にもなってます。
(色付き要素はそれぞれドラッグができて、最後に触った要素が最前面に来る実装になっています。)
https://portal-sample.vercel.app/

ソース(GitHub)はこちら

PortalVueを使うに至った背景

現在携わっているアプリケーションでは、フローティング状態でドラッグ&ドロップで移動できるコンポーネントが1画面に複数存在するような機能が組まれています。

(例えばGmailのPC画面では右ペインでGoogleCalendarやGoogleTodoを操作できますが、これの小さいものがドラッグとリサイズ可能なウィンドウとして存在しているイメージです。)

e0457f65-ed63-4bf0-8431-a3b4f78d3381.png

ドラッグ可能という振る舞いを持った機能を作り始めた当初は、同時にフローティングする要素は最大で一つだけでした。しかしながらアプリケーションの開発が進むにつれ、最大で二つ三つと増える状況になりました。

ドラッグ可能な要素が複数存在していると、それぞれのコンポーネントで重なり合う状態が発生してきます。このような状況だと、クリックやドラッグといったイベントに応じて最前面に操作対象のコンポーネントが出てきて欲しいですが、実現するにはz軸上での前後関係を制御しなければなりません。

もしコンポーネントがすべて兄弟要素であれば、コンポーネントごとにz-indexを振る実装をしても良いかもしれません。ですがフローティングさせるコンポーネントは必ずしも兄弟要素でなないという状況や、スタッキングコンテキストの関係もありz-indexのみで前後関係を制御しようとすると実装が複雑になるため、この辺りの設計を考えることになりました。

目指した設計イメージ

結論としては、「フローティング対象の複数コンポーネントを特定のDOMの直下に兄弟要素として並べる」という方向の実装を試みました。

例えば、重なるようなスタイルが画像のように当てられている要素があったとして、

3716b596-b36c-4c3b-a2cd-eabe47f11338.jpg

以下のように全て兄弟要素だとします。

<div>
    <A/>
    <B/>
    <C/>
    <D/>
</div>

画面の描画としては、兄弟要素がそれぞれ同じz-indexの場合だと、始めの要素より最後の要素の方が上に重なりあう形になります。
つまり同じz-indexの兄弟要素を並べると、DOMの順番だけでフローティングの前後関係が表現できることになるため、この方向を目指しました。

PortalVue(teleport)について

「特定のコンポーネントを全て実DOMとしては、同じ親の直下に並べたい」という課題に対して、Vueの実装で助けになったのが、PortalVueというライブラリでした。

d552c7bb-5b4f-455f-befb-eb0181f71dd2.gif

PortalVueが提供する機能の大きなところとしては以下になります。

  • 特定コンポーネントのマウント先DOMを指定できるようにする
    • マウント先DOMは別コンポーネントのどこにいても良い(多分)
    • (これはちょっと試していないのですが、CSSセレクタも使えるようなのでVueアプリの外側のDOMでもマウントできるかもしれません)
  • 複数のコンポーネントのマウント先を一箇所に集約できる

これらの機能を使うことで、先に説明した複数のフローティングさせたいにコンポーネントを実質的に兄弟要素として扱えるようになります。

PortalVueの使い方

ここでは複数のコンポーネントを兄弟要素にするサンプルをざっくり記述します。
(単一コンポーネントの転送など、もっと基本的な使い方は(PortalVueのドキュメントに記載があるのでそちらを参照していただけたらと思います。)

このライブラリのインターフェースとして、まずPortalTargetとPortalというコンポーネントが用意されています。

import { PortalTarget, Portal } from 'portal-vue'
// (ちなみに他にはMountingPortal・WormHoleといったコンポーネントなどもあるようです。今回は使いませんが。)

PortalTargetというコンポーネントはマウント先の場所のプレースホルダの役割として振る舞います。
nameというプロパティは識別子になっており、後述するPortalコンポーネントでこの識別子を用いてマウントするPortalTargetを指定します。
ここではdestinationという文字列を渡しています。
multipleというオプションは、複数のコンポーネントをPortalTargetに渡す際に必要なオプションです。

また、このコード(index.vue)ではA・Bというコンポーネントを呼び出しており、コンポーネントの詳細は以下に続きます。

index.vue
<div>
    <div>
        <A/>
    </div>
    <B/>
<div/>
<PortalTarget name="destination" multiple />

Portalというコンポーネントはマウント先を変更したいコンポーネントをラップして使うものです。
ここではA・B各コンポーネントのpタグを移動させようと思うので、それぞれpタグをラップさせています。

その他のオプションとして、orderがあり、これはマウント先での順番を指定するパラメータで複数のコンポーネントをPortalTargetに渡す際に必要です。
(なお、指定するパラメータは1始まりにする必要があり、注意が必要かもしれません)

以下のサンプルではAというコンポーネントに2を付与して、Bというコンポーネントに1を付与しています。
最終的にB→Aという順番でコンポーネントを並べるイメージです。

A.vue
<Portal to="destination" :order="2">
  <p>component-A</p>
</Portal>
B.vue
<Portal to="destination" :order="1">
  <p>component-B</p>
</Portal>

ここまで踏まえて、index.vueが実行されると、最終的に作られるDOMは以下の形になります。

  • A・B が本来存在していた箇所にはクラスに'v-portal'が付与された空divが置かれる
  • PortalTargetコンポーネントは'vue-portal-target'というクラスが付与されたdivになる
  • div.vue-portal-targetの配下に、pタグがそれぞれ並ぶ
  • 並び順はorderというオプションでした数値の順番で並び、大きい数字の要素が後ろに並ぶ
結果
<div>
    <div>
        <div class="v-portal"><div/>
    </div>
    <div class="v-portal"><div/>
</div>
<div class="vue-portal-target">
    <p>component-B</p>
    <p>component-A</p>
</div>

実際に作ってみる

以上を踏まえて、アプリケーションではラップするとフローティングウィンドウで表示される以下のようなコンポーネントを作りました。実際のアプリケーションでもおおよそ似たようなコンポーネントを作ってあります。

<floatable>
    // フローティングでドラッグされる何かが入る
</floatable>

実際のアプリケーションコードは持ってこれないので、デモを用意しました。

機能としては、以下です。

  • 複数コンポーネントがフローティング可能
  • フローティング対象は子要素としてslotで受け取る
  • (実際のアプリケーションではウィンドウがリサイズ可能だったりします)

複数の前後制御の状態管理

フローティングさせたいコンポーネントを識別するために、それら識別子を保持するシンプルなリストを用意し、このリストで保持している順番がそのままDOMの順番として扱われる形にしました。

イメージとしては以下の画像になります。
初期状態でfloatingOrderで[a,b,c]というリストを持っていますが、aのコンポーネントをクリックするとこのリストの順番が[b,c,a]に変化してaが一番最後に移動します。
するとこの際、リストの順番に対応する形で描画されるコンポーネントの順番も変わるイメージです。

8419b1e4-76d9-4040-91c6-00a7f3413542.jpg

デモコード

デモの具体的な実装としてはおおよそ以下の形になっており、index.vueのdataにfloatingOrderという変数を用意して、ここで順番を保持しています。
'floating-objects'として名前付けしたPortalTargetを配置しており、floatableコンポーネントに関数を経由してfloatingOrderから各コンポーネントのorder値を渡しています。

bringToFrontという関数は指定したコンポーネントのidをfloatingOrderの最後に移動する処理です。
フローティングしている各コンポーネントが操作されたタイミング(ここではonDragStart)で呼び出されます。

index.vue
<template>
  <main class="relative w-screen h-screen">
    <floatable
      v-for="(item, index) in items"
      :key="index"
      :item="item"
      :order="getOrder(item.id)"
      @onDragStart="bringToFront(item.id)"
    >
      <div>{{ item.text }}</div>
    </floatable>
    <PortalTarget name="floating-objects" multiple />
  </main>
</template>

<script>
import { PortalTarget } from 'portal-vue'
import floatable from '~/components/floatable.vue'
export default {
  components: {
    floatable,
    PortalTarget,
  },
  data() {
    return {
      items: [
        { id: 'a', top: 0, left: 0, text: 'A', color: 'green' },
        { id: 'b', top: 50, left: 50, text: 'B', color: 'red' },
        { id: 'c', top: 100, left: 100, text: 'C', color: 'blue' },
      ],
      floatingOrder: ['a', 'b', 'c'],
    }
  },
  methods: {
    bringToFront(identifier) {...},
    getOrder(identifier) {...},
  },
}
</script>

続いてfloatableコンポーネントは以下のようになっています。
portalコンポーネントのtoでfloatable-objectsを指定することでPortalTargetと紐づけています。
同時にpropsで受けたorder値も渡しています。

floatable.vue
<template>
  <portal to="floating-objects" :order="order">
    <vue-draggable-resizable
      :key="item.id"
      class="absolute flex justify-center items-center shadow-md"
      :class="getColor(item.color)"
      :x="item.top"
      :y="item.left"
      :w="100"
      :h="100"
      @activated="$emit('onDragStart')"
    >
      <slot />
    </vue-draggable-resizable>
  </portal>
</template>

<script>
import VueDraggableResizable from 'vue-draggable-resizable'
import { Portal } from 'portal-vue'
export default {
  components: {
    VueDraggableResizable,
    Portal,
  },
  props: {
    item: {
      type: Object,
      required: true,
    },
    order: {
      type: Number,
      required: true,
    },
  },
  methods: {
    getColor(color) {...},
  },
}
</script>

ここまでの実装で、おおよそ「フローティングしたいコンポーネントを実DOMとしては同じ親の直下に並べる」という希望を満たせる想定です。また、適切なタイミングでorder値を変更する処理を加えると「特定のコンポーネントを最前面に出す」という実装ができます。ここではbringToFrontを呼び出す箇所が該当します。

デモの解説は一旦ここまでですが、実際のアプリケーションではもちろん細かい制御の部分だったり、デモにはない仕様で差異があるものの、前後関係の制御周りなどの基本的な考え方自体はデモと同じです。

記事頭にも記載したリンクと同じですが、実際に動くデモはこちらに置いてあります。
ソース(GitHub)はこちらです。

おわりに

アプリケーション内で複数のコンポーネントをフローティングさせる仕様はあまりないかもしれませんが、PortalVue公式ドキュメントのUseCasesにある通り、小さいところだと、例えばモーダルやオーバーレイの制御に取り入れるといったユースケースでもマッチするかもしれません。

補足としては、記事で詳細な書きませんでしたが、実際のアプリケーションではモバイルでは一切フローティングをさせていない点を加えておきます。Portalコンポーネントにdisableオプションがあるため、モバイルではPortalTargetに転送させない(フローティングさせない)ようにする制御を加えていたりします。

その他の開発上の注意点として、order値が重複やスタイルの当て方に気をつける必要がありました。

  • order値が重複すると、例えば「AをクリックしたがBにクリック判定が入る」、「AをドラッグしようとしたがBがドラッグされる」といったイベント関連で意図しない挙動をする可能性がある
  • Portalで飛ばされた要素は実DOMとして親要素が変わるので、スタイル周りの注意点として、Potalコンポーネントの子要素から先祖要素の仮装DOMに依存したCSSを当てると、転送先で想定した先祖要素が存在せずスタイルが崩れる恐れがある

こうしたリスクや扱いづらい点もあるため、実DOMを触る実装はなるべく避けるべきという見方もあると思いますが、今回のような実装を取り入れた結果として、スタッキングコンテキストの考慮やz-indexの細かな計算が不要になった点がありました。他にも、前後関係の状態がシンプルなリストで表現・保持できるようになった点はメリットとして感じたところもあり、状況によっては今回のような選択肢もありかもしれません。

明日(12/8)の弁護士ドットコム Advent Calendar 2020@t_odashさんです。

  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む