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

Chat-app Creation in the Real-Time Mode Using Vue.js, Nuxt.js, Node.js (Express), Socket.IO, Vue-Socket.IO, Vuetify.js Technologies.

Привіт всім. Нещодавно у мене виникло бажання освоїти бібліотеку 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. Інтерфейс користувача - “Vuetify.js”
    6. Фреймворк сервера - “Express”
    7. Модулі Nuxt.js - “PWA”
    8. Інструменти лінгування - “ESLint”
    9. Тестовий фреймворк - “none”
    10. Режим візуалізації - “Universal”
  • Давайте встановимо SOCKET.IO:
  • $npm install socket.io --save

  • Я також використовував обгортку для SOCKET.IO - Vue.SOCKET.IO.
  • У цій бібліотеці можна викликати події веб-сокетів і підписуватися на них через магазин Vuex, але для практичного огляду бібліотеки це занадто неявно. Тому я реалізував логіку на рівні компонентів (ось опис двох можливих способів її реалізації).

    $npm install vue-socket.io --save

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

Основні моменти:

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

Отже, з основними поняттями ми розібралися, переходимо до самої розробки програми. У папці знаходиться файл 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 http://${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 = [];
 },
}

Далі ми додамо макет до файлу layout to the file index.js у папці layouts (я використовую 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. Передаємо ідентифікатор на сторону клієнта.

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

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.js).

Об'єктні сокети sockets відповідають за обробку подій, які викликаються на стороні сервера.

Давайте додамо підписку на 2 події “updateUsers” and “newMessage”. Потім ми додамо метод exit(), який буде викликаний клацанням кнопки виходу і в якому ми будемо надсилати подію “leftChat” на сервер. Потім користувач буде перенаправлений до реєстраційної форми із запиту на маршруті для відображення повідомлень у снекбарі.

Давайте обробимо цю подію на сервері:

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” на сервер і надсилаємо дані користувача як корисне навантаження у функцію зворотного зв’язку.

Давайте обробимо цю подію на сервері:

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” на сервер, надсилаємо текст повідомлення та ID користувача, після функції зворотного зв'язку очищаємо поле.

Тепер ми обробимо цю подію на сервері:

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 містять докладний посібник про те, як розгорнути програму в цій службі.

Демо

Дякуємо за увагу.

Прочитайте продовження статті з новими функціями тут.