uni-app小程序?qū)崿F(xiàn)微信在線聊天功能(私聊/群聊)
之前學(xué)習(xí)使用uni-app簡單實(shí)現(xiàn)一個(gè)在線聊天的功能,今天記錄一下項(xiàng)目核心功能的實(shí)現(xiàn)過程。頁面UI以及功能邏輯全部來源于微信,即時(shí)聊天業(yè)務(wù)的實(shí)現(xiàn)使用socket.io,前端使用uni-app開發(fā),后端服務(wù)器基于node實(shí)現(xiàn),數(shù)據(jù)庫選擇mongoDB。
首先在系統(tǒng)中注冊兩個(gè)用戶,將對方添加為好友后,開始正常聊天,先簡單看一下聊天功能的效果圖,分為私聊和群聊兩大部分
一對一聊天效果:

在好友列表中添加群成員創(chuàng)建群后即可群聊,群聊效果:

聊天信息列表的渲染
聊天信息列表區(qū)域是一個(gè)滾動(dòng)區(qū),這里使用scroll-view組件,其中對于聊天信息展示,主要分為自己的消息和好友的消息,自己的消息位于右側(cè),好友的消息位于左側(cè),所以靜態(tài)頁面階段要實(shí)現(xiàn)是左側(cè)消息和右側(cè)消息的頁面布局,以及這些消息類型為文字,圖片,語音,位置信息時(shí)的布局。

后端接口返回的聊天信息是按照時(shí)間順序排列的,渲染聊天信息時(shí)使用v-for遍歷接口返回的消息列表的內(nèi)容即可,需要注意的是,還需要使用條件渲染v-if根據(jù)每一條消息的發(fā)送者id和當(dāng)前用戶的id判斷消息的發(fā)送方和接受方,渲染在左右指定的區(qū)域,當(dāng)前用戶的id從本地存儲(chǔ)localStorage中獲??;還有就是使用條件渲染判斷消息的類型,是文字,圖片,語音或定位,合理展示。
<!-- 一條聊天記錄 -->
<view class="chat-item" v-for="(item,index) in msg" :key="item.id">
<!-- 時(shí)間 -->
<view class="time" v-if="item.isShowTime">{{handleTime(item.time)}}</view>
<!-- b - 對方的消息 -->
<view class="content-wrapper-left" v-if="item.fromId !== uid" >
<!-- 頭像 -->
<image :src="item.imgUrl" class="avator avator-left"></image>
<!-- 0 - 文字 -->
<view class="chat-content-left" v-if="item.types === '0'">......</view>
<!-- 1 - 圖片 -->
<view class="chat-image-left" v-if="item.types === '1'">......</view>
<!-- 2 - 語音 -->
<view class="chat-voice-left" v-if="item.types === '2'">......</view>
<!-- 3 - 位置信息 -->
<view class="chat-site-left" v-if="item.types === '3'">......</view>
</view>
<!--a - 自己的信息-->
<view class="content-wrapper-right" v-if="item.fromId === uid">
<!-- 0 - 文字 -->
<view class="chat-content-right" v-if="item.types === '0'">......</view>
<!-- 1 - 圖片 -->
<view class="chat-image-right" v-if="item.types === '1'">......</view>
<!-- 2 - 語音 -->
<view class="chat-voice-right" v-if="item.types === '2'">......</view>
<!-- 3 - 位置信息 -->
<view class="chat-site chat-site-right">......</view>
<!-- 頭像 -->
<image :src="item.imgUrl" class="avator avator-right"></image>
</view>
</view>聊天信息發(fā)送的相關(guān)問題
點(diǎn)擊發(fā)送按鈕,正式將信息發(fā)送給服務(wù)器之前,還有幾個(gè)問題需要解決,這里面有許多坑,在實(shí)現(xiàn)的時(shí)候走了不少彎路。
1.scroll-view如何始終定位在最底部?
如下圖,當(dāng)發(fā)送了一條聊天信息時(shí),聊天信息列表就會(huì)增加這條消息,之所以能夠看到這條消息,那是因?yàn)閟croll-view的滾動(dòng)條在消息添加時(shí)將位置定位到了最底部,這是需要進(jìn)行一些處理的,默認(rèn)效果是這樣的

是不是很變扭?這樣的用戶體驗(yàn)很差,滾動(dòng)條不會(huì)自動(dòng)定位到底部,這里需要給scroll-view組件添加一個(gè)scroll-into-view屬性,按照官方文檔的說法它的值應(yīng)為某子元素id。設(shè)置哪個(gè)方向可滾動(dòng),則在哪個(gè)方向滾動(dòng)到該元素,也就是說可以動(dòng)態(tài)的修改這個(gè)屬性的值,從而讓scroll-view組件的滾動(dòng)到想要滾動(dòng)的頁面元素位置。
這里就給每一個(gè)scroll-view的子元素(聊天記錄item)添加id屬性,屬性值為 msg + 每條聊天記錄的id
<scroll-view class="chat-main"
scroll-y="true"
:scroll-into-view="scrollToView"
:scroll-with-animation="needScrollAnimation"
:style="{height:paddingBottom}">
<!-- 聊天記錄item --->
<view class="chat-item" v-for="(item,index) in msg" :id="'msg' + item.id" :key="item.id" >
......
</view>
</scroll-view>在發(fā)送消息的方法中修改scroll-into-view的值scrollToView,讓其為最新一條聊天記錄即msg.length - 1的id值,必須使用在$nextTick回調(diào)中,這是為了在新的聊天記錄渲染完畢后再去定位。
this.$nextTick(function(){
this.scrollToView = 'msg' + this.msg[this.msg.length - 1].id;
});這樣才能實(shí)現(xiàn)最終的效果

2.如何動(dòng)態(tài)修改scroll-view的高度
如下圖,點(diǎn)擊 + 按鈕發(fā)送位置信息時(shí)會(huì)彈出底部菜單欄,但此時(shí)scroll-view內(nèi)的聊天內(nèi)容會(huì)被覆蓋,用戶想要看最后一條記錄還需操作滾動(dòng)條,這也是不好的用戶體驗(yàn)。

需要做到的是彈出底部菜單欄的同時(shí)減小聊天內(nèi)容區(qū)域scroll-view組件的高度,讓用戶能夠完整的看到最后的聊天記錄。

需要獲取底部菜單欄彈出的高度,隨后讓scroll-view組件減少這部分高度即可。在uni-app中無法操作dom,獲取元素的尺寸使用createSelectorQuery獲取頁面節(jié)點(diǎn),再用 boundingClientRect查詢節(jié)點(diǎn)的尺寸。官方文檔:uni.createSelectorQuery() | uni-app官網(wǎng)
使用如下代碼獲取頁面節(jié)點(diǎn)的尺寸,可能無法及時(shí)獲取到(得到的可能是undefined),這里需要用定時(shí)器包裹,才能拿到菜單欄的高度
<view class="more-view" v-show="showMore">
<swiper :indicator-dots="true">
<swiper-item v-for="(swiper,index1) in moreArr" :key="index1">
<view class="swiper-item" v-for="(list,index2) in swiper" :key="index2">
<view class="item-wrapper" v-for="item in list" :key="item.id">
<view class="pic-wrapper" :class="{hidePicWrapper:!item.pic}">
<image :src="item.pic" @tap="handleMoreFunction(item.flag)"></image>
</view>
<view class="text-wrapper">{{item.text}}</view>
</view>
</view>
</swiper-item>
</swiper>
</view>
......
// 獲取指定選擇器元素的高度
getHeight(classNa){
setTimeout(() => {
const query = uni.createSelectorQuery().in(this);
query.select(classNa).boundingClientRect(data => {
this.$emit('heightChange',data.height);
}).exec();
},10);
},
// 切換菜單欄顯示隱藏
changeMode(){
if(this.showMore){
this.showMore = !this.showMore;
this.getHeight('.more-view');
}
},
拿到底部菜單欄的高度后,使用calc計(jì)算并修改行內(nèi)樣式,并修改scroll-view的元素內(nèi)的子元素定位,這里修改scrollToView的值,一定要置空后再修改,否則會(huì)修改無效。
<scroll-view class="chat-main"
scroll-y="true"
:scroll-into-view="scrollToView"
:scroll-with-animation="needScrollAnimation"
@scrolltoupper="debounce"
:style="{height:scrollViewHeight}"
></scroll-view>
......
// 彈出菜單欄修改scroll-view高度
handleHeightChange(height){
this.scrollViewHeight= `calc(100vh - 208rpx - ${height}px - ${this.statusBarHeight}px)`;
this.scrollToView = '';
this.$nextTick(function(){
this.scrollToView = 'msg' + this.msg[this.msg.length - 1].id;
})
}實(shí)現(xiàn)一對一聊天
關(guān)于websocket
項(xiàng)目中使用的socket.io底層使用到的是websocket協(xié)議,可以實(shí)現(xiàn)服務(wù)器主動(dòng)推送消息給客戶端,一般應(yīng)用于實(shí)時(shí)通信,在線支付等場景,雖然socket.io對其進(jìn)行了封裝,但對其原理的了解還是有必要的。
在websock出現(xiàn)之前,一般使用ajax輪詢(設(shè)置定時(shí)器在相同時(shí)間間隔內(nèi)反復(fù)發(fā)送請求到服務(wù)器拿到服務(wù)器最新的數(shù)據(jù)),長輪詢(在指定時(shí)間內(nèi)不讓當(dāng)前請求斷開),流化技術(shù)等手段進(jìn)行即時(shí)通信,這三者都基于http協(xié)議實(shí)現(xiàn),但都非常占用服務(wù)器的資源, 顯著增加了延時(shí)。
websocket協(xié)議解決這些缺點(diǎn),它是一種全雙工、雙向、單套接字的連接,建立在TCP協(xié)議之上,當(dāng)websocket連接建立后,服務(wù)器和客戶端可以雙向通信,具有以下特點(diǎn):
1)建立在TCP協(xié)議之上,服務(wù)端的實(shí)現(xiàn)比較容易;
2)于HTTP協(xié)議有著良好的兼容性,默認(rèn)的端口也是80和443,并且握手階段采用HTTP協(xié)議;
3)數(shù)據(jù)格式輕量,性能開銷小,通信高效;
4)可以發(fā)送文本,也可以發(fā)送二進(jìn)制數(shù)據(jù);
5)沒有同源限制
http請求響應(yīng)圖解:

客戶端發(fā)送請求,服務(wù)器響應(yīng),至此一次請求響應(yīng)結(jié)束,再次獲取服務(wù)端最新數(shù)據(jù),需要再次重復(fù)上述過程;
websocket圖解:

黃色部分是握手階段,客戶端給服務(wù)端發(fā)送請求,該請求基于http協(xié)議,服務(wù)器返回101狀態(tài)碼,代表成功建立連接,隨后客戶端和服務(wù)器可以開始全雙工數(shù)據(jù)交互,且服務(wù)器可以主動(dòng)推送消息給瀏覽器,下面是websocket的請求報(bào)文:

1.使用websocket請求行的路徑是以ws開頭,代表使用的是websocket協(xié)議
2.請求頭Connection:Upgrade代表當(dāng)前服務(wù)器這是一個(gè)升級的鏈接
3.請求頭Upgrade:websocket代表需要將當(dāng)前的鏈接升級為websocket鏈接
4.請求頭Sec-WebSocket-Key: JnoOq+qL9WP3um80g1Sz3A==是客戶端使用base64編碼的24位隨機(jī)字符序列,用戶服務(wù)器標(biāo)識(shí)當(dāng)鏈接的客戶端,同時(shí)要求服務(wù)器響應(yīng)一個(gè)同樣加密的Sec-WebSocket-Accept頭作為應(yīng)答;
websocket響應(yīng)報(bào)文如下:

1.服務(wù)器響應(yīng)101狀態(tài)碼代表websocket鏈接建立成功
2.響應(yīng)頭Sec-WebSocket-Accept: Eu6A8ipjouG1LVFt6xFMSrPFk1E=是對客戶端請求頭Sec-WebSocket-Key的應(yīng)答,用于給客戶端標(biāo)識(shí)當(dāng)前的服務(wù)器
客戶端websocket實(shí)現(xiàn)
websocket是HTML5的新特性之一,首先你的瀏覽器必須支持websocket
1.創(chuàng)建WebSocket實(shí)例
const ws = new WebSocket('ws:localhost:8000');參數(shù)url:ws://ip地址:端口號(hào)/資源名
2.WebSocket對象包含以下事件
open:連接建立時(shí)觸發(fā)
message:客戶端接收服務(wù)端數(shù)據(jù)時(shí)觸發(fā)
error:通信發(fā)生錯(cuò)誤時(shí)觸發(fā)
close:連接關(guān)閉時(shí)觸發(fā)
3.WebSocket對象常用方法
send():使用連接給服務(wù)端發(fā)送數(shù)據(jù)
客戶端websocket代碼模板:
;((doc,WebSocket) => {
const msg = doc.querySelector('#msg'); // 獲取輸入框,需要發(fā)送的消息
const send = doc.querySelector('#send'); // 發(fā)送按鈕
// 創(chuàng)建websocket實(shí)例
const ws = new WebSocket('ws:localhost:8000');
// 初始化
const init = () => {
bindEvent();
}
// 綁定事件
function bindEvent () {
send.addEventListener('click',handleSendBtnClick,false);
ws.addEventListener('open',handleOpen,false);
ws.addEventListener('close',handleClose,false);
ws.addEventListener('error',handleError,false);
ws.addEventListener('message',handleMessage,false);
}
function handleSendBtnClick () {
const message = msg.value;
// 將數(shù)據(jù)發(fā)送給服務(wù)器
ws.send(JSON.stringify({
message:message
}));
msg.value = '';
}
function handleOpen () {
console.log('open');
// 當(dāng)連接建立時(shí),一般做一些頁面初始化操作
}
function handleClose () {
console.log('close');
// 當(dāng)連接關(guān)閉時(shí)
}
function handleError () {
console.log('error');
// 當(dāng)連接出現(xiàn)異常時(shí)
}
function handleMessage (e) {
// 在這里獲取后端廣播的數(shù)據(jù),數(shù)據(jù)通過事件對象e活得,數(shù)據(jù)存放在e.data中
const showMsg = JSON.parse(e.data);
}
init();
})(document,WebSocket)
由此可見,使用原生websocket完全可以進(jìn)行聊天通信,但是它提供的事件和api有限,對于一些復(fù)雜的需求實(shí)現(xiàn)起來比較困難,socket.io是一個(gè)websocket庫,它對于websocket進(jìn)行了很好的封裝,提供了許多api,以及自定義事件,使用起來比較靈活。
聊天功能的前后端交互順序圖

需要實(shí)現(xiàn)的是客戶端A發(fā)送消息給客戶端B,客戶端B能夠自動(dòng)接收并顯示,實(shí)現(xiàn)私聊的關(guān)鍵是要確定需要將消息發(fā)送給誰,所以在進(jìn)入聊天界面的的時(shí)候,每一個(gè)連接服務(wù)器的客戶端就需要將自己的id告訴服務(wù)器,服務(wù)器會(huì)維護(hù)一個(gè)對象專門用于存放當(dāng)前已連接的用戶id。
客戶端A進(jìn)入聊天界面的的時(shí)候,還需要存放客戶端B的用戶id,在發(fā)送消息的時(shí)候?qū)⒖蛻舳薆的id傳遞給服務(wù)器,讓服務(wù)器知道當(dāng)前的這條消息要發(fā)送給誰,服務(wù)器收到后就會(huì)查詢存放用戶id的對象,如果客戶端B連接那么就將A的消息發(fā)送給它,這就是私聊的大致思路。
建立連接
能夠?qū)崿F(xiàn)客戶端之間的通信首先需要將客戶端與服務(wù)器建立連接,首先下載依賴,客戶端使用weapp.socket.io,服務(wù)端使用socket.io
npm i socket.io@2.3.0 --save npm i express@4.17.1 --save npm i weapp.socket.io@2.1.0 --save
為了保證能連接正常,建議下載指定版本,前后端版本不匹配會(huì)導(dǎo)致連接失敗報(bào)錯(cuò)。
官方文檔英文:Socket.IO
W3Cschool中文文檔:socket.io官方文檔_w3cschool
客戶端:
客戶端下載完畢后,可以將weapp.socket.io.js文件單獨(dú)拿出,其存放的文件位置如下圖

將其放在項(xiàng)目指定文件夾下引入,這里放在socket文件下;隨后在項(xiàng)目的main.js中引入使用,這里將io掛載在Vue的原型上,供全局使用,連接地址為服務(wù)器的地址,端口號(hào)需與服務(wù)器socket.io監(jiān)聽的端口保持一致;
import io from './socket/weapp.socket.io.js'
Vue.prototype.socket = io('http://localhost:8000');服務(wù)器:
服務(wù)器使用node的express框架搭建,在入口js中配置如下,io.on用于綁定事件,connection事件在連接時(shí)觸發(fā),它是socket.io內(nèi)置事件之一。
const express = require('express');
const app = express();
let server = app.listen(8000);
let io = require('socket.io').listen(server);
io.on('connection',(socket) => {
console.log("socket.io連接成功");
});socket.io建立連接會(huì)產(chǎn)生跨域問題,這里直接通過express的方式使用CORS解決跨域:
app.all('*', function(req, res, next) {
res.header("Access-Control-Allow-Origin", "*");
res.header("Access-Control-Allow-Headers", "Content-Type,Content-Length, Authorization, Accept,X-Requested-With");
res.header("Access-Control-Allow-Methods","PUT,POST,GET,DELETE,OPTIONS");
res.header("X-Powered-By",' 3.2.1')
if(req.method=="OPTIONS") res.send(200);/*讓options請求快速返回*/
else next();
});當(dāng)然socket.io也提供了跨域的解決方案,具體可見 Handling CORS | Socket.IO
完成以上配置后,啟動(dòng)項(xiàng)目,客戶端便可使用socket.io與服務(wù)器正常連接。

觀察瀏覽器network選項(xiàng)卡,請求類型為websocket,響應(yīng)狀態(tài)碼101,可見socket.io的連接底層走的就是websocket協(xié)議

存儲(chǔ)連接的用戶
用戶登陸成功跳轉(zhuǎn)到index主頁,每一位用戶在注冊時(shí)都會(huì)在數(shù)據(jù)庫生成一個(gè)唯一的用戶id,這里需要將每一個(gè)連接成功的用戶id發(fā)送給服務(wù)器

=>

socket.io服務(wù)端除了connection(socket連接成功之后觸發(fā)),message(客戶端通過socket.send來傳送消息時(shí)觸發(fā)此事件),disconneting(socket失去連接時(shí)觸發(fā),包括關(guān)閉瀏覽器,主動(dòng)斷開,掉線等任何斷開連接的情況) 等內(nèi)置的默認(rèn)事件外,還可以使用自定義事件,客戶端也類似。
API上,使用emit()觸發(fā)事件,使用on()綁定事件,進(jìn)入首頁后在客戶端onLoad中觸發(fā)自定義事件login,同時(shí)從本地存儲(chǔ)中取出用戶uid,上傳服務(wù)器
export default {
data() {
return {
uid:'', // 當(dāng)前用戶id
},
onLoad() {
this.getStroage();
this.addUserToSocket(this.uid);
},
methods:{
// 獲取本地存儲(chǔ)
getStroage(){
const value = uni.getStorageSync('user');
if(value){
this.uid = value.id;
} else {
uni.navigateTo({
url:'/pages/login/login'
})
}
},
// 添加連接的用戶
addUserToSocket(uid){
this.socket.emit('login',uid);
},
}
}在服務(wù)端綁定login事件,同時(shí)創(chuàng)建對象connectedUsers存放連接的用戶, 將用戶uid作為key保存,value是socket.id,socket.id是connection回調(diào)參數(shù)的一個(gè)屬性,socket.id用于socket.io唯一標(biāo)識(shí)連接的用戶。
當(dāng)用戶退出應(yīng)用時(shí)觸發(fā)disconnecting事件,將此用戶信息從connectedUsers對象中刪除。
let connectedUsers = {};
io.on('connection',(socket) => {
console.log("socket.io連接成功");
// console.log(socket);
// 用戶進(jìn)入主頁時(shí)獲取用戶id保存
socket.on('login',(id) => {
console.log("socket.id:" + socket.id);
socket.name = id;
connectedUsers [id] = socket.id;
});
// 用戶離開
socket.on('disconnecting',() => {
console.log('leave:' + socket.id);
if(users.hasOwnProperty(socket.name)){
delete connectedUsers [socket.name];
}
});
});總結(jié):
1)io.on可用來給當(dāng)前socket連接綁定connection事件,參數(shù)socket可以獲取這次連接的配置信息,最常用的就是socket.id,它是本次連接的唯一標(biāo)識(shí)
io.on('connection',function(socket){ ...... })
2)on用于綁定事件,用于接收傳遞的數(shù)據(jù)
socket.on('自定義事件名',function(參數(shù)1,參數(shù)2,......,參數(shù)n) { ...... });
3)emit用于觸發(fā)事件,用于傳遞數(shù)據(jù)
socket.emit('自定義事件名',參數(shù)1,參數(shù)2,......,參數(shù)n);
4)disconnecting在失去連接時(shí)時(shí)觸發(fā),斷開可能是關(guān)閉瀏覽器,主動(dòng)斷開,掉線等導(dǎo)致
socket.on('disconnecting',() => {})
發(fā)送聊天信息
客戶端發(fā)送消息,將聊天內(nèi)容加工處理后,觸發(fā)自定義事件msg,將內(nèi)容,發(fā)送者id和接收者id發(fā)送給服務(wù)器,代碼如下:
客戶端chatroom.vue:
// 發(fā)送聊天數(shù)據(jù)
sendSocket(msg){
if(this.type === '0'){
// 1對1聊天
this.socket.emit('msg',msg,this.uid,this.fid);
} else {
// 群消息
this.socket.emit('gmsg',msg,this.uid,this.fid);
}
},服務(wù)器綁定msg事件,得到客戶端發(fā)來數(shù)據(jù),首先需要操作數(shù)據(jù)庫完成插入最新的聊天內(nèi)容,更改最后的通訊時(shí)間等操作,如果對方用戶在線,則connectedUsers 對象中必然存在該用戶的id,使用socket.to(指定接收者的socket.io)將消息發(fā)送給指定的用戶,同時(shí)觸發(fā)自定義事件backMsg,用法如下:
發(fā)送給指定 socketid 的客戶端(私密消息)
socket.to(<socketid>).emit('自定義事件名', 參數(shù));
注意:如果不使用socket.to方法直接調(diào)用emit,則會(huì)發(fā)送給所有在線的用戶。
服務(wù)器代碼:
// 引入數(shù)據(jù)庫文件
let dataBase= require("./dataBase");
// 1對1消息發(fā)送
socket.on('msg',(msg,fromId,toId) => {
console.log('服務(wù)器收到用戶' + fromId + '發(fā)送給' + toId + '的消息')
console.log('發(fā)送的消息是:',msg);
// 修改好友最后通訊時(shí)間
dataBase.updateLastMsgTime(fromId,toId);
dataBase.updateLastMsgTime(toId,fromId);
// 添加消息
dataBase.insertMsg(fromId,toId,msg.message,msg.types);
console.log('數(shù)據(jù)庫插入成功');
// 將獲取的消息發(fā)送給好友,users[toId]就是好友的socket.id
if(connectedUsers[toId]){
console.log('將消息發(fā)送給',toId,'成功');
socket.to(connectedUsers[toId]).emit('backMsg',msg,fromId,0);
}
});這樣客戶端綁定backMsg事件,就能拿到發(fā)送消息了!處理消息展示即可,但需要判斷當(dāng)前用戶此時(shí)打開的聊天界面是否就是當(dāng)前發(fā)送者聊天對話框即if(fromId === this.fid && type === 0),否則會(huì)造成聊天內(nèi)容的錯(cuò)誤展示,比如當(dāng)前用戶可能存在多個(gè)好友,客戶端A給客戶端B發(fā)消息時(shí)B打開的是和C的聊天對話框,此時(shí)就會(huì)在C的對話框中錯(cuò)誤的收到A發(fā)來的消息
客戶端chatroom.vue:
this.socket.on('backMsg',(msg,fromId,type) => {
// 如果是1對1消息fromId是當(dāng)前聊天窗口的好友id時(shí)執(zhí)行
if(fromId === this.fid && type === 0){
......
// 一條聊天記錄
let newMsg = {
fromId:fromId,
id:msg.id,
imgUrl:msg.imgUrl,
message:msg.message,
types:msg.types, // 0 - 文字信息,1 - 圖片信息, 2 - 音頻
time:new Date(),
isFirstPlay:true,
};
this.msg.push(newMsg);
// 如果消息是圖片
if(msg.types === '1') {
this.msgImage.push(msg.message)
}
this.$nextTick(function(){
this.scrollToView = 'msg' + this.msg[this.msg.length - 1].id;
});
......
}
});
測試效果如下:

服務(wù)器終端輸出結(jié)果如下:

首頁新消息提示
如下圖,用戶有新消息會(huì)在首頁及時(shí)顯示,并提示未讀消息數(shù)量

需要給首頁綁定獲取消息的自定義事件backMsg,綁定時(shí)機(jī)是在生命周期onLoad中,事件一旦觸發(fā)代表有好友向你發(fā)送消息了,會(huì)獲取服務(wù)器傳來的消息,在事件回調(diào)中要完成兩個(gè)操作,首先查找發(fā)來新消息的好友在首頁好友列表數(shù)組的索引下標(biāo),隨后修改指定的數(shù)組元素內(nèi)容,更新這個(gè)好友最后消息的時(shí)間、最后消息的內(nèi)容、未讀消息數(shù);并將該元素現(xiàn)有位置刪除,添加到整個(gè)數(shù)組的頭部,即把這個(gè)好友item放到首頁列表的最上方,首頁index.vue相關(guān)代碼如下:
<view class="fl-wrapper">
<view class="friend-list" v-for="(item,index) in friends" :key="index" @tap="toChatInterface(item)">
<!-- 用戶頭像 -->
<view class="avator">
<!-- 未讀消息數(shù) -->
<view class="new-message-number" v-show="item.unreadMsg">{{item.unreadMsg}}</view>
<image :src="item.imgUrl" class="img" ></image>
</view>
<view class="wrapper-right">
<view class="wrapper-right-left">
<!-- 好友名 最后聊天時(shí)間 -->
<view class="text">
<view class="name">{{item.nickName}}</view>
<view class="time">{{getTime(item.lastTime)}}</view>
</view>
<!-- 最后聊天消息 -->
<view class="message" v-if="item.lastMsgType==='0'">{{item.lastMsgUsername ? item.lastMsgUsername : ''}}{{item.lastMsg}}</view>
<view class="message" v-if="item.lastMsgType==='1'">{{item.lastMsgUsername ? item.lastMsgUsername : ''}}[圖片]</view>
<view class="message" v-if="item.lastMsgType==='2'">{{item.lastMsgUsername ? item.lastMsgUsername : ''}}[語音]</view>
<view class="message" v-if="item.lastMsgType==='3'">{{item.lastMsgUsername ? item.lastMsgUsername : ''}}[位置]</view>
</view>
</view>
</view>
</view>
......
onLoad() {
this.receiveSocket('backMsg');
}
methods:{
// 接收個(gè)人/群聊天信息
receiveSocket(eventName){
this.socket.on(eventName,(msg,fromId,type) => {
if(type === 0){
let index;
if(eventName == 'backMsg') {
// 獲取有新消息的好友在整個(gè)好友數(shù)組中的索引下標(biāo)
index = this.friends.findIndex((item) => {
return item.id === fromId
});
}
// 修改未讀消息數(shù)
this.getUnreadMsg(this.friends[index]);
// 修改最后聊天時(shí)間
this.friends[index].lastTime = msg.time;
// 修改最后聊天信息
this.friends[index].lastMsg = msg.message;
// 修改最后聊天信息的類型
this.friends[index].lastMsgType = msg.types;
// 刪除當(dāng)前item,將其插入到數(shù)組的首部,即展示在列表最上方
const tempItem = this.friends[index];
this.friends.splice(index,1);
this.friends.unshift(tempItem);
}
});
},
}
此外還有一個(gè)問題就是何時(shí)清空未讀消息數(shù),清空的操作需要進(jìn)行兩次,一次是用戶進(jìn)入聊天頁面時(shí)進(jìn)行清空,在聊天頁生命周期onLoad中調(diào)用清空消息數(shù)的后端接口,清空現(xiàn)有的未讀消息;另一次是在點(diǎn)擊返回按鈕如下圖,返回首頁時(shí)清空,在此按鈕事件的回調(diào)中調(diào)用清空未讀消息數(shù)的接口,這是為了清空用戶和他人聊天時(shí)已讀的消息,兩次操作缺一不可。

實(shí)現(xiàn)群聊
群聊的前后端順序圖如下所示:

需要實(shí)現(xiàn)的是客戶端A在群內(nèi)發(fā)送了消息后,在同一群內(nèi)的客戶端BCD都能同時(shí)收到A發(fā)送的消息。群聊的大致思路和私聊基本相似,不同點(diǎn)在于群聊中引入了房間的概念,在房間內(nèi)的成員就是這個(gè)群聊的群成員,任何群成員的群內(nèi)發(fā)言就會(huì)在這個(gè)房間內(nèi)進(jìn)行廣播,所有在線的群成員都能及時(shí)夠收到。
加入房間
使用socket.join()加入房間,具體使用如下:
socket.join('room',function(){ ...... });
room:房間id,是一個(gè)字符串,用戶自定義,加入房間會(huì)觸發(fā)參數(shù)二回調(diào)
socket.leave(room,function(){ ...... })
與join相對應(yīng)的是leave方法,即退出指定的房間,參數(shù)二異?;卣{(diào)函數(shù)為可選值。需要注意的是,當(dāng)與客戶端斷開連接時(shí),會(huì)自動(dòng)將其從加入的房間中移除
在這個(gè)項(xiàng)目里房間id使用的是每一個(gè)群聊的群id號(hào),它可以唯一標(biāo)識(shí)一個(gè)群聊;
加入房間的操作同樣是在用戶登錄成功進(jìn)入首頁時(shí)進(jìn)行,一個(gè)用戶可能加入了多個(gè)群聊,那么在主頁請求用戶群聊接口后,需要依次遍歷接口返回的群聊列表,為每一個(gè)群聊觸發(fā)addGroup事件,將當(dāng)前的群id發(fā)送給后端,讓當(dāng)前用戶加入每個(gè)群聊的房間。
index.vue
// 獲取當(dāng)前用戶的群消息
getGroup(){
uni.request({
url:`${this.baseUrl}/index/getGroupList`,
method:'POST',
data:{
uid:this.uid, // 用戶id
},
success: (res) => {
let data = res.data.result;
// 遍歷當(dāng)前用戶的群列表
for (var i = 0; i < data.length; i++) {
......
// 觸發(fā)addGroup事件,攜帶群id,加入房間
this.socket.emit('addGroup',data[i].id);
}
......
}
});
},服務(wù)器綁定addGroup事件,調(diào)用socket.join,讓當(dāng)前用戶連接加入房間號(hào)為groupId的房間
io.on('connection',(socket) => {
// 加入群
socket.on('addGroup',(groupId) => {
console.log('用戶',socket.id,'加入了groupId為',groupId,'的群聊');
socket.join(groupId);
});
}效果:例如當(dāng)前這個(gè)用戶加入了三個(gè)群聊,首頁加載后就會(huì)觸發(fā)addGroup三次,依次加入這三個(gè)群id標(biāo)識(shí)的房間。

服務(wù)器終端輸出效果如下:

發(fā)送群消息
某一群成員在群內(nèi)發(fā)送消息,會(huì)和私聊同樣的方式將語音和圖片這些靜態(tài)資源上傳服務(wù)器,返回服務(wù)器存放地址后進(jìn)行封裝,觸發(fā)gmsg事件將處理后的消息提交服務(wù)器
// 發(fā)送聊天數(shù)據(jù)
sendSocket(msg){
if(this.type === '0'){
// 1對1聊天
this.socket.emit('msg',msg,this.uid,this.fid);
} else {
// 群消息
this.socket.emit('gmsg',msg,this.uid,this.fid);
}
},群內(nèi)廣播消息使用到的api是socket.to,具體使用如下:
將內(nèi)容發(fā)送給同在房間名roomName的所有客戶端,除了發(fā)送者
socket.to(roomName).emit('事件名',參數(shù)1,參數(shù)2,......參數(shù)n);
如果需要包含發(fā)送者可以使用
io.in(roomName).emit('事件名',參數(shù)1,參數(shù)2,......參數(shù)n);
也可以同時(shí)發(fā)送給在多間房間的客戶端,使用to鏈?zhǔn)秸{(diào)用的形式,不包含發(fā)送者
socket.to(roomName1).to(roomName2).emit('事件名',參數(shù)1,參數(shù)2,......參數(shù)n);
當(dāng)然,當(dāng)前項(xiàng)目中只需要使用第一種方式即可
服務(wù)器的gmsg事件回調(diào)中,同樣需要將獲取到的消息插入數(shù)據(jù)庫,同時(shí)修改群最后通信時(shí)間以及全體成員的未讀消息數(shù),最后調(diào)用 socket.to方法,觸發(fā)groupMsg事件,將消息發(fā)送給群聊內(nèi)的其它在線用戶。
// 引入數(shù)據(jù)庫文件
let dataBase = require("./dataBase");
// 接收群消息
socket.on('gmsg',(msg,fromId,groupId) => {
console.log('服務(wù)器接收到來自群',groupId,'的用戶',fromId,'的消息',msg);
// 修改群的最后通信時(shí)間
dataBase.updateGroupLastTime(groupId);
// 添加群消息
dataBase.insertGroupMsg(fromId,groupId,msg.message,msg.types);
//將所有成員的未讀消息數(shù)加一
dataBase.changeGroupUnreadMsgNum(groupId);
console.log('消息',msg.message,'插入數(shù)據(jù)庫成功')
// 獲取當(dāng)前用戶的名字和頭像
dataBase.userDetails(fromId).then((data) => {
console.log('查詢發(fā)送者用戶名成功,用戶名是:',data[0]);
console.log('正在將信息',msg.message,'發(fā)送至群',groupId,'內(nèi)');
// 群內(nèi)廣播消息
socket.to(groupId).emit('groupMsg',msg,fromId,0,data[0].name,groupId);
});
});客戶端在線群成員收到消息,執(zhí)行g(shù)roupMsg事件回調(diào)中的方法,內(nèi)部大致邏輯和私聊完全一致,可以將其封裝成公共方法使用,需要注意的依舊是要做群id一致性判斷,防止獲取的消息顯示在其它聊天窗口中,即 if(fromId !== this.uid && groupId === this.fid)。
this.socket.on('groupMsg',(msg,fromId,type,friendName,groupId) => {
// 判斷當(dāng)前打開的群id和接收消息的群id是否一致,防止消息錯(cuò)誤顯示
if(fromId !== this.uid && groupId === this.fid){
......
// 模擬服務(wù)器數(shù)據(jù)
let newMsg = {
fromId:fromId,
id:msg.id,
imgUrl:msg.imgUrl,
message:msg.message,
types:msg.types, // 0 - 文字信息,1 - 圖片信息, 2 - 音頻
time:new Date(),
isFirstPlay:true,
friendName:friendName // 群需顯示發(fā)送消息用戶的名字
};
this.msg.push(newMsg);
// 如果消息是圖片
if(msg.types === '1') {
this.msgImage.push(msg.message)
}
this.$nextTick(function(){
this.scrollToView = 'msg' + this.msg[this.msg.length - 1].id;
});
......
}
});效果演示:輸入一段文字發(fā)送到群內(nèi)

服務(wù)器此時(shí)終端輸出如下

以上就是項(xiàng)目聊天功能難點(diǎn)的全部內(nèi)容,前端實(shí)現(xiàn)實(shí)時(shí)聊天主要就是對于socket.io提供api的合理使用,剩余的難點(diǎn)就是頁面顯示的部分邏輯處理,用戶體驗(yàn)的優(yōu)化,還可以在此基礎(chǔ)上添加更多的功能,若有不足之處懇請指正!
到此這篇關(guān)于uni-app小程序?qū)崿F(xiàn)微信在線聊天(私聊/群聊)的文章就介紹到這了,更多相關(guān)uni-app小程序微信聊天內(nèi)容請搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
- 微信小程序使用uni-app一鍵獲取用戶信息
- 微信小程序使用uni-app實(shí)現(xiàn)首頁搜索框?qū)Ш綑诠δ茉斀?/a>
- uni-app微信小程序使用echarts的詳細(xì)圖文教程
- 如何基于uni-app實(shí)現(xiàn)微信小程序一鍵登錄與退出登錄功能
- 解決uni-app微信小程序input輸入框在底部時(shí),鍵盤彈起頁面整體上移問題
- 微信小程序使用uni-app開發(fā)小程序及部分功能實(shí)現(xiàn)詳解
- uni-app?微信小程序授權(quán)登錄的實(shí)現(xiàn)步驟
- uniapp微信小程序多環(huán)境配置以及使用教程
- Vue微信小程序和uniapp配置環(huán)境地址
相關(guān)文章
基于JS實(shí)現(xiàn)右側(cè)緩慢彈窗動(dòng)態(tài)效果
在現(xiàn)代Web開發(fā)中,動(dòng)態(tài)交互效果是提升用戶體驗(yàn)的重要手段之一,其中,從頁面右側(cè)緩慢滑出的彈窗效果因其不遮擋主要內(nèi)容、易于用戶操作而備受歡迎,本文將詳細(xì)介紹如何使用JavaScript結(jié)合CSS3動(dòng)畫實(shí)現(xiàn)這一效果,并探討其在實(shí)際項(xiàng)目中的應(yīng)用,需要的朋友可以參考下2025-02-02
二級域名或跨域共享Cookies的實(shí)現(xiàn)方法
適用于Asp。 在主域名設(shè)置的Cookie,在各子域名共用;適用于博客等提供二級域名。這個(gè)問題,以網(wǎng)上有眾多帖子,可惜都沒有完整解決。2008-08-08
微信小程序錯(cuò)誤this.setData報(bào)錯(cuò)及解決過程
這篇文章主要介紹了微信小程序錯(cuò)誤this.setData報(bào)錯(cuò)及解決過程,文中通過示例代碼介紹的非常詳細(xì),對大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價(jià)值,需要的朋友可以參考下2019-09-09
JS實(shí)現(xiàn)日期時(shí)間動(dòng)態(tài)顯示的方法
這篇文章主要介紹了JS實(shí)現(xiàn)日期時(shí)間動(dòng)態(tài)顯示的方法,涉及JavaScript基于時(shí)間函數(shù)定時(shí)操作頁面元素屬性的相關(guān)技巧,非常簡單實(shí)用,需要的朋友可以參考下2015-12-12
Javascript圖像處理思路及實(shí)現(xiàn)代碼
HTML5的canvas提供了getImageData接口來獲取canvas中的數(shù)據(jù),所以我們能夠先用drawImage接口將圖片畫在canvas上然后再通過getImageData得到圖片數(shù)據(jù)矩陣,需要了解的朋友可以詳細(xì)參考下2012-12-12
JavaScript實(shí)現(xiàn)單鏈表過程解析
這篇文章主要介紹了JavaScript實(shí)現(xiàn)單鏈表過程,鏈表中的元素在內(nèi)存中不必是連續(xù)的空間。鏈表的每個(gè)元素有一個(gè)存儲(chǔ)元素本身的節(jié)點(diǎn)和指向下一個(gè)元素的引用。下面請和小編一起進(jìn)入文章了解更多的詳細(xì)內(nèi)容吧2021-12-12

