JavaScript
vue.js
vuetifyjs

Vue.jsで延々と同じ質問を聞かれ続けるチャットボットもどき

延々と誰担かを聞かれてクリックで答え続けるだけのブラウザチャットボットもどきを作ってvueの練習。

NEWS、ニューシングル『BLUE』(FIFAワールドカップ日本テレビ系ロシア2018テーマソング)絶賛発売中。(ステマ)

目標

vueで配列をコンポーネントに渡して条件分岐したりデータを受け渡しできるようになる

CodePen

See the Pen vuejs:配列をコンポーネントに渡して条件分岐 by ta-ke-no-bu (@ta-ke-no-bu) on CodePen.

実装

装飾用フレームワークとしてvuetifyjsを使用。
CodePen使用

大枠を使えるようにする

Codepen上でvueを呼び出す
https://cdnjs.cloudflare.com/ajax/libs/vue/2.1.10/vue.min.js

参考:宣言的レンダリング

index.vue
<div id="app">
  中身
</div>
index.js
const app = new Vue({
  el: '#app'
})

コンポーネントの定義

参考:コンポーネントの基本

index.vue
<div id="app">
  <answer-area></answer-area>
</div>
<template id="answer-area">
</template>
index.js
Vue.component('answer-area', {// テンプレートを使えるようにする
  template: '#answer-area'
}

コンポーネントに必要なデータを作って渡す

テンプレート(コンポーネント)でやりたいことを決めて、データを作り、必要なものを子コンポーネントへ渡す。

参考:プロパティを使用した子コンポーネントへのデータの受け渡し
必要なものを(ここではquestions)v-bindで渡す。:はv-bindの省略。

参考:リストレンダリング
配列を渡したいのでv-forを使う。v-forはforeach的なやつ。

index.vue
<div id="app">
  <answer-area  v-for="(question, key, index) in questions"
  <!-- データをコンポーネントに渡す。 -->
               :key="index"
               :who="question.who"
               :image="question.image"
               :messages="question.messages"
               :message="question.message"
               >
  </answer-area>
</div>

<template id="answer-area">
  <div>
    <div><!-- classを自分発言か、相手発言かで切り替えたいのでwhoを使う予定 -->

      <!-- 相手発言の場合、左にアバターを表示したい -->
      <div>
        <div>
          <img src="" /><!-- データからimageを拾って表示したい -->
        </div>
      </div>

      <div>
        <!-- データから(見出しとなるメッセージor回答時の答え)を拾って表示したい -->
        <p><strong>{{ message }}</strong></p>


        <!-- messagesから選択肢を拾ってボタンを押したら回答を返すようにしたい -->
        <div>
          <button>
            選択肢
          </button>
        </div>
      </div>


      <!-- 自分発言の場合、右にアバターを表示したい -->
      <div>
        <div>
          <img src="" /><!-- データからimageを拾って表示したい -->
        </div>
      </div>
    </div>


  </div>
</template>
index.js
Vue.component('answer-area', {
  template: '#answer-area'
}


const app = new Vue({
  el: '#app',
  data() {
    return {
      questions: [//デフォルト表示用のデータ
        {
          "id": "",
          "image": "http://placehold.it/32x32",
          "who": "other",
          "message": "NEWSの中で誰が好き?",
          "messages": [
            {
              "id": 0,
              "name": "増田貴久",
              "color": "yellow accent-2",
            },
            {
              "id": 1,
              "name": "手越祐也",
              "color": "pink lighten-2",
            },
            {
              "id": 2,
              "name": "加藤シゲアキ",
              "color": "green darken-1",
            },
            {
              "id": 3,
              "name": "小山慶一郎",
              "color": "purple darken-3",
            }
          ]
        }
      ],
    }
  }
})

コンポーネントの中身を作りこむ

データを渡したらpropsで受け取ってデータを拾ったり、条件やイベントを入れていく

参考:条件付きレンダリング
v-if="image"など条件分岐を入れていく
vueファイルのif-elseは

  • v-if
  • v-elseif
  • v-else

で記述する

参考:クラスとスタイルのバインディング
動的にクラスを切り替えやデータを受け取りたい時はv-bindを使う

例:div :class="who" whoがotherの時とmeの時の切り替え
img :src='image' questions.imageをバインディング

参考:Props
appからコンポーネントに渡したものはpropsを記述して受け取る

参考:イベントハンドリング
v-on:clickでイベントをセットする。.onceで多重クリック防止でこの時@省略では動かなかった。

参考:イベントと値を送出する
emitでイベントの名前と値を渡す
$emit(this.sendEvent, answer)

index.vue
<div id="app">
  <answer-area  v-for="(question, key, index) in questions"
               :key="index"
               :who="question.who"
               :image="question.image"
               :messages="question.messages"
               :message="question.message"
               >
  </answer-area>
</div>

<template id="answer-area">
  <div>
    <div :class="who"><!-- classをデータからバインドする。:はv-bind:の略 -->

      <div v-if="who === 'other'"><!-- whoがotherだったら表示する条件分岐 -->
        <div v-if="image"><!-- imageがあったら表示するように条件分岐 -->
          <img :src='image' /><!-- srcにイメージをバインド。 -->
        </div>
      </div>

      <div>
        <p v-if="message"><strong>{{ message }}</strong></p>

        <!-- コンポーネントに渡した配列を展開して使う -->
        <div v-for="(mess, key, index) in messages"
          :message="mess.name"
          :key="index"
        >

          <button v-on:click.once="clickParam((mess.name))">
          <!-- v-on:clickでクリックイベントを設定。渡したいデータを取得 -->
            {{mess.name}}
          </button>

        </div>

      </div>


      <!-- 自分発言の場合、右にアバターを表示したい -->
      <div v-if="who === 'me'">
        <div v-if="image">
          <img :src='image' /><!-- データからimageを拾って表示したい -->
        </div>
      </div>
    </div>


  </div>
</template>
index.js
Vue.component('answer-area', {
  template: '#answer-area',
  props: ['name', 'who', 'message', 'messages', 'image','sendEvent'],
  // コンポーネントで使うデータをpropsで受け取る
  data () {
    return {
      answer: ''//クリックイベントで返すデータのセット
    }
  },
  methods: {
    clickParam (answer) {//クリックイベントでanswerデータを返す
      this.$emit(this.sendEvent, answer)//$emitでイベントの名前と値を渡す
    }
  }
})

コンポーネントから受け取ったデータで処理を作る

index.vue
<div id="app">
  <answer-area  v-for="(question, key, index) in questions"
               :key="index"
               :who="question.who"
               :image="question.image"
               :messages="question.messages"
               :message="question.message"
               @send-answer="clickParam"
              <!-- コンポーネント内のクリックイベントで取得したデータを受け取る -->
               send-event="send-answer"
              <!-- イベント名と紐付ける -->
               >
  </answer-area>
</div>
index.js
const app = new Vue({
  el: '#app',
  data() {
    return {
      questions: [
        {
          "id": "",
          "image": "http://placehold.it/32x32",
          "who": "other",
          "message": "NEWSの中で誰が好き?",
          "messages": [
            {
              "id": 0,
              "name": "増田貴久",
              "color": "yellow accent-2",
            },
            {
              "id": 1,
              "name": "手越祐也",
              "color": "pink lighten-2",
            },
            {
              "id": 2,
              "name": "加藤シゲアキ",
              "color": "green darken-1",
            },
            {
              "id": 3,
              "name": "小山慶一郎",
              "color": "purple darken-3",
            }
          ]
        }
      ],
    }
  },
  methods: {//クリックイベントで受け取ったデータの処理
    clickParam (value) { //テンプレートのanswerを拾う
      let questions
      questions = this.questions
      questions.push({//回答。配列に追加
        image: 'https://qiita-image-store.s3.amazonaws.com/0/166550/profile-images/1488505250',
        who: 'me',
        message: value + 'です!', //テンプレートのanswerで答える
      }),
      setTimeout(() => {//返信してる風にちょっと遅らせる
        var obj,height
        obj = document.documentElement
        height = obj.scrollHeight - obj.clientHeight
        window.scroll(0, height)
       }, 500),
      setTimeout(() => {
        questions.push(//botっぽく見えるように遅らせて次の問題を追加
          {
            "id": "",
            "image": "http://placehold.it/32x32",
            "who": "other",
            "message": "NEWSの中で誰が好き?",
            "messages": [
              {
                "id": 0,
                "name": "増田貴久",
                "color": "yellow accent-2",
              },
              {
                "id": 1,
                "name": "手越祐也",
                "color": "pink lighten-2",
              },
              {
                "id": 2,
                "name": "加藤シゲアキ",
                "color": "green darken-1",
              },
              {
                "id": 3,
                "name": "小山慶一郎",
                "color": "purple darken-3",
              }
            ]
          }
        );
        setTimeout(() => {//自分の答えより遅れて表示させる。
          var obj,height
          obj = document.documentElement
          height = obj.scrollHeight - obj.clientHeight
          window.scroll(0, height)
        }, 500)
      }, 1500)
    },
  }
})

vuetifyjsを使って装飾

vuetifyjs

Codepen上でvuetifyjsを呼び出す
css:https://cdnjs.cloudflare.com/ajax/libs/vuetify/1.1.0-beta.2/vuetify.css
js:https://cdnjs.cloudflare.com/ajax/libs/vuetify/1.1.0-beta.2/vuetify.js

メンバーカラーでbuttonを色分け(個人的こだわり。ちなみに黄色担です)

index.vue
<div id="app">
  <answer-area v-for="(question, key, index) in questions"
               :key="index"
               :who="question.who"
               :image="question.image"
               :messages="question.messages"
               :message="question.message"
               @send-answer="clickParam"
               send-event="send-answer"
               >
  </answer-area>
</div>

<template id="answer-area">
  <v-container grid-list-xs text-xs-center>
  <!-- 手っ取り早くグリッド(https://vuetifyjs.com/ja/layout/grid)をつかう -->
    <v-layout row wrap :class="who">
      <v-flex xs3 v-if="who === 'other'">
        <v-avatar v-if="image">
          <img :src='image' />
        </v-avatar>
      </v-flex>

      <v-flex xs9 class="blue-grey lighten-4">
        <p v-if="message"><strong>{{ message }}</strong></p>
        <div v-for="(mess, key, index) in messages"
          :message="mess.name"
          :key="index"
        >
          <v-btn v-on:click.once="clickParam(mess.name)"  color="title black--text" :class="mess.color">
          <!-- メンバーカラーで色分け -->
            {{mess.name}}
          </v-btn>
        </div>
      </v-flex>
      <v-flex xs1 v-if="who === 'me'">
        <v-avatar v-if="image">
          <img :src='image' />
        </v-avatar>
      </v-flex>
    </v-layout>
  </v-container>
</template>

残りはcssで微調整

課題の自己解決(追記)

同じ項目のダブルクリックは制御できたけど隣の項目を急いでクリックしたら二重投稿できちゃうのをなんとかしたい。(2018/06/12追記)

v-on:click.onceを使わずに、preventに変更してv-bindにフラグを追加してdisabledの有無を切り替える

index.vue
        <div v-for="(mess, key, index) in messages"
          :message="mess.name"
          :key="index"
        >
          <v-btn @click.prevent="clickParam(mess.name)"
                 :disabled="isButtonDisabled"
                 color="title black--text" :class="mess.color">
            {{mess.name}}
          </v-btn>
        </div>
index.js
Vue.component('answer-area', {
  template: '#answer-area',
  props: ['name', 'who', 'message', 'messages', 'image','sendEvent'],
  data () {
    return {
      answer: '',
      isButtonDisabled: false// デフォルト値設定
    }
  },
  methods: {
    clickParam (answer) {
      // クリックされたらtrueを返して値を渡す
      if (this.isButtonDisabled) return
      this.isButtonDisabled = true
      this.$emit(this.sendEvent, answer)
    }
  }
})

まとめ

・基本的なものを一通り使ったのでvueに慣れるのにちょうどよかった。
・axiosでパラメーターpostしてjsonを返せばちゃんとしたボットができそう。

・NEWS、ニューシングル『BLUE』(FIFAワールドカップ日本テレビ系ロシア2018テーマソング)絶賛発売中。(二回目)