20200521のvue.jsに関する記事は10件です。

Vue(Vue-CLI)とTypeScriptのhighchartsグラフの数値に桁区切りを入れる

はじめに

highchartsではデフォルトの区切りにスペースを使用しています。highchartsのグラフにカーソルを合わせたときに表示される数値に桁区切りを入れるところでハマったので、本記事を書きました。

通常の表示

Graph.vue
<template>
    <div>
        <highcharts :options="graph"></highcharts>
    </div>
</template>

<script lang="ts">
    import { Component, Vue } from 'vue-property-decorator';
    import { Chart } from 'highcharts-vue';

    export type DataType = {
        graph: any;
    }

    @Component ({
        components: {
            highcharts: Chart 
        },
    })
    export default class Graph extends Vue {
        data (): DataType {
            return {
                graph: {
                    title: {
                        text: 'Xperiaの値段(au)'
                    },
                    xAxis: {
                        categories: ['Xperia 1', 'Xperia 5'],
                        crosshair: true
                    },
                    yAxis: {
                        title: false,
                        labels: {
                            format: '{value} 円'
                        },
                        opposite: false,
                    },
                    credits: {
                        enabled: false
                    },
                    tooltip: {
                        pointFormat: '{series.name}:{point.y:,.0f} 円'
                    },
                    series: [{
                        name: '一括価格',
                        type: 'column',
                        data: [92880, 81400],
                        marker: {
                            enabled: true
                        },
                    }],
                }
            }
        }
    }
</script>

<style>
    div {
        width: 80%;
        height: auto;
        margin: 20px auto auto auto;
    }
</style>

スクリーンショット (32).png

このように、桁区切りがスペースで表示されます。Xperia 1の値段を92 880円から、92,880円に表示したい場合は下記のようにします。

桁区切りで表示

import HighchartsとHighcharts.setOptionsを追加します。

Graph.vue
import { Chart } from 'highcharts-vue';
+ import Highcharts from 'highcharts';
+ Highcharts.setOptions({
+   lang: {
+       thousandsSep: ','
+   }
+ });
export type DataType = {
Graph.vue
<template>
    <div>
        <highcharts :options="graph"></highcharts>
    </div>
</template>

<script lang="ts">
    import { Component, Vue } from 'vue-property-decorator';
    import { Chart } from 'highcharts-vue';
    import Highcharts from 'highcharts';
    Highcharts.setOptions({
        lang: {
            thousandsSep: ','
        }
    });

    export type DataType = {
        graph: any;
    }

    @Component ({
        components: {
            highcharts: Chart 
        },
    })
    export default class Graph extends Vue {
        data (): DataType {
            return {
                graph: {
                    title: {
                        text: 'Xperiaの値段(au)'
                    },
                    xAxis: {
                        categories: ['Xperia 1', 'Xperia 5'],
                        crosshair: true
                    },
                    yAxis: {
                        title: false,
                        labels: {
                            format: '{value} 円'
                        },
                        opposite: false,
                    },
                    credits: {
                        enabled: false
                    },
                    tooltip: {
                        pointFormat: '{series.name}:{point.y:,.0f} 円'
                    },
                    series: [{
                        name: '一括価格',
                        type: 'column',
                        data: [92880, 81400],
                        marker: {
                            enabled: true
                        },
                    }],
                }
            }
        }
    }
</script>

<style>
    div {
        width: 80%;
        height: auto;
        margin: 20px auto auto auto;
    }
</style>

スクリーンショット (33).png

3桁区切りで表示することができました。

おわりに

合わせてこちらもご覧ください。
Vue(Vue-CLI)とTypeScriptでhighchartsのグラフ表示

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

Nuxt.js & Contentful & Netlifyを使いポートフォリオサイトを作成しました

はじめに

簡単に自己紹介をしますと私は現在Vue.js中心に学習中のエンジニア未経験、フロントエンドエンジニア志望の者です。

ただ淡々と学習するのではなくアウトプットを通じて学習したことを深く理解すること、記事を残すことでこれを見た人へ何かしらのヒントになる情報を与えられることができればと思い作成しました。

目標物

TechpitNuxt.js & Contentfulでハイスペックなポートフォリオサイトを超簡単に公開しよう!【JAMstack】という教材からポートフォリオサイトを作成しました。

Nuxt.jsとは?

公式サイト
Vueから設計されたフレームワークです。
SSR(サーバサイドレンダリング)アプリケーションを簡単に作ることができます。

SSR(サーバサイドレンダリング)
本来クライアントサイドで実行して、レンダリングされるjavascriptの処理を、サーバサイドで実行して、レンダリングする仕組み。

Contentfulとは?

公式サイト
Wordpress等と同じCMSの一種です。

CMS
=> Contents Management System(コンテンツ・マネジメント・システム)

webサイトを簡単に構築・管理・更新できるシステムです。

JAM stackとは?

公式サイト
サーバーやデータベースに依存せずにサイトやアプリを作成します。
記事データはAPIで用意し、Nuxt.jsで各ページをマークアップすることで動的なコンテンツで静的なウェブサイトを構築する技術です。

主なメリット

  • 表示が高速である
  • バックエンドをいじらないためセキュリティを気にしないで済む

環境

エディタ
Visual Studio Code

ライブラリ等
node 12.16.3
npm 6.14.4
nuxt 2.12.2
+ nuxtjs/markdownit 1.2.9
+ nuxt-fontawesome 0.4.0
+ fortawesome/free-solid-svg-icons 5.13.0
+ fortawesome/free-brands-svg-icons 5.13.0
+ contentful .14.4

完成した物

こちらのリンクから実際のサイトをご覧できます。

画像

トップページ
スクリーンショット 2020-05-21 18.40.47.png
詳細ページ
スクリーンショット 2020-05-21 18.41.02.png

設計

ポートフォリオサイトページ構成

ページ 表示
トップページ 作品一覧
作品個別ページ 各作品に関する詳細情報
カテゴリページ 特定のカテゴリに属する作品の一覧
タグページ 特定のタグを持つ作品の一覧
検索結果ページ 特定のキーワードを含む作品の一覧

作品データ(Contentful)

フィールド名 説明
Tittle 記事のタイトル
Slug 記事のスラッグ
Subtitle 記事のサブタイトル
Date 作成日時
Category カテゴリ
Tags タグ(複数登録可)
Content 記事本文
Image 記事のサムネイル画像

Slug(スラッグ)について
SlugとはContentfulで記事を作成した時に設定する、サイト内の特定のページを識別するためのURLの一部です。
「vue.js-nuxt.js-portfolio」のように、キーワードをつなげて作ります。

作成中に起きたトラブル

問題1.

contentfulの記事データが読み込めない

解決方法
APIkeyを使う
「Space ID」と「Content Delivery API - access token」

// .contentful.json ファイル内
{
  "CTF_SPACE_ID": "Space ID",
  "CTF_CDA_ACCESS_TOKEN": "Content Delivery API - access token"
}

問題2.

ブラウザに【未定義のプロパティ「field」を読み取れません。】と出てサイトが表示しなくなる。

解決方法
原因:Contentfulで新規記事作成の時Imageを追加していなかった
解決:サムネイル画像を設定

問題3.

npmインストールしたらディレクトリ構成が変わってサイトが表示しなくなってしまった

解決方法
作業内容を戻す

$ git reset --hard HEAD

問題4.

NetlifyでGitHubと連携したデプロイができない。

Error: Cannot find module ‘Contentful’

解決方法?

Git連携を諦めて普通にアップロードする

$ npm run generate

でdistファルダ作成
Netfilyでdistフォルダをドラッグ&ドロップ

まとめ

最後に連携で詰まってしまい、5時間くらいかかってできなかったので一旦普通にデプロイしました。
いずれ再挑戦します。

フロントエンドエンジニア志望としては
Contentfulというサービスの使い方は学んでおいて損はなさそうです。
簡単にコンテンツが追加できるのは楽しいですね。

Nuxt.jsは初めてでしたがVue.jsが基本なので苦手な感じはしませんでした。
色々とエラーで苦しみながら成長できた気がします。
いい経験になりました!

これから自分なりに機能を追加したりスタイルを変えたりしてみようと思います。

ここまで読んでいただきありがとうございました。

Link

ソースコード

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

electron-vue でNeDBを使用する

のじみんです。

今回はelectron-vueでNeDBを使用する方法について
インストールからDB設定するところまでを説明します

※ 公式ドキュメントと同じ内容なのでそちらをまず参考にしてください。↓
electron-vue - ローカルファイルの読み書き

実行環境とバージョン一覧

  • macOS Mojave v10.14.6
  • npm v6.9.0
  • node v10.16.3
  • electron v1.4.13

Installing

まずはNeDBをインストールしましょう

npmの場合
$ npm install --save nedb

もしくは

yarnの場合
$ yarn add nedb

Settings

rendererディレクトリの配下にdatastore.jsというファイルを作成する

$ cd <YOUR_PROJECRT>
$ touch ./src/renderer/datastore.js

datastore.jsの中身は以下のようにする

./src/renderer/datastore.js
import path from 'path';
import { remote } from 'electron';
import Datastore from 'nedb';

const dbPath = path.join(remote.app.getPath('userData'), '/data.db');

// DB初期化
export default new Datastore({
  autoload: true,
  filename: dbPath,
});

上記ではelectronのアプリケーションディレクトリのdata.dbファイルを読み込む処理をしています。
data.dbがない場合は上の処理でファイルが作成されるのでエラーがでることはありません。

main.jsに以下の処理を追記する

./src/renderer/main.js
...

import db from './datastore';
Vue.prototype.$db = db;

...

以上でどのコンポーネントからもDBを呼び出すことができるようになりました。やったね!

usage

このままだとdata.dbの中身は空なのでデータを引っ張ってくることができません。
なのであらかじめ適当なデータを突っ込んでから
electron-vueでdbからデータを取得していきましょう

データを挿入する

まず適当にdata.dbにデータを突っ込んでみましょう。

main.jsに以下の文を追記します。

./src/renderer/main.js
...

import db from './datastore';

Vue.prototype.$db = db;
// 今回追加する処理
db.find({}, (err, doc) => {
  const data = [
    { name: '山田太郎', age: 20 },
    { name: 'のじ先生' age: 10 },
  ];

  // 初回のみ:dbのデータが空だった場合、テーブルの構築をする
  if (doc.length === 0) {
    db.insert(docs);
  }
});

...

一度アプリをビルドする

$ yarn run dev

ここまで終われば、無事data.dbにデータを入れることができました。
今度はelectorn-vueでそのデータを取得してみましょう。

electron-vueでdbからデータを取得する

適当なコンポーネントのscript部分に以下を記述する
※ 以下ではApp.vueで記述していると想定して書いています。

./src/renderer/App.vue
<script>
this.$db.find({}, (err, doc) => {
  console.log(doc);
});
</script>

はい。以上でおしまいです。
あとはビルドしてみてコンソール出力に格納されたデータが表示されている確認してみてください。

$ yarn run dev
アプリケーションのコンソールから出力されているか確認

ほんでは

参考

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

TwitterAPIを使用したユーザー検索機能

1.TwitterAPIを使用したTwitterアカウントの検索機能をLaravelと Vue.jsで実装したい

開発中のプロダクトでTwitterアカウントの検索機能を実装する必要があったので、
その方法を備忘録的に残しておきます。
完成形はこんな感じで、キーワードを入力するとそれに紐づくTwitterアカウントが検索結果に表示されるというものです。
スクリーンショット 2020-05-21 13.29.34.png

2.TwitterAPI申請方法について

TwitterAPIを使用するには、TwitterDeveloperへの申請が必要です。
申請方法はこの辺りの記事を参考にすると良いと思います!
画像付きで詳しく説明してくれています。

2020年度版 Twitter API利用申請の例文からAPIキーの取得まで詳しく解説
Laravel5.7でTwitterOAuthを使ってタイムライン取得(申請から説明)

3.Laravelのプロジェクトの変更

TwitterDeveloperへの申請が完了し、API_KeyとTokenが取得できたら、
Laravelのプロジェクトに下記、変更を加えています。

Composerを使ってabraham/twitteroauthをインストール
.envファイルにAPI_KeyとTokenなどの情報をいれる
configディレクトリにファイルを作成し、.envファイルに書いた定数を呼び出す処理を記述

最低限ここまで設定をすれば、Controllerに諸々記述をすることで
TwitterAPIから情報を取得することができます。

Laravel5.7でTwitterOAuthを使ってタイムライン取得(申請から説明)
こちらの記事では①〜③の方法、さらにServiceProviderとFacadesを使ってControllerでの記述を簡単にする方法までを丁寧に説明してくれていますので、こちらの記事を参考にしてください!
(参考にさせていただきました!ありがとうございます^^)
何度もTwitterAPIから情報を取得する場合、設定しておくととても便利です。

今回は①〜③までの手順を書かせていただきます!

①Composerを使ってabraham/twitteroauthをインストール

ターミナルでLaravelプロジェクトがインストールされているディレクトリに移動します。
そこで下記コマンドを打ってください。

composer require abraham/twitteroauth

ターミナルに下記メッセージが表示されたら、成功です!

Package manifest generated successfully.

②.envファイルにAPI_KeyとTokenなどの情報をいれる

続いて、.envファイルに取得したAPI_KeyとTokenなどを追記します。

.env
TWITTER_CLIENT_ID = TwitterAPIkey
TWITTER_CLIENT_SECRET = TwitterAPI SecretKey
TWITTER_ACCESS_TOKEN = AccessToken
TWITTER_ACCESS_TOKEN_SECRET = AccessTokenSecret
TWITTER_CLIENT_CALLBACK = コールバックしたいURL 

③configディレクトリにファイルを作成し、.envファイルに書いた定数を呼び出す処理を記述

.envファイルに記述したAPI keyなどの情報をconfigファイルを介して呼びだすように設定します。
.envファイルに定義した変数をそのまま呼び出すのはダメみたいです。
その辺は【Laravel】環境変数の使い方の記事を参考に。

まず、configディレクトリにtwitter.phpというファイルを作成します。
そして、twitter.phpに下記のコードをコピペしてください。

config/twitter.php
<?php

return [
    'twitter-api' => env('TWITTER_CLIENT_ID',''),
    'twitter-api-secret' => env('TWITTER_CLIENT_SECRET',''),
    'twitter-token' => env('TWITTER_ACCESS_TOKEN',''),
    'twitter-token-secret' => env('TWITTER_ACCESS_TOKEN_SECRET',''),
    'call_back_url' => env('TWITTER_CLIENT_CALLBACK',''),
];

ここまでで、一旦①〜③の設定は完了です!
続いてTwitterAPIから情報を取得していきましょう。

4.アカウント情報を取得する

それでは、ControllerにTwitterAPIから情報を取得するための記述を記入していきます。
TwitterDeveloperのドキュメントを確認すると
アカウント検索の際にはGET users/searchを使用することがわかります。
情報を取得する際の必須パラメーターqに検索クエリを入れます。
その他のパラメータは下記通りです!

名前     必須    説明         
q   必須   検索キーワード  
page オプション 取得する結果のページを指定します。
count オプション ページごとに取得するユーザー結果の数。(最大値は20)
include_entities オプション entitiesの取得を省略

【①〜③のみを設定した場合】

TwitterController.php
<?php

namespace App\Http\Controllers;

use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Abraham\TwitterOAuth\TwitterOAuth;

class TwitterController extends Controller
{
    // Twitterのアカウント検索
    public function index(Request $request)
    {
        // Vueファイルで入力された検索されたキーワードの定義
        $q = $request->keyword;

        // API keyなどを定義
        $consumer_key = config('twitter.twitter-api'); 
        $consumer_secret = config('twitter.twitter-api-secret'); 
        $access_token = config('twitter.twitter-token'); 
        $access_token_secret = config('twitter.twitter-token-secret'); 

        $connection = new TwitterOAuth($consumer_key, $consumer_secret, $access_token, $access_token_secret); 
        $twitterRequest = $connection->get('users/search', array( "q" => $q, "count" => 20)); 

        return response()->json(['result'=>$twitterRequest], 200);
    }

※今回は、Vueファイルにデータを返すのでJSON形式で return response()->json(['result'=>$twitterRequest], 200);という記述になってます。

【ServiceProviderとFacadesまで設定した場合】

TwitterController.php
<?php

namespace App\Http\Controllers;

use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Abraham\TwitterOAuth\TwitterOAuth;

class TwitterController extends Controller
{
    // Twitterのアカウント検索
    public function index(Request $request)
    {
        // 検索されたキーワードの定義
        $q = $request->keyword;

        // TwitterAPIからユーザー情報を取得
        $twitterRequest = \Twitter::get('users/search', array("q" => $q, "count" => 20));
        return response()->json(['result'=>$twitterRequest], 200);
    }

サービスプロバイダとファサードまで設定しておくと、リクエストを投げるときに簡潔に書くことができます!
Laravel5.7でTwitterOAuthを使ってタイムライン取得(申請から説明)
是非こちらの記事を参考に設定してみてください^^

5.TwitterAPIから取得した情報について

ここまででデータを取得できたのですが、少し問題があります。
GET users/searchは、アカウント名やアカウント画像、認証アカウントなのか・・・
など様々な情報を取得できます。
ただ、アカウント画像に関してはデフォルトでは48x48ピクセルのものになります。
少し小さめですね・・・

オリジナルサイズの画像を取得したい場合、画像のURLを変更することでそれが可能になります〜
TwitterAPIドキュメントにも記載がありますが、画像のURLから_normalを除いてあげればオッケーです。
では、Controllerの記述に追記していましょう〜

TwitterController.php
// 一部省略してます〜 (TwitterAPIからユーザー情報を取得の記述から〜)
        // TwitterAPIからユーザー情報を取得
        $twitterRequest = \Twitter::get('users/search', array("q" => $q, "count" => 20)
        // TwitterAPIからのレスポンス プロフィール画像のURLから _normalの文字列を省く)
        foreach($twitterRequest as $res){
            $image = $res->profile_image_url_https;
            $fullImg = str_replace('_normal', '', $image);
            $res->full_img = $fullImg;
            $twitterRes[] = $res;
        }

その他レスポンスボディについては下記記事が参考になります〜
Tweet objects
Twitter 開発者 ドキュメント日本語訳

これで表示に必要な情報は取得できたかと思います〜!
長くなったので、Vue.jsを使用したインクリメントリサーチの方法などはまた改めて書こうと思います。
読んでくれた方ありがとうございました^^

開発環境

PHP 7.2
Laravel 6.0
Vue 2.5.17

参考リンク

Laravel5.7でTwitterOAuthを使ってタイムライン取得(申請から説明)
2020年度版 Twitter API利用申請の例文からAPIキーの取得まで詳しく解説
【Laravel】環境変数の使い方
Tweet objects
Twitter 開発者 ドキュメント日本語訳
TwitterDeveloperのドキュメント
TwitterAPIドキュメント

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

Nuxt.jsをVue.jsに解体するTips(Layout編)

背景

  • Nuxt.jsで動いているSPAアプリをVue.jsに解体してほしい的な話があり、部分部分をTipsとして投稿します。
  • APIはLaravel(6.x)です。なおLaravelとNuxt.jsは同一リポジトリです(なぜ)
  • Nuxt.jsは2.9。Vueは2.6.10
  • 今回はLayout編です。

Layoutを分けたい

nuxt/pages/~~.vue
<script>
export default {
  layout: 'noauth',
  • 認証に見れるとLayoutと非認証時に見れるLayoutを分けた時があります。そういった時Nuxt.jsは上記のように簡単にできますが、Vue.jsだとコツが必要です。

Layoutをそれぞれ作成する

認証用のLayout

resources/js/layouts/default.vue
<template>
  <div>default
    <router-view />
  </div>
</template>

非認証時に見れるレイアウト

resources/js/layouts/noauth.vue
<template>
  <div>noauth
    <router-view />
  </div>
</template>

resources/js/router.jsの編集

Routerでmeta layoutを定義する

resources/js/router.js
import Vue from 'vue'
import Router from 'vue-router'
import Index from '~/pages/index'

Vue.use(Router)

const router = new Router({
  mode: 'history',
  routes: [
    {
      path: '/',
      name: 'Index',
      meta: { layout: 'default' },
      component: Index,
    },
    {
      path: '/noauth',
      name: 'NoAuth',
      meta: { layout: 'noauth' },
      component: Index,
    },
  ],
});

resources/js/App.vueの編集

メタフィールドをみてLayoutを変更

resources/js/App.vue
<template>
  <div id="app">
    <component v-bind:is="layout" />
  </div>
</template>

<script>
export default {
  data() {
    return {
      defaultLayout: "default"
    };
  },
  computed: {
    layout() {
      return (this.$route.meta.layout || this.defaultLayout) + "-layout";
    }
  }
};
</script>

Layout用のコンポーネントを定義

resources/js/app.js
import Vue from "vue";
import router from '~/router'
import App from '~/App.vue'

import DefaultLayout from '~/layouts/default'
import NoAuthLayout from '~/layouts/noauth'

Vue.component('default-layout', DefaultLayout)
Vue.component('noauth-layout', NoAuthLayout)

new Vue({
  router: router,
  render: h => h(App),
}).$mount('#app')

参考

関連リンク

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

ASP.Net Core+RazorPage+Vue(テキストボックス作成、タグヘルパーとバリデーション属性とvueコンポーネントの関係)

今回の課題

 今回は、Vueコンポーネントとして一番単純な文字列のinputを拡張したコンポーネントを作成。バリデーションを行う上での各種クラスの関連性を見ていきます。
(1)TagHelperの処理
(2)バリデーション属性の処理
(3)vueコンポーネントのバリデーションに関する処理

 以前、「ASP.Net Core でjQuery無しでHTML5のバリデーションを利用する」でいくつか書いていますが、その内容も含みます。その時は属性は「MergeAttributeは上書きできない」と書いたのですが、いろいろとやってみるとバリデーションクラスでtypeを書き換えちゃってます。内部処理によってはできるものとできないものがあるのかもしれません。書き方が悪かったのかもしれません。まだまだ試行錯誤がありそうです。

前提

 ASP.NetCore RazorPage+Vue+blumaの環境を利用します。
 以前に書いた「Vue.jsを利用してみる(1)」と「Vue.jsを利用してみる(1)」を参照しての環境を構築します。

タグヘルパー

 数値や日時などの入力でも共通して使うことを考えて、入力タグの基本クラスを定義します。テキスト入力は、この基本クラスを継承してますが、何も変えていません。

 まず基本クラスです。

VueInputTagHelper.cs
using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.AspNetCore.Mvc.TagHelpers;
using Microsoft.AspNetCore.Mvc.ViewFeatures;
using Microsoft.AspNetCore.Mvc.ViewFeatures.Internal;
using Microsoft.AspNetCore.Razor.TagHelpers;
using System;
using System.Collections.Generic;

namespace RazorPageVue.VueTagHelpers
{
    public class VueInputTagHelper : TagHelper
    {
        /// <summary>
        /// バインド対象のcshtmlのタグの属性名
        /// </summary>
        protected const string ForAttributeName = "asp-for";

        ///// <summary>
        ///// type属性に設定する値(空ならデータ型に合わせて設定される)
        ///// </summary>
        //protected string _overrideType = null;
        // それぞれのタグを作成するので不要

        /// <summary>
        /// バインド対象取得用のプロパティ。HtmlAttributeNameの引数名のタグ属性名が対象となる
        /// </summary>
        [HtmlAttributeName(ForAttributeName)]
        public ModelExpression For { get; set; }

        /// <summary>
        /// タグ属性「name」を受け取るプロパティ。(バインド対象が未設定の場合に出力するタグのname属性の値になる)
        /// </summary>
        public string Name { get; set; }

        /// <summary>
        /// タグ属性「value」を受け取るプロパティ。(バインド対象が未設定の場合に出力するタグのvalue属性の値になる)
        /// </summary>
        public string Value { get; set; }

        /// <summary>
        /// ビューのコンテキスト
        /// </summary>
        [HtmlAttributeNotBound]
        [ViewContext]
        public ViewContext ViewContext { get; set; }

        /// <summary>
        /// バリデーターの処理を実施させる為に設定するテキストボックス用のジェネレータ作成用
        /// (コンストラクタのデータインジェクションで設定)
        /// </summary>
        protected IHtmlGenerator Generator { get; }

        /// <summary>
        /// コンストラクタ
        /// </summary>
        /// <param name="generator">The <see cref="IHtmlGenerator"/>.</param>
        public VueInputTagHelper(IHtmlGenerator generator)
        {
            Generator = generator;
        }

        /// <summary>
        /// タグヘルパーの実行実装
        /// </summary>
        /// <param name="context">タグヘルパーコンテキスト</param>
        /// <param name="output">タグヘルパー出力</param>
        public override void Process(TagHelperContext context, TagHelperOutput output)
        {
            // 引数のコンテキストと出力がnullならエラー
            if (context == null) throw new ArgumentNullException(nameof(context));
            if (output == null) throw new ArgumentNullException(nameof(output));

            // テキストボックスのタグビルダーを作成し、バリデーションなどの属性で設定されているものをタグに取り込む
            // この時、すでに設定されているタグの属性は更新できないので注意
            output.MergeAttributes(GenerateTextBox(Value));
        }

        /// <summary>
        /// テキストボックスのタグビルダーを作成
        /// </summary>
        /// <param name="value">タグのvalueに設定されている値(無ければnull)</param>
        /// <returns>標準のinputタグに近いタグビルダー</returns>
        private TagBuilder GenerateTextBox(object value)
        {
            var modelExplorer = For.ModelExplorer;
            IDictionary<string, object> htmlAttributes = new Dictionary<string, object>(StringComparer.OrdinalIgnoreCase);

            //// typeはforまたはプレフィックスが設定されていて_overrideTypeが設定され手入れば設定する。
            //// これを利用して継承クラスからtypeを設定できるようにしている
            //if (!string.IsNullOrEmpty(_overrideType)) htmlAttributes.Add("type", _overrideType);
            // それぞれのタグを作成するので不要

            // asp-forが設定されているかどうかでnameとvalueの異なるタグビルダーを作成
            if (string.IsNullOrEmpty(For.Name) &&
                string.IsNullOrEmpty(ViewContext.ViewData.TemplateInfo.HtmlFieldPrefix))
            {
                // asp-forが未設定の場合、nameやvalueに設定されている値でタグを作成
                return Generator.GenerateTextBox(
                    ViewContext,
                    modelExplorer,
                    Name,
                    value,
                    null,
                    htmlAttributes);
            }
            else
            {
                // asp-forが設定されている場合、nameやvalueはasp-forの内容に強制される
                return Generator.GenerateTextBox(
                    ViewContext,
                    modelExplorer,
                    For.Name,
                    modelExplorer.Model,
                    null,
                    htmlAttributes);
            }
        }

        /// <summary>
        /// 入力するデータの型のヒントリストを取得する
        /// 基本的に「InputTagHelper」の実装をそのまま利用している
        /// </summary>
        /// <returns>バインドしているデータ型のヒントリスト</returns>
        protected IEnumerable<string> GetInputValueTypeHints()
        {
            var modelExplorer = For.ModelExplorer;

            // テンプレートヒントが有ればそれをリストにして返す
            if (!string.IsNullOrEmpty(modelExplorer.Metadata.TemplateHint))
            {
                yield return modelExplorer.Metadata.TemplateHint;
            }

            // データタイプ名が有ればそれをそれをリストにして返す
            if (!string.IsNullOrEmpty(modelExplorer.Metadata.DataTypeName))
            {
                yield return modelExplorer.Metadata.DataTypeName;
            }

            // In most cases, we don't want to search for Nullable<T>. We want to search for T, which should handle
            // both T and Nullable<T>. However we special-case bool? to avoid turning an <input/> into a <select/>.
            var fieldType = modelExplorer.ModelType;
            if (typeof(bool?) != fieldType)
            {
                fieldType = modelExplorer.Metadata.UnderlyingOrModelType;
            }

            foreach (var typeName in TemplateRenderer.GetTypeNames(modelExplorer.Metadata, fieldType))
            {
                yield return typeName;
            }
        }
    }
}

モデルとのバインド

 タグの入力とモデルをバインドさせているのは

public ModelExpression For { get; set; }

の部分で、このプロパティーにバインドしているモデルの変数情報が入ってきます。この時、このプロパティの属性[HtmlAttributeName(ForAttributeName)]でcshtlmのタグの属性名を設定できるようです。つまり「asp-for」でなくともよいということになります。ここでは定数「ForAttributeName」に"asp-for"を設定して基本のままにしています。

html作成時の処理

 cshtmlからhtmlを作るときは、メソッド「Process」が呼び出され、引数の「output」にhtml作成の情報を構築していくようです。

 inputタグのタグビルダー

 「GenerateTextBox(Value)」メソッドでinputタグのタグビルダーを作成して返します。このタグビルダーがデータのバインド(nameとvalueを設定)とtype属性の設定をしているようです。ここでは今後の数値や時刻等の入力の為に、メンバ「_overrideType」を設定しておけば任意の「type」になるように小細工しています。(個別にタグ作っていていらないので削除しました)
 また、「asp-for」が指定されていない場合は「name」や「value」に設定されている値を利用するようにしています。
 cshtmlのタグ内の「name」や「value」はそれぞれ「Name」「Value」に設定されます。変数名が違うのは言語の仕様の問題でhtmlで利用されるケバブスケースの文字列はプロパティーにできないのでキャメルケースに変換されているからです。少しわかりにくいですね。これも「asp-for」みたいに属性指定にした方がわかりやすいと個人的には思うのですが...。

バリデーションの対応

 タグビルダーの結果とバリデーションで設定したいタグの属性を以下の部分でマージしているようです。バリデーションについては後で記述します。

output.MergeAttributes(GenerateTextBox(Value));

引数の型のヒント

 「GetInputValueTypeHints()」メソッドは引数の型を判定するためのメソッドです。文字列入力では利用していませんが、時刻や数値の場合に利用しますので継承メソッドとして定義しています。まあ、型判定を入れて文字列以外はエラーにするようにしてもいいかもしれません。

文字列用のタグヘルパー

VueTextInputTagHelper
using Microsoft.AspNetCore.Mvc.ViewFeatures;
using Microsoft.AspNetCore.Razor.TagHelpers;

namespace RazorPageVue.VueTagHelpers
{
    [HtmlTargetElement("vue-text-input", Attributes = ForAttributeName, TagStructure = TagStructure.NormalOrSelfClosing)]
    public class VueTextInputTagHelper : VueInputTagHelper
    {
        /// <summary>
        /// コンストラクタ
        /// </summary>
        /// <param name="generator">The <see cref="IHtmlGenerator"/>.</param>
        public VueTextInputTagHelper(IHtmlGenerator generator) : base(generator)
        {
        }
    }
}

 クラス属性の「HtmlTargetElement」でこのタグヘルパーの対象となるcshtml内のタグ名を「vue-text-input」に設定しています。Attributes には基本クラスで設定ている「asp-for」が入るようにしています。このようにテキストの入力クラスのタグヘルパーは処理は一切変更がありません。数値や日時などではいろいろと変わってくる予定です。

バリデーション

 文字列長のバリデーションを以下に示します。

StringLengthAttribute.cs
using Microsoft.AspNetCore.Mvc.ModelBinding.Validation;
using System;
using System.ComponentModel.DataAnnotations;

namespace RazorPageVue.VueValidations
{
    [AttributeUsage(AttributeTargets.Property)]
    public class StringLengthAttribute : ValidationAttribute, IClientModelValidator
    {
        /// <summary>
        /// 最小長 0 ならチェック対象外
        /// </summary>
        public int MaxLength { get; set; } = 0;

        /// <summary>
        /// 最大を超えた場合のエラーメッセージ
        /// </summary>
        public string OverMaxErrorMessage { get; set; }

        /// <summary>
        /// 最大長 0 ならチェック対処具合
        /// </summary>
        public int MinLength { get; set; } = 0;

        /// <summary>
        /// 最大を超えた場合のエラーメッセージ
        /// </summary>
        public string UnderMinErrorMessage { get; set; }

        /// <summary>
        /// バリデーション(サーバーサイド)
        /// </summary>
        /// <param name="value">値</param>
        /// <param name="validationContext">バリデーションコンテキスト</param>
        /// <returns></returns>
        protected override ValidationResult IsValid(
            object value, ValidationContext validationContext)
        {
            // 入力が空の場合は常に正常(空のチェックは入力必須でおこなう)
            if (value == null) return ValidationResult.Success;

            // 最小桁数チェック
            if ((MinLength > 0) && (value.ToString().Trim().Length < MinLength))
            {
                return new ValidationResult(GetUnderMinErrorMessage(validationContext.DisplayName));
            }

            // 最大桁数チェック
            if ((MaxLength > 0) && value.ToString().Trim().Length > MaxLength)
            {
                return new ValidationResult(GetOverMaxErrorMessage(validationContext.DisplayName));
            }

            return ValidationResult.Success;
        }

        /// <summary>   
        /// クライアントでのバリデーション用の操作
        /// </summary>
        /// <param name="context">クライアントのバリデーションコンテキスト</param>
        public void AddValidation(ClientModelValidationContext context)
        {
            if (context == null) throw new ArgumentNullException(nameof(context));

            if (MinLength > 0)
            {
                // 最小値が設定されている場合以下のタグ属性を設定する
                // minlength                            最小桁数
                // min-length-err-msg                   バリデーションで設定されたエラーメッセージ
                context.Attributes["minlength"] = MinLength.ToString();
                if (!string.IsNullOrWhiteSpace(UnderMinErrorMessage)) context.Attributes["minlength-err-msg"] = UnderMinErrorMessage;
            }

            if (MaxLength > 0)
            {
                // 最大値が設定されている場合以下のタグ属性を設定する
                // maxlength                            最大桁数
                // max-length-err-msg                   バリデーションで設定されたエラーメッセージ
                context.Attributes["maxlength"] = MaxLength.ToString(); 
                if (!string.IsNullOrWhiteSpace(OverMaxErrorMessage)) context.Attributes["maxlength-err-msg"] = OverMaxErrorMessage;
            }
        }

        /// <summary>
        /// 最大多数のサーバーバリデーション時のエラーメッセージ取得
        /// </summary>
        /// <param name="displayName">表示名称(DisplayNameアトリビュートで変更できる)</param>
        /// <returns>必須エラーメッセージ</returns>
        string GetOverMaxErrorMessage(string displayName)
        {
            if (string.IsNullOrEmpty(OverMaxErrorMessage))
            {
                return displayName + "の値が最大値「" + MaxLength.ToString() + "」を超えています。";
            }
            else
            {
                return OverMaxErrorMessage;
            }
        }

        /// <summary>
        /// 最小桁数のサーバーバリデーション時のエラーメッセージ取得
        /// </summary>
        /// <param name="displayName">表示名称(DisplayNameアトリビュートで変更できる)</param>
        /// <returns>必須エラーメッセージ</returns>
        string GetUnderMinErrorMessage(string displayName)
        {
            if (string.IsNullOrEmpty(UnderMinErrorMessage))
            {
                return displayName + "の値が最小値「" + MinLength.ToString() + "」より小さいです。";
            }
            else
            {
                return UnderMinErrorMessage;
            }
        }
    }
}

 クラスの前に「[AttributeUsage(AttributeTargets.Property)]」と書かれています。これでこのクラスのクラス名から「Attribute」を除いた名前のプロパティ用のアトリビュートができます。バリデーションクラスを継承しているのでバリデーションアトリビュートになります。
 4つのプロパティに最小長、最小長エラーメッセージ、最大長、最大長エラーメッセージを設定しています。
 「IsValid」メソッドは、サーバーまで来たときに行われるバリデーションで、クライアントでバリデーションエラーになるとここまで来ません(画面を使わずに直接POST処理を実施すれば別ですが)。
 「AddValidation」メソッドがクライアントバリデーションのポイントになります。これは「IClientModelValidator」インターフェースの実装で、この処理がタグヘルパーの「output.MergeAttributes」の中で呼ばれているようです。これによって、ページモデルの参照用のプロパティにこのバリデーションを追加すると実施のhtmlの作成時にタグの属性を追加することができます。ここではプロパティーに設定されている最小長、最小長エラーメッセージ、最大長、最大長エラーメッセージをタグの属性に追加しています。URLやEmailのバリデーションをセットした場合はここでtype属性を変更させます。

Vueコンポーネント

vueTextInput.js
Vue.component('vue-text-input', {
    props:
    {
        id: String,                     // id
        name: String,                   // name
        required: String,               // 必須属性
        requiredErrMsg: String,         // 必須エラーメッセージ
        maxlength: Number,              // 文字列の最大長
        maxlengthErrMsg: String,        // 最大長エラーメッセージ     
        minlength: Number,              // 文字列の最小長
        minlengthErrMsg: String,        // 最小長エラーメッセージ     
        compareId: String,              // 同一比較するコンポーネントのID
        compareErrMsg: String,          // 同一比較エラーメッセージ
        typemismatchErrMsg: String,     // 型異常(url,email)のエラーメッセージ
        value: String                   // 入力された値
    },
    data: function (){
        return {
            compareCompornent: null,    // 自信が比較設定している比較対象のコンポーネント
            comparedComponents: [],     // 自信が比較設定されている対象のコンポーネント
            inputText: this.value
        };
    },
    computed:
    {
        // inputタグのtypeに設定する値
        dataType: function () {
            if ((type === "text") || (type === "url") || (type === "email") || (type === "password")) {
                return type;
            }

            return "text";
        }
    },
    methods: {
        //------------------------------------------------------------
        // 入力が変更された場合に各種バリデーションをチェックする
        //------------------------------------------------------------
        onChange: function () {
            //  デフォルトのバリデーションエラーが有れば処理終了
            if (window.IsDefaultValidationError(this.$el.validity)) {
                // ここでカスタムエラーを削除
                this.$el.setCustomValidity("");
                return;
            }

            // 入力必須バリデーション処理
            if (!window.RequiredValidation(this.$el, this.required, this.requiredErrMsg)) return;

            // 入力文字数バリデーション処理
            if (!window.StringLengthValidation(this.$el, this.maxlength, this.maxlengthErrMsg, this.minlength, this.minlengthErrMsg)) return;

            // 比較バリデーション処理
            if (this.compareCompornent) {
                if (!window.CompareValidation(this.$el, this.compareCompornent.inputText, this.compareErrMsg )) return;
            }
            for (var i = 0; i < this.comparedComponents.length; i++) {
                this.comparedComponents[i].onChange();
            }

            // エラーが無いのでカスタムエラーを削除
            this.$el.setCustomValidity("");
        },
        //------------------------------------------------------------
        // バリデーションエラーでメッセージが設定されている場合エラーメッセージを変更する
        //------------------------------------------------------------
        onInvalid: function (e) {
            // 入力必須エラーメッセージの変更(変更した場合は処理終了)
            if (window.requiredMsgChange(this.$el, this.requiredErrMsg)) return;

            // 入力文字数エラーメッセージの変更
            if (stringLengthMsgChange(this.$el, this.maxlengthErrMsg, this.minlengthErrMsg)) return;

            // 型異常(URL,Email)エラーメッセージの変更
            if (typeMismatchMsgChange(this.$el, this.typemismatchErrMsg)) return;
        }
    },
    mounted: function () {
        // 比較対象が設定されている場合、比較対象コンポーネントと相互に関連付けを行う
        if (this.compareId) {
            for (var j = 0; j < this.$root.$children.length; j++) {
                var targetItem = this.$root.$children[j];
                if (this.compareId === targetItem.$el.id) {
                    this.compareCompornent = targetItem;
                    targetItem.comparedComponents.push(this);
                }
            }
        }

        // バリデーションを実施させる
        this.onChange();
    },
    template: '<input :id=id :name=name type=dataType() v-model="inputText" \
        :required=required \
        :maxlength=maxlength \
        :minlength=minlength \
        @change=onChange @invalid=onInvalid>'
});

 Vueコンポーネントの記述です。バリデーション処理は入力の変更された「onChange」イベント処理で行います。カスタムエラー以外のバリデーションエラーがある場合は、カスタムエラーを消して処理を終了しておきます。(カスタムエラーを消すタイミングがここしかない)
 以下はエラーの判定処理です。(別途Vue用の共通のjsのファイルとして作っています。)

function IsDefaultValidationError(validity) {

    // デフォルトのバリデートアエラーが有ればtrueを返す
    if (validity.valueMissing ||
        validity.badInput ||
        validity.patternMismatch ||
        validity.rangeOverflow ||
        validity.rangeUnderflow ||
        validity.stepMismatch ||
        validity.tooLong ||
        validity.tooShort ||
        validity.typeMismatch ||
        validity.badInput) {

        return true;
    }
    else {
        return false;
    }
}

 「onChange」でいろいろとエラーチェックしていますが、文字列長のチェックは

// 入力文字数バリデーション処理
if (!window.StringLengthValidation(this.$el, this.maxlength, this.maxlengthErrMsg, this.minlength, this.minlengthErrMsg)) return;

の部分で、この実装も「IsDefaultValidationError」と同じファイルに以下のように記述しています。

function StringLengthValidation(element, maxLength, maxlengthErrMsg, minLength, minlengthErrMsg) {

    // 空ならtrue;(Requiredでチェックする)
    if (!element.value) return true;

    // 最大長チェック(ほとんどのブラウザで入力できない様になるので不要と思われる)
    if (maxLength) {
        if (element.value.length > maxLength) {
            if (maxlengthErrMsg) {
                element.setCustomValidity(maxlengthErrMsg);
            }
            else {
                element.setCustomValidity(maxLength + "文字以下で入力してください。");
            }
            return false;
        }
    }

    // 最小長チェック(一部のブラウザでチェックしていない模様)
   if (minLength) {
        if (element.value.length < minLength) {
            if (minlengthErrMsg) {
                element.setCustomValidity(minlengthErrMsg);
            }
            else {
                element.setCustomValidity(minLength + "文字以上で入力してください。");
            }
            return false;
        }
    }

    return true;
}

 エレメントの内容とバリデーションで設定されたタグの属性を利用指定最大、最小をチェックしています。

 上記のバリデーション処理でエラーとなった場合はエラーメッセージも入っていれば対応していますが、ブラウザの標準機能でエラーとなった場合は「onInvalid」イベントでメッセージの変更を行っており、文字列長については

if (stringLengthMsgChange(this.$el, this._props.maxlengthErrMsg, this._props.minlengthErrMsg)) return;

の部分で行っており、この実装は以下のようになっています。

// 文字数チェックのエラーメッセージ変更
function stringLengthMsgChange(element, maxlengthErrMsg, minLengthErrMsg) {

    if (element.validity.tooLong) {
        if (maxlengthErrMsg) {
            element.setCustomValidity(maxlengthErrMsg);
            return true;
        }
    }
    else if (element.validity.tooShort) {
        if (minlengthErrMsg) {
            element.setCustomValidity(minLengthErrMsg);
            return true;
        }
    }
    return false;
}

「element.validity.tooLong」が「true」なら最大長のエラー、「element.validity.tooShort」が「true」なら最小長エラーなので、その場合はエラーメッセージをカスタムエラーを設定しています。カスタムエラーが設定されるとブラウザではそのメッセージ優先されます。

 ちょっと変わったところでは、他の入力と比較するバリデーションでは、比較先のエレメントに参照しているエレメントリストをコンポーネント作成時に設定しておき、どちらが変更されてもバリデーションを実行するようにしています。

 一応、これで単純なVueコンポーネントのタグヘルパーとバリデーションは作れると思います。

最後に

 次回は整数・実数の入力コンポーネントを作るのですが、これはChromeやFireFoxでは加算、減算のボタンがついていますが、IEやEdgeではついていないので、Vueコンポーネントを実行時に動的に置き換える方法で、IEやEdgeでもChromeに似せたコンポーネントに置き換得るようにしてみました。

****
時折LGTMしてくださる方や、何か琴線に触れたのかフォローしてくれた方が居られるようですので、こんな技術にも多少興味がある型が居られるようですので、ぼちぼち書いていきます。
****

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

Nuxt.jsをVue.jsに解体するTips(JavaScriptコンパイル編)

背景

  • Nuxt.jsで動いているSPAアプリをVue.jsに解体してほしい的な話があり、部分部分をTipsとして投稿します。
  • APIはLaravel(6.x)です。なおLaravelとNuxt.jsは同一リポジトリです(なぜ)
  • Nuxt.jsは2.9。Vueは2.6.10
  • 今回はJavaScriptコンパイル編です。

LaravelのディレクトリにNuxtのプロジェクトを移行

  • resources/jsにNuxtのプロジェクトをそのまま移行します

下記のようなディレクトリ構造になります。

resources/js
├── assets
├── components
├── layouts
├── pages
├── plugin
└── store

webpack.mix.jsにaliasを設定する

  • Nuxt.jsのモジュールのパスの指定は相対パスではなくaliasを設定してパスを設定しています。
  • webpack.mix.jsにデフォルトではそんな機能はないので設定します。
webpack.mix.js
const mix = require('laravel-mix');

const path = require('path');
mix.webpackConfig({
  resolve: {
    alias: {
      vue$: 'vue/dist/vue.esm.js',
      '~': path.resolve(__dirname, 'resources/js/'),
    },
  },
})

resources/js/app.jsを作成する

  • VueやVue Routerなどの読み込み設定ファイルを作成します。
resources/js/app.js
import Vue from "vue";
import router from '~/router'
import App from '~/App.vue'

new Vue({
  router: router,
  render: h => h(App),
}).$mount('#app')

resources/js/App.vueを作成する

  • レンダリングするVueファイルを作成し、resources/js/layouts/default.vueを読み込みます。

resources/js/layouts/default.vue

resources/js/App.vue
<template>
  <div id="app">
    <layout />
  </div>
</template>

<script>
import Layout from '~/layouts/default'

export default {
  components: {
    Layout
  }
};

resources/js/layouts/default.vueを編集する

  • をに変更します。
resources/js/layouts/default.vue
<template>
  <div>
    <router-view />
  </div>
</template>

resources/js/router.jsを作成する

  • ルーティングは自動生成されないので手動で設定します。
resources/js/router.js
import Vue from 'vue'
import Router from 'vue-router'
import Index from '~/pages/index'

Vue.use(Router)

const router = new Router({
  mode: 'history',
  routes: [
    {
      path: '/',
      name: 'Index',
      component: Index,
    },
  ],
});

package.jsonの編集

package.json
  "scripts": {
    "dev": "npm run development",
    "development": "cross-env NODE_ENV=development node_modules/webpack/bin/webpack.js --progress --hide-modules --config=node_modules/laravel-mix/setup/webpack.config.js",
    "watch": "npm run development -- --watch",
    "hot": "cross-env NODE_ENV=development node_modules/webpack-dev-server/bin/webpack-dev-server.js --inline --hot --config=node_modules/laravel-mix/setup/webpack.config.js",
    "prod": "npm run production",
    "production": "cross-env NODE_ENV=production node_modules/webpack/bin/webpack.js --no-progress --hide-modules --config=node_modules/laravel-mix/setup/webpack.config.js",
   },

webpack.mix.jsの編集

  • resources/js/app.jsのコンパイルを設定します。
webpack.mix.js
const mix = require('laravel-mix');

const path = require('path');
mix.webpackConfig({
  resolve: {
    alias: {
      vue$: 'vue/dist/vue.esm.js',
      '~': path.resolve(__dirname, 'resources/js/'),
    },
  },
})

mix.js('resources/js/app.js', 'public/js')

必要なnpmモジュールをインストール

$ npm install cross-env vue vue-router laravel-mix --save-dev

コンパイル

$ npm run dev

JavaScriptのコンパイルは通るはずです。(画面確認時にはコンソール上でエラーが出まくると思いますが)
プロジェクトによっては不足しているモジュールがあるかもしれません。随時追加してください。

画面で確認したい場合

  • Sassのコンパイルは未設定ですので、画面崩れまくりますが、画面確認したい時あるかもしれません。

Laravelのルーティングを編集

  • 全部のルーティングをindex.bladeに集約します。
routes/web.php
Route::get('{any}', function() {
    return view('index');
})->where('any', '.*');

index.blade.phpの編集

  • index.blade.phpでapp.jsを読み込みます。
resources/views/index.blade.php
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
    <head>
        <meta charset="utf-8">
        <meta name="viewport" content="width=device-width, initial-scale=1">
        <title>~~~~</title>
    </head>
    <body>
      <div id="app">
      </div>
      <script src="{{ mix('/js/app.js') }}"></script>
    </body>
</html>

これで画面の確認は可能です。

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

新型コロナの自宅待機中に、ビデオチャット上で遊べるアプリを作った話

ビデオチャット上で遊べるアプリを何人かに手伝ってもらいながら3週間で作りました。自分でSFUサーバ立てたりと、思っていたより力作になったので、得られた知見やノウハウを共有したいと思います。

自己紹介

普段はpythonを用いてデータ分析やサービス設計を研究している、東京大学の大学院生です。最近新型コロナの影響でリモート授業になり、家にいる時間を活用してWebアプリを作っています。

作ったサービス

「オンライン遊び場サービス wh.im

要はビデオチャット上で色々ゲーム等を遊べるアプリです。早押しクイズとかワードウルフとか遊べます。オンライン飲み会とかで活躍すると思います。ちなみにサービス名は最近流行りのurlそのままパターンを採用してみました。

開発の背景

新型コロナで外出抑制が続き、人とのコミュニケーションが不足してます。特に自分みたいな一人暮らしの大学生とかは2,3日しゃべらないとかもザラにあり、なんとか友達と前みたいに遊べないかと考えていました。最近はzoom飲みとかも流行っていますが、それもだんだん飽きてきたのは自分だけではないはず。

オンラインでどうコミュニケーションするか

まずは、どうしたらオンラインのコミュニケーションが良くなるかを考えました。オンラインではオフラインコミュニケーションに比べ色々な制約があります。画面の部分しか映らない、声が聞き取りづらい、遅延がある、複数人で喋ったらもう聞き取れない、通信量使う、、、。それを踏まえ、オンラインで上手いことコミュニケーションするには、オンラインの良さを生かさないといけません。

オンラインの強み

オンラインでは、オフラインと違い目の前に超高性能計算機・パソコンがあります。これはオフラインにはない特徴です。オンラインではコミュニケーションの最中、常にパソコンを触っているのです。となると、パソコンでゲームしながらコミュニケーションしたらいいのではないのだろうか。こうして私はビデオチャット&ゲームが同時にできる、「オンライン遊び場サービス wh.im」を開発に着手しました。

サービスとして工夫したこと

サービスを設計にあたって、次の3つのことを意識しました。
- ビデオチャットとゲームの分離
- 会員登録は一切なし、遊び場を作るボタンを押すだけ
- 導線は極力シンプルに

ビデオチャットとゲームの分離

wh.im上に色々なアプリを作りたかったので、ビデオチャット部分とゲーム部分が分離された作りにしようと思いました。vueのコンポーネントで分ける等色々考えましたが、最終的にiframeでゲーム部分を上から載せるのが、シンプルで良いのではないかと考えました。以下イメージ図です。

会員登録は一切なし、遊び場を作るボタンを押すだけ

会員登録は一切ありません。urlを発行するだけです。ビデオチャットが始めやすく、誘いやすいように設計しました。招待を受けた側もurlをクリックするだけで wh.im を始めることができます。

導線は極力シンプルに

ボタン等の導線を極力減らし、サービス上でできることを制限しました。ここはzoom等のビジネスチャットとは違う部分で、ミュート、画面off、画面共有といった機能は省き、友達と会話できる、ゲームできるというコアな部分を重視しました。

開発に使った技術

主に以下の技術を使いました。
- mediasoup(webRTC)
- firebase(BaaS)
- Nuxt.js(JavaScriptフレームワーク)

webRTC

一番苦労しました。日本語の文献も少ないです。

今回一番ネックであったのはログイン不要という部分です。ログイン不要というのは、ユーザーにとって便利な反面、サービス開始の敷居が低くて意図しないサービスの使われ方や、ユーザー数制限といった利用量を抑えるのは難しいのではないか、という懸念がありました。結果として利用が増えても金銭的な負担が発生しにくい方法を考える必要がありました。

webRTCを実装する上で最も簡単なのはNTTのやっているSkyWayだと思います。日本語の記事も比較的多いです。ただ一方、無料枠には500GBの通信量制限があり、有料プランは月額基本費用として10万円かかるということだったので、諦めました。
他にも有名どころですと、agora.io, opentok, twilio, AWS Chimeなどがあります。これらは大体料金は従量制で相場は1人1分0.5円といったところです。4人で一時間使うと120円ほどになります。個人で利用する分にはそれほど高くはありませんが、サービスリリースとなるとどれぐらいの利用料金になるか未知数なので今回は採用しませんでした。

結局無償で使用できるOSSを使い実装することにし、その中でもmediasoupを選びました。

webRTCでの技術選択に関しては時雨堂さんの記事がすごい参考になりました。フローチャート形式で、自分のやるべきことがわかります。感謝。

webRTC OSS 個人的比較

webRTCのOSSで有名どころをStarHistoryで比較してみました。

人気が最もあるjanus-gatewayか、後発で順調にスターが伸びてるmediasoupかで悩みましたが、以下の理由でmediasoupに決めました。(webRTCを触るのは初めてだったのでここらへんはフィーリングです。)
- mediasoupはNode.jsのライブラリになっており、とっつきやすい。janus-gatewayはC言語。
- janusはmeetechoという会社が作っており、meetechoがjanusの導入サポートっぽいことを行っているので、逆にjanusをサポートなしで導入するのは難しいのではないかという邪な推測
- ドキュメントがかっこいい
- 作者がイケメン

Firebase

バックエンドとして使ってます。神サービス。感謝。
FirebaseからはAuthentication, Firestore, Strage, Hostingを使っています。(メインはFirestoreです。)
今回は複数のユーザーが通信することが前提にあるので、firestoreの変更があればクライアント側に通知するリアルタイムアップデート機能が大助かりしました。

Nuxt.js

実は今回始めてNuxt.jsを使ってみました(今まではVue.js)。サーバーサイドレンダリングを使わなかったらVueCLIでもいいかなというのが正直な感想です。ちなみにデザインやcssは苦手なのでVuetifyを使いました。Vuetifyはすごくいいライブラリなのですが、一度使い出したらなかなか抜けれないという沼のようなライブラリでもあります(個人の感想です)。

ゲーム部分の作り込み

ゲーム部分はVue.jsで作りました。友達の協力も得ながら、じゃんけん、早押しゲーム、ワードウルフなどを作ってみました。
ビデオチャット部分とゲーム部分は別々に動いているので、そのwindow間でプレイヤー情報等を送信するためにWebの仕様であるpostMessageを用いました(このAPIは今回初めてしりました)。

オンライン通信ゲームなので、ユーザー間でデータを同期しなければなりません。データ同期用にfirestoreを使うことを考えましたが、ゲームを作るたびにfirebaseでproject立ち上げて、、とやるのは些か面倒でした。ビデオチャット部分ではすでにfirebaseを使っていたので、上記と同様postMessageを活用することで、データ同期をビデオチャット経由で簡単に行えるようにしました。ここらへんの詳細はこちらにまとめました。

作ってみたゲーム

  • じゃんけん
  • ワードウルフ
  • 早押しクイズ
  • NGワードゲーム
  • YesNoゲーム
  • ジェスチャーゲーム

さらに、wh.im では、ゲーム部分を外に切り出したことで、ビデオチャットの部分を実装しなくても、簡単にオリジナルなゲームを作ることができます。フロントの技術をある程度持っていれば、自分のやりたいゲームを開発可能です。例えば...
- 麻雀
- サッカー
- おおぎり
- その他オリジナルゲーム

などなど...!
簡単に作れるように、アプリ開発のためのドキュメントをまとめたので、是非参考にしてください。

まとめ

アイデアの着想したときの見積もりより壮大になり合計3週間ほどかかりました。しかしビデオチャットとゲームを分離することで、設計がシンプルになり、ゲームの開発に集中することができました。 wh.im を使うと、簡単にチャットビデオ上で動くゲームを作れるので、是非使ってみてください。

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

新型コロナの自宅待機中に、ビデオチャットしながらゲームで遊べるサービスを作った話

ビデオチャット上で遊べるアプリを何人かに手伝ってもらいながら3週間で作りました。自分でSFUサーバ立てたりと、思っていたより力作になったので、得られた知見やノウハウを共有したいと思います。

自己紹介

普段はPythonを用いてデータ分析やサービス設計を研究している、東京大学の大学院生です。最近新型コロナの影響でリモート授業になり、家にいる時間を活用してWebアプリを作っています。

作ったサービス

「オンライン遊び場サービス wh.im

要はビデオチャット上で色々なゲームで遊べるアプリです。早押しクイズとかワードウルフとか遊べます。オンライン飲み会とかで活躍すると思います。ちなみにサービス名は最近流行りのurlそのままパターンを採用してみました。lineとかslackに投稿するとそのままurlとして認識してくれます。ウィム!

開発の背景

新型コロナで外出抑制が続き、人とのコミュニケーションが不足してます。特に自分みたいな一人暮らしの大学生とかは2,3日人としゃべらないとかもザラにあり、なんとか友達と前みたいに遊べないかと考えていました。最近はzoom飲みとかも流行っていますが、それもだんだん飽きてきたのは自分だけではないはず。

オンラインでどうコミュニケーションするか

まずは、どうしたらオンラインのコミュニケーションが良くなるかを考えました。オンラインではオフラインコミュニケーションに比べ色々な制約があります。画面の部分しか映らない、声が聞き取りづらい、遅延がある、複数人で喋ったらもう聞き取れない、通信量使う、、、。しかし、オンラインにはオンラインの良さがあります。オンラインでしか遊べないなら、そのオンラインの良さを生かさないといけません。

オンラインの強み

オンラインでは、オフラインと違い目の前に超高性能計算機・パソコンがあります。これはオフラインにはない特徴です。オンラインではコミュニケーションの最中、常にパソコンを触っているのです。となると、パソコンでゲームしながらコミュニケーションしたらいいのではないのだろうか。こうして私はビデオチャット&ゲームが同時にできる、「オンライン遊び場サービス wh.im」の開発に着手しました。

サービスとして工夫したこと

サービスを設計にあたって、次の3つのことを意識しました。
- ビデオチャットとゲームの分離
- 会員登録は一切なし、遊び場を作るボタンを押すだけ
- 導線は極力シンプルに

ビデオチャットとゲームの分離

wh.im上に色々なアプリを作りたかったので、ビデオチャット部分とゲーム部分が分離された作りにしようと思いました。vueのコンポーネントで分ける等色々考えましたが、最終的にiframeでゲーム部分を上から載せるのが、シンプルで良いのではないかと考えました。以下イメージ図です。

会員登録は一切なし、遊び場を作るボタンを押すだけ

会員登録は一切ありません。urlを発行するだけです。ビデオチャットが始めやすく、誘いやすいように設計しました。招待を受けた側もurlをクリックするだけで wh.im を始めることができます。

導線は極力シンプルに

ボタン等の導線を極力減らし、サービス上でできることを制限しました。ここはzoom等のビジネスチャットとは違う部分で、ミュート、画面off、画面共有といった機能は省き、友達と会話できる、ゲームできるというコアな部分を重視しました。

開発に使った技術

主に以下の技術を使いました。
- mediasoup(webRTC)
- firebase(BaaS)
- Nuxt.js(JavaScriptフレームワーク)

webRTC

一番苦労しました。日本語の文献も少ないです。
今回一番ネックであったのはログイン不要とい形の実現です。ログイン不要というのは、ユーザーにとって便利な反面サービス開始の敷居が低く、意図しないサービスの使われ方や、ユーザー数制限といった利用量を抑えるのが難しくなってしまうのではないか、という懸念がありました。結果として利用が増えても金銭的な負担が発生しにくい方法を考える必要がありました。
webRTCをマネージドのサーバを使って実装しようとするとある程度のお金が必要になります。webRTCを実装する上で最も簡単なのはNTTのやっているSkyWayだと思います。SkyWayは日本語の記事も比較的多い一方、無料枠には500GBの通信量制限があり、有料プランは月額基本費用として10万円かかるということだったので、諦めました。
他にも有名どころですと、agora.io, opentok, twilio, AWS Chimeなどがあります。これらは大体料金は従量制で相場は1人1分0.5円といったところです。4人で一時間使うと120円ほどになります。個人で利用する分にはそれほど高くはありませんが、サービスリリースとなるとどれぐらいの利用料金になるか未知数なので今回は採用しませんでした。
結局無償で使用できるOSSを使い実装することにし、その中でもmediasoupを選びました。
webRTCでの技術選択に関しては時雨堂さんの記事がすごい参考になりました。フローチャート形式で、自分のやるべきことがわかります。感謝。

webRTC OSS 個人的比較

webRTCのOSSで有名どころをStarHistoryで比較してみました。

人気が最もあるjanus-gatewayか、後発で順調にスターが伸びてるmediasoupかで悩みましたが、以下の理由でmediasoupに決めました。(webRTCを触るのは初めてだったのでここらへんはフィーリングです。)
- mediasoupはNode.jsのライブラリになっており、とっつきやすい。janus-gatewayはC言語。
- janusはmeetechoという会社が作っており、meetechoがjanusの導入サポートっぽいことを行っているので、逆にjanusをサポートなしで導入するのは難しいのではないかという邪な推測
- ドキュメントがかっこいい
- 作者がイケメン

Firebase

バックエンドとして使ってます。神サービス。感謝。
FirebaseからはAuthentication, Firestore, Strage, Hostingを使っています。(メインはFirestoreです。)
今回は複数のユーザーが通信することが前提にあるので、firestoreの変更があればクライアント側に通知するリアルタイムアップデート機能が大助かりしました。

Nuxt.js

実は今回始めてNuxt.jsを使ってみました(今まではVue.js)。サーバーサイドレンダリングを使わなかったらVueCLIでもいいかなというのが正直な感想です。ちなみにデザインやcssは苦手なのでVuetifyを使いました。Vuetifyはすごくいいライブラリなのですが、一度使い出したらなかなか抜けれないという沼のようなライブラリでもあります(個人の感想です)。

ゲーム部分の作り込み

ゲーム部分はVue.jsで作りました。友達の協力も得ながら、じゃんけん、早押しゲーム、ワードウルフなどを作ってみました。
ビデオチャット部分とゲーム部分は別々に動いているので、そのwindow間でプレイヤー情報等を送信するためにWebの仕様であるpostMessageを用いました(このAPIは今回初めて知りました)。
オンライン通信ゲームなので、ユーザー間でデータを同期しなければなりません。データ同期用にfirestoreを使うことを考えましたが、ゲームを作るたびにfirebaseでproject立ち上げて、、とやるのは些か面倒でした。ビデオチャット部分ではすでにfirebaseを使っていたので、上記と同様postMessageを活用することで、データ同期をビデオチャット経由で簡単に行えるようにしました。ここらへんの詳細はこちらにまとめました。

作ってみたゲーム

  • じゃんけん
  • ワードウルフ
  • 早押しクイズ
  • NGワードゲーム
  • YesNoゲーム
  • ジェスチャーゲーム

いい感じのサムネを友達がいらすとやで作ってくれました。いらすとやすごい。感謝。

さらに、wh.im では、ゲーム部分を外に切り出したことで、ビデオチャットの部分を実装しなくても、簡単にオリジナルなゲームを作ることができます。フロントの技術をある程度持っていれば、自分のやりたいゲームを開発可能です。例えば...
- 麻雀
- サッカー
- おおぎり
- その他オリジナルゲーム
などなど...!
簡単に作れるように、アプリ開発のためのドキュメントをまとめたので、是非参考にしてください。開発からwh.im上での公開までに必要な情報をまとめています。

まとめ

アイデアの着想したときの見積もりより壮大になり合計3週間ほどかかりました。しかしビデオチャットとゲームを分離することで、設計がシンプルになり、ゲームの開発に集中することができました。 wh.im を使うと、簡単にチャットビデオ上で動くゲームを作れるので、是非使ってみてください。

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

webカメラを使って、1000px ×1000px のプロフィール画像を量産してくれるサイトをつくった

完成品

サイト名:1000×1000 証明写真
https://id-photo.ml (ダメだったらこっち:https://id-photo.herokuapp.com/)

作り方!!

・まずファイル構造はこんな感じ
スクリーンショット 2020-05-21 0.03.32.png
今回herokuを使ってデプロイするために、gitやら何やらが入っています。(もしかしたら、必要ないファイルもあるかも。すみません...)


・コード記述内容

index.html
<!DOCTYPE html>
<html>

<head>
  <title>1000×1000証明写真</title>
</head>

<body>
  <b>1000×1000証明写真</b>
  <div id="app">
    <div>
      <video ref="video" id="video" width="500" height="500" autoplay></video>
      <div>
        <button color="info" id="snap" v-on:click="capture()"><img src="img/icon.png"></button>
      </div>
      <canvas ref="canvas" id="canvas" width="1000" height="1000"></canvas>
      <ul>
      <li class="capture" v-for="c in captures" v-bind:key="c.d">
        <img v-bind:src="c" height="50" />
      </li>
      </ul>
    </div>
  </div>

  <script src="https://cdn.jsdelivr.net/npm/vue/dist/vue.js"></script>
  <script>
    new Vue({
      el: '#app',
      data: {
        video: {},
        canvas: {},
        captures: []
      },
      mounted () {
        this.video = this.$refs.video
        if (navigator.mediaDevices && navigator.mediaDevices.getUserMedia) {
          navigator.mediaDevices.getUserMedia({ video: true }).then(stream => {
            this.video.srcObject = stream
            this.video.play()
          })
        }
      },
      methods: {
        capture () {
          this.canvas = this.$refs.canvas
          this.canvas.getContext('2d').drawImage(this.video, 0, 0, 1000, 1000)
          this.captures.push(this.canvas.toDataURL('image/png'))
          console.log(this.captures)
        }
      }
    });
  </script>

</body>
</html>

<style>
#canvas {
  display: none;
}
.capture {
  /* display: inline; */
  padding: 5px;
}
</style>
index.js
const express = require('express');
const app = express();

// public というフォルダに入れられた静的ファイルはそのまま表示
app.use(express.static(__dirname + '/public'));

// bodyParser
var bodyParser = require('body-parser');
app.use(bodyParser.json());

// POSTリクエストを受け付ける
app.post('/post', function (req, res) {

    for (key in req.body) {
        console.log(key, '=', req.body[key]);
    }
    res.end();

    res.send('hello world(POST)');
});

// GET リクエストを受け付ける
app.get('/get', function (req, res) {
    res.send('hello world(GET)');
});

//app.listen(8080);
app.listen(process.env.PORT || 8080);

console.log("server start! (heroku)");
.gitignore
# Dependency directories
node_modules/

# Optional npm cache directory
.npm
Procfile
web: node index.js

そのほかのファイルは下記コマンドからインストールできます。

npm init -y
npm i body-parser express

完成

触って遊んでみましょう。

step1「サイトへ飛ぶ」
https://id-photo.ml (ダメだったらこっち:https://id-photo.herokuapp.com/)

1.jpg


step2「カメラアイコンをクリックして撮影」
カメラのアイコンをクリックすると、1000px × 1000px の画像が下に量産されていきます。
2.jpg


step3「画像をダウンロード」
量産された画像の上で、右クリックをするとメニューがでるので、そこで「名前を付けて画像を保存...」を選択してください。
3.jpg

ダウンロードされた画像が、少し縦に伸びている感じになっているのはスリム効果です。

おわり

一応、今回使用した画像を貼っておきます。ご自由に使用してください。
icon.png

また今回参考にさせていただいた記事です
https://qiita.com/kino15/items/8f8feffca54015555f4b

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

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