Всем привет, недавно у меня появилось желание освоить библиотеку 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 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 })});})
- Событие
“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 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
Как видите, библиотекаSocket.IO очень проста и легка в использовании. После завершения разработки у меня появилось желание развернуть данное приложение и поделиться с вами его демо версией. Я потратил время на поиск подходящего бесплатного сервиса, который поддерживает WebSockets и остановил свой выбор на Heroku. В документации к Nuxt.js есть подробная инструкция о том, как развернуть приложение на данный сервис.
Спасибо за внимание.
Читайте продолжение статьи с новыми фичами здесь.