Skip to content

简易聊天室

WebSocket 原生 API

MDN WebSocket

js
const ws = new WebSocket("地址"); // 创建 websocket 连接,浏览器自动握手

// 事件:握手完成后触发(异步)
ws.onopen = function () {
  console.log("连接到了服务器");
};
// 事件:收到服务器消息后触发
ws.onmessage = function (e) {
  console.log(e.data); // e.data:服务器发送的消息
};
// 事件:连接关闭后触发(可以是客户端主动关闭,也可以是服务器主动关闭或者浏览器关闭了)
// 可以通过 wbsocet 实例的 close 方法来关闭连接
ws.onclose = function () {
  console.log("连接关闭了");
};

// 发送消息到服务器
ws.send(消息);

// 连接状态:0-正在连接中  1-已连接  2-正在关闭中  3-已关闭
ws.readyState;

当使用 live server 插件时,可以从浏览器的 network 选项卡中看到有两个 websocket 连接,这是因为 live server 插件本身就是利用 websocket 来实现了热更新的,通过查看网页的源代码可以看到,live server 插件会在网页中引入一个 js 脚本,其中创建了 websocket 来链接 live server 搭建的服务器。

Socket.io

官方文档

原生的接口虽然简单,但是在实际应用中会造成很多麻烦

比如一个页面,既有 K 线,也有实时聊天,于是:

image-20211029113505007

上图是一段时间中服务器给客户端推送的数据,你能区分这些数据都是什么意思吗?

这就是问题所在:连接双方可以在任何时候发送任何类型的数据,另一方必须要清楚这个数据的含义是什么。

回忆 HTTP 是如何处理这个问题的?通过不同的路径来区分

你会如何解决这个问题?比如发送消息时,在消息中包含一个类型字段,服务器根据类型字段来判断消息的类型,客户端根据类型字段来判断消息的类型

虽然我们可以自行解决这些问题,但毕竟麻烦

Socket.io帮助我们解决了这些问题,它把消息放到不同的事件中,通过监听和触发事件来实现对不同消息的处理

image-20211029123907859

客户端和服务器双方事先约定好不同的事件,事件由谁监听,由谁触发,就可以把各种消息进行有序管理了

注意,Socket.io 为了实现这些要求,对消息格式进行了特殊处理,因此如果一方要使用 Socket.io,双方必须都使用

在客户端,使用 Socket.io 是非常简单的

参见:https://socket.io/docs/v4/client-installation/

在约定事件名时要注意,Socket.io 有一些预定义的事件名,比如 message、connect 等

为了避免冲突,建议自定义事件名使用一个特殊的前缀,比如$

除此之外,Socket.io 对低版本浏览器还进行了兼容处理,如果浏览器不支持 WebSocket,Socket.io 将使用长轮询(long polling)处理。另外,Socket.io 还支持使用命名空间来进一步隔离业务,要了解这些高级功能,以及 Socket.io 的更多 API,请参阅其官方文档

接口文档

测试接口

连接地址:ws://localhost:9527

服务器消息:

  1. 服务器每隔 3 秒钟会发送一个消息给客户端
  2. 每次收到客户端的消息后,服务器会回应一个消息

聊天室接口

连接地址:ws://localhost:9528

服务器触发的事件/客户端需要监听的事件:

事件名触发时机传递的消息传递消息示例
$updateUser有新用户进入
有老用户退出
自己进入
当前聊天室的用户数组['张三', '李四']
$name自己进入分配的用户名称"张三"
$history自己进入历史聊天记录[{
name:"张三",
content:"你好",
date: 1635484786373
}]
$message其他人发送消息消息对象{
name:"张三",
content:"你好",
date: 1635484786373
}

客户端触发的事件/服务器需要监听的事件:

事件名触发时机传递的消息传递消息示例
$message发送聊天消息消息字符串"你好!"

关键代码实现

vue
<template>
  <ChatWindow :me :users :history @chat="handleChat" />
</template>

<script setup>
import ChatWindow from "./components/ChatWindow.vue";
import { ref, onMounted, onBeforeUnmount } from "vue";
import io from "socket.io-client";

let me = ref("AK");
let users = ref([]);
let history = ref([]);

let handleChat = (data) => {
  history.value.push(data);
  socket.emit("$message", data.content);
};

let socket;

onMounted(() => {
  // 异步连接 websocket
  socket = io("ws://localhost:9528");

  // 监听事件
  socket.on("connect", () => {
    console.log("连接成功");
  });
  socket.on("$updateUser", (data) => {
    users.value = data;
  });
  socket.on("$history", (data) => {
    console.log(data);
    history.value = data;
  });
  socket.on("$name", (name) => {
    me.value = name;
  });
  socket.on("$message", (data) => {
    history.value.push(data);
  });
});

onBeforeUnmount(() => {
  // 组件销毁时,关闭 websocket 连接
  socket.disconnect();
});
</script>
vue
<template>
  <div class="container">
    <!-- 用户区域 -->
    <div class="users-area">
      <p class="title">聊天室成员</p>
      <ul class="users-list">
        <li v-for="(name, index) in users" :key="index">{{ name }}</li>
      </ul>
    </div>
    <div class="main">
      <!-- 聊天区域 -->
      <div class="chat-area" ref="chatArea">
        <!-- 每一条对话 -->
        <div
          class="chat-item"
          v-for="item in history"
          :key="item.date"
          :class="{ mine: item.name === props.me }"
        >
          <div class="chat-name">{{ item.name }}</div>
          <div class="chat-content">{{ item.content }}</div>
          <div class="chat-date">{{ formatDate(item.date) }}</div>
        </div>
      </div>
      <!-- 输入框 -->
      <div class="input-area">
        <textarea v-model="content" @keydown.enter="handleEnter"></textarea>
      </div>
    </div>
  </div>
</template>

<script setup>
import { ref, onMounted, watch, useTemplateRef } from "vue";
import moment from "moment";

const props = defineProps({
  users: {
    type: Array,
    default: () => [],
  },
  history: {
    type: Array,
    default: () => [],
  },
  me: {
    type: String,
    required: true,
  },
});

const emit = defineEmits(["chat"]);

let content = ref("");

let chatArea = useTemplateRef("chatArea");

onMounted(() => {
  watch(
    () => props.history,
    () => {
      chatArea.value.scroll(0, chatArea.value.scrollHeight);
    },
    {
      immediate: true,
    }
  );
});

let formatDate = (date) => {
  date = moment(date);
  return date.fromNow().replace(/\s/g, " ");
};

let handleEnter = () => {
  const value = content.value.trim();
  if (value) {
    emit("chat", { name: props.me, content: value, date: Date.now() });
    content.value = "";
  }
};
</script>