Создание чат-приложения в реальном времени с использованием технологий: 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 глобально, используя права администратора.
- После этого создаем проект с помощью команды:
- Далее появится меню с конфигурацией проекта (я указал свою):
- Название проекта - “Nuxt-chat-app”
- Описание - “Simple chat with Nuxt.js”
- Имя автора - “Petr Borul”
- Пакетный менеджер - “NPM”
- UI Фреймворк - “Vuetify.js”
- Server Framework - “Express”
- Nuxt.js modules - “PWA”
- Linting tools - “ESLint”
- Test framework - “none”
- Rendering mode - “Universal”
- Установим пакеты SOCKET.IO:
- Так же я использовал обертку для SOCKET.IO - Vue.SOCKET.IO.
$sudo npm install -g npx.
$npx create-nuxt-app < project-name >
(тут более детальная информация о создании проекта).
$npm install socket.io --save
В данной библиотеке можно вызывать веб сокет ивенты и подписываться на них через 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 }) }); })
- Событие
“connection”
вызывается, когда пользователь получает соединение с сервером. - Далее мы подписываемся на событие, полученное от клиента, с помощью
socket.on()
. - Данная функция принимает строку “Название события” и функцию обратного вызова.
- Добавляем нового пользователя в список пользователей и присваиваем ID, соответствующий ID сокет подключения.
- Отдаем 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
Как видите, библиотекаSocket.IO очень проста и легка в использовании. После завершения разработки у меня появилось желание развернуть данное приложение и поделиться с вами его демо версией. Я потратил время на поиск подходящего бесплатного сервиса, который поддерживает WebSockets и остановил свой выбор на Heroku. В документации к Nuxt.js есть подробная инструкция о том, как развернуть приложение на данный сервис.
Спасибо за внимание.
Читайте продолжение статьи с новыми фичами здесь.