Создание чат-приложения в реальном времени с использованием технологий: Vue.js, Nuxt.js, Node.js (Express), Socket.IO, Vue-Socket.IO, Vuetify.js

Создание чат-приложения в режиме реального времени с использованием технологий: Vue.js, Nuxt.js, Node.js (Express), Socket.IO, Vue-Socket.IO, Vuetify.js

Всем привет, недавно у меня появилось желание освоить библиотеку Socket.IO и создать чат-приложение, так сказать, закрепить теоретические знания практикой. Весь стек технологий (без Socket,IO), который применен в приложении - я активно использую в работе над коммерческими проектами.

Данную библиотеку легко внедрять и в уже рабочий проект, но сегодня речь пойдет о создании приложения с нуля.

Давайте приступим, сам не люблю долгие предисловия.

Настройка и установка базового шаблона Nuxt.js:

  • У вас должен быть установлен Node.js, если его нет - установите.
  • Если у вас версия NPM ниже чем 5.2 - установите npx глобально, используя права администратора.
  • $sudo npm install -g npx.

  • После этого создаем проект с помощью команды:
  • $npx create-nuxt-app < project-name >

    (тут более детальная информация о создании проекта).

  • Далее появится меню с конфигурацией проекта (я указал свою):
    1. Название проекта - “Nuxt-chat-app”
    2. Описание - “Simple chat with Nuxt.js”
    3. Имя автора - “Petr Borul”
    4. Пакетный менеджер - “NPM”
    5. UI Фреймворк - “Vuetify.js”
    6. Server Framework - “Express”
    7. Nuxt.js modules - “PWA”
    8. Linting tools - “ESLint”
    9. Test framework - “none”
    10. Rendering mode - “Universal”
  • Установим пакеты SOCKET.IO:
  • $npm install socket.io --save

  • Так же я использовал обертку для SOCKET.IO - Vue.SOCKET.IO.
  • В данной библиотеке можно вызывать веб сокет ивенты и подписываться на них через Vuex store, но для первого знакомства с библиотекой - это слишком не понятно, поэтому я реализовал логику на уровне компонентов (тут есть описание двух способов

    $npm install vue-socket.io --save

Полную информацию по структуре папок Nuxt.js вы можете найти здесь.

.

Основные моменты:

  • Папка pages содержит представления (views) и маршруты (routes). Фреймворк считывает все файлы .vue внутри папки и создает маршрутизатор для вашего приложения.
  • Папка plugins содержит JavaScript-плагины, запускаемые перед созданием экземпляра корневого приложения Vue.js (тут будет содержаться наш плагин socket.io).
  • Папка middleware содержит функции промежуточной обработки (именованные создаются в данной папке, если вы хотите указать анонимные - вы можете объявить их внутри компонента).
  • Файл nuxt.config.js содержит пользовательскую конфигурацию Nuxt.js.
  • Папка store содержит файлы Vuex хранилища. После создания файла index.js в этой папке хранилище активируется автоматически.

Итак, мы разобрались с основными моментами, давайте приступим к самой разработке.

В папке server есть файл index.js - немного изменим его и вынесем конфигурацию сервера в отдельный файл: app.js.

const app = require('express')();
const server = require('http').createServer(app);
const io = require('socket.io')(server);

Добавим конфигурацию сервера в index.js:

index.js

const { app, server } = require('./app');

Далее укажем Node.js слушать сконфигурированный сервер:

server.listen(port, () => {
   consola.ready({
     message: `Server listening on https://${host}:${port}`,
     badge: true
   })
 })

Далее создадим файл socket.client.jsи добавим его в папку plugins, мы указали расширение файла ‘client’, потому что он нам нужен только на стороне клиента (тут есть полная информация о настройке плагинов).

socket.client.js

import Vue from 'vue'
import VueSocketIO from 'vue-socket.io'
 
export default function () {
 Vue.use(new VueSocketIO({
   debug: false,
   connection: '/',
 }))
}

Теперь зарегистрируем его в файле nuxt.config.js:

 plugins: [
   { src: '~/plugins/socket.client.js' }
 ],

С этого момента к нему можно обратиться в любом компоненте используя название файла this.$socket.emit().

В файле app.js создадим две модели для работы с данными:

const users = require('../utils/users')();
const Message = require('../utils/message')();

message.js:

class Message {
 constructor(name, text, id) {
   this.name = name;
   this.text = text;
   this.id = id;
   this.time = new Date().toString().slice(15, 24);
 }
}
 
module.exports = () => {
 return Message
}

users.js :

class Users {
 constructor() {
   this.users = [];
 }
 
 addUser(user) {
   this.users = [...this.users, user]
 }
 
 getUser(id) {
   return this.users.find(user => user.id === id);
 }
 
 getUsersByRoom(room) {
   return this.users.filter(user => user.room === room);
 }
 
 removeUser(id) {
   this.users = this.users.filter(user => user.id !== id);
 }
}
 
module.exports = () => {
 return new Users()
}

С сервером на данном этапе закончили, сейчас займемся клиентской частью В папке store создадим файл index.js и добавим хранилище:

index.js

export const state = () => ({
 user: {},
 messages: [],
 users: []
})
 
export const mutations = {
 setUser(state, user) {
   state.user = user;
 },
 newMessage(state, msg) {
   state.messages = [...state.messages, msg];
 },
 updateUsers(state, users) {
   state.users = users;
 },
 clearData(state) {
   state.user = {};
   state.messages = [];
   state.users = [];
 },
}

Далее в папке pages в файл index.js добавим разметку (использую UI библиотеку Vuetify.js из-за Material Design, который мне очень нравится).

index.js :

<template>
 <v-layout column justify-center align-center>
   <v-flex xs12 sm8>
     <v-card min-width="370">
       <v-snackbar v-model="snackbar" :timeout="3000" top>
         {{ message }}
         <v-btn dark text @click="snackbar = false">Close</v-btn>
       </v-snackbar>
 
       <v-card-title>
         <h1>Login</h1>
       </v-card-title>
       <v-card-text>
         <v-form ref="form" v-model="valid" lazy-validation @submit.prevent="submit">
           <v-text-field
             v-model="name"
             :counter="16"
             :rules="nameRules"
             label="Name"
             required
           ></v-text-field>
           <v-text-field
             v-model="room"
             :rules="roomRules"
             label="Enter the room"
             required
           ></v-text-field>
           <v-btn :disabled="!valid" color="primary" class="mr-4" type="submit">Submit</v-btn>
         </v-form>
       </v-card-text>
     </v-card>
   </v-flex>
 </v-layout>
</template>
 
<script>
import { mapMutations } from "vuex";
 
export default {
 name: "index",
 layout: "login",
 head: {
   title: "Nuxt-chat"
 },
 data: () => ({
   valid: true,
   name: "",
   message: "",
   id: null,
   nameRules: [
     v => !!v || "Name is required",
     v => (v && v.length <= 16) || "Name must be less than 16 characters"
   ],
   room: "",
   roomRules: [v => !!v || "Enter the room"],
   snackbar: false
 }),
 mounted() {
   const { message } = this.$route.query;
   if (message === "noUser") {
     this.message = "Enter your name and room";
   } else if (message === "leftChat") {
     this.message = "You leaved chat";
   }
   this.snackbar = !!this.message;
 },
 
 methods: {
   ...mapMutations(["setUser"]),
   submit() {
     if (this.$refs.form.validate()) {
       const user = {
         name: this.name,
         room: this.room,
         id: 0
       };
       this.$socket.emit("createUser", user, data => {
         user.id = data.id;
         this.setUser(user);
         this.$router.push("/chat");
       });
     }
   }
 }
};
</script>

При вызове метода submit () происходит валидация формы, и в случае успеха - мы отправляем событие на сервер this.$socket.emit().

На сервер передаем строку с названием события, и функцию обратного вызова, после выполнения которой - мы получаем ID и присваиваем его объекту пользователя, далее записываем в хранилище и направляем на страницу чата.

Давайте опишем обработку событий на сервере:

io.on('connection', socket => {
 socket.on("createUser", (user, cb) => {
   users.addUser({
     ...user,
     id: socket.id
   })
   cb({ id: socket.id })
 });
})
  1. Событие “connection” вызывается, когда пользователь получает соединение с сервером.
  2. Далее мы подписываемся на событие, полученное от клиента, с помощью socket.on().
  3. Данная функция принимает строку “Название события” и функцию обратного вызова.
  4. Добавляем нового пользователя в список пользователей и присваиваем ID, соответствующий ID сокет подключения.
  5. Отдаем ID на сторону клиента.

Теперь создадим разметку файла default.vue в папке layouts он устанавливается по умолчанию для всех компонентов в папке pages eсли не указан layout is not indicated (тут подробная информация).

default.vue :

<template>
 <v-app>
   <v-navigation-drawer app v-model="drawer" mobile-break-point="650">
     <v-list subheader>
       <v-subheader>Users in room</v-subheader>
 
       <v-list-item v-for="(u, index) in users" :key="`user-${index}`" @click.prevent>
         <v-list-item-content>
           <v-list-item-title v-text="u.name"></v-list-item-title>
         </v-list-item-content>
 
         <v-list-item-icon>
           <v-icon :color="u.id === user.id ? 'primary' : 'grey'">mdi-account-circle-outline</v-icon>
         </v-list-item-icon>
       </v-list-item>
     </v-list>
   </v-navigation-drawer>
 
   <v-app-bar app>
     <v-app-bar-nav-icon @click="drawer = !drawer"></v-app-bar-nav-icon>
     <v-toolbar-title>
       Room
       <v-chip color="grey">{{ user.room }}</v-chip>
     </v-toolbar-title>
     <v-spacer></v-spacer>
     <v-btn icon class="mx-1" @click="exit">
       <v-icon>mdi-exit-to-app</v-icon>
     </v-btn>
   </v-app-bar>
 
   <v-content>
     <v-container fluid style="height: 100%">
       <nuxt />
     </v-container>
   </v-content>
 </v-app>
</template>
 
<script>
import { mapState, mapMutations } from "vuex";
 
export default {
 data: () => ({
   drawer: true
 }),
 sockets: {
   updateUsers(users) {
     this.updateUsers(users);
   },
   newMessage(msg) {
     this.newMessage(msg);
   },
 },
 computed: {
   ...mapState(["user", "users"])
 },
 middleware: "auth",
 methods: {
   ...mapMutations(["clearData", "updateUsers", "newMessage"]),
   exit() {
     this.$socket.emit("userLeft", () => {
       this.$router.push("/?message=leftChat");
       this.clearData();
     });
   }
 },
 created() {
   this.$socket.emit("joinRoom", this.user)
 }
};
</script>

Тег < nuxt / > отвечает за отображение страниц на разных роутах

( тут более детальная информация про отображение в Nuxt.js).

Объект sockets отвечает за обработку событий, которые вызываются со стороны сервера.

Давайте добавим подписку на два события “updateUsers” и “newMessage”. Далее добавим метод exit(), он будет вызываться по клику на кнопку выхода, в котором мы будем отправлять событие “leftChat” на сервер, а далее перенаправим пользователя на форму регистрации с query в роуте для вывода сообщения в снекбаре.

Обработаем это события на сервере:

app.js

socket.on('leftChat', (cb) => {
   const id = socket.id;
   const user = users.getUser(id);
   if (user) {
     users.removeUser(id);
     socket.leave(user.room);
     io.to(user.room).emit('updateUsers', users.getUsersByRoom(user.room));
     io.to(user.room).emit('newMessage', new Message('admin', `User ${user.name} left chat`))
   }
   cb()
 });

Теперь создаем файл auth.js в папке middleware и добавим функцию промежуточной обработки в компонент, чтобы на страницу чата мог попасть только авторизованный пользователь.

auth.js :

export default function({ store, redirect }) {
 if(!Object.keys(store.state.user).length) {
   redirect('/?message=noUser')
 }
}

Так же при инициализации компонента отправляем событие “joinRoom” на сервер и передаем данные пользователя как payload в функцию обратной связи.

Давайте обработаем это событие на сервере:

app.js

 socket.on("joinRoom", user => {
   socket.join(user.room);
   io.to(user.room).emit('updateUsers', users.getUsersByRoom(user.room));
   socket.emit('newMessage', new Message('admin', `Hello, ${user.name}`));
   socket.broadcast
     .to(user.room)
     .emit('newMessage', new Message('admin', `User ${user.name} connected to chat`));
 });
  • Добавляем пользователя в комнату, которую он указал при авторизации;
  • Вызываем событие “updateUsers” у всех пользователей данной комнаты;
  • Вызываем событие “newMessage” только у пользователя, вызвавшего событи “joinRoom”;
  • Вызываем событие “newMessage” для всех, кроме текущего пользователя (оповещаем о подключении нового пользователя).

Далее добавим разметку чата:

chat.vue :

<template>
 <div class="chat-wrapper">
   <div class="chat" ref="chat">
     <Message
       v-for="(message,index) in messages"
       :key="`message-${index}`"
       :name="message.name"
       :text="message.text"
       :time="message.time"
       :owner="message.id === user.id"
     />
   </div>
   <div class="chat__form">
     <ChatForm />
   </div>
 </div>
</template>
 
<script>
import { mapState, mapMutations } from "vuex";
import Message from "@/components/message";
import ChatForm from "@/components/ChatForm";
 
export default {
 components: {
   Message,
   ChatForm
 },
 head() {
   return {
     title: `Room ${this.user.room}`
   };
 },
 methods: {
   ...mapMutations(["newMessage"])
 },
 computed: {
   ...mapState(["user", "messages"])
 },
 watch: {
   messages() {
     setTimeout(() => {
       if (this.$refs.chat) {
         this.$refs.chat.scrollTop = this.$refs.chat.scrollHeight;
       }
     }, 0);
   }
 }
};
</script>

Секцию со стилями я опустил, что бы вы сконцентрировались на логике. Компонент, который отвечает за отрисовку сообщений:

Message.vue :

<template>
 <div>
   <div v-if="name === 'admin'" class="system">
     <p class="text-center font-italic">{{ text }}</p>
   </div>
   <div v-else class="msg-wrapper">
     <div class="msg" :class="{owner}">
       <div class="msg__information">
         <span class="msg__name">{{ name }}</span>
         <span class="msg__date">{{ time }}</span>
       </div>
       <p class="msg__text">{{ text }}</p>
     </div>
   </div>
 </div>
</template>
 
<script>
export default {
 props: {
   name: String,
   text: String,
   time: String,
   owner: Boolean
 }
};
</script>

Со стилями всё так же, как и с предыдущим компонентом

Компонент для реализации отправки сообщений:

ChatForm.vue :

<template>
 <v-text-field
   ref="msg"
   label="Message..."
   outlined
   v-model="text"
   @click:append="send"
   @keydown.enter="send"
   append-icon="mdi-send-circle-outline"
 />
</template>
 
<script>
import { mapState } from "vuex";
 
export default {
 data: () => ({
   text: "",
   roomRules: [v => !!v || "Enter the room"]
 }),
 computed: {
   ...mapState(["user"])
 },
 methods: {
   send() {
     if (this.text.length) {
       this.$socket.emit(
         "createMessage",
         {
           text: this.text,
           id: this.user.id
         },
         data => {
           this.text = "";
         }
       );
     }
   }
 }
};
</script>

При подтверждении формы - отправляем событие “createMessage” на сервер, передаем текст сообщения и айди пользователя, после выполнения функции обратной сязи - очищаем поле.

Теперь обработаем это событие на сервере:

app.js

socket.on('createMessage', (data, cb) => {
   const user = users.getUser(data.id);
   if (user) {
     io.to(user.room).emit('newMessage', new Message(user.name,     data.text, data.id))
   }
   cb()
 });

Добавим подписку на случай разрыва соединения, в дальнейшем можно будет добавить возможность реконнекта:

app.js

socket.on('disconnect', () => {
   const id = socket.id;
   const user = users.getUser(id);
   if (user) {
     users.removeUser(id);
     socket.leave(user.room);
     io.to(user.room).emit('updateUsers', users.getUsersByRoom(user.room));
     io.to(user.room).emit('newMessage', new Message('admin', `User ${user.name} left chat`))
   }
 });

На данном этапе это последняя часть приложения. Вы можете запустить локальный сервер с помощью команды:

$npm run dev

Preview

Github

Как видите, библиотекаSocket.IO очень проста и легка в использовании. После завершения разработки у меня появилось желание развернуть данное приложение и поделиться с вами его демо версией. Я потратил время на поиск подходящего бесплатного сервиса, который поддерживает WebSockets и остановил свой выбор на Heroku. В документации к Nuxt.js есть подробная инструкция о том, как развернуть приложение на данный сервис.

Demo

Спасибо за внимание.

Читайте продолжение статьи с новыми фичами здесь.