Создание чат-приложения в режиме реального времени с использованием технологий: 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,()=&gt;{
       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 =()=&gt;{return Message
    }

    users.js :

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

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

    index.js

    export const state =()=&gt;({
     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 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><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 =&gt;{
     socket.on("createUser",(user, cb)=&gt;{
       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}`"><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></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"><v-icon>mdi-exit-to-app</v-icon></v-btn></v-app-bar>
     
       <v-content><v-container fluid style="height: 100%"><nuxt></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)=&gt;{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 =&gt;{
       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"></message></div><div class="chat__form"><chatform></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" append-icon="mdi-send-circle-outline"></v-text-field></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)=&gt;{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',()=&gt;{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

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

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