即时通讯webSocket

181 阅读2分钟

需求:在微信小程序中实现聊天功能

官网文档:developers.weixin.qq.com/miniprogram…

封装组件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)
  },
})