Создание чат-приложения в режиме реального времени с использованием технологий: 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 defaultfunction(){
 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=newDate().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 =()=>{returnnew 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";}elseif(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 defaultfunction({ 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-elseclass="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

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

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