📚
Vue.jsとNode.jsでチャットアプリを作った
概要
Vue.jsでチャットアプリを作成しました。
GitHub↓
機能説明
こちらが機能一覧になります。
・ログイン、ログアウト
・ユーザー登録
・メッセージ送信、編集、削除、一覧表示
メッセージやユーザーのデータはNode.jsでAPIを作り、JSONファイルに保存する仕組みになっています。
VueとNode.jsをインストール
この2つのインストールは以下の記事を参考にしました。
Vue.jsインストール
Node.jsインストール
インストールしたライブラリ
今回使用するライブラリこちらです。プロジェクトを起動したら、こちらの内容をコピーして、以下のファイルに貼り付けてください。
そして、一つ一つインストールしてください。
import { createApp } from 'vue'
import App from './App.vue'
import router from './router'
import store from './store'
import axios from 'axios'
import VueAxios from 'vue-axios'
createApp(App).use(store).use(router).use(VueAxios, axios).mount('#app')
コードを紹介
ここからコードを紹介していきます。
今回は「chat-app」というプロジェクト名にしました。
App.vue
全てのViewファイルで適用されるファイルのソースコードです。
デザインはBootStrapとFontAwesomeを使用しました。
<template>
<header class="d-flex flex-wrap justify-content-center py-3 mb-4 border-bottom">
<div class="d-flex align-items-center mb-3 ml-4 mb-md-0 me-md-auto text-dark text-decoration-none" style="margin-left: 50px;">
<span class="fs-4"><i class="far fa-comment-dots"></i>Vue-Chat</span>
</div>
<ul class="nav nav-pills">
<li class="nav-item"><router-link class="btn btn-primary" aria-current="page" v-if="$store.getters.loggedIn" to="/"><i class="fas fa-home"></i>トップページ</router-link></li>
<li class="nav-item"><button class="btn btn-danger" @click="logout()" v-if="$store.getters.loggedIn" style="margin: 0 20px;"><i class="fas fa-sign-out-alt" style="margin-right: 5px;"></i>ログアウト</button>
</li>
</ul>
</header>
<router-view/>
</template>
<script>
export default {
methods: {
// ログアウトする処理
logout () {
const confirm = window.confirm("ログアウトしてもよろしいですか?")
if(confirm){
this.$store.commit('setUsername', null)
// ログアウト時に画面のキャッシュを消すため、リロードする。
location.reload()
if (this.$route.meta.requiresAuth) {
this.$router.push({
path: '/login',
query: { redirect: this.$route.fullPath }
})
}
}else{
return
}
}
}
}
</script>
<style>
#app {
font-family: Avenir, Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-align: left;
color: #2c3e50;
}
nav {
padding: 30px;
}
nav a {
font-weight: bold;
color: #2c3e50;
}
nav a.router-link-exact-active {
color: #42b983;
}
a{
list-style: none;
}
.fa-comment-dots{
padding-right: 5px;
}
</style>
routerとstoreのコード
ここでは、routerとstoreのコードを紹介します。
routerのコード
import { createRouter, createWebHashHistory } from 'vue-router'
import store from '../store'
const routes = [
// メッセージ一覧ページ
{
path: '/',
name: 'home',
component: () => import('../views/HomeView.vue'),
meta: { requiresAuth: true }
},
// メッセージ編集ページ
{
path: '/edit/:id',
name: 'edit',
component: () => import('../views/EditView.vue'),
meta: { requiresAuth: true }
},
// ログインページ
{
path: '/login',
name: 'login',
component: () => import('../views/LoginView.vue')
},
// ユーザー登録ページ
{
path: '/signup',
name: 'signup',
component: () => import('../views/SignUpView.vue')
},
]
// ルーターを初期化する記述
const router = createRouter({
history: createWebHashHistory(),
routes
})
// 認証がない場合にログインページの遷移させる記述
router.beforeEach((to, from, next) => {
if (to.matched.some((record) => record.meta.requiresAuth)) {
if (!store.getters.loggedIn) {
next({
path: '/login',
query: {
redirect: to.fullPath,
message: true
}
})
} else {
next()
}
} else {
next()
}
})
export default router
storeのコード
import { createStore } from 'vuex'
import { VuexPersistence } from 'vuex-persist'
// Vuex-persistを使えるようにするための記述
const vuexPersist = new VuexPersistence({
storage: localStorage
})
export default createStore({
state: {
messageCount: 0,
messages: [],
userCount: 0,
users: []
},
getters: {
getCount: (state) => {
return state.messages.length
},
getUsername: (state) => {
return state.users
},
getAll: (state) => {
return state.messages
},
getMessageById: (state) => (id) => {
return state.messages.find(messages => messages.id === id)
},
loggedIn: (state) => {
return Boolean(state.username)
},
},
mutations: {
// Vuex-persistを使えるようにするための記述
RESTORE_MUTATION: vuexPersist.RESTORE_MUTATION,
// メッセージ内容を保存
save(state, newMessage) {
if(newMessage.id){
let x = state.messages.find(messages => messages.id === newMessage.id)
x.content = newMessage.content
}else{
newMessage.id = ++state.messageCount
state.messages.unshift(newMessage)
}
},
// ユーザー情報を保存する
userSave(state, newUser){
if(newUser.id){
let x = state.users.find(users => users.id === newUser.id)
x.username = newUser.username
}else{
newUser.id = ++state.messageCount
state.users.unshift(newUser)
}
},
// メッセージを削除する
delete(state, id){
state.messages = state.messages.filter(message => message.id !== id)
},
setUsername(state, username) {
state.username = username
},
setPass(state, pass) {
state.pass = pass
}
},
actions: {
},
modules: {
},
// Vuex-persistを使えるようにするための記述
plugins: [vuexPersist.plugin]
})
Node.jsのソースコード
先にNode.jsのコードを貼っておきます。コードの簡単な説明は、コードの中に書いてあります。
このコードを書く前に以下のディレクトリにJSONファイルを2つ作成してください
chat-app\backend\messages.json
chat-app\backend\Users.json
Node.jsのコード
const express = require('express');
const cors = require('cors');
const app = express();
const fs = require('fs');
const { default: axios } = require('axios');
app.use(express.urlencoded({ extended: true }));
app.use(express.json());
app.use(cors());
//
// メッセージに関するAPI
//
// メッセージの取得
app.get('/api/message/get', (req, res) => {
try{
//message.jsonファイルからメッセージを取りだす
const bufferData = fs.readFileSync('messages.json');
// データを文字列に変換
const dataJSON = bufferData.toString();
//JSONのデータをJavascriptのオブジェクトに
const data = JSON.parse(dataJSON);
res.send(data);
}catch(e){
fs.writeFileSync('messages.json', JSON.stringify([]));
}
})
// メッセージにIDを振るために、メッセージのlengthを取得するメソッド
async function getMessageArrayLength() {
try {
const data = await fs.promises.readFile('messages.json', 'utf8');
const myData = JSON.parse(data);
const arrayLength = myData.length;
return arrayLength;
} catch (error) {
console.error(error);
}
}
// メッセージの保存
app.post('/api/message/post', (req, res) => {
try{
getMessageArrayLength()
.then((messagesArrayLength) => {
// 入力された内容を取得して、message.jsonの配列にプッシュする
fs.readFile('messages.json', 'utf8', (err, data) => {
if (err) {
console.error(err);
return;
}
// ファイルをJSONパースして配列に変換する
let arr = JSON.parse(data);
// 新しいオブジェクトを作成して配列に追加する
arr.push({
id: messagesArrayLength + 1,
messageText: req.body.messageText,
username: req.body.username,
time: req.body.time,
day: req.body.day
});
// 配列をJSON文字列に変換する
let newData = JSON.stringify(arr, null, '\t');
// ファイルに書き込む
fs.writeFile('messages.json', newData, 'utf8', (err) => {
if (err) {
console.error(err);
return;
}
});
});
});
}catch(e){
console.log(e)
}
});
// メッセージ編集
app.put('/api/message/edit', (req, res) => {
try{
const id = req.body.id - 1;
const bufferData = fs.readFileSync('messages.json');
let data = JSON.parse(bufferData);
data[id].messageText = req.body.messageText;
const updatedJsonData = JSON.stringify(data);
fs.writeFileSync('messages.json', updatedJsonData);
}catch(e){
console.log(e);
}
});
// メッセージ削除
app.delete('/api/message/delete', (req, res) => {
const messageData = fs.readFileSync('messages.json');
const messages = JSON.parse(messageData);
const deleteIndex = messages.findIndex(message => message.id === req.body.id);
messages.splice(deleteIndex, 1);
fs.writeFileSync('messages.json', JSON.stringify(messages));
})
//
// ログイン、ユーザー登録に関するAPI
//
// ユーザーが誰も登録されていない場合に、初期データを書き込む関数
async function initializeUsers(username, pass) {
try {
const data = await fs.promises.writeFile('users.json', `[{"id":1,"username":"${username}","pass":"${pass}"}]`, 'utf8');
return data;
} catch (error) {
console.error(error);
}
}
// ユーザー登録
app.post('/api/user/registration', (req, res) => {
try {
async function getUserArrayLength() {
try {
const data = await fs.promises.readFile('users.json', 'utf8');
const myData = JSON.parse(data);
const arrayLength = myData.length;
return arrayLength;
} catch (error) {
console.error(error);
// ファイルが存在しない場合、配列を作成して、入力された内容を保存する。
initializeUsers(req.body.username, req.body.pass);
return 1;
}
}
getUserArrayLength().then((usersArrayLength) => {
fs.readFile('users.json', 'utf8', (err, data) => {
if (err) {
console.error(err);
return;
}
// ファイルをJSONパースして配列に変換する
let arr = JSON.parse(data);
// 新しいオブジェクトを作成して配列に追加する
arr.push({
id: usersArrayLength + 1,
username: req.body.username,
pass: req.body.pass
});
// 配列をJSON文字列に変換する
let newData = JSON.stringify(arr, null, '\t');
// ファイルに書き込む
fs.writeFile('users.json', newData, 'utf8', (err) => {
if (err) {
console.error(err);
return;
}
});
});
});
} catch (e) {
console.log(e);
}
});
// ログイン認証
app.post('/api/user/login', (req, res) => {
try {
const userData = fs.readFileSync('users.json');
const userDataJSON = userData.toString();
const getData = req.body;
const data = JSON.parse(userDataJSON);
//ユーザーを一人ずつ取り出して、入力された内容と比較する。
for (let i = 0; i < data.length; i++) {
if (data[i].username === getData.username && data[i].pass === getData.pass) {
// フロント側に成功メッセージを送る
return res.status(200).send('Success');
}
}
//入力された内容と一致するユーザーがいなければ、失敗メッセージを返す。
res.status(401).send('Authentication failed');
} catch (e) {
console.log(e);
res.status(500).send('Internal server error');
}
});
// 3000番ポートで実行
app.listen(3000, () =>
console.log('3000番ポートで実行')
);
ログイン・ユーザー登録画面
まずはログイン画面から作成していきます。
ログイン画面
<template>
<div class="login-form text-center form-signin" >
<h1 class="h3 mb-3 fw-normal"><i class="fas fa-sign-in-alt" style="margin-right: 5px;"></i>ログイン</h1>
<AuthenticationForm @clicked="login" :buttonText="buttonText"/>
<router-link class="signin-link btn btn-success" to="/signup">ユーザー登録</router-link>
</div>
</template>
<script>
import AuthenticationForm from '../components/AuthenticationForm.vue'
import axios from 'axios'
export default {
data () {
return {
username: '',
pass: '',
buttonText: 'ログイン'
}
},
components: {
AuthenticationForm
},
methods: {
// ログインする処理
login (userContent) {
try{
// Node.jsで認証を行うために、APIリクエストと一緒に、入力された内容をNode.jsに投げる
axios.post("http://localhost:3000/api/user/login", userContent)
.then(response => {
if(response.status === 200){
this.$store.commit('setUsername', userContent.username);
this.$router.push('/');
}
})
.catch((error) => {
window.alert('ユーザー名かパスワードが違います。')
console.log(error)
})
}catch(e){
console.log(e)
}
}
}
}
</script>
<style scoped>
.login-form {
width: 70%;
margin: auto;
margin-top: 200px;
}
.signin-link{
margin-top: 30px;
}
</style>
ユーザー登録画面
<template>
<div class="login-form text-center form-signin" >
<h1 class="h3 mb-3 fw-normal"><i class="fas fa-user-plus" style="margin-right: 3px;"></i>ユーザー登録</h1>
<AuthenticationForm @clicked="signup" :buttonText="buttonText"/>
<router-link class="signin-link btn btn-success" to="/login">ログイン</router-link>
</div>
</template>
<script>
import AuthenticationForm from '../components/AuthenticationForm.vue'
import axios from 'axios'
export default {
data () {
return {
username: '',
pass: '',
buttonText: 'ユーザー登録'
}
},
components: {
AuthenticationForm
},
methods: {
// ユーザー情報を保存する処理
// 引数のuserContentはAuthenticationFormファイルから$emitで入力された内容が渡されている。
signup(userContent){
// axiosを使ってNode.jsにAPIリクエストを送る
axios.post('http://localhost:3000/api/user/registration', userContent)
.then((response) => {
console.log(response);
})
.catch((error) => {
console.error(error);
});
window.alert('ユーザー登録が完了しました。')
this.$router.push('/login');
}
}
}
</script>
<style scoped>
.login-form {
width: 70%;
margin: auto;
margin-top: 200px;
}
.signin-link{
margin-top: 30px;
}
</style>
コンポーネント化したファイル
<template>
<form>
<p>半角英数字のみで入力してください</p>
<div class="form-floating">
<input id="name" class="form-control" type="text" v-model="username" @input="validateInput">
<label for="floatingInput">ユーザー名</label>
</div>
<div class="form-floating">
<input id="password" class="form-control" type="password" placeholder="Password" v-model="pass">
<label for="floatingPassword">パスワード</label>
</div>
<div class="checkbox mb-3">
</div>
<button class="w-100 btn btn-lg btn-primary" type="submit" @click="actionChangeButton">{{ buttonText }}</button>
</form>
</template>
<script>
export default {
name: 'AuthenticationForm',
data () {
return {
username: '',
pass: '',
}
},
props: [
'buttonText'
],
methods: {
// ページ名に応じて、動かすメソッドを変える
actionChangeButton() {
if (this.$route.name === 'login') {
this.login()
} else if (this.$route.name === 'signup') {
this.registration()
}
},
// ログインするためのメソッド
login(){
let userContent = {
username: this.username,
pass: this.pass,
}
// 入力された内容をLoginView.vueファイルのメソッドに渡す。
this.$emit('clicked', userContent)
},
// ユーザー登録をするメソッド
registration(){
let userContent = {
username: this.username,
pass: this.pass,
}
if(userContent.username === ""){
window.alert('ユーザー名が入力されていません')
return
}
if(userContent.pass === ""){
window.alert('パスワードが入力されていません')
return
}
// 入力された内容をSignUpView.vueファイルのメソッドに渡す。
this.$emit('clicked', userContent)
},
validateInput() {
this.username = this.username.replace(/[^a-zA-Z0-9]/g, '');
}
},
}
</script>
チャット機能に関するコード
ここからは、チャット機能に関するコードを紹介していきます。
同じく、コードの説明は、ソースコード内に書いてあります。
コンポーネント化したフォーム部分↓
<template>
<div class="center">
<form>
<div class="input">
<textarea class="form-control" aria-label="With textarea" name="messageText" v-model="messageText" placeholder="メッセージを入力"></textarea>
</div>
<button type="submit" class="btn btn-outline-success" @click="buttonChange">メッセージを{{ propMessage }}</button>
<button type="button" class="btn btn-outline-danger" @click.prevent="submitForm" @click="remove" v-if="this.message">メッセージを削除</button>
</form>
</div>
</template>
<script>
import axios from 'axios'
// 現在の時間を取得する処理
const getTime = () => {
let clock = new Date();
let hour = clock.getHours();
let min = clock.getMinutes();
return hour + ":" + min
}
// 日付を取得する
const getDay = () => {
let clock = new Date();
let month = clock.getMonth()
let day = clock.getDate()
return month + 1 + "/" + day
}
// ローカルストレージからログインしているユーザーの情報を取得
const postUserName = JSON.parse(localStorage.getItem("vuex"))
export default {
name: 'ChatForm',
// 一覧画面からメッセージ内容を取得
props: [
'message',
'propMessage'
],
// メッセージ内容、ユーザー名、送信時間をセット
data() {
return {
messageText: this.message,
username: postUserName.username,
time: getTime(),
day: getDay()
}
},
mounted(){
this.username = postUserName
},
methods: {
// ファイルの名前によってボタンを押したときに呼び出すメソッドを変える
buttonChange() {
if (this.$route.name === 'home') {
this.save()
} else if (this.$route.name === 'edit') {
this.messageEdit()
}
},
// メッセージを保存
save(){
let message = {
// メッセージ内容
messageText: this.messageText,
// 送信者
username: postUserName.username,
// 送信時間
time: getTime(),
// 送信日
day: getDay()
}
if(message.messageText === ""){
window.alert('メッセージを入力してください')
}else{
this.$emit('clicked', message);
}
},
// メッセージを編集
messageEdit(){
const id = parseInt(this.$route.params.id)
let editMessageData = {
id: id,
messageText: this.messageText,
username: postUserName.username,
time: getTime()
}
if(editMessageData.messageText === ""){
window.alert('メッセージを入力してください')
}else{
this.$emit('clicked', editMessageData);
}
},
// メッセージを削除
remove() {
const result = window.confirm('メッセージを削除してよろしいですか?')
if(result){
// URLのパスからIDを取得
const id = parseInt(this.$route.params.id)
axios.delete("http://localhost:3000/api/message/delete", id)
.then(response => {
this.data = response.data
})
.catch(error => {
console.log(error)
})
this.$store.commit('delete', this.message.id)
this.$router.push('/')
}else{
return
}
}
}
}
</script>
<style scoped>
.center{
text-align: left;
}
.input{
margin-bottom: 10px;
}
textarea {
width: 60%;
height: 41px;
}
.btn-outline-danger{
margin-left: 10px;
}
</style>
メッセージ一覧ページ
<template>
<p class="loginName"><strong>{{ this.username }}</strong>でログイン中</p>
<div class="index" role="alert" aria-live="assertive" aria-atomic="true">
<h2> <i class="fas fa-list-ul"></i>メッセージ一覧</h2>
<!-- メッセージの一覧を表示 -->
<div class="message-item" v-for="item in data" :key="item.id">
<div class="toast-header">
<strong class="me-auto"><i class="far fa-clock"></i>{{ item.time }}<i class="fas fa-user"></i>{{ item.username }}</strong>
<small>{{ item.day }}</small>
</div>
<div class="toast-body" >
{{ item.messageText }}
<!-- ログインしているユーザーと送信者の名前が不一致の場合、編集ボタンを表示しない -->
<div class="edit-btn" v-if="item.username === this.username">
<router-link :to= "{name: 'edit', params: {id: item.id}}">
<button class="btn btn-success" id="edit-btn" type="button">編集<i class="fas fa-edit" style="margin-left: 3px;"></i></button>
</router-link>
</div>
</div>
</div>
</div>
<!-- メッセージがない場合のみ表示する -->
<p>{{ this.errorMessage }}</p>
<button id="page-top" href="#"><span><i class="fas fa-chevron-right"></i></span></button>
<div class="fix">
<ChatForm message="" @clicked="playSave" :propMessage="propMessage"/>
</div>
</template>
<script>
import ChatForm from '../components/ChatForm.vue'
import axios from 'axios'
// ローカルストレージからユーザー情報を取得
const postUserName = JSON.parse(localStorage.getItem("vuex"))
export default {
name: 'HomeView',
data(){
return{
data: {},
username: postUserName.username,
errorMessage: "",
propMessage: "送信"
}
},
components: {
ChatForm
},
mounted(){
// ローカルストレージからユーザーデータを取得
this.data = JSON.parse(localStorage.getItem("vuex"))
// 画面のトップへ戻るボタンの実装
const windowTop = document.body.scrollTop;
const pageTop = document.getElementById('page-top');
pageTop.addEventListener('click', function(e) {
e.preventDefault();
window.scrollTo({
top: windowTop,
behavior: 'smooth'
});
});
},
computed: {
HasMessages() {
return this.$store.getters.getCount
},
messages() {
return this.$store.getters.getAll
},
},
created(){
this.getMessages()
},
methods: {
// メッセージ一覧を取得する
getMessages() {
axios.get("http://localhost:3000/api/message/get")
.then(response => {
// メッセージが一件もない場合に以下のメッセージを表示する。
if(response.data.length === 0){
this.errorMessage = "メッセージはありません。"
}
this.data = response.data
})
.catch(error => {
console.log(error)
})
},
// メッセージを送信する
// Valueはコンポーネント化したChatForm.vueから$emitで入力された内容を取得している
playSave(value){
axios.post('http://localhost:3000/api/message/post', value)
.then((response) => {
console.log(response);
if(this.message.id){
value.id = this.message.id
}
this.$store.commit('save', value)
})
.catch((error) => {
console.error(error);
});
this.$router.push('/');
},
},
}
</script>
<style scoped>
h2{
text-align: left;
margin: 0 0 15px 10px;
}
.loginName{
text-align: right;
margin-right: 10px;
}
.message-title{
margin-top: 100px;
}
ul{
margin: 0;
padding: 0;
}
li{
list-style: none;
border-bottom: 1px solid#ccc;
padding-bottom: 10px;
margin-bottom: 10px;
text-align: left;
}
li a{
color: #999;
text-decoration: none;
width: 100%;
display: block;
}
.index{
padding-bottom: 110px;
}
.list-group{
width: 40%;
}
.toast{
margin-bottom: 10px;
box-shadow: none;
}
.fa-comments{
margin-right: 10px;
}
.fa-user{
padding: 0 5px 0 10px;
}
.fa-list-ul{
padding-right: 5px;
}
.toast-body{
text-align: left;
}
.home{
margin-bottom: 200px;
}
.edit-btn{
display: flex;
justify-content: flex-end;
}
.message-item{
margin-bottom: 20px;
}
#page-top{
position: fixed;
bottom: 40px;
right: 4%;
height: 59px;
width: 58px;
color: #FFF;
font-size: 32px;
background-color: #9E9E9E;
border: none;
border-radius: 50%;
outline: none;
opacity: 1;
transform: rotate(-90deg);
transition-duration: 0.5s;
}
.fix{
position: fixed;
bottom: 20px;
left: 5%;
width: 80%;
}
</style>
編集ページ
<template>
<div class="edit">
<h2><i class="fas fa-edit"></i>メッセージ編集</h2>
<ChatForm v-if="messageExists" :message="setMessages" @clicked="messageEdit" :propMessage="propMessage"/>
<p v-else>指定されたメッセージはありません</p>
</div>
</template>
<script>
import ChatForm from '../components/ChatForm.vue';
import axios from 'axios';
export default {
name: 'EditView',
components: {
ChatForm
},
data() {
return {
messageExists: false,
setMessages: [],
propMessage: "編集"
};
},
async created() {
// URLからIDを受け取る
const id = parseInt(this.$route.params.id);
try {
// APIからメッセージ一覧を取得
const response = await axios.get("http://localhost:3000/api/message/get");
// メッセージ一覧からIDと一致するメッセージがあるか確認
this.messageExists = response.data.some(message => message.id === id);
for(let i=0; i <= response.data.length; i++){
if(id === response.data[i].id){
this.setMessages = response.data[i].messageText;
break;
}
}
} catch (error) {
console.log(error);
}
},
computed: {
message() {
// URLからIDを受け取る
const id = parseInt(this.$route.params.id);
return this.$store.getters.getMessageById(id);
}
},
methods: {
// Valueはコンポーネント化したChatForm.vueから$emitで入力された内容を取得している
messageEdit(value){
axios.put('http://localhost:3000/api/message/edit', value)
.then((response) => {
console.log(response.data);
})
.catch((error) => {
console.error(error);
}
);
this.$router.push('/');
},
}
}
</script>
<style scoped>
h2{
text-align: left;
margin: 0 0 30px 10px;
}
.fa-edit{
padding-right: 7px;
}
.post-username{
font-weight: bold;
text-align: left;
margin-left: 10px;
}
</style>
最後に
このアプリは、多くのユーザーの利用を想定していないです。
なので、あくまで参考程度にしてください。
Discussion