20200519のvue.jsに関する記事は14件です。

@nuxt/componentsを使って、コンポーネントを自動でimportしよう

@nuxt/componentsとは

Nuxt.jsでの開発において、コンポーネントを自動でimportしてくれるモジュールです。
import文とcomponentsフィールドの定義を省略できます。
https://github.com/nuxt/component

使用例

READMEのUSAGEに従って進めます。
https://github.com/nuxt/components#usage

通常、コンポーネントを利用する際はimportする必要がありますが、

index.vue
<template>
  <div>
    <ComponentFoo />
  </div>
</template>
<script>
import ComponentFoo from '~/components/ComponentFoo.vue'

export default {
  components: {
    ComponentFoo
  }
}
</script>

@nuxt/componentsを使うと、<script>にimportの記載が不要になります。

index.vue
<template>
  <div>
    <ComponentFoo />
  </div>
</template>

動的なコンポーネントの場合

Lazyをコンポーネントの先頭につけます。

index.vue
<template>
  <div>
    <LazyComponentFoo v-if='foo' />
    <button @click='loadFoo'>Load Foo</button>
  </div>
</template>

<script>
export default {
  data () {
    return {
      foo: null
    }
  },
  methods: {
    async loadFoo () {
      this.foo = await this.getFoo()
    },
    getFoo () {
      return new Promise(resolve => {
        setTimeout(() => {
          resolve('foo');
        }, 2000);
      });
    }
  }
}
</script>

ネストした同名のコンポーネントの場合

たとえば以下のように、異なる階層に同名のBar.vueがある場合。

components/
  Bar.vue
  foo/
    Bar.vue

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

nuxt.config.js
components: {
    dirs: [
      '~/components/',
      {
        path: '~/components/foo/',
        prefix: 'foo'
      }
    ]
  },

foo/は以下のコンポーネントは先頭にFooをつけます。

index.vue
<template>
  <div>
    // components/ のBar.vue
    <Bar />
    // components/foo/ のBar.vue
    <FooBar />
  </div>
</template>

参考

https://github.com/nuxt/components

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

DataTablesを使って遊戯王カードの表を作る!(2)

はじめに

こんにちは。hyです。
前回、前の記事ではDatatablesを用いて遊戯王カードを表示する表を表示するアプリを作りました。
今回はそのアプリのコードについて解説していこうと思います。

使う技術

  • DataTables
    • 簡単に綺麗な表や機能を追加できるjQueryライブラリ
  • Mustache.js
    • javascriptのテンプレートエンジンで,今回は主にDataTableと併用して使います。
  • Vue.js
    • javascriptのフレームワークの一つ。学習コストが低いことが特徴
  • Yu-Gi-Oh! API by YGOPRODeck
    • 遊戯王カードの画像や効果などの情報を取得できるAPI

作成したアプリ

See the Pen datatable-app by higakin (@hgaiji) on CodePen.

解説

以下がDataTableで表を表示するためのコードです。

html
<table id="ygo_table" class="table table-bordered">
  <thead>
    <tr>
      <th>name</th>
      <th>desc</th>
    </tr>
  </thead>
  <tbody>

  </tbody>
</table>
javascript
makeTables : function() {
      const self = this;
      const options = {
        serverSide: true,
        ajax: {
          url:"https://db.ygoprodeck.com/api/v7/cardinfo.php?level=4",
          dataSrc:function(json) {
            return json.data.slice().sort(function(){ return Math.random() - 0.5; }).slice(0, 3);
          },
        },
        columns:[
          {data:"name",render: self.renderName},
          {data:"desc"},
        ]
      }

      $("#ygo_table").DataTable(options);
    },
    renderName :function(data, type, row, meta) {
            const template = '<div class="container">'
                     + '  <div class="row">'
                     + '    <div class="card" style="width: 18rem;">'
                     + '      <img src={{renderSrc}} class="card-img-top" alt="カード画像">'
                     + '        <div class="card-body">'
                     + '          <p class="card-text">{{cardInfo.name}}</p>'
                     + '        </div>'
                     + '      </div>'
                     + '  </div>'
                     + '</div>';
      const view = {
        cardInfo: row,
        renderSrc :function() {
          return this.cardInfo.card_images[0].image_url;
        }
      };
      return Mustache.render(template,view);
    }
  }

Datatablesで表を表示する

 $("#ygo_table").DataTable(options);

この部分の記述で指定いたIDのtableに対して表を表示します。
また、表を表示する時にオプションを設定することができます。

//Datatablesのオプションんの設定
const options = {
        serverSide: true,
        ajax: {
          url:"https://db.ygoprodeck.com/api/v7/cardinfo.php?level=4",
          dataSrc:function(json) {
            return json.data.slice().sort(function(){ return Math.random() - 0.5; }).slice(0, 3);
          },
        },
        columns:[
          {data:"name",render: self.renderName},
          {data:"desc"},
        ]
      }

それぞれのオプションについて解説します。

  • serverSide

    • 表のデータの取得などをサーバ側で処理するためのオプション
  • ajax

    • jOuery.ajaxの機能をほとんど引き続いて使用できるオプション
  • ajax.dataSrc

    • サーバから取得したデータを好きに編集するためのオプション
    • ajax.urlで取得したいサーバーのURLなどを設定
    • ex) url:"https://db.ygoprodeck.com/api/v7/cardinfo.php?level=4"
    • サーバから得たデータを一部だけ使用するなどが可能
  • columns

    • 表の列を設定するためのオプション
    • 各列の詳細な設定を定義することができる
  • columns.render( data, type, row, meta)

    • 列のデータの表示の仕方をより詳細に設定できる
    • 例えば、取得したデータの一部をリンクにするなど
    • このrender関数で Mustache.jsを使用している

columns.renderとMustache.jsの併用

Datatablesのrender関数とMustache.jsを併用して使うことでより詳細なデータの設定ができます。
該当コードは以下の通りです。

renderName :function(data, type, row, meta) {
            const template = '<div class="container">'
                     + '  <div class="row">'
                     + '    <div class="card" style="width: 18rem;">'
                     + '      <img src={{renderSrc}} class="card-img-top" alt="カード画像">'
                     + '        <div class="card-body">'
                     + '          <p class="card-text">{{cardInfo.name}}</p>'
                     + '        </div>'
                     + '      </div>'
                     + '  </div>'
                     + '</div>';
      const view = {
        cardInfo: row,
        renderSrc :function() {
          return this.cardInfo.card_images[0].image_url;
        }
      };
      return Mustache.render(template,view);
    }

Vue.jsの中でjQueryを使うには?

いろいろなやり方があるのですが・・
以下のように、基本的にはmountedの中にjQueryの処理を書いてやればいいです。
mountedはVue.jsのライフサイクルでVueインスタンスがマウントされた(マウントされたDOMが生成された)ちょうど後に呼ばれます。なので JQueryのようなidなどを指定して操作を行う物はこのライフサイクルフックに入れてやればいいんです。

new Vue({
  el:"#app",
  data: {
  },
  mounted:function(){
    this.makeTables();
  },
  ///////
}

まとめ

  • Datatablesの表の表示方法
  • Datatablesのオプションについて
  • datatablesとMustache.jsの併用について
  • vueとJqueryの共存方法などについて

最後に

何かわからないこと疑問点など会ったら気軽にコメントお願いします。
また、もっとこんなコードにしたらいいんじゃないと言うコメントがあればそちらもぜひお願いいたします。

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

Nuxt.js + Sentry の本番運用での設定に戸惑った

フロントエンドのエラーを検知するのは、バグの早期発見、場合によってはリリースバージョンを切り戻しの判断をしたりする上で非常に重要です。

Sentryを使えば、かなり簡単にフロントエンドのログ収集の仕組みを実現出来ます。また、他の同様のツール(Rollbarなど)に比べてモダンなUIかつ、ドキュメントやTipsが豊富な気がします。また、nuxt-community にて sentry-module が提供されてる点もgood。

SentryにはReleaseという機能があり、運用環境へのデプロイとログを紐付けることが出来ます。また、SourceMapを一緒にアップロードしておくことで、本番環境でも stack trace を追えるようになります。

しかし、今回@nuxtjs/sentryを使ってReleaseしようとした際、公式の記述がさらっとしていてちょっと困ったので備忘録として残しておきます

やりたかったこと

NustからSentryにReleaseを作成する。

Releaseの際にはSourceMapをSentryにアップロードする。

本番環境側ではSourceMapは見えないようにしたい。

解決方法

※ Sentry Project 立ててある前提です

Sentry側で用意しておくべきもの

  • DSN: Project Settings の Client Keysから確認
  • Auth Token: アカウント設定の Auth Tokens から作成 (Projectの書き込み権限をもつTokenを発行する)
  • Organization Name: Organization Settings から確認
  • Project Name

設定

$ npm i @nuxtjs/sentry

build時に sentry cli が使うオプションファイル .sentryclirc を準備

.sentryclirc
[defaults]
org = organizarion-name
project = project-name

何も考えずに config

nuxt.config.js
{
  modules: [
    '@nuxtjs/sentry',
  ],
  sentry: {
    dsn: process.env.SENTRY_DSN,
    publishRelease: true,
    sourceMapStyle: 'hidden-source-map',
    config: {
      release: process.env.GIT_SHA,
    },
  },
  // ***
}

release はtag名やpackages.json に記載してるバージョンなど何でもいいのですが、とりあえずリリース時点のコミットハッシュをいれておきました。

sourceMapStyle は webpack の sourcemap を設定するプロパティです。 デフォルトの値は "source-map" になってるので、本番運用では "hidden-source-map" を指定してあげるとクライアント側では SourceMap を隠してくれます。READMEにも書いてあります。

困ったこと

最初はReleaseの実行に必要な環境変数がわからなかったり、本番運用で必須な SourceMap の隠し方が分からず結構時間を取られてしまいました。解決するために こちらの記事 などを参考にさせていただき、SourceMapを除去する Webpack Plugin 書いたりとかしてました。

が、今回記事をまとめるにあたって改めてドキュメントを読んでみたらしっかりと書いてあるんですよね… 残念な英語読解力を痛感しました。

初めて Nuxt と Sentry を使われる方の参考にしていただければ幸いです。

参考

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

【備忘録】Vue.js CLIのプロジェクト構造概要

はじめに

フロントエンドの開発をやってみようと思ってVue CLIに入門したけどなかなかプロジェクト構造に慣れないので備忘録として記事に残しておきます。
素人なので間違いがありましたらご指摘お願いします。

プロジェクト雛形作成

テンプレートではwebpackを使う事にします。コマンド入力後いくらか質問される。

$ vue init webpack sample-app

? Project name sample-app
? Project description sample Application of VueCLI
? Author TaiseiInoue 
? Vue build standalone
? Install vue-router? Yes
? Use ESLint to lint your code? Yes
? Pick an ESLint preset Standard
? Set up unit tests No
? Setup e2e tests with Nightwatch? No
? Should we run `npm install` for you after the project has been created? (recommended) npm

1つずつ見ていく。

アプリケーションプロジェクトの名前

? Project name sample-app

プロジェクトの説明

? Project description sample Application of VueCLI

開発者

? Author TaiseiInoue

ビルドバージョンの選択
完全版がおすすめされているのでそちらを選択

? Vue build standalone

Vue Rooterのインストール(Y/N)

Install vue-router? Yes

ESLintの利用
ESLintはJavaScriptの静的コード解析ツールらしい。

? Use ESLint to lint your code? Yes
? Pick an ESLint preset Standard

単体テスト環境
今回は未選択

? Set up unit tests No

E2Eテスト環境のセットアップ

? Setup e2e tests with Nightwatch? No

インストール方法の選択

? Should we run `npm install` for you after the project has been created? (recommended) npm

プロジェクト構造

僕のイメージでざっくりと説明

sample-app $ tree -dL 1 ./
./
├── build           #webpackの設定ファイルを格納
├── config          #本番環境や開発環境などを設定するファイルを格納
├── node_modules    #ライブラリなどを格納
├── src             #この中にプログラムを書いていく
├── static          #webpacに処理されないファイルを格納
└── test       #テストをコードを格納
sample-app % tree src
src
├── App.vue    #実行エントリポイントとなるコンポーネント
├── assets
│   └── logo.png
├── components    #コンポーネントを格納
│   ├── atoms
│   ├── molecules
│   ├── organisms
│   └── templates
├── main.js         #アプリケーションのエントリポイント
└──  router
    └── index.js  # ルーティングを定義する

components内のHeeloWorld.vue以外は自分で作成したディレクトリ。
気になる方はAtmic Designを参照してください。

どこに何を書けばいいの?

src内のファイルの中身を見ていく

src/components

コンポーネントとはUIの部品をモジュール化したもの。
(例:カスタムしたボタンや登録フォーム、ページ自体など)
コンポーネントの名前はケバブケースかパスカルケースで書くらしい

src/component/template/MyComponent.vue
<template>
    // templateを記述
</template>

<script>
    export default { // この中にMyComponentの中身を記述
        name: "MyComponent"
    }
</script>

<style scoped>

</style>

<style>scoped属性はその単一ファイルコンポーネント内の要素にのみ<style>を適用したいときにつける。

src/router

src/router/index.js
// ルート定義をするファイル
import Vue from 'vue'
import Router from 'vue-router'
import HelloWorld from '@/components/HelloWorld'
import MyComponent from '@/template/MyComponet

Vue.use(Router)

export default new Router({
  routes: [
    {    //デフォルトであるルート
      path: '/',
      name: 'HelloWorld',
      component: HelloWorld
    },
    {    //先ほど書いたcomponentのルート
      path: '/hoge',
      name: 'MyComponent',
      component: template/MyComponent
    }
  ]
})

URLを管理する。
アドレスに対応したコンポーネントを指定する。

src/App.vue

src/App.vue
<template>
  <div id="app">
    <router-view/>
 </div>
</template>

<script>
export default {
  name: 'App'
}
</script>

<style>


</style>

実行エントリーポイント。全てのコンポーネントの親みたいな存在だと思ってる
src/rooter/index.jsで設定したルーティング処理により<rooter-view/>に指定したコンポーネントが表示される。

おわりに

とりあえずここまで理解しておけば入門したと言っても過言じゃない気がしているのでここまで

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

Vue でウィンドウサイズの変更を検知したいとき

Vue を使っていてウィンドウのリサイズを検知するには、Vuetify というコンポーネントフレームワークの v-resize というカスタムディレクティブが便利です。

<template>
  <span v-resize="onResize">{{ windowSize }}</span>
</template>

<script>
  export default {
    data: () => ({
      windowSize: {
        x: 0,
        y: 0,
      },
    }),

    mounted () {
      this.onResize()
    },

    methods: {
      onResize () {
        this.windowSize = { x: window.innerWidth, y: window.innerHeight }
      },
    },
  }
</script>

このように、テンプレートで v-resize に発火したいメソッド名を渡すだけで、ウィンドウのサイズが変更された時にそのメソッドを実行することができます。

ウィンドウや要素の幅に応じて動的に何かを設定する必要があるコードでは便利ですね。手元のコード量がほとんど増えないのが個人的にうれしいポイントです。

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

【Nuxt.js】アプリ開発実践編:Nuxt + Vuex + firebaseでログイン付きToDoリスト①

前置き

Loginfinish.gif

前回のTODOリストに
オプション機能をつけていきます♪
https://note.com/aliz/n/n8411db2c9a20

今回はログイン機能を追加?
実際にサービスを運用するには
必須の機能ですね!?
以前の記事をやっていない方でも
仕組みと書き方が分かれば大丈夫です?‍♀️

内容が濃いのでお知らせした通り、
有料記事になりました?

【使うもの】
・Firebase Authentication
・Vuex(ログイン状態の保持)

【流れ】
ボリュームがあるため2回に分けます!
・ログイン画面の作成
・新規アカウント作成画面の作成
・ログイン時とログアウト時の
 表示の出し分け
・+a
 メールアドレス以外のログイン
 エラー時の処理や
 アカウント作成時にメール送信

❓公式guides, Referenceの読み方
基本的にfirebaseのreferenceは
英語の状態で表示し、
自分でgoogle翻訳で翻訳しましょう??

言語を日本語にすると
古いバージョンだったりするので、
最新の英語を翻訳していくのがベスト!⭕️
ただいきなり全部英語だと
欲しい情報がどこにあるか分からないので
最初は日本語で表示させて
ある程度検討をつけてから英語にしてます?
(英語も理解できるように頑張ろう…?)

Step1: firebaseAuthの準備

まずはfirebaseで既に作成したプロジェクトに
ログイン方法の設定をしていきます?
簡単にできるメールアドレスから!✉️
ログインできるかどうか判断するため
firebase上でアカウントを作成してみます?

?後からアカウント作成画面も作ります
?Googleアカウントでの
 ログインなども追加していきます

・プロジェクトの概要ページから
 サイドメニューのAuthenticationを選択
 ログイン方法を設定を選択

auth1.png

・メールを選択

Frame 2.png

・メールでのログインを有効にし、保存

Frame 3.png

・usersタブに戻りユーザーを追加

Frame 4.png

登録できるとユーザー情報が表示されます?

Frame 5.png

Step2: ログイン機能の実装

Login1.gif

続きはこちら♪
https://note.com/aliz/n/n882048f66734

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

スプレッドシートをDB代わりにGASのWebアプリを作成しデータ更新させてみた。Vue版 その4[完結編]~~

本稿でいよいよ完結します。

(はやくコロナも終わってくれないかなぁ)

今までのはこちら↓

スプレッドシートをDB代わりにGASのWebアプリを作成しデータ更新させてみた。Vue版 その1~~
スプレッドシートをDB代わりにGASのWebアプリを作成しデータ更新させてみた。Vue版 その2~~
スプレッドシートをDB代わりにGASのWebアプリを作成しデータ更新させてみた。Vue版 その3~~

ハマったところもありました。。。

スプレッドシートをDB代わりにGASのWebアプリを作成しデータ更新させてみた。Vue版、ハマったところinputの再描画編~~

スプレッドシートをDB代わりにGASのWebアプリを作成しデータ更新させてみた。Vue版、ハマったところcheckboxの再描画編~~

ソースを公開する前に、データシートとマスターシートの説明をしておこうかなと思います。(忘れてましたすみません)

現状のイケてないところ

  1. モーダルで詳細画面を開いたときに前回の内容が現れ、1パクおいてから表示される。
     ⇒データが切り替わったのを確認できるので、今はこのまま。描画判定するプロパティの変更位置を後にすれば解決するかも・・・

  2. モーダルの詳細画面で更新を掛けた後、一覧をリフレッシュしていない
     ⇒ちょっと試したんですが、実現できず・・・

データシートの構成(シート名:database)

image.png

A列 B列 C列 D列 E列 F列 G列 H列 I列 J列
項目の見出し リンク行 タイムスタンプ 店舗名 郵便番号 住所 電話番号 営業時間 設備 感想
タグのid,name rownum timestamp tempo postnum ken address telnum activetime setsubi note
表示形式 read read text text select textarea text radio checkbox textarea
設定値 30 8 2 5,30 15 3 4 3,30
データ 5 2020/05/15 16:09:34 吉野家 有楽町2号店 100-0006 東京都 千代田区有楽町2丁目 100-0006123 9-21時かも 駐車場,ドライブスルーかも,駐輪場 とってもいい
データ 6 2020/05/15 14:24:24 吉野家 永田町1号店 100-0014 東京都 千代田区永田町1丁目 100-0014123 9-18時かも ドライブスルーかも,駐輪場 うまーい

例えば、「住所」の場合、「address」という文言でhtml内のid,nameに設定します。
そして、表示形式は「textarea」となり、「設定値」が「5,30」なので、5行30文字の大きさで表示します。

「県」の場合は、「ken」という文言でhtml内のid,nameに設定し
表示形式は「select」で設定値が「2」なので、マスターシート(シート名:itemmaster)の2列目を参照という意味になります。

マスターシートの構成(シート名:itemmaster)

A列はサポートしている表示形式になります。(固定)
B列以降はカスタマイズできます。
1行目は、何のデータなのかわかりやすい文言を入れてください。表示やシステムには関係ありません。
そして、2行目から下がその項目のデータになります。
image.png
例えば、「県」や「営業時間」、「設備」は以下のように表示されます。
image.png

ソース公開

お恥ずかしいソースですが公開します。

スプレッドシート(シート名:database)

とりあえずcsvで。データは20件。

リンク行,タイムスタンプ,店舗名,郵便番号,県,住所,電話番号,営業時間,設備,感想,来店回数,好きなメニュー
rownum,timestamp,tempo,postnum,ken,address,telnum,activetime,setsubi,note,repeat,menu
read,read,text,text,select,textarea,text,radio,checkbox,textarea,radio,checkbox
,,30,8,2,"5,30",15,3,4,"3,30",5,6
5,2020/05/15 16:09:34,吉野家 有楽町2号店,100-0006,東京都,千代田区有楽町2丁目,100-0006123,9-21時かも,"駐車場,ドライブスルーかも,駐輪場",とってもいい      ,,
6,2020/05/15 14:24:24,吉野家 永田町1号店,100-0014,東京都,千代田区永田町1丁目,100-0014123,9-18時かも,"ドライブスルーかも,駐輪場",うまーい,,
7,2020/05/15 13:52:57,吉野家 永田町1号店,100-0014,東京都,千代田区永田町1丁目,,9-21時かも,ドライブスルーかも,うまーい,,
8,2020/05/15 17:16:39,吉野家 水道橋1号店,101-0061,東京都,千代田区神田三崎町2丁目,1234566,9-18時かも,"駐車場,駐輪場",とってもいい      ,,
9,2020/05/15 17:38:07,吉野家 小川町1号店A,101-0052,東京都,千代田区神田小川町2丁目,101-0052,24時間かも,駐車場,うまい,,
10,2020/02/12 14:57:55,吉野家 秋葉原1号店,101-0023,東京都,千代田区神田松永町,,9-21時かも,"駐車場,駐輪場",うまーい,,
11,2020/02/13 14:57:55,吉野家 神保町1号店,101-0051,東京都,千代田区神田神保町2丁目,,9-21時かも,"駐車場,駐輪場",,,
12,2020/05/18 9:37:11,吉野家 淡路町1号店,101-0041,東京都,千代田区神田須田町1丁目,101-00411111,9-21時かも,"駐車場,駐輪場",うまいよ~,15回まで,スタミナ超特盛丼
13,2020/02/15 14:57:55,吉野家 お茶の水1号店,101-0062,東京都,千代田区神田駿河台2丁目,,9-21時かも,"駐車場,駐輪場",,,
14,2020/02/16 14:57:55,吉野家 神田1号店,101-0044,東京都,千代田区鍛冶町2丁目,,9-21時かも,"駐車場,駐輪場",,,
15,2020/02/17 14:57:55,吉野家 霞ヶ関1号店,100-8918,東京都,千代田区霞が関,,9-21時かも,ドライブスルーかも,,,
16,2020/02/18 14:57:55,吉野家 霞が関2号店,100-0013,東京都,千代田区霞が関,,9-21時かも,ドライブスルーかも,,,
17,2020/02/19 14:57:55,吉野家 帯広1号店,080-0011,北海道,帯広市西1条南,,9-21時かも,ドライブスルーかも,,,
18,2020/02/20 14:57:55,吉野家 旭川1号店,078-8231,北海道,旭川市豊岡1条,,9-21時かも,,,,
19,2020/02/21 14:57:55,吉野家 旭川2号店,070-0034,北海道,旭川市4条通2丁目,,9-21時かも,,,,
20,2020/02/22 14:57:55,吉野家 岩見沢1号店,068-0825,北海道,岩見沢市日の出町,,9-21時かも,,,,
21,2020/02/23 14:57:55,吉野家 新千歳1号店,006-0012,北海道,千歳市美々 新千歳空港,,9-21時かも,,,,
22,2020/02/24 14:57:55,吉野家 千歳2号店,066-0036,北海道,千歳市北栄2丁目,,9-21時かも,,,,
23,2020/02/25 14:57:55,吉野家 1厚別東1号店,004-0004,北海道,札幌市厚別区厚別東4条3丁目,,9-21時かも,,,,
24,2020/02/26 14:57:55,吉野家 苫小牧新開町店,053-0052,北海道,苫小牧市新開町2丁目,,9-21時かも,,,,

同じスプレッドシート内の別シート(シート名:itemmaster)

とりあえずcsvで。

disptype,県,営業時間,設備,リピート数,メニュー
read,北海道,24時間かも,駐車場,初回,牛丼
text,青森県,9-21時かも,駐輪場,5回まで,スタミナ超特盛丼
textarea,岩手県,9-18時かも,ドライブスルーかも,10回まで,肉だく牛丼
select,宮城県,PMのみかも,,15回まで,ねぎだく牛丼
radio,秋田県,,,,ねぎたま牛丼
checkbox,山形県,,,,ライザップ牛サラダエビアボカド
,福島県,,,,
,茨城県,,,,
,栃木県,,,,
,群馬県,,,,
,埼玉県,,,,
,千葉県,,,,
,東京都,,,,
,神奈川県,,,,
,新潟県,,,,
,富山県,,,,
,石川県,,,,
,福井県,,,,
,山梨県,,,,
,長野県,,,,
,岐阜県,,,,
,静岡県,,,,
,愛知県,,,,
,三重県,,,,
,滋賀県,,,,
,京都府,,,,
,大阪府,,,,
,兵庫県,,,,
,奈良県,,,,
,和歌山県,,,,
,鳥取県,,,,
,島根県,,,,
,岡山県,,,,
,広島県,,,,
,山口県,,,,
,徳島県,,,,
,香川県,,,,
,愛媛県,,,,
,高知県,,,,
,福岡県,,,,
,佐賀県,,,,
,長崎県,,,,
,熊本県,,,,
,大分県,,,,
,宮崎県,,,,
,鹿児島県,,,,
,沖縄県,,,,

コード.gs

// GASでお決まりなので必要
function doGet(e) {
  var template = HtmlService.createTemplateFromFile('vue_index');
  return template.evaluate();
}

//インクルードするためのもの
function include(filename) {
  return HtmlService.createHtmlOutputFromFile(filename).getContent();
}

//マスターデータを取得
function getMasterData(col) {
  var spread = SpreadsheetApp.getActiveSpreadsheet() ;
  var sheet = spread.getSheetByName('itemmaster');
  var idname = sheet.getRange(1, col, 1, 1).getValues();
  var values = sheet.getRange(2, col, sheet.getRange(2, col).getNextDataCell(SpreadsheetApp.Direction.DOWN).getRow()-1, 1).getValues();
  //連想配列にする
  var res = {};
  for (i=0; i < values.length; i++) {
      res[i]={ id:idname + i, item:values[i][0]};
  }
  return res;
}

//更新や新規登録を行う
//排他制御を掛けるので更新処理を一か所にまとめた
function dataSave(sheet, row1, col1, row2, col2, data){
  var msg = "";

  //ドキュメントロックを使用する
  var lock = LockService.getDocumentLock();

  //30秒間のロックを取得
  try {
    //ロックを実施する
    lock.waitLock(30000);

    //ここにメインルーチンを記述する
    sheet.getRange(row1, col1, row2, col2).setValues(data);

    //メッセージを格納
    msg = "保存完了";

  } catch (e) {
    //ロック取得できなかった時の処理等を記述する
    var checkword = "ロックのタイムアウト: 別のプロセスがロックを保持している時間が長すぎました。";

    //通常のエラーとロックエラーを区別する
    if(e.message == checkword){
      //ロックエラーの場合
      msg = "更新処理中でした";
    }else{
      //ソレ以外のエラーの場合
      msg = e.message;
    }    

  } finally {
    //ロックを開放する
    lock.releaseLock();

    //メッセージを表示する
    //ui.alert(msg);
  }
}

//項目名称を取得する
//-1が指定された場合は、オブジェクトの設定値も返す
//0以外の整数が設定された場合、その数の設定項目名を返す
function getItemNameList(col){
  var res = [];
  var spread = SpreadsheetApp.getActiveSpreadsheet() ;
  var sheet = spread.getSheetByName('database');
  if (col == -1) {
    var values = sheet.getRange(1, 1, 4, sheet.getLastColumn()).getValues();
  } else {
    var values = sheet.getRange(1, 1, 1, col).getValues();
  }
  return values;
}

//タイムスタンプの新しい方から取得する。dispRowにて件数調整可能
//[行番号,日付]の二次元配列で時間で降順
function getNewDataList(){
  var dispRow = 10;
  var res = [];
  var spread = SpreadsheetApp.getActiveSpreadsheet() ;
  var sheet = spread.getSheetByName('database');
  //2行目にシステム的な項目、3,4行目にHTMLのタグのタイプ、設定値を入れたので5行目からの取得とする
  var values = sheet.getRange(dispRow, 1, sheet.getLastRow()-1, 2).getValues();
  //ソート sorting_asc sorting_desc
  values.sort(sorting_desc);
  for (var i = 0; i < dispRow + 1; i++){
    res.push(values[i]);
  }
  return res;
}

//スプレッドシート内を文言で検索し行番号を返す(同じ行内に複数出てくると抽出結果も重複する
function rowSearch(str){
  var res = [];
  var spread = SpreadsheetApp.getActiveSpreadsheet() ;
  var sheet = spread.getSheetByName('database');
  var textFinder = sheet.createTextFinder(str);
  var ranges = textFinder.findAll();
  for(var i = 0; i < ranges.length; i++){
    var range = sheet.getRange(ranges[i].getA1Notation());
    res.push(range.getRow());
  }
  var res2 = uniqueArray(res);
  return res2;
}

//項目名を付けてデータを1行返す。
function getItemNameAndData (itemCount,dataNo,dataCol) {
  var res = [];
  res = getItemNameList(itemCount);

  if (dataNo > 0) {
    res.push(getCellValue(dataNo,dataCol));

    // checkボックスは2つ以上選択項目があるものとする
    // checkボックスのデータは配列に置き換える(テキストをカンマ区切りで配列に置き換える
    for (var i = 0; i < res[2].length; i++) {
        if (res[2][i] == "checkbox"){
          if (res[4][i] == '' || res[4][i] == null) {
            res[4][i] = [];    // データがなかったらカラの配列
          } else {
            if ( String(res[4][i]).indexOf(',') != -1) {
              res[4][i] = res[4][i].split(',');  // データがあってカンマ区切りなら配列にする
            } else {
              res[4][i] = [res[4][i]];           // データが1つしかない場合はそのまま配列にする
            }
          }
        }
    }
  } else {
    // 新規作成用
    var tmp2 = Array(res[0].length);

    // select radio のデータはカラにする
    // checkbox のデータはカラの配列にする
    for (var i = 0; i < res[2].length; i++) {
        if (res[2][i] == "select" || res[2][i] == "radio" ){
            tmp2[i] = '';
        } else if (res[2][i] == "checkbox") {
            tmp2[i] = [];
        } else {
            tmp2[i] = "未設定";
        }
    }
    res.push(tmp2);
  }

  //行列を入れ替える
  res = arrayTranspose(res);
  return res;
}

//行番号からセル値を取得
function getCellValue(row, col){
  // 現在アクティブなスプレッドシートを取得
  var ss = SpreadsheetApp.getActiveSpreadsheet();
  var sheet = ss.getSheetByName('database');
  // そのシートにある (1, 1) のセルから3行目までのセル範囲を取得
  if (col == -1 ) {
    var range = sheet.getRange(row, 1, 1, sheet.getLastColumn());
  } else {
    var range = sheet.getRange(row, 1, 1, col);
  }
  // そのセル範囲の値を取得
  var values = range.getValues();
  values[0][0] = row;
  values[0][1] = Utilities.formatDate( values[0][1], 'Asia/Tokyo', 'yyyy/MM/dd HH:mm:ss');
  return values[0];
}

//一次配列から重複を排除する
function uniqueArray(ary){
  var res = [];
  res = ary.filter(function(value, index, self){ 
                       return self.indexOf(value) === index; });
  return res;
}

//ソート昇順
function sorting_asc(a, b){
  if(a[1] < b[1]){
    return -1;
  }else if(a[1] > b[1] ){
    return 1;
  }else{
   return 0;
  }
}

//ソート降順
function sorting_desc(a, b){
  if(a[1] > b[1]){
    return -1;
  }else if(a[1] < b[1] ){
    return 1;
  }else{
   return 0;
  }
}

// 配列の行列入れ替え
// 参考:https://qiita.com/kznr_luk/items/790f1b154d1b6d4de398
// const transpose = a => a[0].map((_, c) => a.map(r => r[c]));
function arrayTranspose(a) {
  return a[0].map((_, c) => a.map(r => r[c]));
}

// splitの結果を返す
function getSplit(array, _order) {
  var tmp = array.split(",");
  if (_order == -1) {
    return tmp;
  } else {
    return tmp[_order];
  }
}

//itemmasterシートのB列以降のデータを配列に格納する
function getItemMasterData() {
  var spread = SpreadsheetApp.getActiveSpreadsheet() ;
  var sheet = spread.getSheetByName('itemmaster');
  var res = [];
  for (var i = 1; i < sheet.getLastColumn()+1; i++) {
    var tmp = getMasterData(i);
    res.push(tmp);
  }
  return res;
}

//一覧取得(検索にも対応)
function getSheetData(_str) {
  var spread = SpreadsheetApp.getActiveSpreadsheet();
  var sheet = spread.getSheetByName('database');

  if (_str == '' || _str == null) {
    // 検索なしの一覧取得
    var tmp1 = getNewDataList();
    var res = [];
    for(var i = 0; i < tmp1.length; i++){
      res.push(getCellValue(tmp1[i][0], 6));
    }
  } else {
    // 検索時
    var tmp1 = rowSearch(_str);
    var res = [];
    for(var i = 0; i < tmp1.length; i++){
      res.push(getCellValue(tmp1[i], 6));
    }
  }
  for (var i = 0; i < res.length; i++) {
    for (var j = 0; j < res[0].length; j++) {
      if (Object.prototype.toString.call(res[i][j]) == '[Object Date]') {
        res[i][j] = formateDate(res[i][j]);
      }
    }
  }
  return res;
}

//データ更新(新規も対応)
function setUpdData(ary) {
  var spread = SpreadsheetApp.getActiveSpreadsheet();
  var sheet = spread.getSheetByName('database');
  var arrayUpd = [ary.length];
  var typeIdx = 2;                // inputなどのタイプの要素番号
  var dataIdx = 4;                // データの要素番号

  if (ary[0][dataIdx] == "未設定") {
    // 新規用
    arrayUpd[0] = sheet.getLastRow()+1;
  } else {
    arrayUpd[0] = ary[0][dataIdx];  // 行番号を格納
  }
  arrayUpd[1] = getDateOfTokyo(); // タイムスタンプをセット

  // 更新データだけの配列を作成しながら、checkboxのデータは配列から文字列に変換する
  for (i=2; i < ary.length; i++) {
    if (ary[i][typeIdx] == "checkbox") {
      arrayUpd[i] = ary[i][dataIdx].join(',');
    } else {
        arrayUpd[i] = ary[i][dataIdx];
    }
  }
  var tmp = [];
  tmp.push(arrayUpd);
  dataSave(sheet,arrayUpd[0], 1, 1, arrayUpd.length,tmp);
  return true;
}

// 今日の日付
var getDateOfTokyo = function() {
  var date = new Date();
  return Utilities.formatDate( date, 'Asia/Tokyo', 'yyyy/MM/dd HH:mm:ss');
}

//日付フォーマット
var formateDate = function(d) {
  var addZero = function(n) {
    return (n < 10)? "0" + String(n):String(n);
  }
  var year = String(d.getFullYear());
  var month = addZero(d.getMonth() + 1);
  var date = addZero(d.getDate());
  var hour = addZero(d.getHours());
  var minite = addZero(d.getMinutes());
  var second = addZero(d.getSeconds());
  return year + "/" + month + "/" + date + " " + hour + ":" + minite + ":" + second;
};

vu_js.html

<script>
var vue_example = new Vue({
    el: '#vue_example',

    data:{
        listtitles:[['', '', '', '', '', 'です']],
        listitems: [['', '', '', '', '', 'です']],
        dispDetails: false,
        showContent: false,
        searchword: '',
        refitem:[],
        initOptions:[['初期値']],             //詳細画面のデータ
    },

    methods:{
        // データプロパティ
        initData: function(ary){ 
                        this.listitems = ary; 
                  },
        // 詳細データの更新
        refData:  function(ary){ 
                        this.refitem = [];
                        this.refitem = ary;
                        this.refitem.push('dummy');
                        this.refitem.pop();
                        //データ更新
                        vue_example.$forceUpdate();
                        this.$nextTick();
                        Vue.nextTick();
                  },
        initTitles: function(ary){ 
                        this.listtitles = ary; 
                  },
        // 検索文字列からデータ検索しデータ表示
        search_word: function(){ 
                        //リスト取得
                        google.script.run.withSuccessHandler(this.initData)
                                         .withFailureHandler(function(arg){
                                             alert("データの初期取得に失敗しました。");
                                          }).getSheetData(this.searchword);
                        //リストの項目名称6個を取得    
                        google.script.run.withSuccessHandler(this.initTitles)
                                         .withFailureHandler(function(arg){
                                             alert("リスト項目名の初期取得に失敗しました。");
                                          }).getItemNameList(6);
                  },
        // 検索文字列のクリア
        search_word_clea: function(){ 
                          this.searchword = '';
                  },
        // モーダル表示のプロパティ変更
        openModal: function(){
                        this.dispDetails = true;
                        this.showContent = true; 
        },
        // モーダル表示のプロパティ変更
        closeModal: function(){
                        this.dispDetails = false;
                        this.showContent = false; 
        },
        // プロパティinitOptionsのデータ更新
        initOption: function(ary){ 
                        this.initOptions = [];
                        this.initOptions = ary;
                        this.initOptions.push('dummy');
                        this.initOptions.pop();
                        //データ更新
                        vue_example.$forceUpdate();
                        this.$nextTick();
                  },
        // モーダル画面を表示し、データ取得(上記メソッドの呼び出し、引数になる)
        ref_item: function(_idx){
                      this.dispDetails = true;
                      this.showContent = true;

                      google.script.run.withSuccessHandler(this.initOption)
                                       .withFailureHandler(function(arg){
                                           alert("リスト項目名の取得に失敗しました。");
                                        }).getItemMasterData();

                      google.script.run.withSuccessHandler(this.refData)
                                       .withFailureHandler(function(arg){
                                           alert("データの取得に失敗しました。");
                                       }).getItemNameAndData(-1, this.listitems[_idx][0], -1);
                  },
        // 更新画面(モーダル)でのデータ更新後、モーダルOFF
        upd_item: function(){
                      google.script.run.withSuccessHandler(this.refitem)
                                       .withFailureHandler(function(arg){
                                           alert("データの更新に失敗しました。");
                                       }).setUpdData(this.refitem);
                      // モーダル画面を閉じる
                      this.dispDetails = false;
                      this.showContent = false;                      
                  },
        // 追加画面表示
        add_disp: function(){
                      // モーダル表示ON
                      this.dispDetails = true;
                      this.showContent = true;

                      google.script.run.withSuccessHandler(this.initOption)
                                       .withFailureHandler(function(arg){
                                           alert("リスト項目名の取得に失敗しました。");
                                        }).getItemMasterData();

                      google.script.run.withSuccessHandler(this.refData)
                                       .withFailureHandler(function(arg){
                                           alert("データの取得に失敗しました。");
                                       }).getItemNameAndData(-1, 0, -1);
                  },
        // データ一覧取得
        list_items: function(){
                      google.script.run.withSuccessHandler(this.initData)
                                       .withFailureHandler(function(arg){
                                           alert("データの取得に失敗しました。");
                                       }).getSheetData();
                  },
    },
    // 初期表示
    created: function() {//リスト取得
                         google.script.run.withSuccessHandler(this.initData)
                                          .withFailureHandler(function(arg){
                                              alert("データの初期取得に失敗しました。");
                                          }).getSheetData();
                         //リストの項目名称6個を取得    
                         google.script.run.withSuccessHandler(this.initTitles)
                                          .withFailureHandler(function(arg){
                                              alert("リスト項目名の初期取得に失敗しました。");
                                          }).getItemNameList(6);
    } 
});
</script>

vue_index.html

<!DOCTYPE html>
<html>
  <head>
    <meta http-equiv="Pragma" content="no-cache">
    <meta http-equiv="Cache-Control" content="no-cache">
    <meta http-equiv="Expires" content="0">
    <base target="_top">
    <?!= include('css'); ?>
    <script src="https://unpkg.com/vue"></script>
  </head>
<body>
Vueで作成したページです
  <div id="vue_example">
    <input type=text name=searchword v-model="searchword" />
    <button v-on:click="search_word()" class='btn-radius-blue'> 検索 </button>&nbsp;
    <button v-on:click="search_word_clea()" class='btn-radius-blue'> 検索クリア </button>&nbsp;
    <button v-on:click="add_disp()" class='btn-radius-blue'> 新規登録 </button><br><br>
    <table>
      <tr v-for="(titles,idx) in listtitles">
        <th v-for="title in titles">
        {{ title }}
        </th>
        <th>ボタン</th>
      </tr>
      <tr v-for="(it,idx) in listitems">
        <td v-for="tmp in it">{{ tmp }}</td>
        <td><button v-on:click="ref_item( idx )" class='btn-radius-blue'>詳細表示</button></td>
      </tr>
    </table>

    <!-- ----以下はモーダル表示-------------------------------------------------------------------- -->

    <div id="modal" v-show="showContent" class="overlay">
    <div id="modal_content" v-if="dispDetails" class="content">
    <table class="modal_table">
      <tr v-for="(refits,edit_Number) in refitem">
        <th>{{ refits[0] }}</th>
        <!-- いったんすべてを表示したいならここをインする
             <th>{{ refits[0] }}:{{ refits[1] }}:{{ refits[2] }}:{{ refits[3] }}:</th>
             <td><input v-model="refits[4]" size=refits[3] maxlength=refits[3] /></td>
        -->
        <td v-if="refits[2] == 'read'">{{ refits[4] }}</td>
        <td v-if="refits[2] == 'text'"><input :id="refits[1]" :name="refits[1]" type=text v-model="refits[4]" size=”refits[3]” maxlength=”refits[3]” /></td>
        <td v-if="refits[2] == 'textarea'">
            <textarea :id="refits[1]" v-model="refits[4]" rows=getSplit(refits[5],0) cols=getSplit(refits[5],1) placeholder="入力して下さい">
            </textarea>
        </td>
        <td v-if="refits[2] == 'select'">
            <select :id="refits[1]" :name="refits[1]" v-model="refits[4]" >
                <option v-for="(items,idx) in initOptions[refits[3]-1]" v-bind:value="items.item">{{ items.item }}</option>
            </select>
        </td>

        <td v-if="refits[2] == 'radio'">
            <div v-for="(items,idx) in initOptions[refits[3]-1]" >
                <input :id="refits[1]" 
                       :name="refits[1]"                                        
                       :key="items.id" 
                       type=radio 
                       v-model="refits[4]"
                       v-bind:value="items.item"
                       :checked="refits[4] == items.item"
                       />{{ items.item }}<br>
                       <!-- 初期のチェックがないらないので、v-modelとv-bind:valueを併記する -->
            </div>
        </td>

        <td v-if="refits[2] == 'checkbox'">
          <div v-for="(items,idx) in initOptions[refits[3]-1]">
                <input :id="refits[1]" 
                       :name="refits[1]" 
                       :key="items.id" 
                       type=checkbox
                       v-model="refits[4]"
                       v-bind:value="items.item"
                       :checked="refits[4].indexOf(items.item) > -1" />{{ items.item }}<br>
                       <!-- 初期のチェックがないらないので、v-modelとv-bind:valueを併記する -->
          </div>
        </td>
      </tr> <!-- trのfor文終了 -->
      <tr>
        <td></td>
        <td><button v-on:click="upd_item" class='btn-radius-orange'> 更新 </button>&nbsp;&nbsp;&nbsp;
            <button v-on:click="closeModal" class='btn-radius-blue'>閉じる</button></td>
      </tr>
    </table>

    </div>
    </div>
    <!-- モーダル表示終了 ----------------------------------------------------->
  </div>

<?!= include('vue_js'); ?>
</body>
</html>

css.html

<style>
body{
    font-family:Verdana,Arial;
    font-size:14px;
    text-align: center; 
}

div{
    text-align:center;
    margin-left:auto;
    margin-right:auto;
    text-align:left;
    width: 90%;
}

h2{
    font-size:14px;
    border-left:5px solid #ccc;
    padding:3px 0 3px 10px;
    margin-bottom:10px;
}

h3{
    border-bottom:1px solid #ccc;
    padding:3px 0;
    margin-bottom:10px;
}

table{
  width: 100%;
  <!-- border-collspase: collapse;     -->
  border-collapse:separate;
  border-spacing: 0;
}

.modal_table{
  width: 80%;
  <!-- border-collspase: collapse;     -->
  border-collapse:separate;
  border-spacing: 0;
}

table th{
  text-align: center;
  color:white;
  background: linear-gradient(#829ebc,#225588);
  border-left: 1px solid #3c6690;
  border-top: 1px solid #3c6690;
  border-bottom: 1px solid #3c6690;
  box-shadow: 0px 1px 1px rgba(255,255,255,0.3) inset;
  padding: 10px 10px;
}

table td{
  text-align: center;
  border-left: 1px solid #a8b7c5;
  border-bottom: 1px solid #a8b7c5;
  border-top:none;
  padding: 5px 10px;
}

table tr:nth-child(odd){
  background-color: #eee
}

table td:last-child{
  border-right: 1px solid #a8b7c5;
}

.btn-radius-orange {
  display: inline-block;
  padding: 7px 20px;
  border-radius: 10px;
  text-decoration: none;
  color: #FFF;
  background-image: linear-gradient(45deg, #FFC107 0%, #ff8b5f 100%);
  transition: .4s;
}

.btn-radius-orange:hover {
  background-image: linear-gradient(45deg, #FFC107 0%, #f76a35 100%);
}

.btn-radius-blue {
  display: inline-block;
  padding: 7px 20px;
  border-radius: 10px;
  text-decoration: none;
  color: #FFF;
  background-image: linear-gradient(45deg, #1F436E 0%, #1C6ECD 100%);
  transition: .4s;
}

.btn-radius-blue:hover {
  background-image: linear-gradient(45deg, #1F436E 0%, #0000BB 100%);
}

input[type="text"] ,input[type="email"] {
  width: 30em; 
}

select { 
  //width:250px; 
}

textarea {
  width: 30em;
  height: 100px;
}

/* #overlay{ */
.overlay{
  /* 要素を重ねた時の順番 */
  z-index:1;

  /* 画面全体を覆う設定 */
  position:fixed;
  top:0;
  left:0;
  width:100%;
  height:100%;
  background-color:rgba(0,0,0,0.5);

  /* 画面の中央に要素を表示させる設定 */
  display: flex;
  align-items: center;
  justify-content: center;

}

/* #content{ */
.content{
  z-index:2;
  overflow-y: scroll;
  width:80%;
  height:80%;
  padding: 1em;
  background:#fff;
  /* 画面の中央に要素を表示させる設定 */
  display: flex;
  justify-content: center;
}

</style>

感想

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

表示に使用するHTMLは1ファイルで100行未満。更新も追加もこれだけ。
Vueファイルも冗長っぽい感じがあるものの135行。
コード.gsは、doPostがなくなったものの、必要な関数を追加し変わらずの320行。

変にゴリゴリ書く感じもそんなにないし、これだけのコードで済むのだからやはりスゴイのかなVue。

なんだかんだで当初目標にしていた、「表示項目を増やしてもコードの編集が無いように」を達成できたので大変満足です。
項目を増やすたびにコードいじってたら手離れよくないですもんねぇ。

ほんと、誰かの助けにでもなれば幸いです。
ありがとうございました。m(_'_)m

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

Vue.js の transition-group が効かない。

注意点 1. key には繰り返す対象から直接持ってくる。

<!-- OK: key には繰り返す対象から直接持ってくる。 -->
<transition-group>
  <template v-for="element of array">
    <div :key="element.property"> {{ element.value }} </div>
  </template>
</transition-group>
<!-- NG: key には v-for の index を使わない -->
<transition-group>
  <template v-for="(element, index) of array">
    <div :key="index">{{ element.value }}</div>
  </template>
</transition-group>

注意点 2. 移動中に移動している要素のプロパティを変更しない。

値をいれると瞬間移動します。

// OK
function onClick () {
  // NG: 移動する前に変更するというのはダメでした。
  // this.array.map(element => 2* element.value)

  this.array = _.shuffle(array)

  // setTimeout で逃します。
  setTimeout(() => {
    this.array.map(element => 2* element.value)
  }, 2000)
}
// NG: 瞬間移動します。
function onClick () {
  this.array = _.shuffle(array)
  this.array.map(element => 2* element.value)
}
  • このエントリーをはてなブックマークに追加
  • Qiitaで続きを読む

スプレッドシートをDB代わりにGASのWebアプリを作成しデータ更新させてみた。Vue版 その3~~

スプレッドシートをDB代わりにGASのWebアプリを作成しデータ更新させてみた。Vue版 その2~~からの続きです。

今回は更新処理を行います。
とはいっても難しいことはありません。
まずは、以下の更新ボタンをクリックします。

vue_index.html
<button v-on:click="upd_item" class='btn-radius-orange'> 更新 </button>

「v-on:click」で指定されている「upd_item」が呼ばれます。

Vue側の以下の「upd_item」が呼ばれます。

vue_js.html
methods:{  //の中の・・・

    // 更新画面(モーダル)でのデータ更新後、モーダルOFF
    upd_item: function(){
              google.script.run.withSuccessHandler(this.refitem)
                               .withFailureHandler(function(arg){
                                   alert("データの更新に失敗しました。");
                               }).setUpdData(this.refitem);
              // モーダル画面を閉じる
              this.dispDetails = false;
              this.showContent = false;                      
          },

データ更新も「google.script.run.」を使い、「コード.gs」の「setUpdData」を「this.refitem」をパラメータに呼び出します。
更新失敗時にはアラートを出すようにしています。
成功時にも一応「this.refitem」を入れてはいますが、画面遷移上、モーダルは消えて、次の詳細を表示しようとする際には新しいデータが入ってくるので特に問題なしです。

「コード.gs」の「setUpdData」は以下のようになっており、更新と新規登録の両方に対応させました。新規登録のモーダルは説明していませんが、更新とほぼ同じです。データがないのでプロパティに「未設定」としたり、データを空にしたり、空の配列を作ったりしたものを表示しているだけです。
「setUpdData」では、更新時はスプレッドシートの行番号をそのままつかますが、新規登録時はmaxにプラス1して行番号を指定しています。違いはそれだけですね。

スプレッドシートなのでお決まりの、どのシートかを設定します。
そして、更新データだけの配列を用意します。
指定した「this.refitem」の中には余計なものがたくさん入っていますからね。

コード.gs
//データ更新(新規も対応)
function setUpdData(ary) {
  var spread = SpreadsheetApp.getActiveSpreadsheet();
  var sheet = spread.getSheetByName('database');
  var arrayUpd = [ary.length];
  var typeIdx = 2;                // inputなどのタイプの要素番号
  var dataIdx = 4;                // データの要素番号

  if (ary[0][dataIdx] == "未設定") {
    // 新規用
    arrayUpd[0] = sheet.getLastRow()+1;
  } else {
    arrayUpd[0] = ary[0][dataIdx];  // 行番号を格納
  }
  arrayUpd[1] = getDateOfTokyo(); // タイムスタンプをセット

  // 更新データだけの配列を作成しながら、checkboxのデータは配列から文字列に変換する
  for (i=2; i < ary.length; i++) {
    if (ary[i][typeIdx] == "checkbox") {
      arrayUpd[i] = ary[i][dataIdx].join(',');
    } else {
        arrayUpd[i] = ary[i][dataIdx];
    }
  }
  var tmp = [];
  tmp.push(arrayUpd);
  dataSave(sheet,arrayUpd[0], 1, 1, arrayUpd.length,tmp);
  return true;
}

更新データの1つ目はスプレッドシートの行番号、2つ目はタイムスタンプ。この2つは、このシリーズではお決まり(固定)とさせていただいてます。
「// 更新データだけの配列を作成しながら、checkboxのデータは配列から文字列に変換する」以降でデータを取得し、配列に入れています。
そして「checkbox」の時だけ、もう1処理しています。
それは、例えば、「駐車場」と「駐輪場」にチェックが入っていた場合、
プロパティデータ上は、[駐車場,駐輪場]という配列になっていますので、
文字列として、、、、「駐車場,駐輪場」の形にして保存することにしています。
(そのため、データを表示する際は、「駐車場,駐輪場」の文字列から[駐車場,駐輪場]の配列に(変換しています。)
そして、「var tmp = [];」で更にpushしていますが、これは、
スプレッドシートにデータを反映するには(1行しか更新しなくても)表形式的な感じで更新するので、2次元配列にする必要があります。そのため「tmp」を用意し、pushしています。

ここから更に「dataSave」に飛ばしています。
ここではバッティングしないように見よう見まねで実装しています。
全ソース公開時に見ていただければと思います。

あとは、モーダルを閉じて、一覧画面に戻ります。

一覧画面に戻った時にリフレッシュしたかったのですが、できず・・・
検索ボックスに何もいれずに「検索」ボタンを押せばリフレッシュされます。

次は、ソースを公開していったん終了ですね。

ではまた。

追記::
完結させましたぁ~
スプレッドシートをDB代わりにGASのWebアプリを作成しデータ更新させてみた。Vue版 その4[完結編]~~

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

vue.js 画像をリサイズしてアップロード

画像をリサイズしてアップロード。

javascript側で 画像を多少加工してPHPに送るとexif情報を失うので、
あとでPHPで対応することができない。
よって先にexifを元にjsで加工しておく必要がある。

参考
https://qiita.com/su_mi1228/items/f8f729a29f0b980f7e32

ボタンのデザインの変更
https://qiita.com/yasumodev/items/c9f8e8f588ded6b179c9

インストール

npm install --save blueimp-load-image

あとはコピペで動く


<template>

    <div>
        <input type="file" accept=".jpeg,.jpg,.png" @change="attachImg">
        <img :src="resizedImg">
    </div>

</template>



<script>

    import loadImage from 'blueimp-load-image';

    export default {

        data() {
            return {
                resizedImg: null
            };
        },

        methods: {

            attachImg(e) {

                const file = e.target.files[0];

                loadImage.parseMetaData(file, (data) => {

                    const options = {
                        maxHeight: 512,
                        maxWidth: 512,
                        canvas: true
                    };

                    if (data.exif) {
                        options.orientation = data.exif.get('Orientation');
                    }

                    this.displayImage(file, options);
                });
            },

            displayImage(file, options) {
                loadImage(
                    file,
                    async (canvas) => {
                        const data = canvas.toDataURL(file.type);

                        console.log(data);

                        // data_url形式をblob objectに変換
                        const blob = this.base64ToBlob(data, file.type);
                        // objectのURLを生成
                        const url = window.URL.createObjectURL(blob);
                        this.resizedImg = url; // resizedImgはdataで定義
                    },
                    options
                );
            },

            base64ToBlob(base64, fileType) {
                const bin = atob(base64.replace(/^.*,/, ''));
                const buffer = new Uint8Array(bin.length);
                for (let i = 0; i < bin.length; i++) {
                    buffer[i] = bin.charCodeAt(i);
                }
                return new Blob([buffer.buffer], {
                    type: fileType ? fileType : 'image/png'
                });
            }



        }
    }
</script>


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

スプレッドシートをDB代わりにGASのWebアプリを作成しデータ更新させてみた。Vue版 その2~~

スプレッドシートをDB代わりにGASのWebアプリを作成しデータ更新させてみた。Vue版 その1~~からの続きです。

ハマったところはこちら↓
スプレッドシートをDB代わりにGASのWebアプリを作成しデータ更新させてみた。Vue版、ハマったところinputの再描画編~~

スプレッドシートをDB代わりにGASのWebアプリを作成しデータ更新させてみた。Vue版、ハマったところcheckboxの再描画編~~

前回は初期表示まででした。
今回は一覧表示されたデータから詳細データをモーダルで表示し更新させます。
以下のように一覧の右にある「詳細表示」ボタンをクリックするところからです。

image.png

クリックされるとこんな感じで詳細画面を表示します。

image.png

この「詳細ボタン」はクリックしたときに「v-on:click」で「ref_item( idx )」が呼び出されます。
「idx」はfor文のインデックスです。

vue_index.html
<td><button v-on:click="ref_item( idx )" class='btn-radius-blue'>詳細表示</button></td>

「ref_item」の説明の前に、詳細画面がモーダルで表示されるので、そのお話を少し。
モーダルで表示する箇所の冒頭を以下に記します。

vue_index.html
    <div id="modal" v-show="showContent" class="overlay">
    <div id="modal_content" v-if="dispDetails" class="content">

divタグを2つ用意しました。そして1つ目には「v-show」を、2つ目には「v-if」を設定しています。
「v-show」は表示するかしないかの設定です。設定されたプロパティがtrueなら表示するし、falseなら表示しない。となります。
「v-if」は、設定されたプロパティがtrueなら内側のタグの処理をします。falseなら処理しない、ふっとばすことになります。
モーダル表示自体は1つ目のタグのclassで指定しています。
なんでそうなるはわかっていませんが、以下のようにCSSを設定しています。

css.html
/* #overlay{ */
.overlay{
  /* 要素を重ねた時の順番 */
  z-index:1;

  /* 画面全体を覆う設定 */
  position:fixed;
  top:0;
  left:0;
  width:100%;
  height:100%;
  background-color:rgba(0,0,0,0.5);

  /* 画面の中央に要素を表示させる設定 */
  display: flex;
  align-items: center;
  justify-content: center;

}

「ref_item」の話に戻りまして、vue_index.htmlの中で以下のように記載されています。
はじめに上記のプロパティ2つをtrueにしています。
こうすることで「v-show」で表示するようになり、
「v-if」は内側のタグを表示するようになります。
(※閉じる処理の時は2つともfalseにしています。)
ここで「v-show」だけでもいいんじゃないの?と思われるかもしれませんが、試行錯誤の結果、「v-if」も記載し、明示的に「v-if」のtrue、falseを切り替えることで「v-if」を記載したよりも内側のタグの表示が正しく表示されるようになりました。詳しくはハマったところを見てやってください。

vue_js.html
// モーダル画面を表示し、データ取得(上記メソッドの呼び出し、引数になる)
ref_item: function(_idx){
      this.dispDetails = true;
      this.showContent = true;
      // マスターデータ取得
      google.script.run.withSuccessHandler(this.initOption)
                       .withFailureHandler(function(arg){
                           alert("リスト項目名の取得に失敗しました。");
                      }).getItemMasterData();
      // 詳細データ取得
      google.script.run.withSuccessHandler(this.refData)
                       .withFailureHandler(function(arg){
                           alert("データの取得に失敗しました。");
                      }).getItemNameAndData(-1, this.listitems[_idx][0], -1);
                  },

もう少し↑の説明を。
ここでもやはり「コード.gs」のfunctionを実行するにあたり「google.script.run」を使用しています。
取得に失敗したら、「.withFailureHandler」に入りアラートで「失敗しました」の旨が表示されます。
成功したら、「.withSuccessHandler」に入り、パラメータ指定されたメソッドが呼び出されます。
「メソッドが呼び出される」のであって、ここには戻り値は書かれていません。
それぞれ「initOption」「refData」メソッドが呼び出され、それぞれのパラメータに「コード.gs」のfunction「getItemMasterData」や「getItemNameAndData」の戻り値が入ります。
そのメソッドは下記になります。
そして、メソッド内で各パラメータに設定しています。

vue_js.html
    methods:{
        // データプロパティ  ↓ここに戻り値がくる
        initData: function(ary){ 
                        this.listitems = ary; 
                  },
        // 詳細データの更新 ↓ここに戻り値がくる
        refData:  function(ary){ 
                        this.refitem = [];
                        this.refitem = ary;
                        this.refitem.push('dummy');
                        this.refitem.pop();
                        //データ更新
                        vue_example.$forceUpdate();
                        this.$nextTick();
                        Vue.nextTick();
                  },

「refData」では、再描画がうまくいかなかったときのナゴリがありますw
配列データは、pushやpopを使わないとVueが気付いてくれないというので"dummy"を入れて削除しました。
forceUpdate()やnextTick()で強制的に更新をかけることもしました。
きっと、そういうこともあるのではないかと残しておきます。
これでようやくマスターデータと詳細データの取得ができたので表示に取り掛かりましょう

こちら↓になります。

vue_index.html
    <table class="modal_table">
      <tr v-for="(refits,edit_Number) in refitem">
        <th>{{ refits[0] }}</th>
        <td v-if="refits[2] == 'read'">{{ refits[4] }}</td>
        <td v-if="refits[2] == 'text'"><input :id="refits[1]" :name="refits[1]" type=text v-model="refits[4]" size=”refits[3]” maxlength=”refits[3]” /></td>

リスト表示では1データを1行で左から右に表示してました。
詳細画面では、1データは上から下に表示するようになりますので、配列の並び(データの持ち方)は、リスト表示の時とは、タテ、ヨコを入れ替えた形で保持させています。
(※コツというほどでもないですが、その方がtableのfor文で回す時 楽なので。)

「refitem」を回して、取得した「refits」も配列になっています。
「refits」の要素番号2がどのような表示をさせたいのかを保持しています。
そしてそれを「v-if」で場合分けしていきます。
今はとりあえず、「read」と「text」を見ていきましょう。
「read」と「text」の設定はデータシートの3行目のものになります。

「read」は読ませるだけなので、特にタグもなく表示しています。

「text」はinputタグを使用し、
「:id」で「refits[1]」を、これはデータシートの2行目で設定している値になります。
「:name」でも同様です。 「:」はバインドの省略形になります。
typeはtextですね。ここには「:」はありません。
「v-model」として「refits[4]」を指定します。これによりデータが画面に表示されます。
「size」、「maxlength」共に「refits[3]」を指定しています。これはデータシートの4行目の値ですね。

次はtextarea です。
設定はほぼ変わりませんが、「rows」「cols」の設定として、やはりデータシートの4行目の値を持ってくるのですが、設定値を「5,30」としています。5行30文字の設定を表しています。
「コード.gs」に「getSplit」関数を用意し、指定した要素番号の数値を返すようにしています。
こんな風に関数を呼べるんですね。

vue_index.html
        <td v-if="refits[2] == 'textarea'">
            <textarea :id="refits[1]" v-model="refits[4]" rows=getSplit(refits[5],0) cols=getSplit(refits[5],1) placeholder="入力して下さい">
            </textarea>
        </td>

続きましてselectです。
selectやradio,checkboxは選んでもらう項目が必要ですので、マスターデータシートに記載します。
データシートの4行目は、この3タイプに限りマスターデータシート列番号を記載します。
このような形でデータとマスターデータをリンク付けしてみました。
selectタグは変わりありませんが、
optionタグをマスターデータが入っている「initOptions」で回します。
この時、マスターデータのデータは連想配列になっていることに注意してくださいね。
「initOptions」で回して「items」となったデータを「v-bind:value」で「items.item」を指定しています。

vue_index.html
        <td v-if="refits[2] == 'select'">
            <select :id="refits[1]" :name="refits[1]" v-model="refits[4]" >
                <option v-for="(items,idx) in initOptions[refits[3]-1]" v-bind:value="items.item">{{ items.item }}</option>
            </select>
        </td>

もう少しです。がんばりましょう。
radioです。
「v-for」をdivタグ内で記述していますがtemplateタグでもいいかと思います。
見栄えの関係でdivを選びました。
今までと異なる点は「:key」です。「items.id」を指定しています。「id」です。
また、初期表示で入っているデータにはチェックをつけたいので「:checked」で「refits[4] == items.item」を入れて判定させています。マスターデータと詳細データが合致していればtrueとなりチェックが入ります。
「v-model」を指定すると、「v-bind:value」にも設定されるそうなのですが、
「v-model」は「refits[4]」
「v-bind:value」は「items.item」と異なるプロパティを設定することにより
期待する動きを得ています。

vue_index.html
        <td v-if="refits[2] == 'radio'">
            <div v-for="(items,idx) in initOptions[refits[3]-1]" >
                <input :id="refits[1]" 
                       :name="refits[1]"                                        
                       :key="items.id" 
                       type=radio 
                       v-model="refits[4]"
                       v-bind:value="items.item"
                       :checked="refits[4] == items.item"
                       />{{ items.item }}<br>
                       <!-- 初期のチェックがないらないので、v-modelとv-bind:valueを併記する -->
            </div>
        </td>

次のcheckboxで詳細表示はいったん終わります。ガンバ!
radioボタンとあまり変わりはありません。異なる点は
その1:データは配列で持つこと。
    そうしないとデータが取れずに、true、falseでしか更新できなくなってしまいます。
その2:radioボタンと違って、複数の項目にチェックができますので、
    「:checked」を「refits[4].indexOf(items.item) > -1」で判定しています。

vue_index.html
        <td v-if="refits[2] == 'checkbox'">
          <div v-for="(items,idx) in initOptions[refits[3]-1]">
                <input :id="refits[1]" 
                       :name="refits[1]" 
                       :key="items.id" 
                       type=checkbox
                       v-model="refits[4]"
                       v-bind:value="items.item"
                       :checked="refits[4].indexOf(items.item) > -1" />{{ items.item }}<br>
                       <!-- 初期のチェックがないらないので、v-modelとv-bind:valueを併記する -->
          </div>
        </td>

後は、更新ボタンと閉じるボタンだけですね。
更新はまた次回にします。
閉じるボタンは、「v-on:click」で「closeModal」が呼ばれてプロパティがfalseに設定され、モーダルが閉じます。

vue_index.html
        <td><button v-on:click="upd_item" class='btn-radius-orange'> 更新 </button>&nbsp;&nbsp;&nbsp;
            <button v-on:click="closeModal" class='btn-radius-blue'>閉じる</button></td>
      </tr>
    </table>

    </div>
    </div>
    <!-- モーダル表示終了 ----------------------------------------------------->
vue_js.html
        // モーダル表示のプロパティ変更
        closeModal: function(){
                        this.dispDetails = false;
                        this.showContent = false; 
        },

ではまた。

追記::
スプレッドシートをDB代わりにGASのWebアプリを作成しデータ更新させてみた。Vue版 その3~~で更新してみました。

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

Go + Vue.js でGETとPOSTをやってみる

はじめに

この記事はGo lang 駆け出しによる駆け出し向けの記事になっていますので基本的なことしか書いておりません。

今回はGo言語とVueを使ってPOSTとGETでリクエストを投げてパラメータの受け渡しをやってみる。
なおGoのフレームワークとしてEchoを使っていく。

こんなやつ↓

今回のコードはgithubに(最下部掲載)

Vue.js前準備

まずはvueのプロジェクト作成
次にaxiosを入れる。リクエストはaxiosを使って送る。

$vue create ~~
$cd ~~
$npm install --save axios vue-axios

main.go側

作成したプロジェクトの中にmain.goファイルを作る

今回はechoを使っていくのでフレームワークを入れる

$go get -u github.com/labstack/echo

大体の流れ↓

CORSを忘れずに。

main.go
package main

import (
    "net/http"
    "github.com/labstack/echo"
    "github.com/labstack/echo/middleware"
)

func main(){
    e := echo.New()

    //CORSの設定(vueのプロジェクトをGOで立てたlocalサーバーで起動する時は不要)
    e.Use(middleware.CORS())

    // リクエストに対するHandler
    e.GET("/getTitle", getTitle)
    e.GET("/getName/:name", getName)
    e.POST("/postName", postName)
    e.POST("/postCompany", postCompany)

    // local サーバー
    e.Logger.Fatal(e.Start(":8000"))
}

各Handler定義

main.go
// GETリクエスト
func getTitle(c echo.Context) error {
    return c.String(http.StatusOK, "New Game")
}

// パラメータ付きのGETリクエスト
func getName(c echo.Context) error {
    name := c.Param("name")
    return c.String(http.StatusOK, name)
}

// application/x-www-form-urlencoded データのPOSTリクエスト
func postName(c echo.Context) error {
    name := c.FormValue("name")
    return c.String(http.StatusOK, name)
}

//JSON受け取り用の構造体
type JsonParam struct {
    Company string `json:"company"`
    Works string `json:"works"`
}

// JSONデータのPOSTリクエスト
func postCompany(c echo.Context) error {
    param := new(JsonParam)
    //バインドしてJSON取得
    if err := c.Bind(param); err != nil {
        return err
    }
    //JSONを返す
    return c.JSON(http.StatusOK, param)
}

GETリクエストではurlに/~~/:パラメータ名とし、Context.Param(パラメータ名)とすることでURLに付加されたのパラメータを取得できる。

POSTでデータを取得する際にデータがform-urlencodedなのかJSONなのかで取得の仕方が変わってくる。
form-urlencodedの場合Context.FormValue(キー)で取れる。
JSONの場合は先に**JSONの構造体を用意しておいてContext.Bind(構造体)で取れる。

これでバックエンド側の準備は完了
次にフロントのvue側を作っていく。

$go run main.go

今回はポート8000でローカルサーバーを起動させておく

Vue.js側

適当にビューを作作成

App.vue
<template>
  <div id="app">
    <button @click="sendRequest">リクエスト送信</button>
    <h1>取得結果</h1>
    <p>GET(パラメータ無):<br/><strong>{{title}}</strong></p>
    <p>GET(パラメータ有):<br/><strong>{{name1}}</strong></p>
    <p>POST(form-urlencoded):<br/><strong>{{name2}}</strong></p>
    <p>POST(JSONデータ):<br/><strong>{{company}}</strong></p>
  </div>
</template>

<script>
//~~省略~~
  data:()=>{
    return{
      title:"",
      name1:"",
      name2:"",
      company:""
    }
  }
</script>

次にリクエストを送る関数を作成していく

App.vue
<script>
import axios from "axios"

//~~省略~~
  methods: {
    sendRequest: async function(){
      //パラメータ無しでGETリクエスト
      const getRequestNoParam = await axios.get("http://localhost:8000/getTitle")

      //パラメータ付きでGETリクエスト
      const getRequest = await axios.get("http://localhost:8000/getName/ひふみん")

      //application/x-www-form-urlencodedでデータを送信
      const params = new URLSearchParams();
      params.append("name","青葉");
      const postRequest = await axios.post("http://localhost:8000/postName",params)

      //JSONデータを送信(axiosはデフォルトでJSONを送信)
      const jsonPostRequest = await axios.post("http://localhost:8000/postCompany", {
        company: "Eagle Jump",
        works: "PECO"
      });

      //取得結果をviewに反映
      this.title = getRequestNoParam.data
      this.name1 = getRequest.data
      this.name2 = postRequest.data
      this.company = jsonPostRequest.data
    }
}
</script>

axiosはPOSTリクエストの時デフォルトでJSONを送信するようになっているのでform-urlencodedを使いたいときはURLSearchParamsAPIを使う。

これで完成!!
npm run serve使ってvueのローカルサーバー(localhost:8080)で起動させればリクエストの送受信ができるはず。

VueプロジェクトをGoのローカルサーバーで動かす

上の場合はvueプロジェクトをnpm run serve使って、vueのローカルサーバー(localhost:8080)で動かして、Goはlocalhost:8000で動かしていた。これだとクロスドメインでCORSの設定が必要になる。

そこで、最後におまけ的な感じでvueプロジェクトをGoで起動したローカルサーバーで動かしてみる。

まずはvueプロジェクトをビルドする。

$npm run build

うまく実行できるとdistフォルダができてるはず。
あとは、これをGoで動かすだけ。

main.goを以下のように修正

main.go
func main(){
    e := echo.New()
    //CORSの設定(vueのプロジェクトをGOで立てたlocalサーバーで起動する時は不要)
    // e.Use(middleware.CORS())

    // npm run buildでビルドしたものをgoで起動  corsも不要になる
    //  /でアクセスしたときのルーティング設定
    e.Static("/", "dist/")

    // リクエストに対するHandler
    e.GET("/getTitle", getTitle)
    e.GET("/getName/:name", getName)
    e.POST("/postName", postName)
    e.POST("/postCompany", postCompany)

    // local サーバー
    e.Logger.Fatal(e.Start(":8000"))
}

当然だけどVue.js側のリクエストを送るときも以下のように省略できる。

App.vue
//パラメータ無しでGETリクエスト 
- const getRequestNoParam = await axios.get("http://localhost:8000/getTitle")
+ const getRequestNoParam = await axios.get("/getTitle")

あとはサーバーを起動して

$go run main.go

localhost:8000にアクセスすれば別々に動かしていた時と同じように使える。

おわり

GoとVueを使って簡単にAPIのやり取りができた!
Goって結構たのしいな。
AWSとかで動かしてみたい...

今回のコードGitHubからどうぞ

参考

公式ドキュメント

@y_ussie 様 : Go言語のWebフレームワーク「Echo」を使ってみる ②(リクエストパラメータの扱い)

@567000 様 : Go(golang) echoとVue-cliをつなげる

ありがとうございました。

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

SkyWayとNuxt.jsでWebRTCのグループビデオ通話機能開発

株式会社OneSmallStepの西(@_takeshi_24)です!

最近新型コロナの影響で在宅ワークも増え、それに伴い、ビデオチャットやTV会議システムも色々と登場してきています。
オンラインのビデオ通話支える仕組みがWebRTCなのですが、今回はこのWebRTCを簡単に実装できるSkyWayについてご紹介します!

WebRTCとは?

WebRTCとはHTMLのAPIの一種で、ブラウザ間で映像や音声などの大容量のデータをリアルタイムに送受信するための技術です。
従来のWebRTCはコンピュータ同士を直接P2Pでつなぐメッシュ方式でした。
メッシュ方式の場合、複数のコンピュータ同士で映像や音声を配信すると、端末が増えるにつれて回線や端末に大きな負荷がかかります。
同時に接続できる端末数に上限があります。

WebRTC SFU

WebRTC SFU (Selective Forwarding Unit ) は、メッシュ方式と異なり、音声や映像をサーバ経由で配信します。
接続するコンピュータが増えても、端末自体にかかる負荷は少なく、複数端末で同時に接続することが可能です。

SkyWay

SkyWayとはWebRTCを用いたビデオ通話や音声通話を簡単に実装できるSDK、APIサービスです。
導入事例を見るとレアジョブ英会話など様々なサービスで利用されているようです。

skyway.png

しかも驚くべきことに、月当たり50万接続までは無料で使えると言う料金設定!
桁間違えてるんじゃないの?笑

SkyWay料金

WebRTCを使った音声・ビデオ通話のためにクライアントサイドに必要なSDKもちゃんと準備されています。
これらを使うことで、ブラウザアプリはもちろん、iOS、Androidなどのスマホ、IoT機器からも簡単に利用することができます。
SkyWaySDK

Nuxt.js/TypeScriptでSkyWayを利用

今回は、Nuxt.js(TypeScript)を使ったビデオ通話Webアプリの開発について説明します。

なお、Nuxt.jsについてはこの記事では紹介しませんので、Nuxt.jsの環境は事前にご用意してください。

開発

SkyWayAPIキーの取得

1.SkyWayを利用するために、まずは何はともあれ、SkyWayのアカウント登録をします。
https://console-webrtc-free.ecl.ntt.com/users/registration

2.アカウント登録後、「新しくアプリケーションを追加する」からアプリケーションを追加します。
スクリーンショット 2020-05-16 19.23.30.png

3.アプリケーション名、利用可能なドメイン名などを入力し、「アプリケーションを作成する」をクリックします。
スクリーンショット 2020-05-16 19.25.31.png

4.アプリが作成され、APIキーが発行されます
スクリーンショット 2020-05-16 19.27.09.png

SDKのインストール

1.Nuxt.jsのプロジェクトにJavaScriptのSDKをインストールします。

yarn add skyway-js

グループビデオ通話機能の開発

Interfaceの定義を作成します。

types/interface.ts
export interface SkywayMediaStream extends MediaStream {
  peerId: string
}

Componentを作成します。

SkywayVideo.vue
<template>
  <div class="skyway-video">
    <video id="local-stream"></video>
    <div>
      <button @click="mute">{{ muteText }}</button>
      <button @click="disconnect">切断</button>
    </div>
    <div id="remote-streams" class="remote-streams">
      <div
        v-for="remoteStream in remoteStreams"
        :ref="remoteStream.peerId"
        :key="remoteStream.peerId"
      >
        <video autoplay :srcObject.prop="remoteStream"></video>
      </div>
    </div>
  </div>
</template>
<script lang="ts">
import Vue from 'vue'
import Peer, { SfuRoom } from 'skyway-js'
import { SkywayMediaStream } from '@/types/interface.ts'
interface SkywayData {
  peer: Peer | null
  room: SfuRoom | null
  localStream: MediaStream | undefined
  isMute: boolean
  remoteStreams: SkywayMediaStream[]
}
export default Vue.extend({
  name: 'SkywayVideo',
  props: {
    userName: {
      type: String,
      default: null
    },
    roomName: {
      type: String,
      default: null
    }
  },
  data: (): SkywayData => ({
    peer: name,
    room: null,
    localStream: undefined,
    isMute: false,
    remoteStreams: []
  }),
  computed: {
    muteText(): string {
      return this.isMute ? 'ミュート解除' : 'ミュート'
    }
  },
  async mounted() {
    const localVideo = document.getElementById(
      'local-stream'
    ) as HTMLMediaElement

    this.localStream = await navigator.mediaDevices.getUserMedia({
      audio: true,
      video: true
    })

    localVideo.muted = true
    localVideo.srcObject = this.localStream
    await localVideo.play()

    this.peer = await new Peer(this.userName, {
      key: process.env.SKYWAY_API_KEY || '',
      debug: 3
    })
    this.peer.on('open', this.connect)
  },
  methods: {
    // 接続処理
    connect() {
      if (!this.peer || !this.peer.open) {
        return
      }

      this.room = this.peer.joinRoom(this.roomName, {
        mode: 'sfu',
        stream: this.localStream
      }) as SfuRoom

      if (this.room) {
        this.room.on('stream', (stream: SkywayMediaStream): void => {
          this.remoteStreams.push(stream)
        })

        this.room.on('peerLeave', (peerId: string): void => {
          const audio = document.getElementById(peerId)
          if (audio) {
            audio.remove()
          }
        })
      }
    },
    // ミュート切り替え
    mute(): void {
      if (this.localStream) {
        const audioTrack = this.localStream.getAudioTracks()[0]
        this.isMute = !this.isMute
        audioTrack.enabled = !this.isMute
      }
    },
    // 切断
    disconnect(): void {
      if (this.room) {
        this.room.close()
      }
    }
  }
})
</script>
<style lang="scss" scoped>
// .skyway-video
</style>

コンポーネントを読み込むページは以下のような感じです。

index.vue
<template>
  <skyway-video :room-name="roomName" :user-name="userName"></skyway-video>
</template>
<script lang="ts">
import Vue from 'vue'
import SkywayVideo from '@/components/molecules/SkywayVideo/SkywayVideo.vue'
export default Vue.extend({
  components: {
    SkywayVideo
  },
  data: () => ({
    roomName: 'sample'
  }),
  computed: {
    userName(): string {
      return this.makeRandum(10)
    }
  },
  methods: {
    makeRandum(retLength: number): string {
      // 生成する文字列に含める文字セット
      const chars: string = 'abcdefghijklmnopqrstuvwxyz0123456789'
      const charsLength: number = chars.length
      const ret: string[] = [...Array(retLength)].map((): string => {
        return chars[Math.floor(Math.random() * charsLength)]
      })
      return ret.join('')
    }
  }
})
</script>
<style lang="scss" scoped>
// .skyway-video
</style>

ポイントを解説。

  • SkywayVideo.vueのlocal-stream内に自分の映像が埋め込まれます。
  • SkywayVideo.vueの以下の箇所で、ユーザーの接続をトリガーに、remoteStreamsにMediaStreamが追加されます。
        this.room.on('stream', (stream: SkywayMediaStream): void => {
          this.remoteStreams.push(stream)
        })
  • userNameとroomNameをPropsで親コンポーネントから受け取っています。
    • userNameが接続のIDになります。
    • roomNameはグループ通話の部屋名になり、roomNameが同じユーザーとグループ通話が可能です。
  • SkywayVideo.vueのmounted()のなかで、ローカルのビデオを表示し、SkyWayに接続します。
    • 音がハウリングしないように、localVideo.muted = trueで、自分自身のVideoは常にミュートにしています。
    • SKYWAY_API_KEYは、SkyWayで取得したAPIキーを環境変数に設定したものです。
  • SkywayVideo.vueのmute()は、自分の音声をミュートにしています。
  • SkywayVideo.vueのdisconnect()は切断処理です。

SkyWayの公式ドキュメントに詳細はありますので、そちらを参考にしてください。

SkyWayで話し中のユーザーにマークをつけたい

ビデオ会議システムでよくあるのが話しているユーザーのビデオにマークをつける処理です。
こちらはvoice-activity-detectionというライブラリで実現可能です。
https://github.com/Jam3/voice-activity-detection

まずは、voice-activity-detectionをインストール

yarn add voice-activity-detection

pluginsにvad.tsを以下の内容で作成します。

plugins/vad.ts
import Vue from 'vue'
import { SkywayMediaStream } from '@/types/interface.ts'
const vad = require('voice-activity-detection')

Vue.mixin({
  methods: {
    startVoiceDetection(
      this: any,
      stream: SkywayMediaStream,
      talkUpdate: (peerId: string | null) => void
    ) {
      const audioContext = new AudioContext()
      const vadOptions = {
        onVoiceStart() {
          talkUpdate(stream.peerId)
        },
        onVoiceStop() {
          talkUpdate(null)
        }
      }
      // streamオブジェクトの音声検出を開始
      this.vadobject = vad(audioContext, stream, vadOptions)
    },
    stopVoiceDetection(this: any) {
      if (this.vadobject) {
        // 音声検出を終了する
        this.vadobject.destroy()
      }
    }
  }
})

nuxt.config.tsで、pluginを読み込みます。

nuxt.config.ts
plugins: [{ src: '~/plugins/vad.ts', ssr: false }],

SkywayVideo.vueに処理を追加します。追加箇所のコメントを参照。

SkywayVideo.vue
<template>
  <div class="skyway-video">
    <video id="local-stream"></video>
    <div>
      <button @click="mute">{{ muteText }}</button>
      <button @click="disconnect">切断</button>
    </div>
    <div id="remote-streams" class="remote-streams">
      <!-- ↓追加箇所:classを追加 -->
      <div
        v-for="remoteStream in remoteStreams"
        :ref="remoteStream.peerId"
        :key="remoteStream.peerId"
        :class="talkingId === remoteStream.peerId ? 'talking' : ''"
      >
        <video autoplay :srcObject.prop="remoteStream"></video>
      </div>
    </div>
  </div>
</template>
<script lang="ts">
import Vue from 'vue'
import Peer, { SfuRoom } from 'skyway-js'
import { SkywayMediaStream } from '@/types/interface.ts'
interface SkywayData {
  peer: Peer | null
  room: SfuRoom | null
  localStream: MediaStream | undefined
  isMute: boolean
  remoteStreams: SkywayMediaStream[]
  // ↓追加箇所
  talkingId: string | null
  // ↑追加箇所
}
export default Vue.extend({
  name: 'SkywayVideo',
  props: {
    userName: {
      type: String,
      default: null
    },
    roomName: {
      type: String,
      default: null
    }
  },
  data: (): SkywayData => ({
    peer: name,
    room: null,
    localStream: undefined,
    isMute: false,
    remoteStreams: [],
    // ↓追加箇所
    talkingId: null
    // ↑追加箇所
  }),
  computed: {
    muteText(): string {
      return this.isMute ? 'ミュート解除' : 'ミュート'
    }
  },
  async mounted() {
    const localVideo = document.getElementById(
      'local-stream'
    ) as HTMLMediaElement

    this.localStream = await navigator.mediaDevices.getUserMedia({
      audio: true,
      video: true
    })

    localVideo.muted = true
    localVideo.srcObject = this.localStream
    await localVideo.play()

    this.peer = await new Peer(this.userName, {
      key: process.env.SKYWAY_API_KEY || '',
      debug: 3
    })
    this.peer.on('open', this.connect)
  },
  methods: {
    // 接続処理
    connect() {
      if (!this.peer || !this.peer.open) {
        return
      }

      this.room = this.peer.joinRoom(this.roomName, {
        mode: 'sfu',
        stream: this.localStream
      }) as SfuRoom

      if (this.room) {
        // ↓追加箇所
        this.room.on('stream', (stream: SkywayMediaStream): void => {
          ;(this as any).startVoiceDetection(stream, this.talkUpdate)
          this.remoteStreams.push(stream)
        })
        // ↑追加箇所

        this.room.on('peerLeave', (peerId: string): void => {
          const audio = document.getElementById(peerId)
          if (audio) {
            audio.remove()
          }
        })
      }
    },
    // ミュート切り替え
    mute(): void {
      if (this.localStream) {
        const audioTrack = this.localStream.getAudioTracks()[0]
        this.isMute = !this.isMute
        audioTrack.enabled = !this.isMute
      }
    },
    // 切断
    disconnect(): void {
      if (this.room) {
        this.room.close()
      }
    },
    // ↓追加箇所
    talkUpdate(peerId: string) {
      this.talkingId = peerId
    }
    // ↑追加箇所
  }
})
</script>
<style lang="scss" scoped>
// .skyway-video
// ↓追加箇所
.talking {
  border: 3px solid #0000ff;
}
// ↑追加箇所
</style>

plugins/vad.tsのstartVoiceDetectionで、MediaStreamの音声を検出しています。
SkywayVideo.vueで、話し中のユーザーのpeerIdをtalkingIdにセットしています。
remoteStreamsのpeerIdとtalkingIdが一致したvideoタグにtalkingのclassを指定して、スタイルを適用させています。

まとめ

話し中のユーザーにマークをつける処理は苦戦しましたが、それ以外はSkyWayのおかげで簡単にWebRTCのテレビ電話処理を作成する事ができました!
今回はテレビ電話でしたが、音声通話だけなら、videoタグじゃなくて、audioタグで可能です。

いつもNuxt.jsのこととか、Firebaseのことを中心につぶやいていますので、Twitter(@_takeshi_24)のフォローよろしくお願いします!

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

Rails + Vue.js でページネーション付きのテーブルを簡単作成

概要

  • 業務でページネーション機能を実装したので、ほとんどそのままの構成で手順を紹介
  • 使用した技術はkaminari(Rails)Vuetify(Vue.js)
  • api経由でデータを取得し、ページネーション付きで表示する
  • ソースコード

Railsの開発環境は特に説明しないが、以下の記事を参考に構築しました
Rails 6 + MySQL on Dockerの環境を秒速で構築する

Bookモデルの定義とサンプルデータの作成

今回はデータベースに保存したBookの一覧をapiで取得します
まずはBookモデルを作成しましょう

# マイグレーションファイルの作成
bin/rails g model Book name:string

# マイグレーションを実行し、Booksテーブルを作成
bin/rails db:migrate

db/seeds.rbを編集しサンプルデータを作成

db/seeds.rb
100.times do |n|
  name  = "example-#{n+1}"
  Book.create!(name: name)
end

seedを実行

bin/rails db:seed

これでBookレコードが100件作成されました

kaminariのインストールとBook一覧取得用apiの作成

Bookテーブルからデータを取得する際にkaminariを使用します
kaminariをインストールします
https://github.com/kaminari/kaminari

Gemfile
# kaminariを追記
gem 'kaminari'
# kaminariのインストール。インストール完了後にサーバーを再起動させましょう
bundle

app/controllers/api/books_controller.rbを作成し、Book一覧を返すapiを実装します

app/controllers/api/books_controller.rb
class Api::BooksController < ApplicationController
  def index
    # 表示するページの番号を指定
    page = params[:page] || 1

    # 1ページあたりの表示件数を指定
    per = params[:per] || 10

    # ページネーションで指定レコードを取得
    books = Book.page(page).per(per)

    # ページネーションした時の全ページ数
    total_pages = books.total_pages

    # レスポンスデータの定義
    response = {
      # bookレコードはidとnameフィールドのみ表示する
      books: books.select(:id, :name),
      total_pages: total_pages
    }

    # json形式でレスポンスを返却
    render json: response
  end
end
config/routes.rb
Rails.application.routes.draw do
  # Book一覧取得用のパス
  get '/api/books', to: 'api/books#index'

  # Book一覧表示用のパス
  get '/books', to: 'books#index'
end

http://localhost:3000/api/booksにアクセスすると次のようなjsonが返ってきます

スクリーンショット 2020-05-18 22.22.11.png

Book一覧表示ページの作成

Book一覧表示用のページを作成します
Vuetifyのv-data-tableコンポーネントv-paginationを使い、Axiosでapiを叩きます
※ ここでは面倒を避けるためCDN経由で環境構築をしてあります。適宜ご自身の環境に合わせた環境構築を行なってください

app/views/layouts/application.html.erb
<!DOCTYPE html>
<html>
  <head>
    <title>AppName</title>
    <%= csrf_meta_tags %>
    <%= csp_meta_tag %>

    <%# CDNで Vue.js, Vuetify, Axios をインストールする %>
    <link href="https://fonts.googleapis.com/css?family=Roboto:100,300,400,500,700,900" rel="stylesheet">
    <link href="https://cdn.jsdelivr.net/npm/@mdi/font@4.x/css/materialdesignicons.min.css" rel="stylesheet">
    <link href="https://cdn.jsdelivr.net/npm/vuetify@2.x/dist/vuetify.min.css" rel="stylesheet">
    <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, minimal-ui">

    <%= stylesheet_link_tag 'application', media: 'all', 'data-turbolinks-track': 'reload' %>
    <%= javascript_pack_tag 'application', 'data-turbolinks-track': 'reload' %>
  </head>

  <%# ここも追加 %>
  <script src="https://cdn.jsdelivr.net/npm/vue@2.x/dist/vue.js"></script>
  <script src="https://cdn.jsdelivr.net/npm/vuetify@2.x/dist/vuetify.js"></script>
  <script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
  <script src="https://cdn.jsdelivr.net/npm/es6-promise@4/dist/es6-promise.auto.min.js"></script>
  <body>
    <%= yield %>
  </body>
</html>
app/views/books/index.html.erb
<div id="app">
  <v-app>
    <v-container>
      <h2>Book一覧</h2>
      <%# テーブル作成用コンポーネント %>
      <v-data-table
        :headers="headers"
        :items="items"
        :items-per-page="itemsPerPage"
        hide-default-footer
      />
    </v-container>
    <%# ページネーション表示用コンポーネント %>
    <v-pagination
      v-model="currentPage"
      :length="totalPages"
      <%# ページを変更した時にfetchBooksを呼び出す %>
      @input="fetchBooks"
    />
  </v-ap>
</div>

<script>
  new Vue({
    el: "#app",
    vuetify: new Vuetify(),
    data() {
      return {
        // テーブルのヘッダー情報。valueの値がレコードのフィールド名に紐付く
        headers: [
          { text: "ID", value: "id"},
          { text: "本の名前", value: "name"},
        ],
        // テーブルのボディー情報。apiで取得したBook一覧をここに格納する
        items: [],
        // 表示するページの番号
        currentPage: 1,
        // 1ページあたりの表示件数
        itemsPerPage: 10,
        // ページネーションした時の全ページ数
        totalPages: null,
      }
    },
    methods: {
      // AxiosでBook取得apiにリクエストを送る
      fetchBooks() {
        const url = `/api/books?page=${this.currentPage}?per=${this.itemsPerPage}`;
        axios
          .get(url)
          .then(res => {
            // Book一覧を取得
            this.items = res.data.books;
            // ページネーションした時の全ページ数を取得
            this.totalPages = res.data.total_pages;
        })
      }
    },
    // DOMが作成された時に fetchBooks を呼び出す
    created() {
      this.fetchBooks()
    },
  });
</script>

http://localhost:3000/books
これでページネーションは完成です
以下のようにページを切り替える度に表示が変わればOKです!

スクリーンショット 2020-05-18 23.38.17.png

スクリーンショット 2020-05-18 23.38.27.png

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