船井総研デジタルのtakizawaです。
今回はこちらの記事の続きとなります。前回はバックエンドの(仮)実装をおこないました。今回はフロントエンドの実装を行います。言語はTypeScriptでVue.js(Vue3)を利用します。
ストアモジュールの実装
早速実装していこうと思います。まずはチャット内容を保持するストアモジュールを実装します。
初めにインターフェースを次のように定義します。
export interface MessageData {
id: number
message: string
isBot: boolean
}
interface SendMessage {
message: string
}
interface State {
messageList: Map<number, MessageData>
isLoading: boolean
}
-
MessageData
は一つの発言とそれに対する属性をまとめたものになります。発言はmessage
フィールドに格納します。isBot
フィールドにはその発言がボットにより発信された場合true
が設定されます。 -
SendMessage
はバックエンドに連携するデータインターフェースです。 -
State
はストアの状態を管理するインターフェースです。messageList
フィールドは会話内容を保持します。またisLoading
は処理中の場合に送信ボタンを非活性するために使用します。
続いてストアを実装します。
import { defineStore } from 'pinia'
...
export const useChatSotre = defineStore({
id: 'chat',
state: (): State => { // 管理する状態の定義と初期化
return {
messageList: new Map<number, MessageData>(),
isLoading: false
}
},
getters: {}, // 今回は使用しない
actions: { // アクション
// ユーザの入力したメッセージをmessageListに追加
addUserMessage(question: string) {
const setId = this.messageList.size + 1
this.messageList.set(setId, {
id: setId,
message: question,
isBot: false
})
},
// ボットから回答を受けメッセージを追加
async getBotReply(question: string) {
// バックエンド連携
const backendUrl = '<バックエンドAPIのURL>'
const sendMessage: SendMessage = {
message: question
}
await fetch(backendUrl, {
method: 'POST',
mode: 'cors',
cache: 'no-cache',
credentials: 'same-origin',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json'
},
body: JSON.stringify(sendMessage)
})
.then((response) => {
response
.json()
.then((value) => {
const replyData = value[0]
// 回答構成
const setId = this.messageList.size + 1
this.messageList.set(setId, {
id: setId,
message: replyData.reply,
isBot: true
})
})
.catch((reason) => {
console.error('Error at reply object construction.', reason)
})
})
.catch((reason) => {
if (reason instanceof Error) {
console.error(reason.message, reason.stack)
window.alert(reason.message)
} else {
console.error('Error occured.', reason)
window.alert('エラー発生')
}
})
.finally(() => {
this.isLoading = false
})
}
}
})
ストアライブラリはPiniaを使用します。Piniaを使用したストアの基本構造は次のようになります。
export const useXxxxStore = defineStore({
id: 'xxxxx', // ストア名
state: (): stateの型 => ({
return {
field: フィールドの初期値,
}
}),
getters: {
// データの加工処理
getterName: (state): 加工データの型 => {
// 処理
return 加工データ
},
...
},
actions: {
// データの変更処理
functionName(): void {
// stateの変更処理
}
}
});
fetch()
基本構造は次の通りです。設定しているパラメータについてはこちらを参照ください。
await fetch(backendUrl, {
method: 'POST',
mode: 'cors',
cache: 'no-cache',
credentials: 'same-origin',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json'
},
body: JSON.stringify(送信オブジェクト)
})
.then((response) => { // レスポンス正常の場合
response
.json()
.then((value) => {
// Json形式でのレスポンスの取得が正常な場合の処理
})
.catch((reason) => {
// エラー処理
})
})
.catch((reason) => {
// レスポンスエラーの場合
})
.finally(() => {
// 最後に必ず実行する処理
})
処理の流れは次の通りです。
-
fetch
関数でバックエンドにPOSTリクエストを送信します。 - レスポンスが正常であれば結果をJSONとして取得しAPIからの返信をメッセージリストに追加。JSON変換が失敗した場合エラー処理をします。
- レスポンスがエラーの場合はエラー処理を行います。
- レスポンスの結果にかかわらず、最後に
isLoading
フラグをfalse
に変更します。
レイアウト
画面レイアウトのイメージを示します。
このうち下部の入力フォーム部分だけコンポーネントとして切り出して実装します。
Formコンポーネントの実装
scriptブロック
まずはscript部分を実装を見てみます。
<script setup lang="ts">
import { ref, computed, onUpdated } from 'vue'
import { useChatSotre } from '@/stores/chatMessage'
const chatStore = useChatSotre()
const sendMessage = ref('')
const isLoading = computed((): boolean => {
return chatStore.isLoading
})
const onSendMessage = (): void => {
chatStore.isLoading = true
chatStore.addUserMessage(sendMessage.value)
chatStore.getBotReply(sendMessage.value)
sendMessage.value = ''
}
onUpdated((): void => {
window.scrollTo({
top: document.body.scrollHeight,
behavior: 'smooth'
})
})
</script>
まず使用するストアをconst chatStore = useChatSotre()
で取得します。
続いてリアクティブ変数sendMessage, isLoading
を定義します。ストアのstateも状態が変化したときに再描画する必要があるためリアクティブ変数に入れなおしておく必要があります。
onSendMessage
はFormをサブミットした際に呼び出す関数です。ストアの状態を変更し、最後にリアクティブ変数sendMessage
を空文字としています。 リアクティブ変数にアクセスする際はvalue
プロパティに対して行います。
最後にonUpdated
ですが、これはライフサイクルフックの一つでDOMの再レンダリングが完了した時点で実行されます。ここではメッセージ表示部分を最下部までスクロールします。
templateブロック
scriptブロックに続けてtemplateブロックを実装します。
<template>
<form v-on:submit.prevent="onSendMessage" class="send-form">
<input type="text" id="sendMessage" v-model="sendMessage" required class="input-text" />
<button type="submit" v-bind:disabled="isLoading">送信</button>
</form>
</template>
class
属性に指定しているのは後ほどstyleブロックで定義するスタイルです。Vue.js特有の属性はv-model
とv-bind
です。v-model
は双方向バインディングで、ここではリアクティブ変数sendMessage
をバインドしています。v-bind
は単純なデータバインディングです。またここでは属性値がない属性disabled
へリアクティブ変数isLoading
をバインドしています。これはisLoading = true
の場合にdisabled属性が指定された状態でレンダリングされます。
styleブロック
scoped
を付けることで定義したスタイルのスコープを同一モジュールにのみ制限できます。つけなければプロジェクト全体に有効です。
<style scoped>
.input-text {
flex: 1%;
border: none;
border-radius: 5px;
padding: 10px;
margin-right: 10px;
margin-top: 5px;
margin-bottom: 5px;
font-size: 16px;
height: 20px;
}
.send-form {
display: flex;
position: fixed;
bottom: 0;
left: 0;
right: 0;
padding: 10px;
background-color: white;
box-shadow: 0 -1px 5px rgba(0, 0, 0, 0.3);
z-index: 1;
}
</style>
ビューの実装
scriptブロック
メッセージの一覧を表示するためにストアからmessageList
をリアクティブ変数とします。
<script setup lang="ts">
import { computed } from 'vue'
import { useChatSotre, type MessageData } from '@/stores/chatMessage'
import ChatForm from '@/components/ChatForm.vue'
const chatStore = useChatSotre()
const messageList = computed((): Map<number, MessageData> => {
return chatStore.messageList
})
</script>
templateブロック
コードは次の通りです。
<template>
<div class="container">
<template v-for="[id, message] in messageList" v-bind:key="id">
<div v-if="message.isBot" class="bot-message">{{ message.message }}</div>
<div v-else class="user-message">{{ message.message }}</div>
</template>
<ChatForm />
</div>
</template>
v-for
でメッセージリストからループしながらid
とmessage
を取得しkey
属性にid
を設定します。
このループtemplateの子要素として<div>
でメッセージを表示します。その際にv-if, v-else
でユーザーの発言かボットの応答かを条件分岐してスタイルを切り替えています。
styleブロック
説明が必要なことはないのでコードだけ記載します。
<style scoped>
.user-message {
max-width: 80%;
width: auto;
margin: 10px;
padding: 10px;
border-radius: 5px;
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.3);
align-self: flex-end;
background-color: #2d81d8;
color: white;
}
.bot-message {
max-width: 80%;
width: auto;
margin: 10px;
padding: 10px;
border-radius: 5px;
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.3);
align-self: flex-start;
background-color: #f2f2f2;
}
.container {
flex: 1;
padding: 20px;
padding-bottom: 70px !important;
display: flex;
flex-direction: column;
}
</style>
routerの実装
単一ページのアプリケーションのたBotBox.vue
をApp.vue
に配置するだけでよいですがサンプルとしてrouterを使用します。詳しくは公式ドキュメントをご覧ください。
実装例だけ示します。
import { createRouter, createWebHistory } from 'vue-router'
import type { RouteRecordRaw } from 'vue-router'
import BotBox from '@/views/BotBox.vue'
const routerSetting: RouteRecordRaw[] = [
{
path: '/',
name: 'Top',
component: BotBox
}
]
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: routerSetting
})
export default router
<script setup lang="ts">
import { RouterView } from 'vue-router'
</script>
<template>
<template class="main-box">
<RouterView />
</template>
</template>
<style scoped>
.main-box {
display: flex;
position: relative;
left: 0;
right: 0;
}
</style>
App.vue
のstyleブロックは自動生成されたものから変更しています。
他スタイルの調整
自動生成されたmain.css
をそのまま使用するとBotBox.vue
のスタイルがうまく効かないため、スタイルを変更します。
@import './base.css';
#app {
max-width: 1280px;
font-weight: normal;
align-items: center;
}
a,
.green {
text-decoration: none;
color: hsla(160, 100%, 37%, 1);
transition: 0.4s;
}
@media (hover: hover) {
a:hover {
background-color: hsla(160, 100%, 37%, 0.2);
}
}
@media (min-width: 100%) {
body {
display: flex;
place-items: center;
}
#app {
display: flex;
}
}
まとめ
今回はTypeScriptでVue.js(Vue3)を利用してフロントエンド側の実装を見ました。
次回はクロスオリジンリソース共有 (CORS) エラーの解消を行い、フロントエンドとバックエンドを連携させます。
続編の記事を投稿しました!
簡単なチャットボットを作ってみよう(CORSエラー対応)