需求背景
最近客户想做一个社团购物的软件,技术选型是采用uni-app开发的微信小程序,其中有一个功能是想开发一个买家和卖家沟通的功能模块,实现实时消息通知。
一、WebSocket是什么?
WebSocket是一种在单个TCP连接上进行全双工通信的协议。WebSocket通信协议于2011年被IETF定为标准RFC 6455,并由RFC7936补充规范。WebSocket API也被W3C定为标准。
WebSocket使得客户端和服务器之间的数据交换变得更加简单,允许服务端主动向客户端推送数据。在WebSocket API中,浏览器和服务器只需要完成一次握手,两者之间就直接可以创建持久性的连接,并进行双向数据传输。
二、开发步骤
1.区分开发场景
在实际开发工作中,我们没有特别多的时间去实现一个scoket的完整功能,会利用到一些开源的插件,例如socket.io等,那么我们要如何区分在不同场景下怎么利用这些插件更好的为我我们的工作服务呢?
1、在H5页面中开发聊天功能,我们可以实际用到原生的js功能去对聊天的新增在做一些追加的操作,可以使用socket.io等类似的插件
2、在uni-app开发微信小程序时,由于我们的运行环境是小程序,我们最好是利用uni-app自带的socket的api来进行开发
3、对于做平台的小程序来讲,可以直接利用微信自带的客服功能,在微信公众平台配置自己的客服,可以说是既省时又省力
2.开发代码
(1)开发过程中的思路设计:
1、在用户登陆时,根据用户的id创建一个全局的socket,与服务器建立联系
2、进入聊天页面,拉取用户联系过的人
3、点击联系卖家,即是创建买家和买家的socket联系,会先获取双方的聊天记录
4、自己发送消息和收到买家消息如何展示,在页面展示过程中,我们一般自己的聊天在右侧,收到别人的消息在左侧,可以通过判断 不同的id来展示相应的消息
5、监听对方发送的消息,自己发送的消息会push到消息数组中,然后监听对方发送过来的消息,再push到消息数组中,这样数组会及时更新消息展示
(2) 效果图片展示
(3) 代码展示,以下代码都是以vue的风格写的
message.js的代码
<template>
<view class="container pad24">
<scroll-view
:show-scrollbar="false"
:style="{ height: scrollHeight + 'px' }"
scroll-y
class="list"
v-if="list.length">
<view class="item acea-row" v-for="item in list" :key="item" @tap="enterChat(item)">
<image
:src="item.targetUserFace|| '/static/images/avatar.png'"
mode="scaleToFill"
/>
<text class="count" v-if="item.waitCount">{{ item.waitCount }}</text>
<view class="info">
<view class="name acea-row row-between-wrapper">
<view class="name-text">{{ item.targetUserName || '商城用户' }}</view>
<view class="time">{{ item.newContentTime }}</view>
</view>
<view class="tip">{{ item.newContent && item.newContent.indexOf('oss') > -1 ? '[图片]' : item.newContent }}</view>
</view>
</view>
</scroll-view>
<empty title="暂无消息" :height="scrollHeight * 1.3" v-else></empty>
</view>
</template>
<script>
import { getChatList } from '@/api/chat.js'
import storage from "@/utils/storage";
import empty from '@/components/AmptyPage.vue';
import api from "@/config/api.js";
import websocketUtil from "@/lib/chat";
export default {
data() {
return {
userId: '',
list: [],
scrollHeight: 500
}
},
components: {
empty
},
computed: {
userInfo() {
return this.$store.state.userInfo;
}
},
onShow() {
this.userId = this.userInfo.id;
this.getList();
},
onLoad(options) {
let self = this;
wx.getSystemInfo({
success: function(res) {
self.scrollHeight = res.windowHeight ;
}
});
// 监听对方发送的消息,对于新的消息,会更新消息通知的数量,对于已知联系人发送的消息,可以自己手动更新,防止多次调用列表接口,对于未知的用户可以调取联系人列表接口,更新联系人
uni.$on('chat', data => {
data = JSON.parse(data);
let ind = null;
if(data && data.content) {
uni.vibrate();
this.list && this.list.map((item, index) => {
if(item.targetUserId === data.fromUserId) {
ind = index;
item.waitCount++;
item.newContent = data.content;
}
})
}
if(!ind && ind != 0) {
this.getList();
}
})
},
watch: {
userId: {
handler: function(newV, oldV) {
if(newV != oldV) {
// 在确认有userId的情况下,我们创建全局的socket,我存在了store里
this.websocket = null;
this.websocket = new websocketUtil(`${api.socketUrl}/websocket?id=${newV}`, 5000);
this.$store.commit('linksocket', this.websocket)
}
},
deep: true
}
},
methods: {
getList() {
getChatList({ userId: this.userId }).then(res => {
if(res.data.success) {
this.list = res.data.result;
}
})
},
enterChat(item) {
// 进入聊天页面要带上自己的id和对方的id
uni.navigateTo({ url: `/pages/message/chat?targetUserId=${item.targetUserId}&roomId=${item.id}` })
}
}
}
</script>
<style lang="scss" scoped>
.container {
background: #fff;
overflow: hidden;
.list {
width: calc(100% + 3rpx);
:-webkit-scrollbar { display: none;
width: 0 !important;
height: 0 !important;
-webkit-appearance: none;
background: transparent;
}
.item {
padding-bottom: 29rpx;
&:first-child {
padding-top: 29rpx;
}
image {
position: relative;
margin-right: 20rpx;
width: 81rpx;
height: 81rpx;
border-radius: 50%;
border: 2rpx solid #EEEEEE;
}
.count {
position: absolute;
left: 59rpx;
z-index: 2;
display: inline-block;
min-width: 30rpx;
height: 30rpx;
background: #FF2828;
border-radius: 20rpx;
color: #fff;
line-height: 30rpx;
font-size: 20rpx;
text-align: center;
}
.info {
flex: 1;
padding-bottom: 28rpx;
display: flex;
flex-direction: column;
justify-content: space-between;
border-bottom: 2rpx solid #EEEEEE;
.name {
margin-bottom: 10rpx;
font-size: 28rpx;
font-weight: 500;
color: #333333;
.time {
font-size: 20rpx;
font-weight: 400;
color: #666666;
}
}
.tip {
font-size: 24rpx;
font-weight: 400;
color: #666666;
}
}
}
}
}
</style>
chat.js的代码
class websocketUtil {
constructor(url, time) {
this.is_open_socket = false //避免重复连接
this.url = url //地址
this.data = null
//心跳检测
this.timeout= time //多少秒执行检测
this.heartbeatInterval= null //检测服务器端是否还活着
this.reconnectTimeOut= null //重连之后多久再次重连
try {
return this.connectSocketInit()
} catch (e) {
console.log('catch');
this.is_open_socket = false
this.reconnect();
}
}
// 进入这个页面的时候创建websocket连接【整个页面随时使用】
connectSocketInit() {
this.socketTask = uni.connectSocket({
url: this.url,
success:()=>{
console.log("正准备建立websocket中...");
// 返回实例
return this.socketTask
},
});
this.socketTask.onOpen((res) => {
console.log("WebSocket连接正常!");
uni.$emit('socket_open', 'WebSocket连接正常');
clearTimeout(this.reconnectTimeOut)
clearTimeout(this.heartbeatInterval)
this.is_open_socket = true;
this.start();
// 注:只有连接正常打开中 ,才能正常收到消息
this.socketTask.onMessage((res) => {
uni.$emit('chat', res.data);
});
})
// 监听连接失败,这里代码我注释掉的原因是因为如果服务器关闭后,和下面的onclose方法一起发起重连操作,这样会导致重复连接
uni.onSocketError((res) => {
console.log('WebSocket连接打开失败,请检查!');
uni.$emit('onSocketError', res);
this.is_open_socket = false;
this.reconnect();
});
// 这里仅是事件监听【如果socket关闭了会执行】
this.socketTask.onClose(() => {
console.log("已经被关闭了")
this.is_open_socket = false;
this.reconnect();
})
}
sendMsg(msg) {
console.log( msg, 'mssg')
//向后端发送命令
msg = JSON.stringify(msg);
try {
//通过 WebSocket 连接发送数据
this.socketTask.send({
data: msg
});
} catch (e) {
if (this.is_open_socket) {
return;
} else {
this.reconnect(url, onErrFn);
}
}
}
//重新连接
reconnect(){
//停止发送心跳
clearInterval(this.heartbeatInterval)
//如果不是人为关闭的话,进行重连
if(!this.is_open_socket){
this.reconnectTimeOut = setTimeout(()=>{
this.connectSocketInit();
},3000)
}
}
//开启心跳检测
start(){
this.heartbeatInterval = setTimeout(()=>{
this.data={value:"传输内容",method:"方法名称"}
console.log(this.data)
this.sendMsg(this.data);
},this.timeout)
}
//关闭连接
stop() {
this.is_open_socket = false;
this.heartbeatInterval = null;
}
}
module.exports = websocketUtil
chat.vue的代码
<template>
<div class="broadcast-details" :style="'height:'+windowH+'px'">
<view class="hd-wrapper" :class="active === true ? 'on' : ''">
<scroll-view
scroll-y="true"
style="height: 100%; overflow: hidden;"
:scroll-top="scrollTop"
scroll-with-animation="true"
:scroll-into-view="intoindex"
lower-threshold="150"
@scrolltoupper="scrolltoupper"
>
<div class="chat" ref="chat" id="chat" >
<template v-for="item in history">
<div class="item acea-row row-top" v-if="item.fromUserId === toUid" :key="item.id">
<div class="pictrue"><img :src="item.avatar || '/static/images/avatar.png'" /></div>
<div class="text">
<div class="name">{{ item.nickName || '麻雀商城用户' }}</div>
<div class="acea-row">
<div class="conter acea-row row-middle" v-if="item.msn_type === 4">
<img src="/static/images/signal2.gif" class="signal" style="margin-right: 0.27rem;" />12’’
</div>
<div class="conter acea-row row-middle" v-if="item.msn_type == 3">
<img :src="item.msn" />
</div>
<div class="conter acea-row row-middle" v-if="item.msn_type === 2">
<i class="em" :class="item.msn"></i>
</div>
<div class="conter acea-row row-middle" v-if="item.msn_type === 1">
{{ item.msn }}
</div>
</div>
</div>
</div>
<div class="item acea-row row-top row-right" v-else :key="item.id">
<div class="text textR">
<div class="name">{{ item.nickName || '麻雀商城用户' }}</div>
<div class="acea-row ">
<div class="conter acea-row row-middle" v-if="item.msn_type === 3">
<img :src="item.msn" />
</div>
<div class="conter acea-row row-middle" v-if="item.msn_type === 2">
<i class="em" :class="item.msn"></i>
</div>
<div class="conter acea-row row-middle" v-if="item.msn_type === 1">
{{ item.msn }}
</div>
</div>
</div>
<div class="pictrue"><img :src="item.avatar || '/static/images/avatar.png'" /></div>
</div>
</template>
</div>
<div :style=" active === true
? 'height:' + footerConH + 'rem;'
: 'height:' + footerH + 'rem;'
"></div>
</scroll-view>
</view>
<div class="footerCon" :class="active === true ? 'on' : ''" :style="'transform: translate3d(0,' + percent + '%,0);'" ref="footerCon">
<form>
<div class="footer acea-row row-between row-bottom" ref="footer">
<img @click="uploadImg" src="/static/images/plus.png" />
<img :src="
active === true
? '/static/images/keyboard.png'
: '/static/images/face.png'
"
@click="emoticon" />
<div class="voice acea-row row-center-wrapper" v-if="voice" @touchstart.prevent="start" @touchmove.prevent="move"
@touchend.prevent="end">
{{ speak }}
</div>
<input type="text" placeholder="请输入内容" class="input" ref="input" v-show="!voice" @input="bindInput" @keyup="keyup"
@focus="focus" cursor-spacing="20" v-model="textCon">
<div class="send" :class="sendColor === true ? 'font-color-red' : ''" @click="sendTest">
发送
</div>
</div>
</form>
</div>
<div class="banner slider-banner">
<swiper class="swiper-wrapper" :autoplay="autoplay" :circular="circular" :interval="interval" :duration="duration"
v-if="emojiGroup.length > 0">
<block v-for="(emojiList, index) in emojiGroup" :key="index">
<swiper-item>
<i class="em" :class="emoji" v-for="emoji in emojiList" :key="emoji" @click="addEmoji(emoji)"></i>
<img src="/static/images/del.png" class="emoji-outer" />
</swiper-item>
</block>
<swiper-slide class="swiper-slide acea-row" v-for="(emojiList, index) in emojiGroup" :key="index">
<i class="em" :class="emoji" v-for="emoji in emojiList" :key="emoji" @click="addEmoji(emoji)"></i>
<img src="/static/images/del.png" class="emoji-outer" />
</swiper-slide>
<div class="swiper-pagination" slot="pagination"></div>
</swiper>
</div>
<div class="recording" v-if="recording">
<img src="/static/images/recording.png" />
</div>
<home></home>
</div>
</template>
<script>
import emojiList from "@/utils/emoji";
import storage from "@/utils/storage.js";
import api from "@/config/api.js";
import { getContactList } from '@/api/chat.js'
import {
HTTP_REQUEST_URL
} from '@/config/app.js';
const chunk = function(arr, num) {
num = num * 1 || 1;
var ret = [];
arr.forEach(function(item, i) {
if (i % num === 0) {
ret.push([]);
}
ret[ret.length - 1].push(item);
});
return ret;
};
const NAME = "CustomerService";
export default {
name: NAME,
props: {
couponList: {
type: Array,
default: () => []
}
},
data: function() {
return {
url: `${HTTP_REQUEST_URL}/api/upload/image`,
headers: {
"Authori-zation": "Bearer " + this.$store.state.token
},
emojiGroup: chunk(emojiList, 20),
active: false,
voice: false,
speak: "按住 说话",
recording: false,
swiperOption: {
pagination: {
el: ".swiper-pagination",
clickable: true
},
speed: 1000,
observer: true,
observeParents: true
},
percent: 0,
footerConH: 0,
footerH: 1.08,
socket: null,
toUid: '',
page: 1,
limit: 30,
loading: false,
loaded: false,
history: [],
sendColor: false,
sendtxt: "",
productId: 0,
productInfo: {},
orderId: "",
orderInfo: {},
cartInfo: {},
autoplay: false,
circular: true,
interval: 3000,
duration: 500,
upload_max: 2, //图片大小
//上传的图片地址
uploadImages: [],
//展示的图片地址
uploads: [],
// 超出限制数组
exceeded_list: [],
windowH: 0,
isBQ: false,
scrollTop:0 ,//滚动距离
textCon:'', //文字
intoindex: 'chat'
};
},
computed: {
websocket() {
return this.$store.state.websocket
},
userInfo() {
return this.$store.state.userInfo
}
},
onLoad(option) {
let self = this;
this.userId = this.userInfo.id;
this.toUid = option.targetUserId;
this.targetUserId = option.targetUserId;
this.roomId = option.roomId;
console.log(this.userId, 'userid', this.websocket)
this.productId = parseInt(option.productId) || 0;
this.orderId = option.orderId || "";
this.getContactList();
uni.getSystemInfo({
success: function(res) {
self.windowH = res.windowHeight
}
})
uni.$on('onSocketError', (data) => {
console.log(data, 'onSocketError')
})
uni.$on('chat', data => { // 监听对方消息
data = JSON.parse(data);
if(data && data.content && data.fromUserId === this.targetUserId) {
uni.vibrate();
this.history.push({
nickName: '测试',
targetUserId: data.fromUserId,
fromUserId: this.targetUserId,
avatar: this.contactInfo.targetUserFace,
msn: data.content,
msn_type: 1,
nickName: this.contactInfo.targetUserName
});
self.getTop();
}
})
},
methods: {
scrolltoupper() {
},
getTop() {
// 滚动页面到当前视图
const self = this;
const query = uni.createSelectorQuery().in(this);
query.select('#chat').boundingClientRect()
.exec((data)=>{
data && data.map(item => {
self.scrollTop = item.height;
})
})
},
getContactList() {
getContactList({ roomId: this.roomId, fromUserId: this.userId, targetUserId: this.targetUserId }).then(res => {
if(res.data.success) {
this.contactInfo = res.data.result;
this.history = res.data.result.detailDTOList.map(item => {
return {
msn: item.content,
msn_type: item.type,
...item,
nickName: item.targetUserId === this.toUid ? this.contactInfo.fromUserName : this.contactInfo.targetUserName,
avatar: item.targetUserId === this.toUid ? this.contactInfo.fromUserFace : this.contactInfo.targetUserFace
}
});
this.scrollTop = this.history.length * 73;
}
})
},
uploadImg() {
let self = this
uni.chooseImage({
count: 1, //默认1
sizeType: ['original', 'compressed'], //可以指定是原图还是压缩图,默认二者都有
sourceType: ['album', 'camera'], //从相册选择
success: (res) => {
for (let i = 0; i < res.tempFiles.length; i++) {
if (Math.ceil(res.tempFiles[i].size / 1024) < this.upload_max * 1024) {
this.uploads.push(res.tempFiles[i].path)
this.uploadImages.push(res.tempFiles[i].path)
} else {
this.exceeded_list.push(i === 0 ? 1 : i + 1);
uni.showModal({
title: '提示',
content: `第${[...new Set(this.exceeded_list)].join(',')}张图片超出限制${this.upload_max}MB,已过滤`
});
}
}
uni.uploadFile({
url: api.common + '/common/upload/file',
filePath: self.uploadImages[0],
name: 'file',
header: {
"Authori-zation": "Bearer " + Boolean(storage.getAccessToken()),
accessToken: storage.getAccessToken()
},
//请求参数
success: (uploadFileRes) => {
let data = JSON.parse(uploadFileRes.data)
console.log(data, 'ddd')
self.sendMsg(data.result, 3)
}
});
},
fail: (err) => {
uni.showModal({
content: JSON.stringify(err)
});
}
});
},
focus: function() {
this.active = false;
},
keyup: function() {
if (this.$refs.input.innerHTML.length > 0) {
this.sendColor = true;
} else {
this.sendColor = false;
}
},
emoticon: function() {
this.voice = false;
if (this.active === true) {
this.active = false;
} else {
this.active = true;
}
this.getTop();
},
addEmoji(name) {
this.sendMsg(name, 2);
},
sendMsg(msn, type) {
if(msn) {
let message = {
fromUserId: this.userId,
targetUserId: this.toUid,
content: msn,
msn,
type,
msn_type: type,
nickName: this.userInfo.nickName
}
this.websocket.sendMsg(message)
this.history.push({
...message,
avatar: this.userInfo.face,
});
this.getTop();
}
},
sendTest() {
this.sendMsg(this.textCon, 1);
this.textCon = ''
this.getTop();
},
bindInput: function(e) {
if(e.detail.value){
this.sendColor = true
}else{
this.sendColor = false
}
},
move() {
clearTimeout(this.timeOutEvent);
this.timeOutEvent = 0;
}
}
};
</script>
<style lang="scss" scoped>
@import url('@/static/emoji-awesome/css/google.min.css');
page {
width: 100%;
height: 100%;
}
.broadcast_num {
padding: 0 10rpx !important;
}
.em {
display: inline-block;
width: 50rpx;
height: 50rpx;
}
.swiper-wrapper {
.em {
margin: 40rpx 0 0 50rpx;
}
}
.emoji-outer {
position: absolute;
right: 50rpx;
bottom: 30rpx;
width: 50rpx;
height: 50rpx;
}
.broadcast-details {
display: flex;
flex-direction: column;
width: 100%;
overflow: hidden;
.hd-wrapper {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
&.on{
padding-bottom: 300rpx;
}
}
}
</style>
(4) 遇到的问题及解决办法
Q: 怎么避免你和某个联系 人的聊天会同步给其他联系人?
A:可以在监听对方发送的消息的时候,前端判断是否是和你正在聊天的用户,如果不是消息则不会被放到消息队列中,后端也要做一下校验,这样可以防止消息会发给其他人
Q: 在联系人页面,怎么显示消息通知有多少条?有性能比较好的方法吗?
A:可以联系人页面监听发送的消息,并且在当前列表中找到对应的数据,并更新消息数量,如果在消息列表中找不到对应的联系人,则是新联系人可以重新获取联系人列表,这样可以避免多次调取联系人来获取消息,并且能及时的显示消息的通知
总结
以上就是今天要讲的内容,本文仅仅简单介绍了uni-app中开发消息模块的思路及方法,如果你有更好的想法可以和我分享