Skip to content

Websocket 聊天室

第三方库:Socket.IO

Socket.IO 是一个用于实现实时双向通信的库,通常用于构建需要实时交互的 web 应用程序。它建立在 WebSocket 协议之上,但比 WebSocket 提供了更高级的功能和更好的兼容性。

主要特性

  1. 实时双向通信:支持客户端和服务器之间的实时消息交换。
  2. 自动重连:连接断开后,Socket.IO 会自动尝试重新连接。
  3. 事件驱动架构:使用事件的方式处理通信,支持自定义事件,使得开发更加直观和灵活。
  4. 跨平台兼容性:即使在不支持 WebSocket 的环境中,Socket.IO 也能通过轮询等其他技术进行通信。
  5. 命名空间(Namespaces):允许通过命名空间将不同的通信逻辑隔离开来,便于管理和扩展。
  6. 房间(Rooms):可以将客户端分配到特定的房间,便于进行组播、广播等操作。

使用场景

  • 即时通讯应用:如聊天软件、客服系统。
  • 协同编辑:实时同步文档或表格的编辑状态。
  • 多人在线游戏:同步游戏状态和玩家动作。
  • 实时数据更新:如股票、天气等实时信息推送。
  • 实时通知和警报系统。

服务端:Node.js + Express

客户端:Vue3 + Vite

快速上手

客户端需要安装 sokcet.io-client 这个库,安装完成后需要在 main.js 注册使用这个库

js
// main.js

// 创建一个 socket 客户端实例
const socket = io("http://localhost:3000", {
  // 这里是在配置客户端与服务器端建立连接的优先级列表
  // 1. 第一优先级使用 websocket
  // 2. 第二优先级使用 polling(长轮询)
  // 3. 第三优先级使用 flashsocket
  transports: ["websocket", "polling", "flashsocket"],
});

// 将 socket 实例挂载到 app.config.globalProperties 上
app.config.globalProperties.$socket = socket;

实战--聊天室

客户端

js
import "./assets/main.css";

import { createApp } from "vue";
import App from "./App.vue";
import router from "./router";
import { io } from "socket.io-client";

const app = createApp(App);

app.use(router);

// 创建一个 socket 客户端实例
const socket = io("http://localhost:3000", {
  // 这里是在配置客户端与服务器端建立连接的优先级列表
  // 1. 第一优先级使用 websocket
  // 2. 第二优先级使用 polling(长轮询)
  // 3. 第三优先级使用 flashsocket
  transports: ["websocket", "polling", "flashsocket"],
});

// 将 socket 实例挂载到 app.config.globalProperties 上
app.config.globalProperties.$socket = socket;

app.mount("#app");
js
import { createRouter, createWebHistory } from "vue-router";
import Login from "@/views/Login.vue";
import Chat from "@/views/Chat.vue";

const routes = [
  {
    path: "/",
    name: "login",
    component: Login,
  },
  {
    // 动态路由:后面会跟上用户名
    path: "/chat/:username",
    name: "chat",
    component: Chat,
  },
];

const router = createRouter({
  history: createWebHistory(import.meta.env.BASE_URL),
  routes,
});

export default router;
vue
<template>
  <div class="login-container">
    <h1>欢迎来到聊天室</h1>
    <p>请输入你在聊天室的昵称</p>
    <input
      v-model="username"
      type="text"
      name="username"
      id="username"
      placeholder="请输入用户名"
      class="input"
    />
    <button @click="login" class="button">进入聊天室</button>
  </div>
</template>

<script setup>
import { ref, getCurrentInstance, onMounted } from "vue";
import { useRouter } from "vue-router";

const router = useRouter();

const username = ref("");

const { proxy } = getCurrentInstance();
const socket = proxy.$socket; // 获取到了 socket 实例

const login = () => {
  if (!username.value) {
    alert("请输入用户名");
    return;
  }
  // 通过 socket 来触发一个登录事件
  socket.emit("login", username.value);
};

onMounted(() => {
  // 在组件进行挂载的时候,需要监听 login 事件
  socket.on("login", (data) => {
    // 跳转到 chat 页面
    router.push({
      name: "chat",
      params: {
        username: data.username,
      },
    });
  });
});
</script>

<style scoped>
.login-container {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  height: 100vh;
  background-color: #f0f2f5;
  text-align: center;
}

h1 {
  margin-bottom: 20px;
  color: #333;
}

p {
  margin-bottom: 10px;
  color: #555;
}

.input {
  padding: 10px;
  width: 80%;
  max-width: 300px;
  margin-bottom: 20px;
  border: 1px solid #ccc;
  border-radius: 5px;
  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}

.button {
  padding: 10px 20px;
  color: white;
  background-color: #007bff;
  border: none;
  border-radius: 5px;
  cursor: pointer;
  transition: background-color 0.3s ease;
}

.button:hover {
  background-color: #0056b3;
}
</style>
vue
<template>
  <div class="chat-container">
    <header>
      <h1>聊天室</h1>
      <p>【{{ username }}】进入了聊天室</p>
      <p>当前聊天室人数:{{ count }}</p>
    </header>
    <!-- 显示聊天的信息 -->
    <div class="chat-content" ref="chatContainer">
      <div
        v-for="(item, index) in content"
        :key="index"
        :class="getMessageClass(item.type)"
      >
        {{ item.msg }}
      </div>
    </div>
    <div class="chat-input">
      <input
        v-model="msg"
        type="text"
        name="msg"
        id="msg"
        placeholder="输入消息..."
        @keyup.enter="sendMsg"
      />
      <button @click="sendMsg" class="send-button">发送</button>
    </div>
  </div>
</template>

<script setup>
import { ref, onMounted, getCurrentInstance, nextTick } from "vue";
import { useRouter, useRoute } from "vue-router";

const router = useRouter(); // 获取到路由实例
const route = useRoute(); // 获取到当前路由

const username = ref("");
const count = ref(0);
const msg = ref("");
const content = ref([]); // 存放聊天内容
const chatContainer = ref(null);

const { proxy } = getCurrentInstance();
const socket = proxy.$socket;

const sendMsg = () => {
  if (!msg.value) {
    alert("请输入消息");
    return;
  }
  socket.emit("msg", {
    username: username.value,
    msg: msg.value,
  });
  msg.value = "";
};

const getMessageClass = (type) => {
  if (type === 1) return "rightBox";
  if (type === 2) return "leftBox";
  return "centerBox";
};

const scrollBottom = () => {
  nextTick(() => {
    // 获取到 chatContainer 的 DOM 元素
    const container = chatContainer.value;
    if (container) {
      container.scrollTo({
        top: container.scrollHeight, // 滚动到底部
        behavior: "smooth",
      });
    }
  });
};

onMounted(() => {
  // 在组件挂载的时候,就需要做一些处理,以及监听一些事件
  // 1. 需要做的一些处理:拿到用户名和聊天室的人数
  if (route.params.username) {
    // 获取用户名
    username.value = route.params.username;
    // 通过 socket 来触发一个 count 事件获取在线用户数量
    socket.emit("count");
  } else {
    router.replace("/");
  }
  socket.on("count", (data) => {
    count.value = data;
  });
  socket.on("msg", (data) => {
    if (data.type) {
      count.value = data.count;
      content.value.push({
        type: 3,
        msg:
          data.type === "loginIn"
            ? `【${data.username}】进入了聊天室`
            : `【${data.username}】离开了聊天室`,
      });
    } else {
      // 说明就是普通的聊天消息
      // 将聊天的消息推入到 content 数组中
      if (data.username === username.value) {
        // 当前处于发布消息的用户
        // type 为 1 代表自己发送的消息
        // type 为 2 代表其他用户发送的消息
        // 这个 type 主要是为了处理样式
        content.value.push({
          type: 1,
          msg: data.msg,
        });
      } else {
        // 其他用户收到消息
        content.value.push({
          type: 2,
          msg: `【${data.username}】说:${data.msg}`,
        });
      }
    }
    scrollBottom();
  });
});
</script>

<style scoped>
.chat-container {
  display: flex;
  flex-direction: column;
  height: 100vh;
  background-color: #f0f2f5;
}

header {
  text-align: center;
  padding: 20px;
  background-color: #007bff;
  color: white;
}

.chat-content {
  flex: 1;
  overflow-y: auto;
  padding: 20px;
  background-color: #fff;
  border-top: 1px solid #ccc;
  border-bottom: 1px solid #ccc;
}

.rightBox,
.leftBox,
.centerBox {
  max-width: 70%;
  margin: 10px 0;
  padding: 10px;
  border-radius: 5px;
  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}

.rightBox {
  background-color: #d1e7dd;
  margin-left: auto;
}

.leftBox {
  background-color: #f8d7da;
  margin-right: auto;
}

.centerBox {
  text-align: center;
  color: #6c757d;
  margin: 10px auto;
}

.chat-input {
  display: flex;
  padding: 10px;
  background-color: #f0f2f5;
  border-top: 1px solid #ccc;
}

.chat-input input {
  flex: 1;
  padding: 10px;
  border: 1px solid #ccc;
  border-radius: 5px;
  margin-right: 10px;
  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}

.send-button {
  padding: 10px 20px;
  color: white;
  background-color: #007bff;
  border: none;
  border-radius: 5px;
  cursor: pointer;
  transition: background-color 0.3s ease;
}

.send-button:hover {
  background-color: #0056b3;
}
</style>

服务端

js
const createError = require("http-errors");
const express = require("express");
const app = express();

// catch 404 and forward to error handler
app.use(function (req, res, next) {
  next(createError(404));
});

// error handler
app.use(function (err, req, res, next) {
  // set locals, only providing error in development
  res.locals.message = err.message;
  res.locals.error = req.app.get("env") === "development" ? err : {};

  // render the error page
  res.status(err.status || 500);
  res.render("error");
});

module.exports = app;
js
#!/usr/bin/env node

/**
 * Module dependencies.
 */

const app = require("../app");
const debug = require("debug")("server:server");
const http = require("http");

/**
 * Get port from environment and store in Express.
 */

const port = normalizePort(process.env.PORT || "3000");
app.set("port", port);

/**
 * Create HTTP server.
 */

// 创建一个服务器
const server = http.createServer(app);

// 引入 Socket.IO 库,并立即调用以初始化 Socket.IO 服务器
// 传入的参数是一个 HTTP 服务器实例 `server`,将 Socket.IO 服务器绑定到这个 HTTP 服务器上
const io = require("socket.io")(server, {
  // 配置 CORS(跨域资源共享)选项,以允许前端应用与后端进行跨域通信
  cors: {
    // 允许的前端地址,即可以与该 Socket.IO 服务器进行通信的来源
    // 这里设置为 "http://localhost:5173",这通常是前端应用在开发环境中的地址
    origin: "http://localhost:5173",
    // 允许的 HTTP 方法,用于控制前端可以使用哪些方法与服务器进行通信
    methods: ["GET", "POST"],
    // 允许的 HTTP 头,用于控制前端在请求时可以使用哪些请求头
    // 这里允许 "Content-Type" 头,以支持发送不同格式的数据
    allowedHeaders: ["Content-Type"],
    // 是否允许发送凭证(如 cookies 或 HTTP 认证信息)到服务器
    // 设为 true,表示允许发送凭证,这在某些需要认证的情况下非常有用
    credentials: true,
  },
});

let count = 0; // 保存当前在线用户数量

// 监听连接事件
// 当客户端和服务器建立Websocket连接后,会触发 connection 事件
io.on("connection", function (socket) {
  let username = ""; // 保存当前用户的用户名
  // 新用户登录
  socket.on("login", (data) => {
    count += 1; // 在线用户数量加1
    username = data; // 保存当前用户的用户名
    // 触发当前用户的 login 事件,将当前用户的用户名和在线用户数量发送给客户端
    socket.emit("login", {
      username: data,
      count,
    });
    // 向其他用户广播一条消息
    socket.broadcast.emit("msg", {
      username: data,
      count,
      type: "loginIn",
    });
  });
  socket.on("count", () => {
    // 触发客户端的 count 事件,将当前在线用户数量发送给客户端
    socket.emit("count", count);
  });
  socket.on("msg", (data) => {
    if (data.username) {
      // 要做的事情:
      // 1. 将这条消息原封不动的发给当前用户
      socket.emit("msg", data);
      // 2. 将这条消息广播给所有用户
      socket.broadcast.emit("msg", data);
    }
  });
  // 当前的用户离开
  socket.on("disconnect", () => {
    if (username) {
      count -= 1; // 在线用户数量减1
      // 向其他用户广播一条消息
      socket.broadcast.emit("msg", {
        username,
        count,
        type: "loginOut",
      });
    }
  });
});

/**
 * Listen on provided port, on all network interfaces.
 */

server.listen(port);
server.on("error", onError);
server.on("listening", onListening);

/**
 * Normalize a port into a number, string, or false.
 */

function normalizePort(val) {
  var port = parseInt(val, 10);

  if (isNaN(port)) {
    // named pipe
    return val;
  }

  if (port >= 0) {
    // port number
    return port;
  }

  return false;
}

/**
 * Event listener for HTTP server "error" event.
 */

function onError(error) {
  if (error.syscall !== "listen") {
    throw error;
  }

  var bind = typeof port === "string" ? "Pipe " + port : "Port " + port;

  // handle specific listen errors with friendly messages
  switch (error.code) {
    case "EACCES":
      console.error(bind + " requires elevated privileges");
      process.exit(1);
      break;
    case "EADDRINUSE":
      console.error(bind + " is already in use");
      process.exit(1);
      break;
    default:
      throw error;
  }
}

/**
 * Event listener for HTTP server "listening" event.
 */

function onListening() {
  const addr = server.address();
  const bind = typeof addr === "string" ? "pipe " + addr : "port " + addr.port;
  debug("Listening on " + bind);
  console.log(`服务器已启动,监听${port}端口...`);
}

-EOF-

MIT License