需求:在微信小程序中实现聊天功能
封装组件webSocket
const webSocketUrl = 'ws://43.138.231.91:2346'
const userinfo = wx.getStorageSync('userinfo')
// 创建
export const connectSocket = () => {
closeWebSocket()
wx.connectSocket({
url: webSocketUrl,
success(res) {
console.log('创建成功', res);
},
fail(err) {
console.log('创建失败', err);
}
})
}
// 销毁
export const closeWebSocket = () => {
wx.closeSocket()
console.log('关闭上一次连接');
}
// 连接
wx.onSocketOpen(() => {
console.log('连接成功,登录中...');
const obj = {
uuid: "2fadb319-5499-4486-9416-05ac3da1b5a1",
// uuid:userinfo.value.group_model.uuid
}
sendMessage(obj)
})
// 发消息
export const sendMessage = (obj) => {
return new Promise((resolve, reject) => {
wx.sendSocketMessage({
data: JSON.stringify(obj),
success(res) {
console.log('发送成功', res)
resolve(res)
},
fail(err) {
console.log("发送失败", err)
reject(err)
}
})
})
}
// 接收到消息
wx.onSocketMessage((res) => {
const data = JSON.parse(res.data)
console.log('接收到消息', data)
if (data.code == 0) return
if (data.msg === '登入成功') return
const pageUrl = currentPage()
const app = getApp()
if (pageUrl.includes('/pages/information/detail/detail')) {
app.eventBus.emit('messageDetail', data)
return
}
if (pageUrl.includes('/pages/information')) {
app.eventBus.emit('messageList', data)
}
app.eventBus.emit('messageOther', data)
})
// 获取当前页面路径
const currentPage = () => {
const pages = getCurrentPages();
const currentPage = pages[pages.length - 1];
return `/${currentPage.route}`;
}
在app.js中创建并连接webSocket
APP({
onLaunch(){
connectSocket()
}
})
发送消息
方法
async sendMethod({ content, type }) {
const obj = {
uuid: "2fadb319-5499-4486-9416-05ac3da1b5a1",
friend_uuid: this.data.friend_uuid,
content,
type,
}
const res = await sendMessage(obj)
if (type === 1) this.setData({ inputValue: "" })
},
文字消息
<input type="text" value="{{inputValue}}" placeholder="请输入要发送的消息" bindinput="onInput" bindconfirm="onSend" />
<button bind:tap="onSend">发送</button>
onInput(e) {
console.log(e);
const { detail: { value } } = e
this.setData({ inputValue: value })
},
onSend() {
const obj = {
content: this.data.inputValue,
type: 1
}
this.sendMethod(obj)
},
图片消息
<text class="iconfont icon-tupian" bind:tap="choosePhoto"></text>
// 图片消息
choosePhoto(e) {
const that = this
wx.chooseMedia({
count: 9,
mediaType: ["image"],
sourceType: ['album', 'camera'],
async success(res) {
console.log(res);
res.tempFiles.forEach(async (item) => {
const res = await uploadFile(item.tempFilePath)
if (res.code != 1) return
const obj = {
type: 2,
content: res.data.url
}
that.sendMethod(obj)
})
},
fail(err) {
console.error(err);
}
})
},
// 查看图片
onPreviewImage(e) {
const { currentTarget: { dataset: { data } } } = e
const imageArr = this.data.list.filter(item => item.type === 2)
const urls = imageArr.map(item => item.content)
wx.previewImage({
urls,
current: data.content,
})
},
语音消息
<input type="text" disabled="{{true}}" placeholder="按住开始说话" bind:touchstart="onRecordStart" bind:touchend="onRecordEnd" bind:touchcancel="onRecordCancel" style="text-align: center;"/>
<!-- 录音蒙版 -->
<view class="record_bg" wx:if="{{startRecord}}">
<text class="iconfont icon-luyin"></text>
</view>
<!-- 播放蒙版 -->
<view class="record_bg" wx:if="{{playRecord}}" bind:tap="onCloseMask">
<text class="iconfont icon-yuyinbofang"></text>
</view>
// 录音开始
onRecordStart() {
this.setData({ startRecord: true })
startRecording()
},
// 录音异常终止
onRecordCancel() {
errorRecording()
console.log('录音异常终止');
this.setData({ startRecord: false })
},
// 录音结束
async onRecordEnd() {
this.setData({ startRecord: false })
const that = this
const { tempFilePath } = await stopRecording()
const uploadRes = await uploadFile(tempFilePath)
if (uploadRes.code != 1) return
const obj = {
content: uploadRes.data.url,
type: 3
}
that.sendMethod(obj)
},
// 播放录音
onPlayRecord(e) {
const { currentTarget: { dataset: { value } } } = e
playRecording(value, this.onCloseMask)
this.setData({ playRecord: true })
},
// 关闭显示蒙版
onCloseMask() {
this.setData({ playRecord: false })
stopPalyRecording()
},
录音、播放组件封装
const recorderManager = wx.getRecorderManager()
const innerAudioContext = wx.createInnerAudioContext();
// 开始录音
export const startRecording = () => {
recorderManager.start({
format: 'mp3', // 录音格式
});
recorderManager.onStart(() => {
console.log('recorder start')
})
}
// 录音结束
export const stopRecording = () => {
return new Promise((resolve,reject)=>{
recorderManager.stop()
recorderManager.onStop((res) => {
resolve(res)
})
})
}
// 录音异常终止
export const errorRecording = ()=>{
recorderManager.stop()
}
// 播放录音
export const playRecording = (src,callback)=>{
stopPalyRecording()
innerAudioContext.src = src;
innerAudioContext.play();
innerAudioContext.onEnded(() => {
callback()
})
}
// 停止播放录音
export const stopPalyRecording = ()=>{
innerAudioContext.stop()
}
聊天页完整代码
视图层
<view class="container_gray container">
<view class="pageHead" style="height: {{height}}rpx;">
<i class="iconfont icon-icon-arrow-left2" bind:tap="onBack"></i>
<view class="title">
<view class="name">{{nickname}}</view>
<view class="company">哈哈哈哈哈公司</view>
</view>
</view>
<view class="main " style="height: calc(100vh - {{150 + height}}rpx - {{KeyBorad_height}}px);margin-top: {{height}}rpx;">
<scroll-view style="height: calc(100vh - {{150 + height}}rpx - {{KeyBorad_height}}px);" class="scroll" scroll-y scroll-with-animation="true" scroll-into-view="{{toView}}" enable-passive="{{true}}" scroll-anchoring="{{true}}" upper-threshold="6" bindscrolltoupper="scrollToupper">
<view class="loading" wx:if="{{loading}}">
<image src="/images/loading.gif" mode="widthFix" />
<text>加载中...</text>
</view>
<view class="item {{item.uuid===friend_uuid?'item_left':'item_right'}} item{{item.id}}" wx:for="{{list}}" wx:key="index">
<image class="avatar" src="{{item.user.avatar||item.avatar}}" mode="widthFix" />
<view class="message">
<!-- <image wx:if="{{item.user_id===40}}" src="/images/loading.gif" class="loading" mode="widthFix" /> -->
<view wx:if="{{item.type===1}}">{{item.content}}</view>
<image wx:if="{{item.type===2}}" src="{{item.content}}" mode="widthFix" bind:tap="onPreviewImage" data-data="{{item}}" />
<view wx:if="{{item.type===3}}" class="voice" bind:tap="onPlayRecord" data-value="{{item.content}}">
<text class="iconfont icon-yuyinbofang"></text>
<text class="iconfont icon-yuyinbofang"></text>
</view>
</view>
</view>
<view id="toBottom"></view>
</scroll-view>
</view>
<!-- 按钮 -->
<view class="operate">
<text class="iconfont icon-jianpan1" wx:if="{{isRecord}}" bind:tap="onChangeRecordInput" ></text>
<text class="iconfont icon-yuyin" wx:else bind:tap="onChangeRecordInput"></text>
<text class="iconfont icon-tupian" bind:tap="choosePhoto"></text>
<input wx:if="{{isRecord}}" type="text" disabled="{{true}}" placeholder="按住开始说话" bind:touchstart="onRecordStart" bind:touchend="onRecordEnd" bind:touchcancel="onRecordCancel" style="text-align: center;"/>
<input wx:else type="text" value="{{inputValue}}" placeholder="请输入要发送的消息" bindfocus="onFocus" bindkeyboardheightchange="onKeyboardheightchange" bindinput="onInput" bindconfirm="onSend" />
<button bind:tap="onSend">发送</button>
</view>
<!-- 录音蒙版 -->
<view class="record_bg" wx:if="{{startRecord}}">
<text class="iconfont icon-luyin"></text>
</view>
<!-- 播放蒙版 -->
<view class="record_bg" wx:if="{{playRecord}}" bind:tap="onCloseMask">
<text class="iconfont icon-yuyinbofang"></text>
</view>
</view>
css 样式
.container {
height: 100vh;
width: 100vw;
position: relative;
}
.pageHead {
width: 100vw;
position: fixed;
top: 0;
left: 0;
box-sizing: border-box;
padding: 30rpx;
padding-top: 16rpx;
border-bottom: 1px solid #eee;
background-color: #fff;
}
.pageHead .iconfont {
position: absolute;
top: 66%;
transform: translateY(-50%);
}
.pageHead .title {
position: absolute;
left: 50%;
bottom: 10rpx;
transform: translateX(-50%);
white-space: nowrap;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.pageHead .title .name {
font-size: 30rpx;
font-weight: bold;
}
.pageHead .title .company {
font-size: 24rpx;
color: #aaa;
padding-top: 6rpx;
}
/* 内容 */
.main {
margin-top: 150rpx;
/* height: calc(100vh - 300rpx); */
overflow: scroll;
width: 100vw;
position: relative;
z-index: 2;
}
.hide {
position: absolute;
z-index: 0;
opacity: 0;
}
.main view.loading {
display: flex;
align-items: center;
justify-content: center;
padding: 40rpx 0;
color: #aaa;
}
.main view.loading image {
width: 40rpx;
margin-right: 26rpx;
}
.main view.loading text {
letter-spacing: 4rpx;
}
.main .item {
display: flex;
justify-content: space-between;
align-items: flex-start;
padding: 40rpx 40rpx;
padding-bottom: 0;
}
.main .item .avatar {
min-width: 60rpx;
width: 60rpx;
padding-top: 10rpx;
}
.main .item .message {
flex: 1;
max-width: calc(100vw - 300rpx);
}
.main .item .message>view {
display: inline-block;
font-size: 28rpx;
padding: 16rpx 16rpx;
border-radius: 20rpx;
}
.main .item .message>view.voice {
padding: 16rpx 30rpx;
}
.main .item .message>view.voice text {
margin: 0 2rpx;
}
.main .item .message image {
width: 100%;
}
.main .item .message .loading {
width: 30rpx;
margin-right: 10rpx;
}
.item_left .avatar {
margin-right: 16rpx;
}
.item_left .message {
margin-right: 140rpx;
}
.main .item_left .message>view {
border-bottom-left-radius: 0;
background-color: #fff;
}
.item_right {
flex-direction: row-reverse;
}
.item_right .message {
text-align: right;
margin-right: 16rpx;
margin-left: 140rpx;
}
.main .item_right .message>view {
border-bottom-right-radius: 0;
background-color: #0099ff;
color: #fff;
text-align: left;
}
#toBottom{
height: 20rpx;
padding-top: 20rpx;
}
/* 发送 */
.operate {
height: 150rpx;
width: 100vw;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 30rpx;
box-sizing: border-box;
}
.operate input {
flex: 1;
/* border: 1px solid #ddd; */
background-color: #fff;
height: 70rpx;
padding: 0 10rpx;
margin-right: 20rpx;
border-radius: 10rpx;
}
.operate button {
font-size: 26rpx;
height: 70rpx;
line-height: 70rpx;
width: 100rpx;
border-radius: 10rpx;
background-color: #0099ff;
color: #fff;
}
.operate>text {
font-size: 50rpx;
line-height: 50rpx;
margin-right: 20rpx;
color: #333;
}
/* */
.record_bg {
position: fixed;
z-index: 5;
height: 100vh;
width: 100vw;
background: rgba(0, 0, 0, 0.5);
}
.record_bg .iconfont {
font-size: 200rpx;
color: rgb(236, 236, 236);
position: absolute;
top: 50%;
left: 50%;
transform: translateX(-50%);
}
逻辑层
// pages/information/detail/detail.js
import { navigationHeight } from "../../../utils/system";
import { sendMessage } from "../../../utils/webSocket";
import { debounce } from "../../../utils/throttle"
import core from "../../../utils/core";
import { uploadFile } from "../../../utils/uploadFile"
import { startRecording, stopRecording, playRecording, stopPalyRecording, errorRecording } from "../../../utils/record"
import { getMessageApi } from "../../../utils/unreadMessage"
const app = getApp();
Page({
/**
* 页面的初始数据
*/
data: {
height: wx.getStorageSync('system')?.navigationHeight || navigationHeight(),
KeyBorad_height: 0,
list: [],
loading: false,
total: 0,
page: 0,
friend_id: "",
friend_uuid: "",
nickname:"",
toView: 'toBottom',
inputValue: "",
isRecord: false,
startRecord: false,
playRecord: false,
},
// 返回
onBack() {
this.haveReady()
wx.switchTab({
url: '/pages/information/index/index',
})
},
// 列表
onListTable(isFirst = false) {
let that = this
const isAll = that.data.total === that.data.list.length || that.data.total === that.data.listArr.length
if ((isAll && !isFirst) || that.data.loading) return
that.setData({
page: ++that.data.page,
loading: true
})
core.get({
url: "/api/chat/message",
data: { friend_id: that.data.friend_id, page: that.data.page },
success(res) {
console.log('res', res.data.data);
if (res.code != 1) return
if (isFirst) {
that.setData({
list: res.data.data,
total: res.data.total,
listArr: res.data.data,
loading: false
})
setTimeout(() => {
that.setData({ toView: "toBottom" })
})
} else {
that.data.list.unshift(...res.data.data)
that.setData({ list: that.data.list, toView: null, loading: false })
}
// const index = res.data.data.length - 1
// if (index < 0) return
// that.getScrollTop(`item${res.data.data[index].id}`)
}
})
},
// 当前scrollTop
scroll: debounce((e) => {
const { detail: { scrollTop } } = e
console.log('scrollTop', scrollTop);
}, 1000),
// 触顶获取数据
scrollToupper() {
if (this.data.loading) return
this.onListTable()
},
// 获取scrollTop
getScrollTop(className) {
const that = this
const query = wx.createSelectorQuery()
query.select(`.${className}`).boundingClientRect()
query.selectViewport().scrollOffset()
wx.nextTick(query.exec(function (res) {
console.log(`.${className}`);
res[0].top // #the-id节点的上边界坐标
res[1].scrollTop // 显示区域的竖直滚动位置
console.log(res);
console.log('aaa=' + res[0].top)
console.log('bbb=' + res[1].scrollTop)
that.setData({ loading: false })
}))
},
// 文字消息
onFocus(e) {
// const {detail:{height}} = e
// this.setData({ KeyBorad_height: height})
// keyBoardHeight()
// setTimeout(() => {
// this.setData({ KeyBorad_height: wx.getStorageSync('system').keyBoardHeight })
// })
},
onKeyboardheightchange(e) {
console.log(e);
const { detail: { height } } = e
this.setData({ KeyBorad_height: height, toView: 'toBottom' })
},
onInput(e) {
console.log(e);
const { detail: { value } } = e
this.setData({ inputValue: value })
},
onSend() {
const obj = {
content: this.data.inputValue,
type: 1
}
this.sendMethod(obj)
},
// 图片消息
choosePhoto(e) {
const that = this
wx.chooseMedia({
count: 9,
mediaType: ["image"],
sourceType: ['album', 'camera'],
async success(res) {
console.log(res);
res.tempFiles.forEach(async (item) => {
const res = await uploadFile(item.tempFilePath)
if (res.code != 1) return
const obj = {
type: 2,
content: res.data.url
}
that.sendMethod(obj)
})
},
fail(err) {
console.error(err);
}
})
},
onPreviewImage(e) {
const { currentTarget: { dataset: { data } } } = e
const imageArr = this.data.list.filter(item => item.type === 2)
const urls = imageArr.map(item => item.content)
wx.previewImage({
urls,
current: data.content,
})
},
// 切换
onChangeRecordInput() {
this.setData({ isRecord: !this.data.isRecord })
},
// 语音消息
onRecordStart() {
this.setData({ startRecord: true })
startRecording()
},
onRecordCancel() {
errorRecording()
console.log('录音异常终止');
this.setData({ startRecord: false })
},
async onRecordEnd() {
this.setData({ startRecord: false })
const that = this
const { tempFilePath } = await stopRecording()
const uploadRes = await uploadFile(tempFilePath)
if (uploadRes.code != 1) return
const obj = {
content: uploadRes.data.url,
type: 3
}
that.sendMethod(obj)
},
onPlayRecord(e) {
const { currentTarget: { dataset: { value } } } = e
playRecording(value, this.onCloseMask)
this.setData({ playRecord: true })
},
onCloseMask() {
this.setData({ playRecord: false })
stopPalyRecording()
},
// 发送
async sendMethod({ content, type }) {
const obj = {
// user_id: 40,
uuid: "2fadb319-5499-4486-9416-05ac3da1b5a1",
friend_uuid: this.data.friend_uuid,
content,
type,
}
const res = await sendMessage(obj)
if (type === 1) this.setData({ inputValue: "" })
},
// 已读
haveReady() {
const that = this
core.post({
url: "/api/chat/read",
data: { friend_id: that.data.friend_id },
success(res) {
}
})
},
/**
* 生命周期函数--监听页面加载
*/
onLoad(options) {
this.setData({
friend_id: options.friend_id,
friend_uuid: options.friend_uuid,
nickname:options.nickname
})
this.onListTable(true)
this.haveReady()
},
/**
* 生命周期函数--监听页面初次渲染完成
*/
onReady() {
// this.setData({ scrollTop: 10000 })
},
/**
* 生命周期函数--监听页面显示
*/
onShow() {
app.eventBus.on('messageDetail', (data) => {
this.data.list.push(data)
this.setData({
list: this.data.list,
toView: 'toBottom'
})
})
},
/**
* 生命周期函数--监听页面隐藏
*/
onHide() {
app.eventBus.off('messageDetail')
},
/**
* 生命周期函数--监听页面卸载
*/
onUnload() {
getMessageApi(app)
},
})