48
头图

前言

在上个月初,接到一个需求,要开发一个 聊天通讯 模块 并且 集成到 项目中的多个 入口,实现业务数据的记录追踪.

接到需求后,还挺开心,这是我第一次 搞 通讯 类的需求,之前一直是 B 端 的业务需求,不过现在也是在做这个方向,感觉 B 端 方向 挺有意思,管理着项目的整个项目上游和下游,然后服务于 内部人员 和 外部人员 使用,感觉挺自豪的。

下面就就跟着我来看看 如何 开发一个 聊天通讯 服务吧 ! (主要站在前端的角度来讲如何开发设计 )

技术栈

徽章.png

  • Vue 2.x
  • Websoket
  • Vuex
  • Element
  • vue-at

本项目是 以 Vue 技术栈生态开发的,其实不管用什么语言 , 思路是关键 ! 知道每一步需要干什么, 然后将每一步操作 整合起来 , 最终服务就跑起来了.

当中的每一步需要干什么 就是 编程 中的 function 功能,根据这个功能然后在细化分析需要有到哪些技术点 。在开发的过程中,你不可能对整个链路的所有技术点 熟悉,这就需要遇到啥困难,临时学习就可以了。

开始分析需求

首先,我们要等待 UI 设计师 的设计稿 画出来, 然后根据 UI 设计师的 设计稿分析整体 聊天通讯 的结构,从view 结构 来 划分 应该 大体 包括哪些 component , 每个component 中 又包括哪些小的 component , 这样从 大 到 小 的方向将 设计稿 转化为 程序员视角的 component .

确立了有哪些component , 接下来 就是 确定 每个 小的 component 又有哪些 功能了。 现在 UI 设计师们,一般画完界面后,会通过第三方软件 / 平台 来将效果图 转化成网页,并且可以通过 URL 可以直接访问,当光标放到页面中的某个元素时,可以获取到当前元素的 css style , 不过,我建议不之 copy ,有时和自己写的布局代码会冲突,按需copy .

效果图

真实效果图,我就在这里不放出来了,为了保密性,只把整体结构,列出来,然后带着大家分析结构和功能,如何进行编码设计和组件设计。

功能分析图

根据效果图,在进行组件划分时,我要记住这个原则:高内聚,低耦合 , 组件职责单一性

我们将组件划分为:

  • 联系人组件
  • 聊天组件 ---- 包括了 历史记录组件

功能根据 UI 设计师 提供的 URL 网页来看交互效果来定,并和组长 / 产品经理 交流需求,确定需求,以及砍掉不合理需求。

需求确定后,就是梳理组件部分的功能了。

组件构成

在分析组件之前,我们需要先了解一下Vue Component ,使用Vue 的 朋友应该很熟悉了,一个组件的构成由以下组成:

  1. data 组件内部状态
  2. computed 计算属性,监听data 变化来实现对应的业务逻辑需求
  3. watch 监听state 变化
  4. method 组将的功能编写区
  5. props 组件接受父组件 传递来的值,进行约束类型等
  6. lifecycle 组件的生命周期, 可以在组件创建到销毁的过程中执行对应的业务逻辑

联系人组件

这个组件主要是用来在聊天的时候,可以通过分组快速的找到某个人联系它,功能相对简单。

功能:

  1. 查找联系人
  2. 有通知某人操作

功能分析

功能1: 查找联系人

通过现有联系人json 数据来 查找输入的联系人进行匹配。 (简单)

功能2: 通知某人

当用户点击到某个联系人时,将点击的人 放到输入框里 显示 @xxx [ 经过格式化处理 ] , 并将选中的联系人信息加入到发送消息的 json 对象中。

有多种实现方案,当用户点击了某联系人时,将触发事件,携带值传递给父组件[聊天组件的入口 index.vue ] 接收,然后将值传递给 聊天主体组件 ,通过 在 聊天主体组件 中 通过 $refs 进行传递值。

下面只提供示例代码

从联系人列表获取选中联系人

//联系人组件 concat.vue
​
​
getLogname(val){
    this.$emit('toParent',{tag:'add',logname:val})
},

聊天框显示选中的联系人

在聊天入口组件 接收 子向父 组件传递 选中联系人数据,然后给 聊天主体 组件绑定 ref , 通过refs 来将联系人数据传递到 聊天主体 组件显示。 [这块 数据传递有多种方法,例如 Vuex]

//聊天组件入口 index.vue   它包括 联系人组件  聊天主体组件  历史记录组件
​
//联系人组件
<Concat @toParent='innerHtmlToChat'/>
​
//聊天主体组件    
<ChatRoom @fullScreen="getFullStatus" @closeWindow="close" ref="chatRoom"/>
​
​
    
 // 接受
 innerHtmlToChat(data){
    this.$refs.chatRoom.$refs.inputConents.innerHTML+=`&nbsp;@&nbsp;${data.logname}`  //拼接到聊天输入框里
},     
​

效果展示

从联系人列表选中人员,发送消息

@人 接收到推送消息

聊天主体组件

这个组件就负责的功能就多了,这块我主要把关键的功能带大家来分析过一遍

关键功能;

  1. @ 好友功能,实现推送通知(在线通知 / 离线-上线通知)
  2. 聊天工具 [ 支持表情 支持大文件上传 ]
  3. 发送消息 [ 这块就可以跟业务挂钩了,发送信息时,并携带一些符合你项目需求的数据]

功能分析

功能1 : @ 实现

vue-at 文档 : https://github.com/von7750/vu...

它的功能和 微信QQ @ 功能一样,在聊天输入框里,当你 输入 @ 键时, 弹出好友列表,然后从中选择联系人进行聊天。

@ 功能必须包括以下3个关键功能;

  • 可以弹出联系人列表
  • 可以监听输入字符内容进行过滤显示对应数据
  • 删除 @ 联系人
  • .......

一开始, 我是 自己造了个 @ 功能 轮子 搞了搞,后来才发现市场上有相应的轮子,直接用第三方了,挺不错的 vue-at

下面来跟着我,来捋一下思路如何实现这个轮子,此处就不放实现代码了。

先来分析一波:

当在编辑区,输入 @ 时, 弹出框

  1. 我们可以在 mounted 生命周期中监听 按键 code = 50 / 229 (中文/英文) 时,做出处理
  2. 由于我们这块采用的 div 可编辑属性 ,那么就获取到 可编辑属性的光标位置
  3. 然后通过光标位置 动态来改变 弹出框联系人列表的样式 top left , 实现跟着光标的 位置显示联系人列表。
  4. 然后 从列表中选择 联系人进行聊天,并将 联系人列表弹框 隐藏掉。

上面就实现了基本的 选中联系人功能

删除选中的联系人

由于这块是采用的可编辑属性, 我们可以获取选中的人,但无法直接判断是删除的哪个人,这时,只能通过判断 innerHTML 中是否包含某联系人,来进行删除已保存的联系人。

这时,已经基本满足了业务需求实现了。

第三方插件已经的够好了,我们就没必要再造轮子,浪费时间了, 但 实现思路 必须的懂。 下面,我就来演示如何使用 第三方插件vue-at 实现 @ 功能

1. 安装插件

npm i vue-at@2.x

2.组件 内部导入插件组件

import At from "vue-at";

3.注册插件组件

 components: {
        At
 },

4. 页面中使用

At 组件 必须包括 可编辑 输入内容区域, 这样,当输入 @ 时,会弹出联系人列表框。

  • members : 数据源
  • filter-match : 过滤数据
  • deleteMatch : 删除的联系人
  • insert : 获取联系人
<At
    :members="filtercontactListContainer"
    :filter-match="filterMatch"
    :deleteMatch="deleteMatch"
    @insert="getValue"
    >
    <template slot="item" slot-scope="s">
        <div v-text="s.item" style="width:100%"></div>
    </template>
    <div
         class="inputContent"
         contenteditable="true"
         ref="inputConents"
         ></div>
</At>
// 过滤联系人
filterMatch(name, chunk) {
    return name.toLowerCase().indexOf(chunk.toLowerCase()) === 0;
},
// 删除联系人
deleteMatch(name, chunk, suffix) {
    this.contactList = this.contactList.filter(
            item => item.logname != chunk.trim()
        );
  return chunk === name + suffix;
},
// 获取联系人
getValue(val) {
     this.contactList.push({ logname: val });
},

功能2:聊天工具箱

聊天软件除了普通文字聊天,还有一些辅助服务来增加聊天的丰富性,例如: 表情 , 文件上传, 截图上传 .... 功能

我们先来看看 市场 热门聊天软件它们有哪些 聊天工具。

微信聊天工具箱

  • 表情
  • 文件上传
  • 截屏
  • 聊天记录
  • 视频聊天 / 语音聊天

QQ 聊天工具箱

  • 表情
  • GIF 动图
  • 截屏
  • 文件上传
  • 腾讯文档
  • 图片发送
  • ..... 腾讯业务相关功能

介绍了市场上热门聊天的工具箱有哪些工具,回归正题: 我们的聊天工具箱 有哪些功能呢, 其实有哪些功能根据 业务来定,后期工具箱可以不断扩充。 我们的工具箱基本上满足日常聊天需求

  • 表情
  • 文件上传 支持大文件 ( 几个G 都可以)
  • 截屏 Ctrl + Alt + A
  • 历史记录

下面我就来将比较几个重要的功能: 文件上传截屏 , 其它功能都很简单。

文件上传

上传组件我采用的是 Element el-upload 组件,由于我业务 要求上传文件支持大文件, 采用的 分片续传 方式来实现。

分片续传思路

  1. 我们上传也是采用的 websoket 上传,首次发送时,必须发送一些必要的文件基本信息

    • 文件名
    • 文件大小
    • 发送者
    • 一些跟业务相关的字段数据
    • 时间
    • 文件分片大小
    • 文件分片片数
    • 上传进度标识
  2. 首次发送完文件的基本信息后,开始发送分片文件信息,首先将文件分片后,然后依次读取片文件流,发送时携带文件流,等文件分片循环结束后,发送一个结束标识告诉后台发送完毕了 [这块你可以和后端商量设计数据格式]

示例代码演示

<el-upload
           ref="upload"
           class="upload-demo"
           drag
           :auto-upload="false"
           :file-list="fileList"
           :http-request="httpRequest"
           style="width:200px"
           >
    <i class="el-icon-upload"></i>
    <div class="el-upload__text" trigger>
        <em> 将文件拖到此处然后点击上传文件</em>
    </div>
</el-upload>

覆盖掉 Element 默认上传方式,改用自定义上传方式。

开始分片上传

    // 上传文件
    httpRequest(options) {
      let that = this;
​
      //每个文件切片大小
      const bytesPerPiece = 1024 * 2048;
     // 文件必要的信息
      const { name, size } = options.file;
     // 文件分割片数
      const chunkCount = Math.ceil(size / bytesPerPiece);
      
    // 获取到文件后,发送文件的基本信息
      const fileBaseInfo = {
        fileName: name,
        fileSize: size,
        segments: "historymessage",
        loginName: localStorage.getItem("usrname"),
        time: new Date().toLocaleString(),
        chunkSize: bytesPerPiece,
        chunkCount: chunkCount,
        messagetype: "bufferfile",
        process: "begin",
          
          
        ... 一些跟业务挂钩的 字段
​
      };
​
​
      that.$websoketGlobal.ws.send(JSON.stringify(fileBaseInfo));
      
      let start = 0;
​
      // 进行分片
      var blob = options.file.slice(start, start + bytesPerPiece);
      //创建`FileReader`
      var reader = new FileReader();
      //开始读取指定的 Blob中的内容, 一旦完成, result 属性中保存的将是被读取文件的 ArrayBuffer 数据对象.
      reader.readAsArrayBuffer(blob);
      //读取操作完成时自动触发。
      reader.onload = function(e) {
        // 发送文件流
        that.$websoketGlobal.ws.send(reader.result);
        start += bytesPerPiece;
        if (start < size) {
          var blob = options.file.slice(start, start + bytesPerPiece);
          reader.readAsArrayBuffer(blob);
        } else {
          fileBaseInfo.process = "end";
          // 发送上传文件结束 标识
          that.$websoketGlobal.ws.send(JSON.stringify(fileBaseInfo));
        }
        that.uploadStatus = false;
        that.fileList = [];
      };
    },

效果演示

功能3: 截屏功能

PC 中,这是一个很重要的业务,通过这种技术可以从网上截取下自己感兴趣的文章图片供自己使用观看,可以帮助人们更好的去理解使用知识。

由于我们的输入内容区域采用的 可编辑 区域,此处可以插入任意内容,也可以使用外部 的截图功能,粘贴到输入框区域,这块就没必要的造轮子了

1. 可编辑区域

我们给 div 加上 该属性 contenteditable 就可以控制 div 中可输入哪些内容,外部复制过来内容也可以直接显示,还可以显示其带的css 效果。我们先来看看 contenteditable 有哪些属性吧 !

描述
inherit默认值继承自父元素
true或空字符串,表示元素是可编辑的;
false表示元素不是可编辑的。
plaintext-only纯文本
caret符号
events

注意

不允许简写为 <label contenteditable>Example Label</label>

正确的用法是 <label contenteditable="true">Example Label</label>

浏览器支持情况

使用

<div
     class="inputContent"
     contenteditable="true"
     ref="inputConents">
</div>

效果展示

2. 截屏

由于采用的是 可编辑 ,那么就可以随意从外部 copy , 哈哈,有意思的来了,支持 Windows 自带的截屏 + PC 第三方 截屏......

💥快捷操作方法:

  • windows 自带的的截屏快捷键

    截取整个屏幕 Print Screen

    截取当前活动屏幕 Alt+Print Screen

  • QQ 截屏功能,支持个性化操作截图 Ctrl + Alt + A
  • 微信 截屏功能, 支持个性化操作截图 Alt + A
  • 专门的截屏工具....

站在巨人的肩膀上, 直接起飞。😄 , 不过确实站在用户角度想,这点确实有点不好😘。

实际效果演示

2.1 微信截屏 show time

2.2 QQ 截屏

功能4: 发送功能

这个功能贯穿这个聊天项目,项目采用的是 websoket 实现的通信服务,全双工通信 , 发送聊天内容时,需要携带一些很业务相关的数据,来实现业务跟踪分析。下面,来简单复习过一下 websoket , 对没有使用过websoket 同学也时学习。

WebSoket

WebSocket是一种在单个TCP连接上进行全双工通信的协议。 WebSocket使得客户端和服务器之间的数据交换变得更加简单,允许服务端主动向客户端推送数据。在WebSocket API中,浏览器和服务器只需要完成一次握手,两者之间就直接可以创建持久性的连接,并进行双向数据传输。

WebSoket 特点

  • 服务器可以主动向客户端推送信息,客户端也可以主动向服务器发送信息,是真正的双向平等对话。
  • 属于服务器推送技术的一种。
  • 与 HTTP 协议有着良好的兼容性。默认端口也是80和443,并且握手阶段采用 HTTP 协议.
  • 数据格式比较轻量,性能开销小,通信高效。
  • 可以发送文本,也可以发送二进制数据。
  • 没有同源限制,客户端可以与任意服务器通信。
  • 协议标识符是ws(如果加密,则为wss),服务器网址就是 URL。

WebSoket 操作 API

创建Websoket连接🔗

let socket = new WebSocket("ws://域名/服务路径")

连接 Websoket 成功触发

open() 方法在连接成功时,触发

socket.onopen = function() {
    console.log("websocket连接成功");
};

发送消息

send()方法并传入一个字符串ArrayBufferBlob .

socket.send("公众号: 前端自学社区")

接收服务端返回的数据

message 事件会在 WebSocket 接收到新消息时被触发。

socket.onmessage = function(res) { 
 console.log(res.data)
}

关闭 WebSoket 连接

WebSocket.close() 方法关闭 WebSocke连接或连接尝试(如果有的话)。 如果连接已经关闭,则此方法不执行任何操作。

socket.onclose = function() {
    // 关闭 websocket
    console.log("连接已关闭...");
    //断线重新连接
    setTimeout(() => {
        that.initWebsoket();
    }, 2000);
};

WebSoket 错误处理

websocket的连接由于一些错误事件的发生 (例如无法发送一些数据)而被关闭时,一个error事件将被引发.

// 监听可能发生的错误
socket.addEventListener('error', function (event) {
  console.log('WebSocket error: ', event);
});

通过上面我们了解了 Websoket 如何使用,接下来就是 实操了,下面走起!

项目采用的是 Vue 技术栈,更多写法偏向于 Vue 。 由于 WebSoket 贯穿整个项目,而且需要实时推送 @ , 我们将 Websoket 尽量放在全局入口,接收信息onmessage 事件也放在 入口文件中,这样全局都能接收到数据,接收到的数据 利用 Vuex 进行管理聊天的数据 [ 历史数据 推送数据 发送数据 ]

1. 新建 一个 websoket文件,用于全局使用

export default {
    ws: {},
    setWs: function(wsUrl) {
        this.ws = wsUrl
    }
}

2. 在Vue入口文件index.js中 全局注册

import Vue from 'vue'
import websoketGlobal from './utils/websoket'

Vue.prototype.$websoketGlobal = websoketGlobal

3. 在 App.vue 中 接收 Websoket 推送的消息

这块的设计很关键,决定了聊天数据的存储和设计,过多细节代码就不放了

大体思路我说说一下:

  • 传输格式上定了,那么接收的数据结构也就定了,更多的就是在数据结构上下文章了, 前后端需要约束好字段属性。

    从聊天页面显示状态来看:

    1. 区分数据类型的字段,这样前端在接收到推送的消息时,知道在页面中该如何显示,例如(该显示图片样式还是文本样式)
    2. 区分发送消息显示左右的字段, 前端通过接收到推送的消息时, 会首先判断是否为自己,不是的话显示在左边样式
    3. 区分 系统的推送字段, 根据这个字段显示对应的样式。
    4. ........... 更多字段属性 需要根据你实际业务而来定

    从信息推送状态来看:

    1. @ 推送全局 Notification 通知 和 聊天内部推送 设计

      • @ 推送 根据指定字段类型判断 ,然后实现全局 推送
      • 聊天内容推送: 由于它和具体某个聊天有关系,它也属于历史聊天数据,在聊天中根据 内容数据类型 来确定如何显示
mounted(){
    this.$websoketGlobal.ws.onmessage = res => {
        const result = JSON.parse(res.data);
​
        // 推送数据
​
        //聊天历史数据 新增加发送的数据
​
​
        // 获取聊天历史数据
​
        //聊天历史数据 新增加发送的数据
​
    };
}

4. 在聊天组件中使用 Websoket

在聊天组件中,其实使用的就是 发送功能 和 获取 历史记录 功能,还有就是根据 推送的消息内容字段来决定页面中数据如何显示。下面聊天的样式代码就不放了,主要放一下 发送消息的 示例代码

send() {
    let that = this;
​
    // 定义数据结构: 传递什么内容是 前提 前端和后端商量好的  
    const obj = {
        messageId: Number(
            Math.random()
            .toString()
            .substr(3, length) + Date.now()
        ).toString(36),
        //文件类型  
        messagetype: "textmessage",
        //@ 联系热
        call: that.contactList,
        //聊天输入内容  
        inputConent: that.$refs.inputConents.innerHTML ,
        // 当前时间  
        time: currentDate,
​
        ..... 再定义一些符合你业务的字段    
    };
    
    // 发送消息
    that.$websoketGlobal.ws.send(JSON.stringify(obj));
    that.$refs.inputConents.innerHTML = "";
    that.contactList = []
}
},

在每次进入聊天组件时,需要首先获取聊天的历史记录,聊天入口根据你的业务来定,传递必须参数.

mounted(){
    this.$websoketGlobal.ws.send(
        JSON.stringify({
            id: 1
            messagetype: "historymessage"
        })
    );
}

功能5: 离线 / 在线推送

这个相当于 微信 / QQ 在线 和 上线 收到的消息。 当 A 用户 @ 了 B 用户 (此时 B 用户 不在线),当 B 用户 上线时,它会收到 一条信息。这个是怎么实现呢?

我就结合项目来大体说一下思路,具体实现就不说了,实现主要在后端。 当时,向后端大佬同时还特意请教了一下。

\
当 A用户 登录了 系统,此时就会和 Websoket 建立连接,后端会记录起来,该用户的标识,状态为登录。

当 A 用户 @ 了 B 用户 ,正常逻辑会推送给B用户一条信息,B 不在线,就不推给他?

怎么知道B 用户是否在线呢?

前面也说到了,登录系统就会建立连接,后端会暂时存储起来在线的用户,当A 用户 向 B 用户发送的消息后,后端看在线用户列表里没有B 用户,那么他就不会推送。当B用户上线了,会自动推送,前端接收,直接提醒用户。

聊天室入口组件

聊天室入口组件包括: 联系人组件 + 聊天主体组件 , 它做的事情其实很简单了。

  1. 如何打开聊天室 ?
  2. 如何给聊天室传递历史数据?

如何打开聊天室?

外部可能通过多个入口来打开聊天室,通过一个状态来控制显示聊天室,传递类型为Boolean

如何给聊天室传递历史数据?

外部通过给聊天室组件传递必要数据,这些必要数据然后在联系人组件聊天主体组件 内部消耗,获取各自需要的数据,这样聊天室入口组件的职责单一,很好进行管理。

下面来看看聊天室的入口组件:

<template>
  <div>
    <transition name="el-fade-in-linear" :duration="4000">
      <div
        class="chat-container"
      >
        <div
          class="left-concat"
        >
            //联系人组件
          <Concat @toParent="innerHtmlToChat" />
        </div>
        <div
          class="right-chatRoom"
        >
            // 聊天室主体组件
          <ChatRoom
            ref="chatRoom"
          />
        </div>
      </div>
    </transition>
  </div>
</template>

内部的通信主要是由 Vuex 来进行管理, 由于聊天室在全局都需要唤醒,可以将聊天入口组件放到全局入口文件,这样,不管项目需要多少个入口,只需要传递唤醒聊天入口组件的状态入口组件需要的必要参数 来获取历史聊天数据。

<Chat
      // 控制是否显示聊天室
      v-if="$store.state.chatStore.roomStatus"
      //聊天室需要的必要数据
      :orderInfo="$store.state.chatStore"
 />

这样,当项目其它模块需要 聊天室 这个功能,只需要 一行代码 即可 接入,作为插槽接入。

<template slot="note" slot-scope="props">
    <i class="el-icon-chat-dot-square"  @click="openChatRoome(props.data.row)"></i>
</template>
openChat(row){
    this.$store.commit("Chat", { status: true, data: row });
},

总结

在开发这个 聊天服务 中也遇到了很多难点和坑,不过一个一个踩过来了,越往后做思路越开。 开发完这个 聊天服务 对技术理解又有更深的认知了,在你感觉某个功能很难困难,不知道怎么实现,你先行动起来,按照自己的思路一步一步推理,推理的过程就会思路打开了,会有多种方式来实现了。

最后

聊天服务开发了一个月,写文章写了一个周左右,写作不易,如果文章学到了,点个赞👍👍👍关注,支持一下!

关注公众号: 前端自学社区 , 加入自学交流群,一起成长学习!


程序员海军
942 声望7k 粉丝