头图

概述

本次关于 uni-app 代码整体重构工作,基于上一期针对 uni-app 官网 demo 从 vue2 迁移 vue3 框架衍生而来,在迁移过程中有明显感知,目前的项目存在的问题为,项目部分代码风格较为不统一,命名不够规范,注释不够清晰、可读性差、以造成如果复用困难重重,本地重构期望能够充分展示 api 在实际项目中的调用方式,尽可能达到示例代码可移植,或能辅助进行即时通讯功能二次开发的能力。

目的

  • 使代码更加可读。
  • 简化或去除冗余代码。
  • 部分组件以及逻辑重命名、重拆分、重合并。
  • 增加全局状态管理。
  • 修改 SDK 引入方式为通过 npm 形式引入
  • 收束 SDK 大部分 API 到统一文件、方便管理调用。
  • 升级 SDK api 至最新的调用方式(监听、发送消息)
  • 增加会话列表接口、消息漫游接口。
  • SDK 指环信 IM uni-app SDK

重构计划

一、修改原 WebIM 的导出导入使用方式。

目的

  1. 现有 uniSDK 已支持 npm 形式导入。
  2. 原有实例化代码与 config 配置较为混乱不够清晰。
  3. 分离初始化以及配置形成独立文件方便管理。

实现

  1. 项目目录中创建 EaseIM 文件夹并创建 index.js,在 index.js 中完成导入 SDK 并实现实例化并导出。
  2. EaseIM -> config 文件夹并将 SDK 中相关配置在此文件中书写并导出供实例化使用。

影响(无影响)

二、引入 pinia 进行状态管理

Pinia 文档

Uni-app Pinia 文档

pinia 还能通过$reset()方法即可完成对某个 store 的初始化,利用该方法可以非常方便的在切换账号时针对缓存在 stores 中的数据初始化,防止切换后的账号与上一个账号的数据造成冲突。

目的

  1. 存放 SDK 部分数据以及状态(登录状态、会话列表数据、消息数据)
  2. 方便各组件状态或数据取用避免数据层层传递。
  3. 用以平替原有本地持久化数据存储。
  4. 可以替代原有 disp 发布订阅管理工具,因为 store 中状态改变,各组件可以进行重新计算或监听,无需通过发布订阅通知改变状态。

实现

  1. 在 mian.js 中引入 pinia,并挂载
//Pinia
import * as Pinia from 'pinia';
export function createApp() {
  const app = createSSRApp(App);
  app.use(Pinia.createPinia());
  return {
    app,
    Pinia,
  };
}
  1. 项目目录中新建 stores 并创建各个所需 store,类似目录如下:

影响(无影响)

三、重新梳理 App.vue 根组件中代码

目的

  1. 简化项目中 App.vue 根组件中的冗长代码。
  2. 迁移根组件中的监听代码。
  3. globalData,method 中代码转为 stores 中或剔除。
  4. disp 代码剔除。

实现

  1. App.vue 中的监听嵌入至 EaseIM 文件夹下的 listener 集中管理,并在 App.vue 中重新进行挂载
  2. import '@/EaseIM';从而实现实例化 SDK。
  3. 将需要 IM 连接成功后调用的数据,合为一个方法中,并在 onConnected 触发后调用。
  4. 部分关于 SDK 调用的代码迁入至 EaseIM 文件夹下的 imApis 文件夹中
  5. 部分有关 SDK 的工具方法代码迁入至 EaseIM 文件夹下的 utils 文件夹中

影响

App.vue 改动相对较大,主要为监听的迁移,一部分方法迁移至 stores 中,并且需要重新进行监听的挂载。具体代码可在后续迁移前后比对中看到,或者文尾的看到 github 中看到代码地址。

四、优化 login 页面代码

目的

原有 login 组件登录部分代码比较冗长并且可读性较差因此进行优化。

实现

  1. 删除原有操作 input 的代码,改为通过 v-model 双向绑定。
  2. 拆分登录代码为通过 username+password,以及手机号+验证码两个登录方法。
  3. 增加登录存储 token 方法,方便后续重连时通过用户名+token 形式进行重新登录。
  4. 登录成功之后将登录的 id 以及手机号信息 set 进入到 stores 中。

影响(无影响)

五、增加 home 页面

目的

  1. 作为 Conversation、Contacts、Me 三个核心页面容器组件,方便页面切换管理。
  2. 作为 Tabbar 的容器组件

实现

  1. 去除原有会话、联系人、我的(原 setting 页面)pages.json 的路由配置,增加 home 页面路由相关配置。
  2. pages 中增加 home 组件,并以组件化的形式引入三个核心页面组件。
  3. 项目根目录中新建 layout 文件夹并增加 tabbar 组件,将三个页面中的 tabbar 功能抽离至 tabbar 组件中,并增加相应切换逻辑。

影响

此改动主要影响为要涉及到将原 setting 组件改为 Me 组件,并将三个原有页面 pages.json 删除并在 home 中引入,并无其他副作用。

六、重构 Conversation、Contacts、Me 等基础组件

目的

  1. 将原有数据(会话列表数据,联系人数据,昵称头像数据)来源切换为从 SDK 接口+stores 中获取。
  2. 去除组件内的 disp 发布订阅相关代码,以及 WebIM 的使用。
  3. 调整原组件代码中的不合理的命名,去除不再使用的方法简化该组件代码。

实现

以 Conversation 组件举例
  1. 以 SDK 接口 getConversationlist 获取会话列表数据,缓存至 stores 中并做排序处理,在组件中使用计算属性获取作为新的会话列表数据来源。
  2. 由于会话列表的更新是动态的,因此不再需要 disp 订阅一系列的事件进行处理,因此相关代码可以开始进行删除。
  3. 原有的通过会话列表跳转至联系人页面或者其他群组页面命名改为单词从 into 改为 entry 并改为驼峰命名,经过改造该组件用不到的方法则完全删除。

影响

主要影响则是这些组件内的逻辑代码会有从结构以及数据源会有较大变化,需要边改造边验证,并且会与 stores、EaseIM 等组件有较大的关系,需要耐心进行调整。

七、增加 emChatContainer 组件

目的

  1. 新增此组件命名更为语义化,能够通过组件名看出其实际功能为 emChat 聊天页组件容器。
  2. 合并原有 singleChatEntry 组件以及 groupChatEntry 组件,两个相似功能组件至统一的一个 emChatContainer 内。

实现

  1. 在 pages 下新建一个名为 emChatContainer 的组件,并先将 components 下的 chat 组件参考 singleChatEntry 组件引入,并在 pages 中配置对应路由路径映射。
  2. 观察发现该组件作为 chat 组件容器,主要向下传递两个核心参数,1)目标 ID(也就是聊天的目标环信 ID)。2)chatType(也就是目标聊天的类型,常规为单聊、群聊。),且这两个核心参数经常被 chat 组件中的各个子组件用到,一层层向下传递较为繁琐,因此使用到 Vue 组件传参方法之一的,provide、inject 方式将参数注册并向下传递下去。
  3. 完成合并之后将 singleChatEntry、groupChatEntry 删去,并且将原有用到向该组件跳转的方法路径全部指向 emChatContainer,且在 pages.json 中删除对应的页面路径。

影响

从会话进入到聊天页、从联系人、群组页面进入到聊天页的路由跳转路径全部改为 emChatContainer,并且将会改变 chat 组件使用 targetId(聊天目标 ID)以及 chatType 的方式,因为需要改为通过 inject 接收。

八、emChat 组件重构

目的

  1. 改写该组件下不合理的文件命名。
  2. 删除非必要的 js 文件或组件。
  3. 该组件内各个功能子组件进行局部代码重构。

实现

  1. 配合 emChatContainer 将 chat 组件改名为 emChat。
  2. 删除其组件内的 msgpackager.js、msgstorage.js、msgtype.js、pushStorage.js,这几个 js 文件。
  3. messagelist inputbar 改为驼峰命名。
  4. messageList 组件内的消息列表来源改为从 stores 中获取,增加下拉通过 getHistroyMessage 获取历史消息。
  5. 子组件内接收目标 id 以及消息类型改为通过 inject 接收。
  6. msgType 从 EaseIM/constant 中获取。
  7. 发送消息 API 全部改为 SDK4.xPromise 写法,在 EaseIM/imApis/emMessages 统一并导出,在需要的发送的组件中导入调用,剔除原有发送消息的方式。

影响

该组件调整难度最大,因为牵扯的组件,以及需要新增的调整的代码较多,需要逐个组件修改并验证,具体代码将在下方局部展示。详情请参看源码地址。

九、新增重连中提示监听回调

目的

能够在 IM websocket 断开的时候有相应的回调出来,并给到用户相应的提示。

实现

在 addEventHandler 监听中增加 onReconnecting 监听回调,并且在实际触发的时候增加 Toast 提示监听 IM 正在重连中。

PS:onReconnecting 属于实验性回调。

影响(无影响)

重构前后代码片段展示

一、重构前后项目目录展示

重构前项目目录结构

image.png

重构后项目目录结构

image.png

二、重构前后 SDK 引入以及初始化展示

重构前 SDK 引入初始化代码片段

import websdk from 'easemob-websdk/uniApp/Easemob-chat';
import config from './WebIMConfig';
console.group = console.group || {};
console.groupEnd = console.groupEnd || {};
var window = {};
let WebIM = (window.WebIM = uni.WebIM = websdk);
window.WebIM.config = config;

WebIM.conn = new WebIM.connection({
  appKey: WebIM.config.appkey,
  url: WebIM.config.xmppURL,
  apiUrl: WebIM.config.apiURL,
});

export default WebIM;

重构后 SDK 引入初始化代码片段

import EaseSDK from 'easemob-websdk/uniApp/Easemob-chat';
import { EM_APP_KEY, EM_API_URL, EM_WEB_SOCKET_URL } from './config';
let EMClient = (uni.EMClient = {});
EMClient = new EaseSDK.connection({
  appKey: EM_APP_KEY,
  apiUrl: EM_API_URL,
  url: EM_WEB_SOCKET_URL,
});
uni.EMClient = EMClient;
export { EaseSDK, EMClient };

三、重构前后 App.vue 组件代码片段展示

该组件代码过于长,为防止水文嫌疑,因此截取部分代码展示[手动狗头]

重构前 App.vue 组件代码片段

<script>
import WebIM from '@/utils/WebIM.js';
import msgStorage from '@/components/chat/msgstorage';
import _chunkArr from './utils/chunkArr';
import msgType from '@/components/chat/msgtype';
import disp from '@/utils/broadcast';
import { onGetSilentConfig } from './components/chat/pushStorage';
let logout = false;

function ack(receiveMsg) {
  // 处理未读消息回执
  var bodyId = receiveMsg.id; // 需要发送已读回执的消息id

  var ackMsg = new WebIM.message('read', WebIM.conn.getUniqueId());
  ackMsg.set({
    id: bodyId,
    to: receiveMsg.from,
  });
  WebIM.conn.send(ackMsg.body);
}

function onMessageError(err) {
  if (err.type === 'error') {
    uni.showToast({
      title: err.errorText,
    });
    return false;
  }

  return true;
}

function getCurrentRoute() {
  let pages = getCurrentPages();
  if (pages.length > 0) {
    let currentPage = pages[pages.length - 1];
    return currentPage.route;
  }
  return '/';
}

// 包含陌生人版本
//该方法用以计算本地存储消息的未读总数。
function calcUnReadSpot(message) {
  let myName = uni.getStorageSync('myUsername');
  let pushObj = uni.getStorageSync('pushStorageData');
  let pushAry = pushObj[myName] || [];
  uni.getStorageInfo({
    success: function (res) {
      let storageKeys = res.keys;
      let newChatMsgKeys = [];
      let historyChatMsgKeys = [];
      storageKeys.forEach((item) => {
        if (item.indexOf(myName) > -1 && item.indexOf('rendered_') == -1) {
          newChatMsgKeys.push(item);
        }
      });
      let count = newChatMsgKeys.reduce(function (result, curMember, idx) {
        let newName = curMember.split(myName)[0];
        let chatMsgs;
        chatMsgs = uni.getStorageSync(curMember) || [];
        //过滤消息来源与当前登录ID一致的消息,不计入总数中。
        chatMsgs = chatMsgs.filter((msg) => msg.yourname !== myName);
        if (pushAry.includes(newName)) return result;
        return result + chatMsgs.length;
      }, 0);
      getApp().globalData.unReadMessageNum = count;
      disp.fire('em.unreadspot', message);
    },
  });
}

function saveGroups() {
  var me = this;
  return WebIM.conn.getGroup({
    limit: 50,
    success: function (res) {
      uni.setStorage({
        key: 'listGroup',
        data: res.data,
      });
    },
    error: function (err) {
      console.log(err);
    },
  });
}

export default {
  globalData: {
    phoneNumber: '',
    unReadMessageNum: 0,
    userInfo: null,
    userInfoFromServer: null, //用户属性从环信服务器获取
    friendUserInfoMap: new Map(), //好友属性
    saveFriendList: [],
    saveGroupInvitedList: [],
    isIPX: false, //是否为iphone X
    conn: {
      closed: false,
      curOpenOpt: {},

      open(opt) {
        uni.showLoading({
          title: '正在初始化客户端..',
          mask: true,
        });
        const actionOpen = () => {
          this.curOpenOpt = opt;
          WebIM.conn
            .open(opt)
            .then(() => {
              //token获取成功,即可开始请求用户属性。
              disp.fire('em.mian.profile.update');
              disp.fire('em.mian.friendProfile.update');
            })
            .catch((err) => {
              console.log('>>>>>token获取失败', err);
            });
          this.closed = false;
        };
        if (WebIM.conn.isOpened()) {
          WebIM.conn.close();
          setTimeout(() => {
            actionOpen();
          }, 300);
        } else {
          actionOpen();
        }
      },

      reopen() {
        if (this.closed) {
          //this.open(this.curOpenOpt);
          WebIM.conn.open(this.curOpenOpt);
          this.closed = false;
        }
      },
    },
    onLoginSuccess: function (myName) {
      uni.hideLoading();
      uni.redirectTo({
        url: '../conversation/conversation?myName=' + myName,
      });
    },
  onLaunch() {
    var me = this;
    var logs = uni.getStorageSync('logs') || [];
    logs.unshift(Date.now());
    uni.setStorageSync('logs', logs);

    disp.on('em.main.ready', function () {
      calcUnReadSpot();
    });
    uni.WebIM.conn.listen({
      onOpened(message) {
        if (
          getCurrentRoute() == 'pages/login/login' ||
          getCurrentRoute() == 'pages/login_token/login_token'
        ) {
          me.globalData.onLoginSuccess(
            uni.getStorageSync('myUsername').toLowerCase()
          );
          me.fetchFriendListFromServer();
        }
      },

      onReconnect() {
        uni.showToast({
          title: '重连中...',
          duration: 2000,
        });
      },

      onSocketConnected() {
        uni.showToast({
          title: 'socket连接成功',
          duration: 2000,
        });
      },

      onClosed() {
        uni.showToast({
          title: '退出登录',
          icon: 'none',
          duration: 2000,
        });
        uni.redirectTo({
          url: '../login/login',
        });
        me.globalData.conn.closed = true;
        WebIM.conn.close();
      },
      onTextMessage(message) {
        console.log('onTextMessage', message);

        if (message) {
          if (onMessageError(message)) {
            msgStorage.saveReceiveMsg(message, msgType.TEXT);
          }

          calcUnReadSpot(message);
          ack(message);
          onGetSilentConfig(message);
        }
      },
      onPictureMessage(message) {
        console.log('onPictureMessage', message);

        if (message) {
          if (onMessageError(message)) {
            msgStorage.saveReceiveMsg(message, msgType.IMAGE);
          }

          calcUnReadSpot(message);
          ack(message);
          onGetSilentConfig(message);
        }
      },
    });
    this.globalData.checkIsIPhoneX();
  },

  methods: {
    async fetchUserInfoWithLoginId() {
      const userId = await uni.WebIM.conn.user;
      if (userId) {
        try {
          const { data } = await uni.WebIM.conn.fetchUserInfoById(userId);
          this.globalData.userInfoFromServer = Object.assign({}, data[userId]);
        } catch (error) {
          console.log(error);
          uni.showToast({
            title: '用户属性获取失败',
            icon: 'none',
            duration: 2000,
          });
        }
      }
    },
    async fetchFriendInfoFromServer() {
      let friendList = [];
      try {
        const res = await uni.WebIM.conn.getContacts();
        friendList = Object.assign([], res?.data);
        if (friendList.length && friendList.length < 99) {
          const { data } = await uni.WebIM.conn.fetchUserInfoById(friendList);
          this.setFriendUserInfotoMap(data);
        } else {
          let newArr = _chunkArr(friendList, 99);
          for (let i = 0; i < newArr.length; i++) {
            const { data } = await uni.WebIM.conn.fetchUserInfoById(newArr[i]);
            this.setFriendUserInfotoMap(data);
          }
        }
      } catch (error) {
        console.log(error);
        uni.showToast({
          title: '用户属性获取失败',
          icon: 'none',
        });
      }
    },
    setFriendUserInfotoMap(data) {
      if (Object.keys(data).length) {
        for (const key in data) {
          if (Object.hasOwnProperty.call(data, key)) {
            const values = data[key];
            Object.values(values).length &&
              this.globalData.friendUserInfoMap.set(key, values);
          }
        }
      }
    },
    async fetchFriendListFromServer() {
      uni.removeStorageSync('member');
      try {
        const { data } = await WebIM.conn.getContacts();
        console.log('>>>>>>App.vue 拉取好友列表');
        if (data.length) {
          uni.setStorage({
            key: 'member',
            data: [...data],
          });
        }
      } catch (error) {
        console.log('>>>>>好友列表获取失败', error);
      }
    },
  },
};
</script>
<style lang="scss">
@import './app.css';
</style>

重构后 App.vue 组件代码片段

可以看到比原有 App.vue 组件有明显的代码简化。
<script>
/* EaseIM */
import '@/EaseIM';
import { emConnectListener, emMountGlobalListener } from '@/EaseIM/listener';
import { emUserInfos, emGroups, emContacts } from '@/EaseIM/imApis';
import { CONNECT_CALLBACK_TYPE } from '@/EaseIM/constant';
import { useLoginStore } from '@/stores/login';
import { useGroupStore } from '@/stores/group';
import { useContactsStore } from '@/stores/contacts';
import { EMClient } from './EaseIM';

export default {
  setup() {
    const loginStore = useLoginStore();
    const groupStore = useGroupStore();
    const contactsStore = useContactsStore();
    /* 链接所需监听回调 */
    //传给监听callback回调
    const connectedCallback = (type) => {
      console.log('>>>>>连接成功回调', type);
      if (type === CONNECT_CALLBACK_TYPE.CONNECT_CALLBACK) {
        onConnectedSuccess();
      }
      if (type === CONNECT_CALLBACK_TYPE.DISCONNECT_CALLBACK) {
        onDisconnect();
      }
      if (type === CONNECT_CALLBACK_TYPE.RECONNECTING_CALLBACK) {
        onReconnecting();
      }
    };
    //IM连接成功
    const onConnectedSuccess = () => {
      const loginUserId = loginStore.loginUserBaseInfos.loginUserId;
      if (!loginStore.loginStatus) {
        fetchLoginUserNeedData();
      }
      loginStore.setLoginStatus(true);
      uni.hideLoading();
      uni.redirectTo({
        url: '../home/index?myName=' + loginUserId,
      });
    };
    //IM断开连接
    const onDisconnect = () => {
      //断开回调触发后,如果业务登录状态为true则说明异常断开需要重新登录
      if (!loginStore.loginStatus) {
        uni.showToast({
          title: '退出登录',
          icon: 'none',
          duration: 2000,
        });
        uni.redirectTo({
          url: '../login/login',
        });
        EMClient.close();
      } else {
        //执行通过token机型重新登录
        const loginUserId = uni.getStorageSync('myUsername');
        const loginUserToken =
          loginUserId && uni.getStorageSync(`EM_${loginUserId}_TOKEN`);
        EMClient.open({ user: loginUserId, accessToken: loginUserToken.token });
      }
    };
    //IM重连中
    const onReconnecting = () => {
      uni.showToast({
        title: 'IM 重连中...',
        icon: 'none',
      });
    };
    //挂载IM websocket连接成功监听
    emConnectListener(connectedCallback);
    const { fetchUserInfoWithLoginId, fetchOtherInfoFromServer } =
      emUserInfos();
    const { fetchJoinedGroupListFromServer } = emGroups();
    const { fetchContactsListFromServer } = emContacts();
    //获取登录所需基础参数
    const fetchLoginUserNeedData = async () => {
      //获取好友列表
      const friendList = await fetchContactsListFromServer();
      await contactsStore.setFriendList(friendList);
      //获取群组列表
      const joinedGroupList = await fetchJoinedGroupListFromServer();
      joinedGroupList.length &&
        (await groupStore.setJoinedGroupList(joinedGroupList));
      if (friendList.length) {
        //获取好友用户属性
        const friendProfiles = await fetchOtherInfoFromServer(friendList);
        contactsStore.setFriendUserInfotoMap(friendProfiles);
      }
      //获取当前登录用户好友信息
      const profiles = await fetchUserInfoWithLoginId();
      await loginStore.setLoginUserProfiles(profiles[EMClient.user]);
    };
    //挂载全局所需监听回调【好友关系、消息监听、群组监听】
    emMountGlobalListener();
  },
};
</script>
<style lang="scss">
@import './app.css';
</style>

四、重构前后会话列表(conversation)组件代码片段对比

重构前会话列表代码片段展示

PS:template 中代码变化不大,为缩减长度暂时省去 template 相关代码
<script setup>
import { reactive, computed } from 'vue';
import { onLoad, onShow, onUnload } from '@dcloudio/uni-app';
import swipeDelete from '@/components/swipedelete/swipedelete';
import msgtype from '@/components/chat/msgtype';
import dateFormater from '@/utils/dateFormater';
import disp from '@/utils/broadcast';
const WebIM = uni.WebIM;
let isfirstTime = true;
const conversationState = reactive({
  //       msgtype,
  search_btn: true,
  search_chats: false,
  show_mask: false,
  yourname: '',
  unReadSpotNum: 0,
  unReadNoticeNum: 0,
  messageNum: 0,
  unReadTotalNotNum: 0,
  conversationList: [],
  show_clear: false,
  member: '',
  isIPX: false,
  gotop: false,
  input_code: '',
  groupName: {},
  winSize: {},
  popButton: ['删除该聊天'],
  showPop: false,
  currentVal: '',
  pushConfigData: [],
  defaultAvatar: '/static/images/theme2x.png',
  defaultGroupAvatar: '/static/images/groupTheme.png',
});
onLoad(() => {
  disp.on('em.subscribe', onChatPageSubscribe);
  //监听解散群
  disp.on('em.invite.deleteGroup', onChatPageDeleteGroup);
  //监听未读消息数
  disp.on('em.unreadspot', onChatPageUnreadspot);
  //监听未读加群“通知”
  disp.on('em.invite.joingroup', onChatPageJoingroup);
  //监听好友删除
  disp.on('em.contacts.remove', onChatPageRemoveContacts);
  //监听好友关系解除
  disp.on('em.unsubscribed', onChatPageUnsubscribed);
  if (!uni.getStorageSync('listGroup')) {
    listGroups();
  }
  if (!uni.getStorageSync('member')) {
    getRoster();
  }

  readJoinedGroupName();
});
onShow(() => {
  uni.hideHomeButton && uni.hideHomeButton();
  setTimeout(() => {
    getLocalConversationlist();
  }, 100);
  conversationState.unReadMessageNum =
    getApp().globalData.unReadMessageNum > 99
      ? '99+'
      : getApp().globalData.unReadMessageNum;
  conversationState.messageNum = getApp().globalData.saveFriendList.length;
  conversationState.unReadNoticeNum =
    getApp().globalData.saveGroupInvitedList.length;
  conversationState.unReadTotalNotNum =
    getApp().globalData.saveFriendList.length +
    getApp().globalData.saveGroupInvitedList.length;
  if (getApp().globalData.isIPX) {
    conversationState.isIPX = true;
  }
});
const showConversationAvatar = computed(() => {
  const friendUserInfoMap = getApp().globalData.friendUserInfoMap;
  return (item) => {
    if (item.chatType === 'singleChat' || item.chatType === 'chat') {
      if (
        friendUserInfoMap.has(item.username) &&
        friendUserInfoMap.get(item.username)?.avatarurl
      ) {
        return friendUserInfoMap.get(item.username).avatarurl;
      } else {
        return conversationState.defaultAvatar;
      }
    } else if (
      item.chatType.toLowerCase() === 'groupchat' ||
      item.chatType === 'chatRoom'
    ) {
      return conversationState.defaultGroupAvatar;
    }
  };
});
const showConversationName = computed(() => {
  const friendUserInfoMap = getApp().globalData.friendUserInfoMap;
  return (item) => {
    if (item.chatType === 'singleChat' || item.chatType === 'chat') {
      if (
        friendUserInfoMap.has(item.username) &&
        friendUserInfoMap.get(item.username)?.nickname
      ) {
        return friendUserInfoMap.get(item.username).nickname;
      } else {
        return item.username;
      }
    } else if (
      item.chatType === msgtype.chatType.GROUP_CHAT ||
      item.chatType === msgtype.chatType.CHAT_ROOM ||
      item.chatType === 'groupchat'
    ) {
      return item.groupName;
    }
  };
});
const handleTime = computed(() => {
  return (item) => {
    return dateFormater('MM/DD/HH:mm', item.time);
  };
});

const listGroups = () => {
  return uni.WebIM.conn.getGroup({
    limit: 50,
    success: function (res) {
      uni.setStorage({
        key: 'listGroup',
        data: res.data,
      });
      readJoinedGroupName();
      getLocalConversationlist();
    },
    error: function (err) {
      console.log(err);
    },
  });
};

const getRoster = async () => {
  const { data } = await WebIM.conn.getContacts();
  if (data.length) {
    uni.setStorage({
      key: 'member',
      data: [...data],
    });
    conversationState.member = [...data];
    //if(!systemReady){
    disp.fire('em.main.ready');
    //systemReady = true;
    //}
    getLocalConversationlist();
    conversationState.unReadSpotNum =
      getApp().globalData.unReadMessageNum > 99
        ? '99+'
        : getApp().globalData.unReadMessageNum;
  }
  console.log('>>>>好友列表获取成功', data);
};

const readJoinedGroupName = () => {
  const joinedGroupList = uni.getStorageSync('listGroup');
  const groupList = joinedGroupList?.data || joinedGroupList || [];
  let groupName = {};
  groupList.forEach((item) => {
    groupName[item.groupid] = item.groupname;
  });
  conversationState.groupName = groupName;
};
// 包含陌生人版本
const getLocalConversationlist = () => {
  const myName = uni.getStorageSync('myUsername');
  uni.getStorageInfo({
    success: (res) => {
      let storageKeys = res.keys;
      let newChatMsgKeys = [];
      let historyChatMsgKeys = [];
      let len = myName.length;
      storageKeys.forEach((item) => {
        if (item.slice(-len) == myName && item.indexOf('rendered_') == -1) {
          newChatMsgKeys.push(item);
        } else if (
          item.slice(-len) == myName &&
          item.indexOf('rendered_') > -1
        ) {
          historyChatMsgKeys.push(item);
        } else if (item === 'INFORM') {
          newChatMsgKeys.push(item);
        }
      });
      packageConversation(newChatMsgKeys, historyChatMsgKeys);
    },
  });
};
//组件会话列表方法
const packageConversation = (newChatMsgKeys, historyChatMsgKeys) => {
  const myName = uni.getStorageSync('myUsername');
  let conversationList = [];
  let lastChatMsg; //最后一条消息
  for (let i = historyChatMsgKeys.length; i > 0, i--; ) {
    let index = newChatMsgKeys.indexOf(historyChatMsgKeys[i].slice(9));
    if (index > -1) {
      let newChatMsgs = uni.getStorageSync(newChatMsgKeys[index]) || [];
      if (newChatMsgs.length) {
        lastChatMsg = newChatMsgs[newChatMsgs.length - 1];
        lastChatMsg.unReadCount = newChatMsgs.length;
        newChatMsgKeys.splice(index, 1);
      } else {
        let historyChatMsgs = uni.getStorageSync(historyChatMsgKeys[i]);
        if (historyChatMsgs.length) {
          lastChatMsg = historyChatMsgs[historyChatMsgs.length - 1];
        }
      }
    } else {
      let historyChatMsgs = uni.getStorageSync(historyChatMsgKeys[i]);
      if (historyChatMsgs.length) {
        lastChatMsg = historyChatMsgs[historyChatMsgs.length - 1];
      }
    }
    if (
      lastChatMsg.chatType == msgtype.chatType.GROUP_CHAT ||
      lastChatMsg.chatType == msgtype.chatType.CHAT_ROOM ||
      lastChatMsg.chatType == 'groupchat'
    ) {
      lastChatMsg.groupName = conversationState.groupName[lastChatMsg.info.to];
    }
    lastChatMsg &&
      lastChatMsg.username != myName &&
      conversationList.push(lastChatMsg);
  }
  for (let i = newChatMsgKeys.length; i > 0, i--; ) {
    let newChatMsgs = uni.getStorageSync(newChatMsgKeys[i]) || [];
    if (newChatMsgs.length) {
      lastChatMsg = newChatMsgs[newChatMsgs.length - 1];
      lastChatMsg.unReadCount = newChatMsgs.length;
      if (
        lastChatMsg.chatType == msgtype.chatType.GROUP_CHAT ||
        lastChatMsg.chatType == msgtype.chatType.CHAT_ROOM ||
        lastChatMsg.chatType == 'groupchat'
      ) {
        lastChatMsg.groupName =
          conversationState.groupName[lastChatMsg.info.to];
      }
      lastChatMsg.username != myName && conversationList.push(lastChatMsg);
    }
  }
  conversationList.sort((a, b) => {
    return b.time - a.time;
  });
  console.log('>>>>>>conversationList', conversationList);
  conversationState.conversationList = conversationList;
};
const openSearch = () => {
  conversationState.search_btn = false;
  conversationState.search_chats = true;
  conversationState.gotop = true;
};
const onSearch = (val) => {
  let searchValue = val.detail.value;
  var myName = uni.getStorageSync('myUsername');
  let serchList = [];
  let conversationList = [];
  uni.getStorageInfo({
    success: function (res) {
      let storageKeys = res.keys;
      let chatKeys = [];
      let len = myName.length;
      storageKeys.forEach((item) => {
        if (item.slice(-len) == myName) {
          chatKeys.push(item);
        }
      });
      chatKeys.forEach((item, index) => {
        if (item.indexOf(searchValue) != -1) {
          serchList.push(item);
        }
      });
      let lastChatMsg = '';
      serchList.forEach((item, index) => {
        let chatMsgs = uni.getStorageSync(item) || [];
        if (chatMsgs.length) {
          lastChatMsg = chatMsgs[chatMsgs.length - 1];
          conversationList.push(lastChatMsg);
        }
      });
      conversationState.conversationList = conversationList;
    },
  });
};
const cancel = () => {
  getLocalConversationlist();
  conversationState.search_btn = true;
  conversationState.search_chats = false;
  conversationState.unReadSpotNum =
    getApp().globalData.unReadMessageNum > 99
      ? '99+'
      : getApp().globalData.unReadMessageNum;
  conversationState.gotop = false;
};
const clearInput = () => {
  conversationState.input_code = '';
  conversationState.show_clear = false;
};
const onInput = (e) => {
  let inputValue = e.detail.value;
  if (inputValue) {
    conversationState.show_clear = true;
  } else {
    conversationState.show_clear = false;
  }
};
const tab_contacts = () => {
  uni.redirectTo({
    url: '../main/main?myName=' + uni.getStorageSync('myUsername'),
  });
};
const close_mask = () => {
  conversationState.search_btn = true;
  conversationState.search_chats = false;
  conversationState.show_mask = false;
};
const tab_setting = () => {
  uni.redirectTo({
    url: '../setting/setting',
  });
};
const tab_notification = () => {
  uni.redirectTo({
    url: '../notification/notification',
  });
};
const into_chatRoom = (event) => {
  let detail = JSON.parse(event.currentTarget.dataset.item);
  if (
    detail.chatType == msgtype.chatType.GROUP_CHAT ||
    detail.chatType == msgtype.chatType.CHAT_ROOM ||
    detail.groupName
  ) {
    into_groupChatRoom(detail);
  } else {
    into_singleChatRoom(detail);
  }
};
// 单聊
const into_singleChatRoom = (detail) => {
  var myName = uni.getStorageSync('myUsername');
  var nameList = {
    myName: myName,
    your: detail.username,
  };
  const friendUserInfoMap = getApp().globalData.friendUserInfoMap;
  if (
    friendUserInfoMap.has(nameList.your) &&
    friendUserInfoMap.get(nameList.your)?.nickname
  ) {
    nameList.yourNickName = friendUserInfoMap.get(nameList.your).nickname;
  }
  uni.navigateTo({
    url:
      '../singleChatEntry/singleChatEntry?username=' + JSON.stringify(nameList),
  });
};
// 群聊 和 聊天室 (两个概念)
const into_groupChatRoom = (detail) => {
  var myName = uni.getStorageSync('myUsername');
  var nameList = {
    myName: myName,
    your: detail.groupName,
    groupId: detail.info.to,
  };
  uni.navigateTo({
    url:
      '../groupChatEntry/groupChatEntry?username=' + JSON.stringify(nameList),
  });
};
const into_inform = () => {
  uni.redirectTo({
    url: '../notification/notification',
  });
};

const removeAndRefresh = (event) => {
  let removeId = event.currentTarget.dataset.item.info.from;
  let ary = getApp().globalData.saveFriendList;
  let idx;
  if (ary.length > 0) {
    ary.forEach((v, k) => {
      if (v.from == removeId) {
        idx = k;
      }
    });
    getApp().globalData.saveFriendList.splice(idx, 1);
  }
  uni.removeStorageSync('INFORM');
};

const del_chat = (event) => {
  let detail = event.currentTarget.dataset.item;
  let nameList = {};
  // 删除当前选中群组聊天列表
  if (detail.chatType == 'groupchat' || detail.chatType == 'chatRoom') {
    nameList = {
      your: detail.info.to,
    };
    //删除当前选中通知列表
  } else if (detail.chatType === 'INFORM') {
    nameList = {
      your: 'INFORM',
    };
  }
  //删除当前选中好友聊天列表
  else {
    nameList = {
      your: detail.username,
    };
  }
  var myName = uni.getStorageSync('myUsername');
  var currentPage = getCurrentPages();

  uni.showModal({
    title: '确认删除?',
    confirmText: '删除',
    success: function (res) {
      if (res.confirm) {
        uni.removeStorageSync(nameList.your + myName);
        uni.removeStorageSync('rendered_' + nameList.your + myName);
        nameList.your === 'INFORM' && removeAndRefresh(event);
        // if (Object.keys(currentPage[0]).length>0) {
        //   currentPage[0].onShow();
        // }
        disp.fire('em.chat.session.remove');
        getLocalConversationlist();
      }
    },
    fail: function (err) {
      console.log('删除列表', err);
    },
  });
};
const removeLocalStorage = (yourname) => {
  var myName = uni.getStorageSync('myUsername');
  uni.removeStorageSync(yourname + myName);
  uni.removeStorageSync('rendered_' + yourname + myName);
};

/* 获取窗口尺寸 */
const getWindowSize = () => {
  uni.getSystemInfo({
    success: (res) => {
      conversationState.winSize = {
        witdh: res.windowWidth,
        height: res.windowHeight,
      };
    },
  });
};
const hidePop = () => {
  conversationState.showPop = false;
};
const pickerMenuChange = () => {
  del_chat(conversationState.currentVal);
};

/*  disp event callback function */
const onChatPageSubscribe = () => {
  getLocalConversationlist();
  conversationState.messageNum = getApp().globalData.saveFriendList.length;
  conversationState.unReadTotalNotNum =
    getApp().globalData.saveFriendList.length +
    getApp().globalData.saveGroupInvitedList.length;
};
const onChatPageDeleteGroup = (infos) => {
  listGroups();
  getRoster();
  getLocalConversationlist();
  conversationState.messageNum = getApp().globalData.saveFriendList.length;
  //如果会话存在则执行删除会话
  removeLocalStorage(infos.gid);
};
const onChatPageUnreadspot = (message) => {
  getLocalConversationlist();
  let currentLoginUser = WebIM.conn.context.userId;
  let id =
    message && message.chatType === 'groupchat' ? message?.to : message?.from;
  let pushObj = uni.getStorageSync('pushStorageData');
  let pushAry = pushObj[currentLoginUser] || [];
  conversationState.pushConfigData = pushAry;

  // if (message && pushValue.includes(id)) return
  conversationState.unReadSpotNum =
    getApp().globalData.unReadMessageNum > 99
      ? '99+'
      : getApp().globalData.unReadMessageNum;
};
const onChatPageJoingroup = () => {
  conversationState.unReadMessageNum =
    getApp().globalData.saveGroupInvitedList.length;
  conversationState.unReadTotalNotNum =
    getApp().globalData.saveFriendList.length +
    getApp().globalData.saveGroupInvitedList.length;
  getLocalConversationlist();
};
const onChatPageRemoveContacts = () => {
  getLocalConversationlist();
  getRoster();
};
const onChatPageUnsubscribed = (message) => {
  uni.showToast({
    title: `与${message.from}好友关系解除`,
    icon: 'none',
  });
};
onUnload(() => {
  //页面卸载同步取消onload中的订阅,防止重复订阅事件。
  disp.off('em.subscribe', conversationState.onChatPageSubscribe);
  disp.off('em.invite.deleteGroup', conversationState.onChatPageDeleteGroup);
  disp.off('em.unreadspot', conversationState.onChatPageUnreadspot);
  disp.off('em.invite.joingroup', conversationState.onChatPageJoingroup);
  disp.off('em.contacts.remove', conversationState.onChatPageRemoveContacts);
  disp.off('em.unsubscribed', conversationState.onChatPageUnsubscribed);
});
</script>
<style>
@import './conversation.css';
</style>

重构后会话列表代码片段展示

<script setup>
import { reactive, computed, watch, watchEffect } from 'vue';
import { onLoad, onShow } from '@dcloudio/uni-app';
import swipeDelete from '@/components/swipedelete/swipedelete';
import { emConversation } from '@/EaseIM/imApis';
import { CHAT_TYPE, MESSAGE_TYPE } from '@/EaseIM/constant';
import { useConversationStore } from '@/stores/conversation';
import { useContactsStore } from '@/stores/contacts';
import { useGroupStore } from '@/stores/group';
import dateFormater from '@/utils/dateFormater';
/* store */
import { useInformStore } from '@/stores/inform';
const conversationState = reactive({
  search_btn: true,
  search_chats: false,
  search_keyword: '',
  show_mask: false,
  yourname: '',
  unReadSpotNum: 0,
  unReadNoticeNum: 0,
  messageNum: 0,
  unReadTotalNotNum: 0,
  conversationList: [], //搜索后返回的会话数据,
  show_clear: false,
  member: '',
  isIPX: false,
  gotop: false,
  groupName: {},
  winSize: {},
  popButton: ['删除该聊天'],
  showPop: false,
  currentVal: '',
  pushConfigData: [],
  defaultAvatar: '/static/images/theme2x.png',
  defaultGroupAvatar: '/static/images/groupTheme.png',
});
//群组名称
const groupStore = useGroupStore();
const getGroupName = (groupid) => {
  const joinedGroupList = groupStore.joinedGroupList;
  let groupName = '';
  if (joinedGroupList.length) {
    joinedGroupList.forEach((item) => {
      if (item.groupid === groupid) {
        console.log(item.groupname);
        return (groupName = item.groupname);
      }
    });
    return groupName;
  } else {
    return groupid;
  }
};
/* 系统通知 */
const informStore = useInformStore();
//最近一条系统通知
const lastInformData = computed(() => {
  return (
    informStore.getAllInformsList[informStore.getAllInformsList.length - 1] ||
    null
  );
});
//未处理系统通知总数
const unReadNoticeNum = computed(() => {
  return informStore.getAllInformsList.filter((inform) => !inform.isHandled)
    .length;
});
/* 会话列表 */
const conversationStore = useConversationStore();
const {
  fetchConversationFromServer,
  removeConversationFromServer,
  sendChannelAck,
} = emConversation();
const fetchConversationList = async () => {
  const res = await fetchConversationFromServer();
  if (res?.data?.channel_infos) {
    conversationStore.setConversationList(
      Object.assign([], res.data.channel_infos)
    );
  }
};
//会话列表数据
const conversationList = computed(() => {
  return conversationStore.sortedConversationList;
});
watchEffect(() => {
  console.log('>>>>>执行更新会话列表数据');
  conversationState.conversationList = Object.assign(
    [],
    conversationList.value
  );
});
//会话列表name&头像展示处理
const contactsStore = useContactsStore();
//好友属性
const friendUserInfoMap = computed(() => {
  return contactsStore.friendUserInfoMap;
});
//会话列表头像
const showConversationAvatar = computed(() => {
  return (item) => {
    switch (item.chatType) {
      case CHAT_TYPE.SINGLE_CHAT:
        const friendInfo = friendUserInfoMap.value.get(item.channel_id) || {};
        return friendInfo.avatarurl ?? conversationState.defaultAvatar;
      case CHAT_TYPE.GROUP_CHAT:
        return conversationState.defaultGroupAvatar;
      default:
        return null;
    }
  };
});
//会话列表名称
const showConversationName = computed(() => {
  return (item) => {
    switch (item.chatType) {
      case CHAT_TYPE.SINGLE_CHAT:
        const friendInfo = friendUserInfoMap.value.get(item.channel_id);
        return friendInfo?.nickname || item.channel_id;
      case CHAT_TYPE.GROUP_CHAT:
        return getGroupName(item.channel_id);
      default:
        return null;
    }
  };
});
//时间展示
const handleTime = computed(() => {
  return (item) => {
    return dateFormater('MM/DD/HH:mm', item.time);
  };
});
//删除会话
const deleteConversation = async (eventItem) => {
  const { channel_id, chatType } = eventItem;
  try {
    const res = await uni.showModal({
      title: '确认删除?',
      confirmText: '删除',
    });
    if (res.confirm) {
      await removeConversationFromServer(channel_id, chatType);
      conversationStore.deleteConversation(channel_id);
    }
  } catch (error) {
    uni.showToast({
      title: '删除失败',
      icon: 'none',
      duration: 2000,
    });
    console.log('删除失败', error);
  }
};

/* 搜索会话相关逻辑 */
//开启搜索模式
const openSearch = () => {
  conversationState.search_btn = false;
  conversationState.search_chats = true;
  conversationState.gotop = true;
};
//执行搜索方法
const actionSearch = () => {
  const keyWord = conversationState.search_keyword;
  let resConversationList = [];
  if (keyWord) {
    resConversationList = conversationStore.conversationList.filter((item) => {
      if (item.chatType === CHAT_TYPE.SINGLE_CHAT || item.chatType === 'chat') {
        if (
          friendUserInfoMap.value.has(item.channel_id) &&
          friendUserInfoMap.value.get(item.channel_id)?.nickname
        ) {
          return (
            item.lastMessage.msg?.indexOf(keyWord) > -1 ||
            item.channel_id?.indexOf(keyWord) > -1 ||
            friendUserInfoMap.value
              .get(item.channel_id)
              .nickname?.indexOf(keyWord) > -1
          );
        } else {
          return (
            item.lastMessage.msg?.indexOf(keyWord) > -1 ||
            item.channel_id?.indexOf(keyWord) > -1
          );
        }
      }
      if (
        item.chatType === CHAT_TYPE.GROUP_CHAT ||
        item.chatType === 'groupchat'
      ) {
        return (
          item.channel_id.indexOf(keyWord) > -1 ||
          getGroupName(item.channel_id).indexOf(keyWord) > -1 ||
          item.lastMessage.msg.indexOf(keyWord) > -1
        );
      }
    });
  }
  console.log('>>>>>执行搜索', resConversationList);
  conversationState.conversationList = resConversationList;
};
//取消搜索
const cancelSearch = () => {
  conversationState.search_btn = true;
  conversationState.search_chats = false;
  conversationState.gotop = false;
  conversationState.conversationList = conversationList.value;
};
//清空搜索框
const clearSearchInput = () => {
  conversationState.search_keyword = '';
  conversationState.show_clear = false;
};
//输入框事件触发
const onInput = (e) => {
  let inputValue = e.detail.value;
  if (inputValue) {
    conversationState.show_clear = true;
  } else {
    cancelSearch();
  }
};
const close_mask = () => {
  conversationState.search_btn = true;
  conversationState.search_chats = false;
  conversationState.show_mask = false;
};

/* 获取窗口尺寸 */
const getWindowSize = () => {
  uni.getSystemInfo({
    success: (res) => {
      conversationState.winSize = {
        witdh: res.windowWidth,
        height: res.windowHeight,
      };
    },
  });
};
const hidePop = () => {
  conversationState.showPop = false;
};
const entryInform = () => {
  uni.navigateTo({
    url: '../notification/notification',
  });
};
const entryemChat = (params) => {
  console.log('params', params);
  //发送channelack 清除服务端该会话未读数,并且清除本地未读红点
  sendChannelAck(params.channel_id, params.chatType);
  conversationStore.clearConversationUnReadNum(params.channel_id);
  uni.navigateTo({
    url: `../emChatContainer/index?targetId=${params.channel_id}&chatType=${params.chatType}`,
  });
};
onLoad(() => {
  if (!conversationList.value.length) {
    fetchConversationList();
  }
});
onShow(() => {
  uni.hideHomeButton && uni.hideHomeButton();
});
</script>
<style>
@import './conversation.css';
</style>

五、重构后新增 Tabbar 组件代码片段展示

<template>
  <view :class="isIPX ? 'chatRoom_tab_X' : 'chatRoom_tab'">
    <view class="tableBar" @click="changeTab('conversation')">
      <view
        v-if="unReadSpotNum > 0 || unReadSpotNum == '99+'"
        :class="
          'em-unread-spot ' +
          (unReadSpotNum == '99+' ? 'em-unread-spot-litleFont' : '')
        "
        >{{ unReadSpotNum + unReadTotalNotNum }}</view
      >
      <image
        :class="unReadSpotNum > 0 || unReadSpotNum == '99+' ? 'haveSpot' : ''"
        :src="
          tabType === 'conversation'
            ? highlightConversationImg
            : conversationImg
        "
      ></image>
      <text :class="tabType === 'conversation' && 'activeText'">会话</text>
    </view>

    <view class="tableBar" @click="changeTab('contacts')">
      <image
        :src="tabType === 'contacts' ? highlightContactsImg : contactsImg"
      ></image>
      <text :class="tabType === 'contacts' && 'activeText'">联系人</text>
    </view>

    <view class="tableBar" @click="changeTab('me')">
      <image :src="tabType === 'me' ? highlightSettingImg : settingImg"></image>
      <text :class="tabType === 'me' && 'activeText'">我的</text>
    </view>
  </view>
</template>

<script setup>
import { ref, toRefs } from 'vue';
/* images */
const conversationImg = '/static/images/session2x.png';
const highlightConversationImg = '/static/images/sessionhighlight2x.png';
const contactsImg = '/static/images/comtacts2x.png';
const highlightContactsImg = '/static/images/comtactshighlight2x.png';
const settingImg = '/static/images/setting2x.png';
const highlightSettingImg = '/static/images/settinghighlight2x.png';
/* props */
const props = defineProps({
  tabType: {
    type: String,
    default: 'conversation',
    required: true,
  },
});
/* emits */
const emits = defineEmits(['switchHomeComponent']);
const { tabType } = toRefs(props);
const isIPX = ref(false);
const unReadSpotNum = ref(0);
const unReadTotalNotNum = ref(0);

const changeTab = (type) => {
  emits('switchHomeComponent', type);
};
</script>

<style scoped>
@import './index.css';
</style>

六、重构后新增 home 组件代码片段展示

没有使用 Vue 中的动态组件(component)实现是因为 uni-app 打包到某些平台不支持。
<template>
  <view>
    <template v-if="isActiveComps === 'conversation'">
      <Conversation />
    </template>
    <template v-if="isActiveComps === 'contacts'">
      <Contacts />
    </template>
    <template v-if="isActiveComps === 'me'">
      <Me />
    </template>
    <Tabbar
      :tab-type="isActiveComps"
      @switchHomeComponent="switchHomeComponent"
    />
  </view>
</template>

<script setup>
import { ref, watchEffect } from 'vue';
import { onLoad } from '@dcloudio/uni-app';
/* components */
import Tabbar from '@/layout/tabbar';
import Conversation from '@/pages/conversation/conversation.vue';
import Contacts from '@/pages/contacts/contacts.vue';
import Me from '@/pages/me/me.vue';
const isActiveComps = ref('conversation');

const switchHomeComponent = (type) => {
  isActiveComps.value = type;
};
/* 设置当前标题 */
const titleMap = {
  conversation: '会话列表',
  contacts: '联系人',
  me: '我的',
};
watchEffect(() => {
  uni.setNavigationBarTitle({
    title: titleMap[isActiveComps.value],
  });
});
onLoad((options) => {
  //通过路由传参的形式可指定该页面展示某个指定组件
  if (options.page) {
    switchHomeComponent(options.page);
  }
});
</script>

七、重构后新增 emChatContainer 组件代码展示

<template>
  <div>
    <em-chat />
  </div>
</template>

<script setup>
import { toRefs, reactive, provide, readonly, computed } from 'vue';
import EmChat from '@/components/emChat';
import { onNavigationBarButtonTap } from '@dcloudio/uni-app';
import { useContactsStore } from '@/stores/contacts';
import { useGroupStore } from '@/stores/group';
import { CHAT_TYPE } from '@/EaseIM/constant';
const props = defineProps({
  targetId: {
    type: String,
    value: '',
    required: true,
  },
  chatType: {
    type: String,
    value: '',
    required: true,
  },
});

const { targetId, chatType } = toRefs(reactive(props));
console.log(targetId, chatType);
provide('targetId', readonly(targetId));
provide('chatType', readonly(chatType));

/* 处理NavigationBarTitle展示 */
//群组名称
const groupStore = useGroupStore();
const getGroupName = (groupid) => {
  const joinedGroupList = groupStore.joinedGroupList;
  let groupName = '';
  if (joinedGroupList.length) {
    joinedGroupList.forEach((item) => {
      if (item.groupid === groupid) {
        console.log(item.groupname);
        return (groupName = item.groupname);
      }
    });
    return groupName;
  } else {
    return groupid;
  }
};
const contactsStore = useContactsStore();
//好友属性
const friendUserInfoMap = computed(() => {
  return contactsStore.friendUserInfoMap;
});
//会话列表名称
const getTheIdName = (chatType, targetId) => {
  switch (chatType) {
    case CHAT_TYPE.SINGLE_CHAT:
      const friendInfo = friendUserInfoMap.value.get(targetId);
      return friendInfo?.nickname || targetId;
    case CHAT_TYPE.GROUP_CHAT:
      return getGroupName(targetId);
    default:
      return null;
  }
};
uni.setNavigationBarTitle({
  title: getTheIdName(chatType.value, targetId.value),
});

onNavigationBarButtonTap(() => {
  uni.navigateTo({
    url: `/pages/moreMenu/moreMenu?username=${targetId.value}&type=${chatType.value}`,
  });
});
</script>

<style scoped>
@import './index.css';
</style>

八、重构后新增 EaseIM 文件部分代码片段

config(针对 IM 相关配置文件)

export const EM_API_URL = 'https://a1.easemob.com';
export const EM_WEB_SOCKET_URL = 'wss://im-api-wechat.easemob.com/websocket';
export const EM_APP_KEY = 'easemob#easeim';

constant(IM 相关常量)

export const CHAT_TYPE = {
  SINGLE_CHAT: 'singleChat',
  GROUP_CHAT: 'groupChat',
};
export const HANDLER_EVENT_NAME = {
  CONNECT_EVENT: 'connectEvent',
  MESSAGES_EVENT: 'messagesEvent',
  CONTACTS_EVENT: 'contactsEvent',
  GROUP_EVENT: 'groupEvent',
};

export const CONNECT_CALLBACK_TYPE = {
  CONNECT_CALLBACK: 'connected',
  DISCONNECT_CALLBACK: 'disconnected',
  RECONNECTING_CALLBACK: 'reconnecting',
};

export const MESSAGE_TYPE = {
  IMAGE: 'img',
  TEXT: 'txt',
  LOCATION: 'location',
  VIDEO: 'video',
  AUDIO: 'audio',
  EMOJI: 'emoji',
  FILE: 'file',
  CUSTOM: 'custom',
};

imApis(SDK 接口管理)

import { EMClient } from '../index';
const emContacts = () => {
  const fetchContactsListFromServer = () => {
    return new Promise((resolve, reject) => {
      EMClient.getContacts()
        .then((res) => {
          const { data } = res;
          resolve(data);
        })
        .catch((error) => {
          reject(error);
        });
    });
  };
  const removeContactFromServer = (contactId) => {
    if (contactId) {
      EMClient.deleteContact(contactId);
    }
  };
  const addContact = (contactId, applyMsg) => {
    if (contactId) {
      EMClient.addContact(contactId, applyMsg);
    }
  };
  const acceptContactInvite = (contactId) => {
    if (contactId) {
      EMClient.acceptContactInvite(contactId);
    }
  };
  const declineContactInvite = (contactId) => {
    if (contactId) {
      EMClient.declineContactInvite(contactId);
    }
  };
  return {
    fetchContactsListFromServer,
    removeContactFromServer,
    acceptContactInvite,
    declineContactInvite,
    addContact,
  };
};
export default emContacts;

listener(SDK 监听回调管理)

import { EMClient } from '../index';
import { CONNECT_CALLBACK_TYPE, HANDLER_EVENT_NAME } from '../constant';
export const emConnectListener = (callback, listenerEventName) => {
  console.log('>>>>连接监听已挂载');
  const connectListenFunc = {
    onConnected: () => {
      console.log('connected...');
      callback && callback(CONNECT_CALLBACK_TYPE.CONNECT_CALLBACK);
    },
    onDisconnected: () => {
      callback && callback(CONNECT_CALLBACK_TYPE.DISCONNECT_CALLBACK);
      console.log('disconnected...');
    },
    onReconnecting: () => {
      callback && callback(CONNECT_CALLBACK_TYPE.RECONNECTING_CALLBACK);
    },
  };
  EMClient.removeEventHandler(
    listenerEventName || HANDLER_EVENT_NAME.CONNECT_EVENT
  );
  EMClient.addEventHandler(
    listenerEventName || HANDLER_EVENT_NAME.CONNECT_EVENT,
    connectListenFunc
  );
};

utils(IM 相关工具函数文件)

/* 用以获取消息存储格式时的key */
const getEMKey = (loginId, fromId, toId, chatType) => {
  let key = '';
  if (chatType === 'singleChat') {
    if (loginId === fromId) {
      key = toId;
    } else {
      key = fromId;
    }
  } else if (chatType === 'groupChat') {
    key = toId;
  }
  return key;
};
export default getEMKey;

index.js(引入 SDK 并初始化 SDK 并导出)

import EaseSDK from 'easemob-websdk/uniApp/Easemob-chat';
import { EM_APP_KEY, EM_API_URL, EM_WEB_SOCKET_URL } from './config';
let EMClient = (uni.EMClient = {});
EMClient = new EaseSDK.connection({
  appKey: EM_APP_KEY,
  apiUrl: EM_API_URL,
  url: EM_WEB_SOCKET_URL,
});
uni.EMClient = EMClient;
export { EaseSDK, EMClient };

九、重构前后发送消息代码片段展示

重构前发送文本消息组件

<template>
  <!-- <chat-suit-emoji id="chat-suit-emoji" bind:newEmojiStr="emojiAction"></chat-suit-emoji> -->
  <form class="text-input">
    <view :class="mainState.isIPX ? 'f-row-x' : 'f-row'">
      <!-- 发送语音 -->
      <view>
        <image
          class="icon-mic"
          src="/static/images/voice.png"
          @tap="openRecordModal"
        ></image>
      </view>
      <!-- 输入框 -->
      <textarea
        class="f news"
        type="text"
        cursor-spacing="65"
        confirm-type="done"
        v-model.trim="mainState.inputMessage"
        @confirm="sendMessage"
        @input="bindMessage"
        @tap="focus"
        @focus="focus"
        @blur="blur"
        :confirm-hold="mainState.isIPX ? true : false"
        auto-height
        :show-confirm-bar="false"
        maxlength="300"
      />
      <view>
        <image
          class="icon-mic"
          src="/static/images/Emoji.png"
          @tap="openEmoji"
        ></image>
      </view>
      <view v-show="!mainState.inputMessage" @tap="openFunModal">
        <image class="icon-mic" src="/static/images/ad.png"></image>
      </view>
      <button
        class="send-btn-style"
        hover-class="hover"
        @tap="sendMessage"
        v-show="mainState.inputMessage"
      >
        发送
      </button>
    </view>
  </form>
</template>

<script setup>
import { reactive, toRefs } from 'vue';
import msgType from '@/components/chat/msgtype';
import msgStorage from '@/components/chat/msgstorage';
import disp from '@/utils/broadcast';
const WebIM = uni.WebIM;
/* props */
const props = defineProps({
  chatParams: {
    type: Object,
    default: () => ({}),
  },
  chatType: {
    type: String,
    default: msgType.chatType.SINGLE_CHAT,
  },
});
const { chatParams, chatType } = toRefs(props);
/* emits */
const $emits = defineEmits([
  'inputFocused',
  'inputBlured',
  'closeFunModal',
  'closeFunModal',
  'openEmoji',
  'openRecordModal',
  'openFunModal',
]);
const mainState = reactive({
  inputMessage: '',
  // render input 的值
  userMessage: '', // input 的实时值
  isIPX: false,
});

mainState.isIPX = getApp().globalData.isIPX;
const focus = () => {
  $emits('inputFocused', null, {
    bubbles: true,
  });
};
const blur = () => {
  $emits('inputBlured', null, {
    bubbles: true,
  });
};
const isGroupChat = () => {
  return chatType.value == msgType.chatType.CHAT_ROOM;
};

const getSendToParam = () => {
  console.log('chatParmas', chatParams);
  return isGroupChat() ? chatParams.value.groupId : chatParams.value.your;
};

const bindMessage = (e) => {
  mainState.userMessage = e.detail.value;
};
const emojiAction = (emoji) => {
  let str;
  let msglen = mainState.userMessage.length - 1;

  if (emoji && emoji != '[del]') {
    str = mainState.userMessage + emoji;
  } else if (emoji == '[del]') {
    let start = mainState.userMessage.lastIndexOf('[');
    let end = mainState.userMessage.lastIndexOf(']');
    let len = end - start;

    if (end != -1 && end == msglen && len >= 3 && len <= 4) {
      str = mainState.userMessage.slice(0, start);
    } else {
      str = mainState.userMessage.slice(0, msglen);
    }
  }
  mainState.userMessage = str;
  mainState.inputMessage = str;
};
const sendMessage = () => {
  if (mainState.userMessage.match(/^\s*$/)) return;
  let id = WebIM.conn.getUniqueId();
  let msg = new WebIM.message(msgType.TEXT, id);
  msg.set({
    msg: mainState.userMessage,
    from: WebIM.conn.user,
    to: getSendToParam(),
    // roomType: false,
    chatType: isGroupChat()
      ? msgType.chatType.GROUP_CHAT
      : msgType.chatType.SINGLE_CHAT,
    success(id, serverMsgId) {
      console.log('成功了');
      // 关闭表情弹窗
      $emits.cancelEmoji && $emits.cancelEmoji();
      $emits.closeFunModal && $emits.closeFunModal();
      disp.fire('em.chat.sendSuccess', id, mainState.userMessage);
    },
    fail(id, serverMsgId) {
      console.log('失败了');
    },
  });

  WebIM.conn.send(msg.body);
  let obj = {
    msg: msg,
    type: msgType.TEXT,
  };
  saveSendMsg(obj);
  mainState.userMessage = '';
  mainState.inputMessage = '';
  uni.hideKeyboard();
};
const saveSendMsg = (evt) => {
  msgStorage.saveMsg(evt.msg, evt.type);
};
const openEmoji = () => {
  $emits('openEmoji');
};
const openRecordModal = () => {
  $emits('openRecordModal');
};
const openFunModal = () => {
  $emits('openFunModal');
};
defineExpose({
  emojiAction,
});
</script>
<style>
@import './main.css';
</style>

重构后发送文本消息组件

<template>
  <form class="text-input">
    <view class="f-row">
      <!-- 发送语音 -->
      <view @click="emits('toggleRecordModal')">
        <image class="icon-mic" src="/static/images/voice.png"></image>
      </view>
      <!-- 输入框 -->
      <textarea
        class="f news"
        type="text"
        cursor-spacing="65"
        confirm-type="send"
        v-model.trim="inputContent"
        @focus="inputFocus"
        @confirm="sendTextMessage"
        :confirm-hold="true"
        auto-height
        :show-confirm-bar="false"
        maxlength="300"
      />
      <view @click="emits('openEmojiModal')">
        <image class="icon-mic" src="/static/images/Emoji.png"></image>
      </view>
      <view v-show="!inputContent" @click="emits('openFunModal')">
        <image class="icon-mic" src="/static/images/ad.png"></image>
      </view>
      <button
        class="send-btn-style"
        hover-class="hover"
        @tap="sendTextMessage"
        v-show="inputContent"
      >
        发送
      </button>
    </view>
  </form>
</template>

<script setup>
import { ref, inject } from 'vue';
import { emMessages } from '@/EaseIM/imApis';
/* emits */
const emits = defineEmits([
  'toggleRecordModal',
  'openEmojiModal',
  'openFunModal',
  'closeAllModal',
]);
const inputContent = ref('');
//删除输入内容中的emojiMapStr
const delEmojiMapString = () => {
  if (!inputContent.value) return;
  let newInputContent = '';
  let inputContentlength = inputContent.value.length - 1;

  let start = inputContent.value.lastIndexOf('[');
  let end = inputContent.value.lastIndexOf(']');
  let len = end - start;

  if (end != -1 && end == inputContentlength && len >= 3 && len <= 4) {
    newInputContent = inputContent.value.slice(0, start);
  } else {
    newInputContent = inputContent.value.slice(0, inputContentlength);
  }
  inputContent.value = newInputContent;
};
//发送文本消息
const { sendDisplayMessages } = emMessages();
const injectTargetId = inject('targetId');
const injeactChatType = inject('chatType');
const sendTextMessage = async () => {
  const params = {
    // 消息类型。
    type: 'txt',
    // 消息内容。
    msg: inputContent.value,
    // 消息接收方:单聊为对方用户 ID,群聊和聊天室分别为群组 ID 和聊天室 ID。
    to: injectTargetId.value,
    // 会话类型:单聊、群聊和聊天室分别为 `singleChat`、`groupChat` 和 `chatRoom`。
    chatType: injeactChatType.value,
  };
  try {
    const res = await sendDisplayMessages({ ...params });
    emits('closeAllModal');
    console.log('>>>>>文本消息发送成功', res);
  } catch (error) {
    console.log('>>>>>文本消息发送失败', error);
    uni.showToast({
      title: '消息发送失败',
      icon: 'none',
    });
  } finally {
    inputContent.value = '';
    uni.hideKeyboard();
  }
};
const inputFocus = () => {
  console.log('>>>>输入框聚焦');
  emits('closeAllModal');
};
defineExpose({
  inputContent,
  delEmojiMapString,
});
</script>
<style>
@import './index.css';
</style>

十、重构前后消息列表(messageList)代码展示

重构前消息列表代码

<template>
  <view
    scroll-y="true"
    :class="
      msglistState.view + ' wrap ' + (msglistState.isIPX ? 'scroll_view_X' : '')
    "
    @tap="onTap"
    upper-threshold="-50"
    :scroll-into-view="msglistState.toView"
  >
    <view>
      <!-- 弹出举报入口 -->
      <uni-popup ref="alertReport">
        <button @click="showSelectReportType">举报</button>
        <button @click="cannelReport">取消</button>
      </uni-popup>
      <!-- 展示举报选项 -->
      <uni-popup ref="selectReportType">
        <button
          v-for="(item, index) in msglistState.typeList"
          :key="index"
          @click="pickReportType(item)"
        >
          {{ item.text }}
        </button>
        <button type="warn" @click="hideSelectReportType">取消</button>
      </uni-popup>
      <!-- 填写举报原因 -->
      <uni-popup ref="inputReportReason" type="dialog">
        <uni-popup-dialog
          mode="input"
          title="举报原因"
          placeholder="请输入举报原因"
          @confirm="reportMsg"
          @cancel="msglistState.reason = ''"
        >
          <uni-easyinput
            type="textarea"
            v-model="msglistState.reason"
            placeholder="请填写举报内容"
            :maxlength="300"
          ></uni-easyinput>
        </uni-popup-dialog>
      </uni-popup>
    </view>
    <view class="tips"
      >本应用仅用于环信产品功能开发测试,请勿用于非法用途。任何涉及转账、汇款、裸聊、网恋、网购退款、投资理财等统统都是诈骗,请勿相信!</view
    >
    <view
      @longtap="actionAleartReportMsg(item)"
      class="message"
      v-for="item in msglistState.chatMsg"
      :key="item.mid"
      :id="item.mid"
    >
      <!-- <view class="time">
                <text class="time-text">{{ item.time }}</text>
      </view>-->
      <view class="main" :class="item.style">
        <view class="user">
          <!-- yourname:就是消息的 from -->
          <text v-if="!item.style" class="user-text">{{
            showMessageListNickname(item.yourname) + ' ' + handleTime(item)
          }}</text>
        </view>
        <image class="avatar" :src="showMessageListAvatar(item)" />
        <view class="msg">
          <image
            class="err"
            :class="item.style == 'self' && item.isFail ? 'show' : 'hide'"
            src="/static/images/msgerr.png"
          />

          <image
            v-if="item.style == 'self'"
            src="/static/images/poprightarrow2x.png"
            class="msg_poprightarrow"
          />
          <image
            v-if="item.style == ''"
            src="/static/images/popleftarrow2x.png"
            class="msg_popleftarrow"
          />
          <view
            v-if="
              item.msg.type == msgtype.IMAGE || item.msg.type == msgtype.VIDEO
            "
          >
            <image
              v-if="item.msg.type == msgtype.IMAGE"
              class="avatar"
              :src="item.msg.data"
              style="width: 90px; height: 120px; margin: 2px auto"
              mode="aspectFit"
              @tap="previewImage"
              :data-url="item.msg.data"
            />
            <video
              v-if="item.msg.type == msgtype.VIDEO"
              :src="item.msg.data"
              controls
              style="width: 300rpx"
            />
          </view>
          <audio-msg
            v-if="item.msg.type == msgtype.AUDIO"
            :msg="item"
          ></audio-msg>
          <file-msg v-if="item.msg.type == msgtype.FILE" :msg="item"></file-msg>
          <view
            v-else-if="
              item.msg.type == msgtype.TEXT || item.msg.type == msgtype.EMOJI
            "
          >
            <view
              class="template"
              v-for="(d_item, d_index) in item.msg.data"
              :key="d_index"
            >
              <text
                :data-msg="item"
                v-if="d_item.type == msgtype.TEXT"
                class="msg-text"
                style="float: left"
                selectable="true"
                >{{ d_item.data }}</text
              >

              <image
                v-if="d_item.type == msgtype.EMOJI"
                class="avatar"
                :src="'/static/images/faces/' + d_item.data"
                style="
                  width: 25px;
                  height: 25px;
                  margin: 0 0 2px 0;
                  float: left;
                "
              />
            </view>
          </view>
          <!-- 个人名片 -->
          <view
            v-else-if="
              item.msg.type == msgtype.CUSTOM && item.customEvent === 'userCard'
            "
            @click="to_profile_page(item.msg.data)"
          >
            <view class="usercard_mian">
              <image
                :src="
                  item.msg.data.avatarurl ||
                  item.msg.data.avatar ||
                  defaultAvatar
                "
              />
              <text class="name">{{
                item.msg.data.nickname || item.msg.data.uid
              }}</text>
            </view>
            <!-- <u-divider :use-slot="false" /> -->
            <text>[个人名片]</text>
          </view>
        </view>
      </view>
    </view>
  </view>
  <!-- <view style="height: 1px;"></view> -->
</template>

<script setup>
import { reactive, ref, computed, onMounted, onUnmounted } from 'vue';
import msgStorage from '../msgstorage';
// let msgStorage = require("../msgstorage");
import disp from '@/utils/broadcast';
import dateFormater from '@/utils/dateFormater';
// let disp = require('../../../utils/broadcast');
import msgtype from '@/components/chat/msgtype';
import audioMsg from './type/audio/audio';
import fileMsg from './type/file';
let LIST_STATUS = {
  SHORT: 'scroll_view_change',
  NORMAL: 'scroll_view',
};
let page = 0;
let Index = 0;
let curMsgMid = '';
let isFail = false;

const WebIM = uni.WebIM;
/* props */
const props = defineProps({
  chatParams: {
    type: Object,
    default: () => ({}),
    required: true,
  },
});
const { chatParams } = props;
console.log('msglist', chatParams);
/* emits */
const $emit = defineEmits(['msglistTap']);
const msglistState = reactive({
  view: LIST_STATUS.NORMAL,
  toView: '',
  chatMsg: [],
  __visibility__: false,
  isIPX: false,
  title: '消息举报',
  list: [
    {
      text: '举报',
    },
  ],
  rptMsgId: '', // 举报消息id
  rptType: '', // 举报类型
  reason: '',
  typeList: [
    {
      text: '涉政',
    },
    {
      text: '涉黄',
    },
    {
      text: '广告',
    },
    {
      text: '辱骂',
    },
    {
      text: '暴恐',
    },
    {
      text: '违禁',
    },
  ],
  defaultAvatar: '/static/images/theme2x.png',
  defaultGroupAvatar: '/static/images/groupTheme.png',
  usernameObj: null,
});
//做初始参数设置
msglistState.__visibility__ = true;
page = 0;
Index = 0;

onUnmounted(() => {
  msglistState.__visibility__ = false;
  msgStorage.off('newChatMsg', dispMsg);
});

onMounted(() => {
  if (getApp().globalData.isIPX) {
    msglistState.isIPX = true;
  }
  //根据原有uni demo 处理似乎支付宝小程序有参数传递问题,因此针对该平台单独取传递的参数
  if (uni.getSystemInfoSync().uniPlatform === 'mp-alipay') {
    msglistState.usernameObj = Object.assign({}, uni.username);
  } else {
    msglistState.usernameObj = Object.assign({}, chatParams);
  }
  const usernameObj = msglistState.usernameObj;
  console.log('usernameObj', usernameObj);
  let myUsername = uni.getStorageSync('myUsername');
  let sessionKey = usernameObj.groupId
    ? usernameObj.groupId + myUsername
    : usernameObj.your + myUsername;
  let chatMsg = uni.getStorageSync(sessionKey) || [];
  renderMsg(null, null, chatMsg, sessionKey);
  uni.setStorageSync(sessionKey, null);
  disp.on('em.error.sendMsgErr', function (err) {
    // curMsgMid = err.data.mid;
    isFail = true;
    // return;
    console.log('发送失败了');
    return;
    let msgList = me.chatMsg;
    msgList.map((item) => {
      if (
        item.mid.substring(item.mid.length - 10) ==
        curMsgMid.substring(curMsgMid.length - 10)
      ) {
        item.msg.data[0].isFail = true;
        item.isFail = true;
        me.setData({
          chatMsg: msgList,
        });
      }
    });
    uni.setStorageSync('rendered_' + sessionKey, msgList);
  });
  msgStorage.on('newChatMsg', dispMsg);
});
/* computed */
//消息列表头像展示
const showMessageListAvatar = computed(() => {
  const friendUserInfoMap = getApp().globalData.friendUserInfoMap;
  const myUserInfos = getApp().globalData.userInfoFromServer;
  return (item) => {
    if (!item.style) {
      if (
        friendUserInfoMap.has(item.username) &&
        friendUserInfoMap.get(item.username)?.avatarurl
      ) {
        return friendUserInfoMap.get(item.username).avatarurl;
      } else {
        return msglistState.defaultAvatar;
      }
    } else {
      if (myUserInfos?.avatarurl) {
        return myUserInfos.avatarurl;
      } else {
        return msglistState.defaultAvatar;
      }
    }
  };
});
//消息列表昵称显示
const showMessageListNickname = computed(() => {
  const friendUserInfoMap = getApp().globalData.friendUserInfoMap;
  return (hxId) => {
    if (friendUserInfoMap.has(hxId) && friendUserInfoMap.get(hxId)?.nickname) {
      return friendUserInfoMap.get(hxId).nickname;
    } else {
      return hxId;
    }
  };
});
//处理时间显示
const handleTime = computed(() => {
  return (item) => {
    return dateFormater('MM/DD/HH:mm', item.time);
  };
});

const normalScroll = () => {
  msglistState.view = LIST_STATUS.NORMAL;
};
//TODO 待优化
//此处用到了发布订阅默认去订阅,msgstorage 文件中 发布的newChatMsg 事件从而取到了存储后的消息list
let curChatMsgList = null;
const dispMsg = (renderableMsg, type, curChatMsg, sesskey) => {
  const usernameObj = msglistState.usernameObj;
  let myUsername = uni.getStorageSync('myUsername');
  let sessionKey = usernameObj.groupId
    ? usernameObj.groupId + myUsername
    : usernameObj.your + myUsername;
  curChatMsgList = curChatMsg;

  if (!msglistState.__visibility__) return; // 判断是否属于当前会话

  if (usernameObj.groupId) {
    // 群消息的 to 是 id,from 是 name
    if (
      renderableMsg.info.from == usernameObj.groupId ||
      renderableMsg.info.to == usernameObj.groupId
    ) {
      if (sesskey == sessionKey) {
        renderMsg(renderableMsg, type, curChatMsg, sessionKey, 'newMsg');
      }
    }
  } else if (
    renderableMsg.info.from == usernameObj.your ||
    renderableMsg.info.to == usernameObj.your
  ) {
    if (sesskey == sessionKey) {
      renderMsg(renderableMsg, type, curChatMsg, sessionKey, 'newMsg');
    }
  }
};
//消息渲染方法
const renderMsg = (renderableMsg, type, curChatMsg, sessionKey, isnew) => {
  console.log('curChatMsg, sessionKey, isnew', curChatMsg, sessionKey, isnew);
  let historyChatMsgs = uni.getStorageSync('rendered_' + sessionKey) || [];
  historyChatMsgs = historyChatMsgs.concat(curChatMsg);
  if (!historyChatMsgs.length) return;
  if (isnew == 'newMsg') {
    msglistState.chatMsg = msglistState.chatMsg.concat(curChatMsg);
    msglistState.toView = historyChatMsgs[historyChatMsgs.length - 1].mid;
  } else {
    msglistState.chatMsg = historyChatMsgs.slice(-10);
    msglistState.toView = historyChatMsgs[historyChatMsgs.length - 1].mid;
  }

  uni.setStorageSync('rendered_' + sessionKey, historyChatMsgs);
  let chatMsg = uni.getStorageSync(sessionKey) || [];
  chatMsg.map(function (item, index) {
    curChatMsg.map(function (item2, index2) {
      if (item2.mid == item.mid) {
        chatMsg.splice(index, 1);
      }
    });
  });
  uni.setStorageSync(sessionKey, chatMsg);
  Index = historyChatMsgs.slice(-10).length;
  // setTimeout 兼容支付宝小程序
  setTimeout(() => {
    uni.pageScrollTo({
      scrollTop: 5000,
      duration: 100,
      fail: (e) => {
        //console.log('滚失败了', e)
      },
    });
  }, 100);

  if (isFail) {
    renderFail(sessionKey);
  }
};
const renderFail = (sessionKey) => {
  let msgList = msglistState.chatMsg;
  msgList.map((item) => {
    if (
      item.mid.substring(item.mid.length - 10) ==
      curMsgMid.substring(curMsgMid.length - 10)
    ) {
      item.msg.data[0].isFail = true;
      item.isFail = true;
      msglistState.chatMsg = msgList;
    }
  });

  if (curChatMsgList[0].mid == curMsgMid) {
    curChatMsgList[0].msg.data[0].isShow = false;
    curChatMsgList[0].isShow = false;
  }

  uni.setStorageSync('rendered_' + sessionKey, msgList);
  isFail = false;
};
const onTap = () => {
  $emit('msglistTap', null, {
    bubbles: true,
  });
};

const shortScroll = () => {
  msglistState.view = LIST_STATUS.SHORT;
};

const previewImage = (event) => {
  var url = event.target.dataset.url;
  uni.previewImage({
    urls: [url], // 需要预览的图片 http 链接列表
  });
};
const getHistoryMsg = () => {
  let usernameObj = msglistState.usernameObj;
  let myUsername = uni.getStorageSync('myUsername');
  let sessionKey = usernameObj.groupId
    ? usernameObj.groupId + myUsername
    : usernameObj.your + myUsername;
  let historyChatMsgs = uni.getStorageSync('rendered_' + sessionKey) || [];
  if (Index < historyChatMsgs.length) {
    let timesMsgList = historyChatMsgs.slice(-Index - 10, -Index);
    msglistState.chatMsg = timesMsgList.concat(msglistState.chatMsg);
    msglistState.toView = timesMsgList[timesMsgList.length - 1].mid;
    Index += timesMsgList.length;
    if (timesMsgList.length == 10) {
      page++;
    }
    uni.stopPullDownRefresh();
  }
};
const to_profile_page = (userInfo) => {
  if (userInfo) {
    uni.navigateTo({
      url: `../profile/profile?otherProfile=${JSON.stringify(userInfo)}`,
    });
  }
};

/* 举报消息 */
//弹出举报
const alertReport = ref(null);
const actionAleartReportMsg = (item) => {
  if (item.style !== 'self') {
    alertReport.value.open('bottom');
    msglistState.showRpt = true;
    msglistState.rptMsgId = item.mid;
  }
};
//取消举报
const cannelReport = () => {
  alertReport.value.close();
};

//选择举报类型
const selectReportType = ref(null);
//展示举报类型面板
const showSelectReportType = () => {
  alertReport.value.close();
  selectReportType.value.open('bottom');
};
const pickReportType = (item) => {
  msglistState.rptType = item.text;
  hideSelectReportType();
  actionAleartReportReason(item);
};
const hideSelectReportType = () => {
  selectReportType.value.close();
};
//填写举报原因
const inputReportReason = ref(null);
const actionAleartReportReason = (item) => {
  console.log('>>>>>>输入举报内容', item);
  inputReportReason.value.open();
};
const reportMsg = () => {
  if (msglistState.reason === '') {
    uni.showToast({ title: '请填写举报原因', icon: 'none' });
    return;
  }
  WebIM.conn
    .reportMessage({
      reportType: msglistState.rptType, // 举报类型
      reportReason: msglistState.reason, // 举报原因。
      messageId: msglistState.rptMsgId, // 上报消息id
    })
    .then(() => {
      uni.showToast({ title: '举报成功', icon: 'none' });
    })
    .catch((e) => {
      console.log('>>>>举报失败', e);
      uni.showToast({ title: '举报失败', icon: 'none' });
    })
    .finally(() => {
      msglistState.reason = '';
      msglistState.rptType = '';
      msglistState.rptMsgId = '';
    });
};
defineExpose({
  normalScroll,
  getHistoryMsg,
  shortScroll,
});
</script>
<style>
@import './msglist.css';
</style>

重构前消息列表代码

<template>
  <view
    scroll-y="true"
    :class="
      msglistState.view + ' wrap ' + (msglistState.isIPX ? 'scroll_view_X' : '')
    "
    upper-threshold="-50"
    :scroll-into-view="msglistState.toView"
  >
    <view>
      <!-- 弹出举报入口 -->
      <uni-popup ref="alertReport">
        <button @click="showSelectReportType">举报</button>
        <button @click="cannelReport">取消</button>
      </uni-popup>
      <!-- 展示举报选项 -->
      <uni-popup ref="selectReportType">
        <button
          v-for="(item, index) in msglistState.typeList"
          :key="index"
          @click="pickReportType(item)"
        >
          {{ item.text }}
        </button>
        <button type="warn" @click="hideSelectReportType">取消</button>
      </uni-popup>
      <!-- 填写举报原因 -->
      <uni-popup ref="inputReportReason" type="dialog">
        <uni-popup-dialog
          mode="input"
          title="举报原因"
          placeholder="请输入举报原因"
          @confirm="reportMsg"
          @cancel="msglistState.reason = ''"
        >
          <uni-easyinput
            type="textarea"
            v-model="msglistState.reason"
            placeholder="请填写举报内容"
            :maxlength="300"
          ></uni-easyinput>
        </uni-popup-dialog>
      </uni-popup>
    </view>
    <view class="tips"
      >本应用仅用于环信产品功能开发测试,请勿用于非法用途。任何涉及转账、汇款、裸聊、网恋、网购退款、投资理财等统统都是诈骗,请勿相信!</view
    >
    <view
      @longtap="actionAleartReportMsg(msgBody)"
      class="message"
      v-for="(msgBody, index) in messageList"
      :key="msgBody.id + index + ''"
      :id="msgBody.id"
    >
      <!-- 消息体 -->
      <view class="main" :class="isSelf(msgBody) ? 'self' : ''">
        <view class="user">
          <!-- yourname:就是消息的 from -->
          <text v-if="!isSelf(msgBody)" class="user-text">{{
            showMessageListNickname(msgBody.from) + ' ' + handleTime(msgBody)
          }}</text>
        </view>
        <image class="avatar" :src="showMessageListAvatar(msgBody)" />
        <view class="msg">
          <image
            v-if="isSelf(msgBody)"
            src="/static/images/poprightarrow2x.png"
            class="msg_poprightarrow"
          />
          <image
            v-if="!isSelf(msgBody)"
            src="/static/images/popleftarrow2x.png"
            class="msg_popleftarrow"
          />
          <!-- 文本类型消息 -->
          <view v-if="msgBody.type === MESSAGE_TYPE.TEXT">
            <view
              class="template"
              v-for="(d_item, d_index) in parseMsgEmoji(msgBody.msg)"
              :key="d_index"
            >
              <text
                :data-msg="msgBody"
                v-if="d_item.type == MESSAGE_TYPE.TEXT"
                class="msg-text"
                style="float: left"
                selectable="true"
                >{{ d_item.data }}</text
              >

              <image
                v-if="d_item.type == MESSAGE_TYPE.EMOJI"
                class="avatar"
                :src="'/static/images/faces/' + d_item.data"
                style="
                  width: 25px;
                  height: 25px;
                  margin: 0 0 2px 0;
                  float: left;
                "
              />
            </view>
          </view>
          <!-- 文件类型消息 -->
          <file-msg
            v-if="msgBody.type === MESSAGE_TYPE.FILE"
            :msg="msgBody"
          ></file-msg>
          <!-- 语音片段类型消息 -->
          <audio-msg
            v-if="msgBody.type === MESSAGE_TYPE.AUDIO"
            :msg="msgBody"
          ></audio-msg>
          <!-- 图片以及视频类型消息 -->
          <view
            v-if="
              msgBody.type == MESSAGE_TYPE.IMAGE ||
              msgBody.type == MESSAGE_TYPE.VIDEO
            "
          >
            <image
              v-if="msgBody.type == MESSAGE_TYPE.IMAGE"
              class="avatar"
              :src="msgBody.url"
              style="width: 90px; height: 120px; margin: 2px auto"
              mode="aspectFit"
              @tap="previewImage(msgBody.url)"
            />
            <video
              v-if="msgBody.type == MESSAGE_TYPE.VIDEO"
              :src="msgBody.url"
              controls
              style="width: 300rpx"
            />
          </view>
          <!-- 自定义类型消息 -->
          <view
            v-if="
              msgBody.type == MESSAGE_TYPE.CUSTOM &&
              msgBody.customEvent === 'userCard'
            "
            @click="entryProfilePage(msgBody.customExts)"
          >
            <view class="usercard_mian">
              <image
                :src="
                  msgBody.customExts.avatarurl ||
                  msgBody.customExts.avatar ||
                  msglistState.defaultAvatar
                "
              />
              <text class="name">{{
                msgBody.customExts.nickname || msgBody.customExts.uid
              }}</text>
            </view>
            <!-- <u-divider :use-slot="false" /> -->
            <text>[个人名片]</text>
          </view>
        </view>
      </view>
    </view>
  </view>
</template>

<script setup>
import {
  ref,
  reactive,
  computed,
  watch,
  onMounted,
  inject,
  nextTick,
} from 'vue';
import { onPullDownRefresh, onNavigationBarButtonTap } from '@dcloudio/uni-app';
/* EaseIM */
import parseEmoji from '@/EaseIM/utils/paseEmoji';
import { CHAT_TYPE, MESSAGE_TYPE } from '@/EaseIM/constant';
/* stores */
import { useLoginStore } from '@/stores/login';
import { useMessageStore } from '@/stores/message';
import { useContactsStore } from '@/stores/contacts';
/* utils */
import dateFormater from '@/utils/dateFormater';
/* im apis */
import { emMessages } from '@/EaseIM/imApis';
/* components */
import FileMsg from './type/file';
import AudioMsg from './type/audio/audio';
const msglistState = reactive({
  isIPX: false,
  toView: 0,
  //漫游当前游标
  view: 'wrap',
  title: '消息举报',
  list: [
    {
      text: '举报',
    },
  ],
  rptMsgId: '', // 举报消息id
  rptType: '', // 举报类型
  reason: '',
  typeList: [
    {
      text: '涉政',
    },
    {
      text: '涉黄',
    },
    {
      text: '广告',
    },
    {
      text: '辱骂',
    },
    {
      text: '暴恐',
    },
    {
      text: '违禁',
    },
  ],
  defaultAvatar: '/static/images/theme2x.png',
  defaultGroupAvatar: '/static/images/groupTheme.png',
});
const injectTargetId = inject('targetId');
const injectChatType = inject('chatType');
/* 消息相关逻辑处理 */
const { reportMessages, fetchHistoryMessagesFromServer } = emMessages();
//该用户当前的聊天记录
const messageStore = useMessageStore();
const messageList = computed(() => {
  return (
    messageStore.messageCollection[injectTargetId.value] ||
    getMoreHistoryMessages() ||
    []
  );
});
//获取更多历史消息
const getMoreHistoryMessages = async () => {
  const sourceMessage =
    messageStore.messageCollection[injectTargetId.value] || [];
  const cursorMsgId = (sourceMessage.length && sourceMessage[0]?.id) || -1;
  const params = {
    targetId: injectTargetId.value,
    chatType: injectChatType.value,
    cursor: cursorMsgId,
  };
  try {
    let res = await fetchHistoryMessagesFromServer(params);
    if (res.messages.length) {
      messageStore.fetchHistoryPushToMsgCollection(
        injectTargetId.value,
        res.messages.reverse()
      );
    } else {
      uni.showToast({ title: '暂无更多历史记录', icon: 'none' });
    }
    uni.stopPullDownRefresh();
  } catch (error) {
    uni.stopPullDownRefresh();
    uni.showToast('历史消息获取失败...');
    console.log('>>>>>返回失败', error);
  }
};
onMounted(() => {
  nextTick(() => {
    uni.pageScrollTo({
      scrollTop: 100000,
      duration: 50,
    });
  });
});
//监听消息内容改变,滚动列表
watch(
  messageList,
  () => {
    nextTick(() => {
      uni.pageScrollTo({
        scrollTop: 100000,
        duration: 100,
      });
    });
  },
  {
    deep: true,
  }
);
//消息列表头像展示
const loginStore = useLoginStore();
const contactsStore = useContactsStore();
//登录用户属性
const myUserInfos = computed(() => {
  return loginStore.loginUserProfiles;
});
//好友属性
const friendUserInfoMap = computed(() => {
  return contactsStore.friendUserInfoMap;
});
//判消息来源是否为自己
const isSelf = computed(() => {
  return (item) => {
    return item.from === loginStore.loginUserBaseInfos.loginUserId;
  };
});

const showMessageListAvatar = computed(() => {
  const friendMap = friendUserInfoMap.value;
  return (item) => {
    if (item.from !== loginStore.loginUserBaseInfos.loginUserId) {
      return friendMap.get(item.from)?.avatarurl || msglistState.defaultAvatar;
    } else {
      return myUserInfos.value?.avatarurl || msglistState.defaultAvatar;
    }
  };
});
//消息列表昵称显示
const showMessageListNickname = computed(() => {
  const friendMap = friendUserInfoMap.value;
  return (hxId) => {
    return friendMap.get(hxId)?.nickname || hxId;
  };
});
//处理时间显示
const handleTime = computed(() => {
  return (item) => {
    return dateFormater('MM/DD/HH:mm', item.time);
  };
});
//解析表情图片
const parseMsgEmoji = computed(() => {
  return (content) => {
    return parseEmoji(content);
  };
});

//预览图片方法
const previewImage = (url) => {
  uni.previewImage({
    urls: [url], // 需要预览的图片 http 链接列表
  });
};
//点击查看个人名片
const entryProfilePage = (userInfo) => {
  if (userInfo) {
    uni.navigateTo({
      url: `../profile/profile?otherProfile=${JSON.stringify(userInfo)}`,
    });
  }
};

/* 举报消息 */
//弹出举报
const alertReport = ref(null);
const actionAleartReportMsg = (item) => {
  if (item.style !== 'self') {
    alertReport.value.open('bottom');
    msglistState.showRpt = true;
    msglistState.rptMsgId = item.id;
  }
};
//取消举报
const cannelReport = () => {
  alertReport.value.close();
};

//选择举报类型
const selectReportType = ref(null);
//展示举报类型面板
const showSelectReportType = () => {
  alertReport.value.close();
  selectReportType.value.open('bottom');
};
const pickReportType = (item) => {
  msglistState.rptType = item.text;
  hideSelectReportType();
  actionAleartReportReason(item);
};
const hideSelectReportType = () => {
  selectReportType.value.close();
};
//填写举报原因
const inputReportReason = ref(null);
const actionAleartReportReason = (item) => {
  inputReportReason.value.open();
};

const reportMsg = async () => {
  if (msglistState.reason === '') {
    uni.showToast({ title: '请填写举报原因', icon: 'none' });
    return;
  }
  const reportParams = {
    reportType: msglistState.rptType,
    reportReason: msglistState.reason,
    messageId: msglistState.rptMsgId,
  };
  try {
    await reportMessages({ ...reportParams });
    uni.showToast({ title: '举报成功', icon: 'none' });
  } catch (error) {
    console.log('>>>>举报失败', error);
    uni.showToast({ title: '举报失败', icon: 'none' });
  } finally {
    msglistState.reason = '';
    msglistState.rptType = '';
    msglistState.rptMsgId = '';
  }
};
onPullDownRefresh(() => {
  getMoreHistoryMessages();
  console.log('>>>>>开始了下拉页面');
});
</script>

<style scoped>
@import './index.css';
</style>

还有更多重构代码篇幅有限不便一一展示,感兴趣请至片尾点击 github 地址查看。

重构过程中遇到的部分问题记录

问题一、打包至微信小程序中三大页面组件样式丢失。

问题简述:该问题在 H5 以及 app 中运行均正常展示,但测试发现运行至微信小程序中,会话列表、联系人、我的页面三个页面样式无法加载,效果如下图:

image.png

排查解决:发现这三个组件由原来页面级跳转改为了动态切换组件,但是在 pages.json 中仍然配置有该三大组件的路由映射地址,导致打包运行至微信小程序中时,样式出现丢失未能加载。去掉 pages.json 中仍存在的路由映射地址即可恢复正常。
image.png
image.png

问题二、打包至微信小程序时发现 emoji 表情图片无法正常加载展示。

问题简述:打包至微信小程序时点击 emoji 发送,发送框无法展示 emoji 映射的静态资源图片,效果如下图:

image.png
排查解决:发现微信小程序中相对路径匹配资源路径有些问题,将路径做了调整,如下图:

image.png
调整后

image.png

问题三、打包至微信小程序发送图片时发现截取图片类型时异常,导致发送失败。

问题描述:打包至微信小程序时发现发送图片功能异常,导致消息发送失败。

排查解决:经排查发现微信小程序微信小程序从基础库 2.21.0 开始, wx.chooseImage 停止维护,需使用 uni.chooseMedia 代替。
因此经过处理,判断如果是微信小程序平台,字节平台,京东平台使用 uni.chooseMedia 去进行文件的选取。
当然还需要注意 uni.chooseMedia 与 uni.chooseImage 返回的字段不一致,因此在后续发送时也需要针对性的进行处理。

问题四、运行至原生客户端(安卓、IOS)平台,发送语音、发送图片、拍照发送图片等功能提示 XXX 模块未加载。

问题描述:运行至原生端,在点击附件类消息发送时,例如发送语音、发送图片、拍照发送图片等功能提示 XXX 模块未加载。

排查解决:在 HB 中进行云打包之前,请记得 manifest.json / App 模块配置中勾选如下模块。

image.png

最终小结

非常高兴能够对 webim-uniapp-demo 重构,这个事情是我一直都想要做的事情,因为原有的项目代码已经不能很好的帮助想要拿此项目作为参考或者复用的的开发者完成高效的 IM 功能开发。

在重构过程中受益匪浅。因为在这个过程中,我通过对整个 demo 的代码重新整理和改写,不仅使自己对 SDK 的集成有了更深入的认识,更让我意识到 IM 相关功能相比于传统业务项目来说具有更多的灵活性。这种灵活性可能是因为 IM 功能通常需要处理实时性和互动性方面的需求,而这些特点也让开发者有更多的空间去创造新的功能和体验。此外,在这个过程中,我还了解到一些 SDK 的最佳实践,对 Vue3 中的组合式 API 使用也让我感受到了 Vue3 语法的灵活性,im 监听以及 api 的拆分以及仿 hook 的使用方式,有助于后续的扩展维护。

任何项目的经验都是宝贵的,但愿帮助我将来在其他的项目中更好地开发出稳定、可靠和高效的应用程序。

如果你有使用到环信 uni-app-demo,如果改写后的代码能够对你有所帮助,那么这件事情真是泰裤辣!

友情链接

环信 uni-app 文档地址

重构前 uni-app-demo-Vue2 版本源码地址

重构前 uni-app-demo-Vue3 版本源码地址

重构后 uni-app-demo 源码地址

环信 uni-app Demo升级改造计划——Vue2迁移到Vue3(一)

最后多说一句,如果觉得有帮助请点赞支持一下!本 demo 还有三期计划(增加音视频功能),敬请期待!


环信
4 声望3 粉丝

环信成立于2013年,是国内领先的企业级软件服务提供商,于2016年荣膺“Gartner 2016 Cool Vendor”。旗下主要产品线包括国内上线最早规模最大的即时通讯能力PaaS平台——环信即时通讯云,国内领先的全场景音视频PaaS...