Help us understand the problem. What is going on with this article?

Vue.js初心者がチャットアプリケーションの開発を通じて勉強した話

はじめに

Vue.js初心者がチャットアプリケーションを作りながら勉強した話です。
題材はBuild a Real-time Chat App with Pusher and Vue.js(*1)です。(ありがとうございます)

このアプリケーション全てを理解するのは初心者には大変だったので
細かい部分の理解は置いておき、全体の理解に努めたつもりです。

完成品はこちら:hugging:
image.png
↓ログインするとチャット画面に遷移
image.png

ちなみに私のスペックは

  • Vue.jsの知識は全くない
  • 開発経験はある(Reactとか)

という感じです。

(*1) 一部日本語訳と私のコメントですm(_ _)m

手順

では、本題です。

製作物の確認(1/9)

Slackのようなチャットアプリケーションを作ります。
要件は

  • 複数の部屋がある
  • ルームメンバーをリストし、ログイン状態を表示する
  • 他のユーザが入力開始するタイミングを検出し表示する

です。(本格的ですね:flushed:
バックエンドはChatKitというサービスを利用し、フロントエンドをVue.jsで実装します。

事前準備として、Vue CLIをグローバルにインストールしておきます。
(Nodeがマシンにインストールされている必要があります)

$ npm install -g @vue/cli

バックエンドの準備(2/9)

PusherのChatKitというサービスを利用するので、まずは右上からサインアップします。
(Githubでサインアップしてエラーが出た場合、 パスワードのリセットで解消する可能性があります)

サインインするとポップアップが出ますが、一旦スキップでも大丈夫です。
image.png

「CHATKIT」を選び、「CREATE」を押します。
image.png
INSTANCE NAMEは VueChatTut でOKです。

インスタンスができたら「Console」タブの「CREATE USER」からユーザを作成します。
image.png

"John"(User Identifier)と“John Wick"(Display Name)とします。
同じ要領で

  • salt, Evelyn Salt
  • hunt, Ethan Hunt

というユーザも作りましょう。

続いて部屋を作り、ユーザを割り当てます。
「Create and join a room」をクリックし、"John Wick"(Select a user to create the room)を選択、"General"(Room Name)と記入し「CREATE ROOM」をクリックします。

image.png

部屋ができたら「Add user to room」でsaltとhuntを追加します。
同様に

  • Weapons (john, salt)
  • Combat (john, hunt)

という部屋も作り、メンバーを追加しましょう。

次に、「Add message to room」からテストメッセージを送っておきます。例えば、"General"で"John Wick"(Select a message author)を選択、"test"(Messge)と記入し「CREATE MESSAGE」をクリックします。

プロジェクトの作成(3/9)

Vue CLIでプロジェクトを作ります。何個か質問されますが、全てEnterしました。

$ vue create vue-chatkit

不要なファイルの削除と、今回使うファイルの作成を行います。

$ mkdir src/assets/css
$ mkdir src/store
$ mkdir src/views

$ touch src/assets/css/{loading.css,loading-btn.css}
# ↑はhttps://github.com/sitepoint-editors/vue-chatkit/tree/master/src/assets/cssから内容をコピペしておきます

$ touch src/components/{ChatNavBar.vue,LoginForm.vue,MessageForm.vue,MessageList.vue,RoomList.vue,UserList.vue}
$ touch src/store/{actions.js,index.js,mutations.js}
$ touch src/views/{ChatDashboard.vue,Login.vue}
$ touch src/{chatkit.js,router.js}

$ rm src/components/HelloWorld.vue

srcディレクトリ以下はこんな感じになります。

$ tree -L 2 --matchdirs src
src
├── App.vue ←大元のビュー
├── assets ←CSSや画像
│   ├── css
│   └── logo.png
├── chatkit.js ←ChatKitと接続する
├── components ←コンポーネント。コンポーネントを集めてビューを作る
│   ├── ChatNavBar.vue
│   ├── LoginForm.vue
│   ├── MessageForm.vue
│   ├── MessageList.vue
│   ├── RoomList.vue
│   └── UserList.vue
├── main.js ←Vueアプリケーションを起動する
├── router.js ←ルーティング
├── store ←状態管理
│   ├── actions.js
│   ├── index.js
│   └── mutations.js
└── views ←ビュー
    ├── ChatDashboard.vue
    └── Login.vue

5 directories, 16 files

次に、依存関係をインストールします。

$ npm i @pusher/chatkit-client bootstrap-vue moment vue-chat-scroll vuex-persist vue-router vuex
  • @pusher/chatkit-client、ChatKitのリアルタイムクライアントインターフェイス
  • bootstrap-vue、CSSフレームワーク
  • moment、日付と時刻のフォーマットユーティリティ
  • vue-chat-scroll、新しいコンテンツが追加されると自動的に下にスクロールする
  • vuex、Vue.jsアプリケーションのための状態管理ライブラリ。Reactで言うReduxのイメージ
  • vuex-persist、ブラウザのローカルストレージにVuexの状態を保存する
  • vue-router、Vue.jsのルータ

では、Vue.jsプロジェクトの設定をしましょう。src/main.jsを開き、以下のように更新します。

src/main.js
import Vue from 'vue'
import BootstrapVue from 'bootstrap-vue'
import VueChatScroll from 'vue-chat-scroll'

import App from './App.vue'
import router from './router'
import store from './store/index'

import 'bootstrap/dist/css/bootstrap.css'
import 'bootstrap-vue/dist/bootstrap-vue.css'
import './assets/css/loading.css'
import './assets/css/loading-btn.css'

Vue.config.productionTip = false // trueにすると開発者向けメッセージがコンソールに出る
Vue.use(BootstrapVue) // ライブラリを利用する宣言
Vue.use(VueChatScroll)

new Vue({
  router,
  store,
  render: h => h(App)
}).$mount('#app') // Vueインスタンスを作成し、#appにマウント(idがappであるDOMを置換)
// 詳しく知りたい方はネットで調べてみてください。私のような初心者の内はおまじないという認識で良さそう

src/router.jsを以下のように更新します。

src/router.js
import Vue from 'vue'
import Router from 'vue-router'
import Login from './views/Login.vue'
import ChatDashboard from './views/ChatDashboard.vue'

Vue.use(Router)

export default new Router({
  mode: 'history', // ページのリロードなしにURL遷移を実現する(SPAのためという理解)
  base: process.env.BASE_URL,
  routes: [
// 「/」というパスのルートをLoginコンポーネントにマップする
    {
      path: '/',
      name: 'login',
      component: Login
    },
    {
      path: '/chat',
      name: 'chat',
      component: ChatDashboard,
    }
  ]
})

src/store/index.jsを更新します。

src/store/index.js
import Vue from 'vue'
import Vuex from 'vuex'
import VuexPersistence from 'vuex-persist'
import mutations from './mutations'
import actions from './actions'

Vue.use(Vuex)

const debug = process.env.NODE_ENV !== 'production'

const vuexLocal = new VuexPersistence({
  storage: window.localStorage
})

export default new Vuex.Store({
  state: {
  }, // ストアの状態
  mutations, // ストアの状態を変更するメソッド群(ミューテーションをコミットすることで変更できる)
  actions, // ミューテーションをコミットするメソッド群
  getters: {
  }, // ストアの状態を加工して取得するメソッド群
  plugins: [vuexLocal.plugin], // LocalStorageを使うよ
  strict: debug // 開発時に状態変更をデバッキングツールで追跡できるようにする(ChromeかFireFoxの方は「Vue.js devtools」という拡張機能が便利)
})

UIインターフェイスの構築(4/9)

ここからちょっとハードになって行きますよ〜まずはsrc/App.vueを更新します。

src/App.vue
<template>
  <div id="app">
    <router-view/> // 先ほどのrouter.jsによってマッチしたコンポーネントが描画される
  </div>
</template>

次に、src/store/index.jsのstateとgettersセクションを更新します。
loadingで「CSSローダーを実行する必要があるか」やerrorで「エラー情報」を管理していますが、一個一個分かっていなくてOKです。

// ...
state: {
  loading: false,
  sending: false,
  error: null,
  user: [],
  reconnect: false,
  activeRoom: null,
  rooms: [],
  users: [],
  messages: [],
  userTyping: null
},
getters: {
  hasError: state => state.error ? true : false
},
// ...

ログイン画面src/view/Loing.vueを作ります。構成はこんな感じで、フォームがコンポーネントとなっています。
image.png

src/view/Loing.vue
<template> // テンプレートはtemplateタグで囲む
  <div class="login">
// b-xxはbootstrapのコンポーネント
    <b-jumbotron  header="Vue.js Chat"
                  lead="Powered by Chatkit SDK and Bootstrap-Vue"
                  bg-variant="info"
                  text-variant="white">
      <p>For more information visit website</p>
      <b-btn target="_blank" href="https://pusher.com/chatkit">More Info</b-btn>
    </b-jumbotron>
    <b-container>
      <b-row>
        <b-col lg="4" md="3"></b-col>
        <b-col lg="4" md="6">
          <LoginForm />
        </b-col>
        <b-col lg="4" md="3"></b-col>
      </b-row>
    </b-container>
  </div>
</template>

<script>
import LoginForm from '@/components/LoginForm.vue'

export default {
  name: 'login',
  components: {
// Loginのテンプレート内でLoginFormを使えるようにする
    LoginForm
  }
}
</script>

ログイン画面で利用するコンポーネントsrc/view/LoginForm.vueを更新します。

src/view/LoginForm.vue
<template>
  <div class="login-form">
    <h5 class="text-center">Chat Login</h5>
    <hr>
// ↓preventDefaultを実行し、onSubmitメソッドを呼ぶ
    <b-form @submit.prevent="onSubmit">
 // ↓showの頭にコロンをつけているので、値としてscriptタグ内のhasErrorという算出プロパティを使える。また、変数を表示する場合は{{}}で囲う
      <b-alert variant="danger" :show="hasError">{{ error }} </b-alert>

      <b-form-group id="userInputGroup"
                    label="User Name"
                    label-for="userInput">
        <b-form-input id="userInput"
                      type="text"
                      placeholder="Enter user name"
// ↓双方向データバインディングを作成する。入力に応じてuserIdが更新される
                      v-model="userId"
                      autocomplete="off"
                      :disabled="loading"
                      required>
        </b-form-input>
      </b-form-group>

      <b-button type="submit"
                variant="primary"
                class="ld-ext-right"
                v-bind:class="{ running: loading }" // v-bind:classでクラスを動的に切り替えられる。loadingがtrueのときrunningクラスをつける
                :disabled="isValid">
                Login <div class="ld ld-ring ld-spin"></div>
      </b-button>
    </b-form>
  </div>
</template>

<script>
import { mapState, mapGetters } from 'vuex'

export default {
  name: 'login-form',
  data() { // データ定義
    return {
      userId: '',
    }
  },
  computed: { // 算出プロパティ。テンプレートからロジックを切り出せる
    isValid: function() {
      const result = this.userId.length < 3;
      return result ? result : this.loading
    },
    ...mapState([
      'loading',
      'error'
    ]), // stateを返す
    ...mapGetters([
      'hasError'
    ]) // getterの評価後の値を返す
  }
}
</script>

ここまで書くと表示確認ができます:blush:
npm run serve でVue devサーバを起動し、http://localhost:8080 を開いてみましょう。

image.png

続いて、チャット画面src/view/ChatDashBoard.vueを作ります。
チャット画面は

  • ChatNavBar、ナビゲーションバー
  • RoomList、ログインしたユーザがアクセスできる部屋の一覧表示
  • UserList、選択したルームのメンバーをリスト
  • MessageList、選択したルームに投稿されたメッセージをリスト
  • MessageForm、選択したルームにメッセージを送信するためのフォーム

の5つのコンポーネントで構成されています。
image.png

src/view/ChatDashBoard.vue
<template>
  <div class="chat-dashboard">
    <ChatNavBar />
    <b-container fluid class="ld-over" v-bind:class="{ running: loading }">
      <div class="ld ld-ring ld-spin"></div>
      <b-row>
        <b-col cols="2">
          <RoomList />
        </b-col>

        <b-col cols="8">
          <b-row>
            <b-col id="chat-content">
              <MessageList />
            </b-col>
          </b-row>
          <b-row>
            <b-col>
              <MessageForm />
            </b-col>
          </b-row>
        </b-col>

        <b-col cols="2">
          <UserList />
        </b-col>
      </b-row>
    </b-container>
  </div>
</template>

<script>
import ChatNavBar from '@/components/ChatNavBar.vue'
import RoomList from '@/components/RoomList.vue'
import MessageList from '@/components/MessageList.vue'
import MessageForm from '@/components/MessageForm.vue'
import UserList from '@/components/UserList.vue'
import { mapState } from 'vuex';

export default {
  name: 'Chat',
  components: {
    ChatNavBar,
    RoomList,
    UserList,
    MessageList,
    MessageForm
  },
  computed: {
    ...mapState([
      'loading'
    ])
  }
}
</script>

全てのコンポーネントを表示するため、ボイラープレートコードを挿入します。
ここはコピペで大丈夫です。

src/view/ChatNavBar.vue
<template>
  <b-navbar id="chat-navbar" toggleable="md" type="dark" variant="info">
    <b-navbar-brand href="#">
      Vue Chat
    </b-navbar-brand>
    <b-navbar-nav class="ml-auto">
      <b-nav-text>{{ user.name }} | </b-nav-text>
      <b-nav-item href="#" active>Logout</b-nav-item>
    </b-navbar-nav>
  </b-navbar>
</template>

<script>
import { mapState } from 'vuex'

export default {
  name: 'ChatNavBar',
  computed: {
    ...mapState([
      'user',
    ])
  },
}
</script>

<style>
  #chat-navbar {
    margin-bottom: 15px;
  }
</style>
src/components/RoomList.vue
<template>
  <div class="room-list">
    <h4>Channels</h4>
    <hr>
    <b-list-group v-if="activeRoom">
      <b-list-group-item v-for="room in rooms"
                        :key="room.name"
                        :active="activeRoom.id === room.id"
                        href="#"
                        @click="onChange(room)">
        # {{ room.name }}
      </b-list-group-item>
    </b-list-group>
  </div>
</template>

<script>
import { mapState } from 'vuex'

export default {
  name: 'RoomList',
  computed: {
    ...mapState([
      'rooms',
      'activeRoom'
    ]),
  }
}
</script>
src/components/UserList.vue
<template>
  <div class="user-list">
    <h4>Members</h4>
    <hr>
    <b-list-group>
      <b-list-group-item v-for="user in users" :key="user.username">
        {{ user.name }}
        <b-badge v-if="user.presence"
        :variant="statusColor(user.presence)"
        pill>
        {{ user.presence }}</b-badge>
      </b-list-group-item>
    </b-list-group>
  </div>
</template>

<script>
import { mapState } from 'vuex'

export default {
  name: 'user-list',
  computed: {
    ...mapState([
      'loading',
      'users'
    ])
  },
  methods: {
    statusColor(status) {
      return status === 'online' ? 'success' : 'warning'
    }
  }
}
</script>
src/components/MessageList.vue
<template>
  <div class="message-list">
    <h4>Messages</h4>
    <hr>
    <div id="chat-messages" class="message-group" v-chat-scroll="{smooth: true}">
      <div class="message" v-for="(message, index) in messages" :key="index">
        <div class="clearfix">
          <h4 class="message-title">{{ message.name }}</h4>
          <small class="text-muted float-right">@{{ message.username }}</small>
        </div>
        <p class="message-text">
          {{ message.text }}
        </p>
        <div class="clearfix">
          <small class="text-muted float-right">{{ message.date }}</small>
        </div>
      </div>
    </div>
  </div>
</template>

<script>
import { mapState } from 'vuex'

export default {
  name: 'message-list',
  computed: {
    ...mapState([
      'messages',
    ])
  }
}
</script>

<style>
.message-list {
  margin-bottom: 15px;
  padding-right: 15px;
}
.message-group {
  height: 65vh !important;
  overflow-y: scroll;
}
.message {
  border: 1px solid lightblue;
  border-radius: 4px;
  padding: 10px;
  margin-bottom: 15px;
}
.message-title {
  font-size: 1rem;
  display:inline;
}
.message-text {
  color: gray;
  margin-bottom: 0;
}
.user-typing {
  height: 1rem;
}
</style>
src/components/MessageForm.vue
<template>
  <div class="message-form ld-over">
    <small class="text-muted">@{{ user.username }}</small>
    <b-form @submit.prevent="onSubmit" class="ld-over" v-bind:class="{ running: sending }">
      <div class="ld ld-ring ld-spin"></div>
      <b-alert variant="danger" :show="hasError">{{ error }} </b-alert>
      <b-form-group>
        <b-form-input id="message-input"
                      type="text"
                      v-model="message"
                      placeholder="Enter Message"
                      autocomplete="off"
                      required>
        </b-form-input>
      </b-form-group>
      <div class="clearfix">
        <b-button type="submit" variant="primary" class="float-right">
          Send
        </b-button>
      </div>
    </b-form>
  </div>
</template>

<script>
import { mapState, mapGetters } from 'vuex'

export default {
  name: 'message-form',
  data() {
    return {
      message: ''
    }
  },
  computed: {
    ...mapState([
      'user',
      'sending',
      'error',
      'activeRoom'
    ]),
    ...mapGetters([
      'hasError'
    ])
  }
}
</script>

かなりそれっぽくなりましたね。
image.png

データがない状態なので、stateにモックデータを入れてみます。

src/store/index.js
// ...
state: {
  loading: false,
  sending: false,
  error: 'Relax! This is just a drill error message',
  user: {
    username: 'Jack',
    name: 'Jack Sparrow'
  },
  reconnect: false,
  activeRoom: {
    id: '124'
  },
  rooms: [
    {
      id: '123',
      name: 'Ships'
    },
    {
      id: '124',
      name: 'Treasure'
    }
  ],
  users: [
    {
      username: 'Jack',
      name: 'Jack Sparrow',
      presence: 'online'
    },
    {
      username: 'Barbossa',
      name: 'Hector Barbossa',
      presence: 'offline'
    }
  ],
  messages: [
    {
      username: 'Jack',
      date: '11/12/1644',
      text: 'Not all treasure is silver and gold mate'
    },
    {
      username: 'Jack',
      date: '12/12/1644',
      text: 'If you were waiting for the opportune moment, that was it'
    },
    {
      username: 'Hector',
      date: '12/12/1644',
      text: 'You know Jack, I thought I had you figured out'
    }
  ],
  userTyping: null
},
// ...

いい感じです。
image.png

stateは元に戻しておきます。

/src/store/index.js
// ...
state: {
  loading: false,
  sending: false,
  error: null,
  user: null,
  reconnect: false,
  activeRoom: null,
  rooms: [],
  users: [],
  messages: [],
  userTyping: null
}
// ...

パスワードレス認証(5/9) ※公開するサービスでは適切で安全な認証システムにしましょう

プロジェクトのルートに以下のファイルを作成し、環境変数を設定します。

.env.local
VUE_APP_INSTANCE_LOCATOR=
VUE_APP_TOKEN_URL=
VUE_APP_MESSAGE_LIMIT=10

ChatKitの「Credentials」タブに移動し、「TEST TOKEN PROVIDER」の「ENABLED?」にチェックをつけます。
そして、
VUE_APP_INSTANCE_LOCATORに「Instance Locator」
VUE_APP_TOKEN_URLに「Your Test Token Provider Endpoint」
の値を書きます。

VUE_APP_MESSAGE_LIMITは取得するメッセージの数を制限しているだけです。

次に、src/chatkit.jsに行き、ChatKitへ接続する土台を作ります。

src/chatkit.js
import { ChatManager, TokenProvider } from '@pusher/chatkit-client'

const INSTANCE_LOCATOR = process.env.VUE_APP_INSTANCE_LOCATOR;
const TOKEN_URL = process.env.VUE_APP_TOKEN_URL;
const MESSAGE_LIMIT = Number(process.env.VUE_APP_MESSAGE_LIMIT) || 10;

let currentUser = null;
let activeRoom = null;

async function connectUser(userId) {
  const chatManager = new ChatManager({
    instanceLocator: INSTANCE_LOCATOR,
    tokenProvider: new TokenProvider({ url: TOKEN_URL }),
    userId
  });
  currentUser = await chatManager.connect();
  return currentUser;
}

export default {
  connectUser
}

続いて、stateにデータをセットする処理を追加します。
長いですが、ただのセッターなのでがっつり見る必要はありません。

src/store/mutations
export default {
  setError(state, error) {
    state.error = error;
  },
  setLoading(state, loading) {
    state.loading = loading;
  },
  setUser(state, user) {
    state.user = user;
  },
  setReconnect(state, reconnect) {
    state.reconnect = reconnect;
  },
  setActiveRoom(state, roomId) {
    state.activeRoom = roomId;
  },
  setRooms(state, rooms) {
    state.rooms = rooms
  },
  setUsers(state, users) {
    state.users = users
  },
 clearChatRoom(state) {
    state.users = [];
    state.messages = [];
  },
  setMessages(state, messages) {
    state.messages = messages
  },
  addMessage(state, message) {
    state.messages.push(message)
  },
  setSending(state, status) {
    state.sending = status
  },
  setUserTyping(state, userId) {
    state.userTyping = userId
  },
  reset(state) {
    state.error = null;
    state.users = [];
    state.messages = [];
    state.rooms = [];
    state.user = null
  }
}

次に、src/store/actions.jsを更新します。

src/store/actions.js
import chatkit from '../chatkit';

// Helper function for displaying error messages
function handleError(commit, error) {
  const message = error.message || error.info.error_description;
  commit('setError', message);
}

export default {
// ChatKitに接続し、stateを更新する
  async login({ commit, state }, userId) {
    try {
      commit('setError', '');
      commit('setLoading', true);
      // Connect user to ChatKit service
      const currentUser = await chatkit.connectUser(userId);
      commit('setUser', {
        username: currentUser.id,
        name: currentUser.name
      });
      commit('setReconnect', false);

      // Test state.user
      console.log(state.user);
    } catch (error) {
      handleError(commit, error)
    } finally {
      commit('setLoading', false);
    }
  }
}

今、作ったメソッドをsrc/components/LoginForm.vueから実行します。

src/components/LoginForm.vue
import { mapState, mapGetters, mapActions } from 'vuex'

//...
export default {
  //...
  methods: {
    ...mapActions([
      'login'
    ]),
    async onSubmit() {
      const result = await this.login(this.userId);
      if(result) {
// ログインできたらチャット画面に遷移する。loginメソッドがbooleanを返していないからまだ動かない
        this.$router.push('chat');
      }
    }
  }
}

.env.localをロードするために、Vueサーバを再起動します。
間違ったユーザ名を入力するとエラーになることが確認できます。

image.png

チャットに参加する(6/9)

チャット入室時にRoomListUserListが反映されるようsrc/chatkit.jsを更新します。

src/chatkit.js
//...
import moment from 'moment'
import store from './store/index'

//...
function setMembers() {
  const members = activeRoom.users.map(user => ({
    username: user.id,
    name: user.name,
    presence: user.presence.state
  }));
  store.commit('setUsers', members);
}

async function subscribeToRoom(roomId) {
  store.commit('clearChatRoom');
  activeRoom = await currentUser.subscribeToRoom({
    roomId,
    messageLimit: MESSAGE_LIMIT,
    hooks: {
      onMessage: message => {
        store.commit('addMessage', {
          name: message.sender.name,
          username: message.senderId,
          text: message.text,
          date: moment(message.createdAt).format('h:mm:ss a D-MM-YYYY')
        });
      },
      onPresenceChanged: () => {
        setMembers();
      },
      onUserStartedTyping: user => {
        store.commit('setUserTyping', user.id)
      },
      onUserStoppedTyping: () => {
        store.commit('setUserTyping', null)
      }
    }
  });
  setMembers();
  return activeRoom;
}

export default {
  connectUser,
  subscribeToRoom
}

ChatKitサービスのイベントハンドラの意味はこちらです。

  • onMessage、メッセージを受信する
  • onPresenceChanged、ユーザがログインまたはログアウトした時にイベントを受け取る
  • onUserStartedTyping、ユーザが入力しているイベントを受け取る
  • onUserStopperTyping、ユーザが入力を停止したというイベントを受け取る

ログイン後、チャット画面にリダイレクトするよう更新します。

src/store/actions.js
//...
try {
  //... (place right after the `setUser` commit statement)
  // Save list of user's rooms in store
  const rooms = currentUser.rooms.map(room => ({
    id: room.id,
    name: room.name
  }))
  commit('setRooms', rooms);

  // Subscribe user to a room
  const activeRoom = state.activeRoom || rooms[0]; // pick last used room, or the first one
  commit('setActiveRoom', {
    id: activeRoom.id,
    name: activeRoom.name
  });
  await chatkit.subscribeToRoom(activeRoom.id);

  return true;
} catch (error) {
  //...
}

これで正しいユーザ名でログインした時チャット画面に遷移するようになりました。

部屋を変更する(7/9)

RoomListのクリックで部屋を変更できるようにします。
まず、src/store/actions.jsloginメソッドの後にこちらを追加します。
stateのactiveRoomを更新するものです。

src/store/actions.js
async changeRoom({ commit }, roomId) {
  try {
    const { id, name } = await chatkit.subscribeToRoom(roomId);
    commit('setActiveRoom', { id, name });
  } catch (error) {
    handleError(commit, error)
  }
},

次に、src/componenents/RoomList.vueのscriptタグ内に以下を追加します。
すでにルーム名をクリックするとonChangeを呼び出す実装はされている@click="onChange(room)"ので、先ほど作ったchangeRoomonChangeメソッドから実行します。

src/componenents/RoomList.vue
import { mapState, mapActions } from 'vuex'
//...
export default {
  //...
  methods: {
    ...mapActions([
      'changeRoom'
    ]),
    onChange(room) {
      this.changeRoom(room.id)
    }
  }
}

ブラウザで部屋を切り替えると、MessageListUserListが更新されることが確認できます:ok_woman:

もしCannot read property 'subscribeToRoom' of nullというエラーが出たらログインし直してみてください。
次のセクションで対応します。

ページの更新後のユーザの再接続(8/9)

前セクションでご紹介したエラーはページをリロードした際に、ChatKitサーバに接続する参照がnullにリセットされるために起こるものです。
これを修正するには再接続操作を実行する必要があります。

src/components/ChatNavBar.vueのscriptタグ内を以下のように更新します。

src/components/ChatNavBar.vue
<script>
import { mapState, mapActions, mapMutations } from 'vuex'

export default {
  name: 'ChatNavBar',
  computed: {
    ...mapState([
      'user',
      'reconnect'
    ])
  },
  methods: {
    ...mapActions([
      'logout',
      'login'
    ]),
    ...mapMutations([
      'setReconnect'
    ]),
    onLogout() {
      this.$router.push({ path: '/' });
      this.logout();
    },
    unload() {
      if(this.user.username) { // User hasn't logged out
        this.setReconnect(true);
      }
    }
  },
  mounted() {
    window.addEventListener('beforeunload', this.unload);
    if(this.reconnect) {
      this.login(this.user.username);
    }
  }
}
</script>
  1. upload。ページの更新が発生すると呼び出される。user.usernameが設定されている=ログインしていると、stateのreconnectにtrueを設定する。
  2. mounted。ChatNavBarビューのレンダリングが完了するたびに呼び出される。最初に、ページがアンロードされる直前に呼び出されるイベントリスナーにハンドラーを割り当てる。また、stateのreconnectがtrueであれば、ログイン手順が実行され、ChatKitサービスに再接続される。

また、ログアウト機能も追加されていますが、これは次のセクションで。

これらの更新を行った後、ページをリロードして部屋を切り替えてもエラーにならないはずです:ok_woman:

メッセージの送信、ユーザの入力の検出、ログアウト(9/9)

ラストです。src/chatkit.jsを更新します。

src/chatkit.js
//...
async function sendMessage(text) {
  const messageId = await currentUser.sendMessage({
    text,
    roomId: activeRoom.id
  });
  return messageId;
}

export function isTyping(roomId) {
  currentUser.isTypingIn({ roomId });
}

function disconnectUser() {
  currentUser.disconnect();
}

export default {
  connectUser,
  subscribeToRoom,
  sendMessage,
  disconnectUser
}

src/atore/actions.jsに移動し、changeRoomメソッドの直後に以下を挿入します。

src/atore/actions.js
async sendMessage({ commit }, message) {
  try {
    commit('setError', '');
    commit('setSending', true);
    const messageId = await chatkit.sendMessage(message);
    return messageId;
  } catch (error) {
    handleError(commit, error)
  } finally {
    commit('setSending', false);
  }
},
async logout({ commit }) {
  commit('reset');
  chatkit.disconnectUser();
  window.localStorage.clear();
}

logoutメソッドでは、セキュリティのためストアのリセットとローカルストレージのクリアも行います。

次に、src/components/MessageForm.vueのinputディレクティブを更新します。

src/components/MessageForm.vue
<b-form-input id="message-input"
              type="text"
              v-model="message"
              @input="isTyping"
              placeholder="Enter Message"
              autocomplete="off"
              required>
</b-form-input>

src/components/MessageForm.vueのscriptタグ内を以下の通り更新します。

src/components/MessageForm.vue
<script>
import { mapActions, mapState, mapGetters } from 'vuex'
import { isTyping } from '../chatkit.js'

export default {
  name: 'message-form',
  data() {
    return {
      message: ''
    }
  },
  computed: {
    ...mapState([
      'user',
      'sending',
      'error',
      'activeRoom'
    ]),
    ...mapGetters([
      'hasError'
    ])
  },
  methods: {
    ...mapActions([
      'sendMessage',
    ]),
    async onSubmit() {
      const result = await this.sendMessage(this.message);
      if(result) {
        this.message = '';
      }
    },
     async isTyping() {
      await isTyping(this.activeRoom.id);
    }
  }
}
</script>

そして、src/MessageList.vueを更新します。

src/MessageList.vue
import { mapState } from 'vuex'

export default {
  name: 'message-list',
  computed: {
    ...mapState([
      'messages',
      'userTyping'
    ])
  }
}

メッセージ送信機能が動作するはずです。

別のユーザが入力しているという通知を表示するには、src/MessageList.vuemessage-group直後にこのスニペットを追加します。

src/MessageList.vue
<div class="user-typing">
  <small class="text-muted" v-if="userTyping">@{{ userTyping }} is typing....</small>
</div>

最後に、src/components/ChatNavBar.vueにログアウト処理をつけます。

src/components/ChatNavBar.vue
 <b-nav-item href="#" @click="onLogout" active>Logout</b-nav-item>

完成です٩( 'ω' )و

まとめ

知識ない状態でアプリケーションをイチから作るのは(記事があるとは言え)不安もありましたが、この辺が良かったのでまたやりたいと思います:v:

  • ドキュメントをただ読むより楽しい!
  • ファイル構成や、ライフサイクル、機能といったVue.jsの特徴を知ることができた
  • チャットのライブラリやUIライブラリなど実用的なツールを知ることができた
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした