👻

Amplify・Vue3でチャットアプリを作ってみる

2023/07/08に公開

はじめに

こちらのAmplify Vue Workshopという記事を参考にして作成しています。「1.前提知識/条件」から「5.チャットの実装(オプション)」までの手順を実行しました。ただし、参考記事ではVue.jsのOptions APIで実装されていたため、Vue3で導入されたComposition APIに置き換えて実装し直しました。また、TypeScriptの恩恵を受けるために、TypeScriptも採用しています。

環境構築

基本的にはWorkshopの手順とほぼ同じですが、Vueプロジェクト作成時のオプション選択が異なります。Vueプロジェクトを新規作成する際の手順はこちらのリンクに記載されています。

Vueのカスタマイズ項目について下記の通りで選択

✔ **Add TypeScript?** … No / Yes
✔ **Add JSX Support?** … No / Yes
✔ **Add Vue Router for Single Page Application development?** … No / Yes
✔ **Add Pinia for state management?** › No
✔ **Add Vitest for Unit Testing?** … No / Yes
✔ **Add an End-to-End Testing Solution?** › No
✔ **Add ESLint for code quality?** … No / Yes
✔ **Add Prettier for code formatting?** … No / Yes

トラブル事例

Could not find a declaration file for module './aws-exports'.

src/main.ts内で発生するエラーメッセージです。

src/main.ts
import { createApp } from 'vue'

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

// Amplify
import { Amplify } from 'aws-amplify'
import awsconfig from './aws-exports'
Amplify.configure(awsconfig)
・・・省略

調査した結果、問題の原因はaws-exportsファイルの拡張子が.jsであったことでした。この問題を解決するためには、.jsの代わりに.tsの拡張子に変更する必要があります。恐らく、Vueプロジェクト作成時にオプションでTypeScriptを追加したことが原因かなと思います。

参考にした記事
AWS Amplify エラー対処 Could not find a declaration file for module './aws-exports'.

Component name "Chat" should always be multi-word. eslint vue/multi-word-component-names

vue/multi-word-component-namesで問題が説明されていますが、問題はコンポーネント名が単一の単語であることです。コンポーネント名は複数の単語である必要があります。これは既存及び将来のHTML要素との競合を防ぐためにコンポーネント名は常に複数語でなければならないようです。

本来ならばコンポーネント名は複数の単語にする必要がありますが、今回の場合はその必要性があまりないため、対応は行いませんでした。代わりに、単一の単語でもエラー回避するために、eslintの設定ファイルにvue/multi-word-component-namesをオフにする設定を追加しました。

.eslintrc.cjs
rules: {
    'vue/multi-word-component-names': 0
}

ホスティング

Workshopの手順と同じですので、説明は省略しますが、素晴らしい点は、わずかな設定だけでアプリの自動デプロイとホスティング機能を提供が提供されることです。高速でホスティングできる便利さには感心しました!

認証の実装

Workshopの手順とほぼ同じですが、フロントエンドの実装には一部の違いがあります。

フロントエンド

src/components/Home.vue
src/components/Home.vue
<script setup lang="ts">
defineProps({
  username: String
})
</script>

<template>
  <div class="home">
    <h1>Login User, {{ username }} !</h1>
  </div>
</template>

チャットの実装

VueのComposition APIとTypeScriptを組み合わせて実装しているため、Workshopの実装とは一部異なっています。作成したコードはGitHubで公開しており、必要に応じてご確認いただけます。

バックエンド

  1. GraphQL APIとデータベースの作成
amplify add api
amplify add apiの質問に対する回答
? Select from one of the below mentioned services: GraphQL
? Here is the GraphQL API that we will create. Select a setting to edit or contin
ue Authorization modes: API key (default, expiration time: 7 days from now)
? Choose the default authorization type for the API Amazon Cognito User Pool
Use a Cognito user pool configured as a part of this project.
? Configure additional auth types? No
? Here is the GraphQL API that we will create. Select a setting to edit or contin
ue Continue
? Choose a schema template: Blank Schema
  1. 自動設定されるデータベースのスキーマを編集
    Workshopの実装どおりだと、この後のGraphQL APIの追加コマンドでエラーが発生するため、修正を行っています。
amplify/backend/api/viteproject/schema.graphql
amplify/backend/api/viteproject/schema.graphql
type Message
  @model
  @auth(
    rules: [
      { allow: owner, operations: [read, create, delete] }
      { allow: private, operations: [read] }
    ]
  ) {
  id: ID!
  content: String!
}
  1. GraphQL APIの追加
amplify push
amplify pushの質問に対する回答
? Do you want to generate code for your newly created GraphQL API Yes
? Choose the code generation language target typescript
? Enter the file name pattern of graphql queries, mutations and subscriptions src
/graphql/**/*.ts
? Do you want to generate/update all possible GraphQL operations - queries, mutat
ions and subscriptions Yes
? Enter maximum statement depth [increase from default if your schema is deeply n
ested] 3
? Enter the file name for the generated code src/API.ts

フロントエンド

チャット画面の作成 
GraphqQLの呼び出し周りの実装で、subscribeToMessagesメソッドでsrc/API.tsで定義された型を利用しようと試みましたが、うまくいきませんでした。そのため、一時的にany型を使用して型エラーを回避しています。

src/components/Chat.vue
src/components/Chat.vue
<template>
  <div class="main-contents">
    <div class="message_base">
      <div v-for="message in messages" :key="message.id">
        <div :class="[message.owner === username ? 'message' : 'message_opponent']">
          {{ message.content }}
        </div>
        <div :class="[message.owner === username ? 'username' : 'username_opponent']">
          {{ message.owner }}&emsp;{{ formattedCreatedAt(message.createdAt) }}
        </div>
      </div>
    </div>
    <input
      class="message-input"
      placeholder="Enter a message (send with Shift+Enter)"
      v-model="content"
      @keydown.enter.shift="sendMessage"
    />
  </div>
</template>

<script setup lang="ts">
import { API, graphqlOperation } from '@aws-amplify/api'
import { createMessage } from '@/graphql/mutations'
import { listMessages } from '@/graphql/queries'
import { onCreateMessage } from '@/graphql/subscriptions'
import { ref, onBeforeUnmount, onUpdated, computed } from 'vue'
import type { CreateMessageInput } from '@/API'

interface Message {
  id: string
  content: string
  owner: string
  createdAt: string
}

const props = defineProps<{
  username: string
}>()

const messages = ref<Message[]>([])
const content = ref('')
let subscription: { unsubscribe: () => void }

const sendMessage = async (event: KeyboardEvent) => {
  if (event.key !== 'Enter' || !content.value) return

  const message: CreateMessageInput = {
    id: new Date().getTime() + props.username,
    content: content.value
  }

  // Mutation(createMessage) の実装
  try {
    await API.graphql(graphqlOperation(createMessage, { input: message }))
  } catch (error) {
    console.warn(error)
  }
  content.value = ''
}

const fetchMessages = async () => {
  try {
    // Query(listMessages) の実装
    const result = await API.graphql(graphqlOperation(listMessages, { input: 100 }))
    const data = 'data' in result ? result.data : result
    const items: Message[] = data.listMessages.items
    messages.value = items.sort((a: Message, b: Message) => (a.id > b.id ? 1 : -1))
  } catch (error) {
    console.warn(error)
  }
}

const subscribeToMessages = async () => {
  // Subscription(onCreateMessages) の実装
  subscription = (API.graphql(graphqlOperation(onCreateMessage)) as any).subscribe({
    next: (response: any) => {
      const newMessage = response.value.data.onCreateMessage as Message
      messages.value = [...messages.value, newMessage]
    },
    error: (error: any) => {
      console.warn(error)
    }
  })
}

const scrollBottom = () => {
  const container = document.querySelector('.message_base')
  container!.scrollTop = container!.scrollHeight
}

const formattedCreatedAt = computed(() => {
  return (createdAt: string) => {
    const date = new Date(createdAt)
    const hours = String(date.getHours()).padStart(2, '0')
    const minutes = String(date.getMinutes()).padStart(2, '0')
    return `${hours}:${minutes}`
  }
})

onBeforeUnmount(() => {
  if (subscription) {
    // Subscription(onCreateMessages) の実装
    subscription.unsubscribe()
  }
})

onUpdated(() => {
  scrollBottom()
})

fetchMessages()
subscribeToMessages()
</script>

formattedCreatedAtメソッドはWorkshopには存在しない機能ですが、チャットの投稿日時を計算し、画面に表示する機能です。元々の機能では名前の表示だけでしたが、日時も表示するようにアレンジしました。

完成物

こちらが完成物になります

Discussion