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從本地存儲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í),聊天信息列表就會增加這條消息,之所以能夠看到這條消息,那是因?yàn)閟croll-view的滾動(dòng)條在消息添加時(shí)將位置定位到了最底部,這是需要進(jìn)行一些處理的,默認(rèn)效果是這樣的
是不是很變扭?這樣的用戶體驗(yàn)很差,滾動(dòng)條不會自動(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í)會彈出底部菜單欄,但此時(shí)scroll-view內(nèi)的聊天內(nèi)容會被覆蓋,用戶想要看最后一條記錄還需操作滾動(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的值,一定要置空后再修改,否則會修改無效。
<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)識當(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)識當(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地址:端口號/資源名
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ù)器會維護(hù)一個(gè)對象專門用于存放當(dāng)前已連接的用戶id。
客戶端A進(jìn)入聊天界面的的時(shí)候,還需要存放客戶端B的用戶id,在發(fā)送消息的時(shí)候?qū)⒖蛻舳薆的id傳遞給服務(wù)器,讓服務(wù)器知道當(dāng)前的這條消息要發(fā)送給誰,服務(wù)器收到后就會查詢存放用戶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
為了保證能連接正常,建議下載指定版本,前后端版本不匹配會導(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ù)器的地址,端口號需與服務(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建立連接會產(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é)議
存儲連接的用戶
用戶登陸成功跳轉(zhuǎn)到index主頁,每一位用戶在注冊時(shí)都會在數(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í)從本地存儲中取出用戶uid,上傳服務(wù)器
export default { data() { return { uid:'', // 當(dāng)前用戶id }, onLoad() { this.getStroage(); this.addUserToSocket(this.uid); }, methods:{ // 獲取本地存儲 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)識連接的用戶。
當(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)識
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,則會發(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),否則會造成聊天內(nèi)容的錯(cuò)誤展示,比如當(dāng)前用戶可能存在多個(gè)好友,客戶端A給客戶端B發(fā)消息時(shí)B打開的是和C的聊天對話框,此時(shí)就會在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é)果如下:
首頁新消息提示
如下圖,用戶有新消息會在首頁及時(shí)顯示,并提示未讀消息數(shù)量
需要給首頁綁定獲取消息的自定義事件backMsg,綁定時(shí)機(jī)是在生命周期onLoad中,事件一旦觸發(fā)代表有好友向你發(fā)送消息了,會獲取服務(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ā)言就會在這個(gè)房間內(nèi)進(jìn)行廣播,所有在線的群成員都能及時(shí)夠收到。
加入房間
使用socket.join()加入房間,具體使用如下:
socket.join('room',function(){ ...... });
room:房間id,是一個(gè)字符串,用戶自定義,加入房間會觸發(fā)參數(shù)二回調(diào)
socket.leave(room,function(){ ...... })
與join相對應(yīng)的是leave方法,即退出指定的房間,參數(shù)二異?;卣{(diào)函數(shù)為可選值。需要注意的是,當(dāng)與客戶端斷開連接時(shí),會自動(dòng)將其從加入的房間中移除
在這個(gè)項(xiàng)目里房間id使用的是每一個(gè)群聊的群id號,它可以唯一標(biāo)識一個(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)前用戶連接加入房間號為groupId的房間
io.on('connection',(socket) => { // 加入群 socket.on('addGroup',(groupId) => { console.log('用戶',socket.id,'加入了groupId為',groupId,'的群聊'); socket.join(groupId); }); }
效果:例如當(dāng)前這個(gè)用戶加入了三個(gè)群聊,首頁加載后就會觸發(fā)addGroup三次,依次加入這三個(gè)群id標(biāo)識的房間。
服務(wù)器終端輸出效果如下:
發(fā)送群消息
某一群成員在群內(nèi)發(fā)送消息,會和私聊同樣的方式將語音和圖片這些靜態(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運(yùn)動(dòng)相關(guān)知識點(diǎn)小結(jié)(附彈性運(yùn)動(dòng)示例)
這篇文章主要介紹了JS運(yùn)動(dòng)相關(guān)知識點(diǎn),總結(jié)分析了JavaScript運(yùn)動(dòng)所涉及的相關(guān)知識點(diǎn)與注意事項(xiàng),并附帶了一個(gè)JavaScript彈性運(yùn)動(dòng)的實(shí)例供大家參考,需要的朋友可以參考下2016-01-01全面解析Bootstrap中nav、collapse的使用方法
這篇文章主要為大家詳細(xì)解析了Bootstrap中nav、collapse的使用方法,感興趣的朋友可以參考一下2016-05-05uniapp自定義多列瀑布流組件項(xiàng)目實(shí)戰(zhàn)總結(jié)
這篇文章主要為大家介紹了uniapp自定義多列瀑布流組件實(shí)戰(zhàn)總結(jié),有需要的朋友可以借鑒參考下,希望能夠有所幫助,祝大家多多進(jìn)步,早日升職加薪2023-09-09dedecms頁面如何獲取會員狀態(tài)的實(shí)例代碼
下面小編就為大家?guī)硪黄猟edecms頁面如何獲取會員狀態(tài)的實(shí)例代碼。一起跟隨小編過來看看吧,希望對大家有所幫助。2016-03-03JavaScript實(shí)現(xiàn)串行請求的示例代碼
這篇文章主要介紹了JavaScript實(shí)現(xiàn)串行請求的示例代碼,幫助大家更好的理解和使用JavaScript,感興趣的朋友可以了解下2020-09-09JavaScript焦點(diǎn)事件、鼠標(biāo)事件和滾輪事件使用詳解
這篇文章主要介紹了JavaScript焦點(diǎn)事件、鼠標(biāo)事件和滾輪事件使用詳解,通過示例給大家講解的非常細(xì)致,有需要的小伙伴可以參考下。2016-01-01