环信uni-app-demo 升级改造计划第一期(vue2迁移vue3)

lxf2023-05-31 12:00:01

前言

由于该demo为早期通过工具从微信小程序转换为的uni-app项目,经过实际的使用以及复用反馈,目前俨然已经不适用于当前的开发使用,因此开启了整体升级改造计划,目前一期计划将vue2代码进行手动转换为vue3+vite,并剔除原项目中已经无用的项目代码,下面记录一下升级操作,如果升级过程,对大家有所帮助,深感荣幸~

前期准备

  • 【重要】阅读uni-app官网文档Vue2升级Vue3指南文档地址
  • 调研迁移到Vue3中原有的demo中哪些三方库或者方法将不可用主要uview UI库不支持Vue3)。
  • 下载并运行环信官网uni-app项目。下载地址
  • 在HubilderX中创建容器项目所谓容器项目即为创建一个空白的Vue3模板,用以逐步将Vue2的项目代码逐步挪到此项目中。
  • 在空白项目中引入uni-ui组件,主要为了使用其组件替换原项目uviewUI组件
  • 确认升级流程以及方式本次升级采用渐进式语法修改形式,主要方式为迁移一个组件则将修改一个组件的语法为vue3,如该组件依赖多个组件则先切断相组件的连接注释大法,后续逐步放开并配套修改。

核心迁移步骤

第一步、导入环信uni-app SDK

原有Vue2版本uni-app-demo项目为本地引入SDK包,对于有些习惯npm安装导入的同学不太友好,目前uniSDK已经支持npm 安装并导入,因此将原有本地引入js文件改为通过npm 安装SDK并import导入SDK。

 //第一步 打开终端执行 npm install easemob-websdk
 //第二步 复制原demo中的utils文件夹至空白项目中
 //第三步 找到utils文件夹中的WebIM.js 文件中的导入SDK方式改写为impot 导入 easemob-websdk/uniApp包,具体代码如下。
 /* 原项目引入SDK代码 */
 import websdk from "../newSDK/uniapp-sdk-4.1.2";
 /* 改写后的代码 */
 import websdk from 'easemob-websdk/uniApp/Easemob-chat';

第二步、CommonJS导入导出改写为ESM

这种改写原因两点:

1、CommonJS规范在Vite中使用本身并不支持,如果支持则需要进行单独配置

2、原始项目中既有CommonJS导入方式,也有ESM导入,借此机会进行统一。

进行到此主要是先将原始项目中的CommonJS导出WebIM实例改为ESM导出,后续会在语法改造过程中将所有CommonJS规范改写为ESM导出,后续将不在本文中提及,实例代码如下

    /* 原始项目utils/WebIM.js的导入导出WebIM实例代码段 */
    //导入方式
    let WebIM = (wx.WebIM = require("./utils/WebIM")["default"]);
    //导出方式
    module.exports = {
      "default": WebIM
    };
    
    /* 改写后导入导出 */
    //导入方式
    import WebIM from '@/utils/WebIM.js';
    //导出方式
    export default WebIM

第三步、迁入App.vue组件

完整的复制原始项目中的App.vue组件(uni的Vue3模板中也支持Vue2代码,因此可以放心进行CV)

App.vue组件涉及到的改动为注释掉暂时没有引入的js文件,后续进行引入,去除scss中的uview样式代码,引入后续将要完全剔除uview组件。

App.vue中代码较多此示例做了大量的缩减,大致调整之后的结构如下。

<script>
import WebIM from '@/utils/WebIM.js';
//这些导入暂时注释,后续再进行引入
//let msgStorage = require("./components/chat/msgstorage");
//let msgType = require("./components/chat/msgtype");
//let disp = require("./utils/broadcast");
//let logout = false;

//import { onGetSilentConfig } from './components/chat/pushStorage'
export default {
//export default的代码块原封不动,此处先进行了删除,实际迁入不用动。
    data (){
        return {
        
        }
    }
}
</script>
<style lang="scss">
@import './app.css';
 /*注意这行代码删除 @import "uview-ui/index.scss"; */
</style>

第四步 牛刀小试~ 迁入Login组件

先迁入一个Login组件热热身,毕竟从登录开始,原始项目中有注册、Token登录、等等但目前暂不需要所以只需迁入Login组件。

在迁入前我们先了解并思考一下,Vue2 的Options API 与 Vue3 Composition API一些特点,主要目的是用较小的代价进行Vue3语法改造。 Vue3模版支持setup语法糖,因此可以直接使用使用setup语法糖方式进行语法改造。

<script setup>
    /* 原始代码片段 */
    let WebIM = require("../../utils/WebIM")["default"];
    let __test_account__, __test_psword__;
    let disp = require("../../utils/broadcast");
    data() {
        return {
          usePwdLogin:false, //是否用户名+手机号方式登录
          name: "",
          psd: "",
          grant_type: "password",
          psdFocus: "",
          nameFocus: "",
          showPassword:false,
          type:'text',
              btnText: '获取验证码'
        };
      },
      /* 改造后的代码 */
    //使用reactive替换并包裹原有data中的参数
    import { reactive } from 'vue'
    import disp from '@/utils/broadcast.js'; //修改为ESM导入
    const WebIM = uni.WebIM; //从挂载到uni下的WebIM中取出WebIM并赋值用以替换原有单独require导入的WebIM
    const loginState = reactive({
      usePwdLogin: true, //是否用户名+手机号方式登录
      name: '',
      psd: '',
      grant_type: 'password',
      psdFocus: '',
      nameFocus: '',
      showPassword: false,
      type: 'text',
      btnText: '获取验证码',
    });
    
    //methods中的方法提取到外层中,例如将login 登录IM进行调整
    //登录IM
const loginIM = () => {
  runAnimation = !runAnimation;
  if (!loginState.usePwdLogin) {
    if (!__test_account__ && loginState.name == '') {
      uni.showToast({
        title: '请输入手机号!',
        icon: 'none',
      });
      return;
    } else if (!__test_account__ && loginState.psd == '') {
      uni.showToast({
        title: '请输入验证码!',
        icon: 'none',
      });
      return;
    }
    const that = loginState;
    uni.request({
      url: 'https://a1.easemob.com/inside/app/user/login/V2',
      header: {
        'content-type': 'application/json',
      },
      method: 'POST',
      data: {
        phoneNumber: that.name,
        smsCode: that.psd,
      },
      success(res) {
        if (res.statusCode == 200) {
          const { phoneNumber, token, chatUserName } = res.data;
          getApp().globalData.conn.open({
            user: chatUserName,
            accessToken: token,
          });
          getApp().globalData.phoneNumber = phoneNumber;
          uni.setStorage({
            key: 'myUsername',
            data: chatUserName,
          });
        } else if (res.statusCode == 400) {
          if (res.data.errorInfo) {
            switch (res.data.errorInfo) {
              case 'UserId password error.':
                uni.showToast({
                  title: '用户名或密码错误!',
                  icon: 'none',
                });
                break;
              case 'phone number illegal':
                uni.showToast({
                  title: '请输入正确的手机号',
                  icon: 'none',
                });
                break;
              case 'SMS verification code error.':
                uni.showToast({
                  title: '验证码错误',
                  icon: 'none',
                });
                break;
              case 'Sms code cannot be empty':
                uni.showToast({
                  title: '验证码不能为空',
                  icon: 'none',
                });
                break;
              case 'Please send SMS to get mobile phone verification code.':
                uni.showToast({
                  title: '请使用短信验证码登录',
                  icon: 'none',
                });
                break;
              default:
                uni.showToast({
                  title: res.data.errorInfo,
                  icon: 'none',
                });
                break;
            }
          }
        } else {
          uni.showToast({
            title: '登录失败!',
            icon: 'none',
          });
        }
      },
      fail(error) {
        uni.showToast({
          title: '登录失败!',
          icon: 'none',
        });
      },
    });
  } else {
    if (!__test_account__ && loginState.name == '') {
      uni.showToast({
        title: '请输入用户名!',
        icon: 'none',
      });
      return;
    } else if (!__test_account__ && loginState.psd == '') {
      uni.showToast({
        title: '请输入密码!',
        icon: 'none',
      });
      return;
    }
    uni.setStorage({
      key: 'myUsername',
      data: __test_account__ || loginState.name.toLowerCase(),
    });
    console.log(111, {
      apiUrl: WebIM.config.apiURL,
      user: __test_account__ || loginState.name.toLowerCase(),
      pwd: __test_psword__ || loginState.psd,
      grant_type: loginState.grant_type,
      appKey: WebIM.config.appkey,
    });
    getApp().globalData.conn.open({
      apiUrl: WebIM.config.apiURL,
      user: __test_account__ || loginState.name.toLowerCase(),
      pwd: __test_psword__ || loginState.psd,
      grant_type: loginState.grant_type,
      appKey: WebIM.config.appkey,
    });
  }
};
</script>

改造中会遇到了原Vue2中原data部分参数通过使用reactive包裹并重命名,需要注意把语法中的this.、me.、this.setData进行替换为包裹后的state命名,另外template中也要同步进行替换,这一点在后续所有组件改造中都会遇到。

Login组件需要page.json中进行路由的配置,只有配置成功之后我们方可运行项目并展示页面

此时就可以启动项目运行观察一下看看页面是否可以正常的进行展示,当然是运行到小程序还是H5以及App上自行选择。

第五步、 迁入“Home页中的”三个Tab页面【conversation会话列表,mian联系人页、Setting我的页面】

迁移各组件,此处使用conversation组件作为示例,其余两个组件完全相同的步骤,全部示例代码将在文章末尾给出地址。

在原项目中包括已迁移进来的App.vue组件中有下面这样一个方法,其作用即为环信IM连接成功之后触发onOpened该监听回调,进行路由跳转进入到会话页面,因此不难理解,open之后首个跳转的页面即为conversation。

    onLoginSuccess: function (myName) {
      uni.hideLoading();
      uni.redirectTo({
        url: "../conversation/conversation?myName=" + myName,
      });
    },
  • 在原始项目中copy conversation(会话)组件至容器项目相同目录下,另外不要忘记顺手在page.json下配置路由。

  • 开始改写会话组件中的代码

//script 标签增加 setup 使其支持setup语法糖
<script setup>
    /* 引入所需组合式API */
    //computed 用以替换options API中的计算属性,Vue3中计算属性使用略有差异。
    import {reactive,computed} from 'vue'
    /* 引入所需声明周期钩子函数替换原有钩子函数,该写法uni-appvue2升级vue3指南有提及 */
    import { onLoad, onShow, onUnload } from '@dcloudio/uni-app';
    /* 调整disp为import导入 */
    // let disp = require("../../utils/broadcast");
    import disp from '@/utils/broadcast';
    /* 调整WebIM引入直接从uni下取 */
    // var WebIM = require("../../utils/WebIM")["default"];
    const WebIM = uni.WebIM
    let isfirstTime = true;
    /* components中的组件暂时注释,template中的组件引入也暂时注释,
     * 另options API中的components中的组件注册也暂时注释 
    */
    // import swipeDelete from "../../components/swipedelete/swipedelete";
    // import longPressModal from "../../components/longPressModal/index";
    
    /* data 提出用reactive包裹并命名 */
    const conversationState = reactive({
          // 内容省略...
    });
    
    /* onLoad替换 */
    onLoad(() => {
      //所有通过this. 进行方法方法调用全部删除
      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替换 */
    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 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
            ) {
              return item.groupName;
            }
          };
        });
        const handleTime = computed(() => {
          return (item) => {
            return dateFormater('MM/DD/HH:mm', item.time);
          };
        });
  /* 将methods中方法全量提取到外层与onLoad onShow等API平级 */
      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;
    };
    
    //还有很多方法就不一一展示,暂时进行了省略...
    /* onUnload */
    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

在做这三个组件迁移的时候主要的注意事项为,this的替换,template中的默认从vue2中data取的参数也要替换为被reactive包裹后的变量名。

启动运行调整

建议迁移一个组件调试一个组件,运行到H5端,从登录页面登录进去,并点击三个页面进行切换,观察是否有相应的报错,发现即进行修改并重新运行测试。

第六步、迁入复杂度最高的聊天相关组件。

以单聊作为说明示例:

1)迁入单聊入口组件[pages/chatroom]

chatroom组件(groupChatroom作用相同)为单聊功能聊天的入口组件,pages中其他组件发起单聊聊天时均会跳转至该组件,而该组件同时又承载components下的chat组件作为容器形成聊天功能。

将chatroom组件copy至容器项目pages下并配置路由映射,为了语义化将chatroom更名为singleChatEntry,并进行语法改造,此时singleChatEntry如下:

不要忘了,路由路径配套也要从chatroom更名为singleChatEntry

<template>
  <chat
    id="chat"
    ref="chatComp"
    :chatParams="chatParams"
    chatType="singleChat"
  ></chat>
</template>

<script setup>
import { ref, reactive } from 'vue';
import {
  onLoad,
  onUnload,
  onPullDownRefresh,
  onNavigationBarButtonTap,
} from '@dcloudio/uni-app';
import disp from '@/utils/broadcast';
import chat from '@/components/chat/chat.vue';

const chatComp = ref(null);
let chatParams = reactive({});
onNavigationBarButtonTap(() => {
  uni.navigateTo({
    url: `/pages/moreMenu/moreMenu?username=${chatParams.your}&type=singleChat`,
  });
});
onLoad((options) => {
  let params = JSON.parse(options.username);
  chatParams = Object.assign(chatParams, params);
  // 生成的支付宝小程序在onLoad里获取不到,这里放到全局变量下
  uni.username = params;
  uni.setNavigationBarTitle({
    title: params?.yourNickName || params?.your,
  });
});
onPullDownRefresh(() => {
  uni.showNavigationBarLoading();
  chatComp.value.getMore();
  // 停止下拉动作
  uni.hideNavigationBarLoading();
  uni.stopPullDownRefresh();
});

onUnload(() => {
  disp.fire('em.chatroom.leave');
});
</script>
<style>
    @import './singleChatEntry.css';
</style>

2)完整迁入components组件

环信uni-app-demo 升级改造计划第一期(vue2迁移vue3)

components组件结构如上图,由于音视频功能已经废弃本次迁移决定剔除,但目前迁移方案采取“抓大放小,后续清算”的策略先一起迁入,后续剔除。

引入之后运行起来之后会发现有很多require not a function字眼的错误,同样我们要将所有CommonJS的导出修改为ESM导出,剩下的则是一点一点的去进行语法改造,整个chat下其实涉及组件非常多,因为IM所有消息的收发,以及渲染均囊括在此组件。

这里提一下msgpackager.js、msgstorage.js、msgtype.js、pushStorage.js几个js文件的作用。

msgpackager.js 主要为将收发的IM消息进行结构重组

msgstorage.js 将收发消息进行本地缓存

msgtype.js 消息类型以及聊天类型的常量文件

pushStorage.js 推送处理相关

迁入进去之后将开始针对大大小小十几个文件进行语法以及引入改造,另外其中个别文件还牵扯到使用的uviewUI那么则需要进行重写,最终经过改造以及剔除不再使用的组件以及音视频相关代码之后,结构如图: 环信uni-app-demo 升级改造计划第一期(vue2迁移vue3)

有一点较为基础但是还是要强调注意的事项要提一下,在components/chat下的组件改造中经常出现父子组件的调用,那么父组件在使用子组件的方法的时候,由于Vue3中不能再通过类似$ref直接去调用子组件中的方法或者值,子组件需要通过defineExpose主动进行暴露方可使用,这个需要进行注意。

迁移中发现H5的录音采用的recorder-core.js库,js按需导入中有用到require,那么需要改写为import导入,但是发现实例化时发现依然不是一个构造函数,通过改写从window下访问即正常使用,相关代码如下:

    /* 原代码片段 */
    handleRecording(e) {
      const sysInfo = uni.getSystemInfoSync();
      console.log("getSystemInfoSync", sysInfo);
      if (sysInfo.app === "alipay") {
        // https://forum.alipay.com/mini-app/post/7301031?ant_source=opendoc_recommend
        uni.showModal({
          content: "支付宝小程序不支持语音消息,请查看支付宝相关api了解详情"
        });
        return;
      }
      let me = this;
      me.recordClicked = true;
      // h5不支持uni.getRecorderManager, 需要单独处理
      if (sysInfo.uniPlatform === "web") {
        import("../../../../../recorderCore/src/recorder-core").then((Recorder) => {
          require("../../../../../recorderCore/src/engine/mp3");
          require("../../../../../recorderCore/src/engine/mp3-engine");
          if (me.recordClicked == true) {
            clearInterval(recordTimeInterval);
            me.initStartRecord(e);
            me.rec = new Recorder.default({
              type: "mp3"
            });
            me.rec.open(
              () => {
                me.saveRecordTime();
                me.rec.start();
              },
              (msg, isUserNotAllow) => {
                if (isUserNotAllow) {
                  uni.showToast({
                    title: "鉴权失败,请重试",
                    icon: "none"
                  });
                } else {
                  uni.showToast({
                    title: `开启失败,请重试`,
                    icon: "none"
                  });
                }
              }
            );
          }
        });
      } else {
        setTimeout(() => {
          if (me.recordClicked == true) {
            me.executeRecord(e);
          }
        }, 350);
      }
    }
    /* 调整后代码片段 */
    const handleRecording = async (e) => {
      const sysInfo = uni.getSystemInfoSync();
      console.log('getSystemInfoSync', sysInfo);
      if (sysInfo.app === 'alipay') {
        // https://forum.alipay.com/mini-app/post/7301031?ant_source=opendoc_recommend
        uni.showModal({
          content: '支付宝小程序不支持语音消息,请查看支付宝相关api了解详情',
        });
        return;
      }
      audioState.recordClicked = true;
      // h5不支持uni.getRecorderManager, 需要单独处理
      if (sysInfo.uniPlatform === 'web') {
        // console.log('>>>>>>进入了web层面注册页面');
        // #ifdef H5
        await import('@/recorderCore/src/recorder-core');
        await import('@/recorderCore/src/engine/mp3');
        await import('@/recorderCore/src/engine/mp3-engine');
        if (audioState.recordClicked == true) {
          clearInterval(recordTimeInterval);
          initStartRecord(e);
          audioState.rec = new window.Recorder({
            type: 'mp3',
          });
          audioState.rec.open(
            () => {
              saveRecordTime();
              audioState.rec.start();
            },
            (msg, isUserNotAllow) => {
              if (isUserNotAllow) {
                uni.showToast({
                  title: '鉴权失败,请重试',
                  icon: 'none',
                });
              } else {
                uni.showToast({
                  title: `开启失败,请重试`,
                  icon: 'none',
                });
              }
            }
          );
        }
        // #endif
      } else {
        setTimeout(() => {
          if (audioState.recordClicked == true) {
            executeRecord(e);
          }
        }, 350);
      }
};

3)启动进行后续调整测试

启动之后验证发现更多的是一些细节问题,同样边改边验证。

后续总结

在首期迁移vue2升级vue3的工作中其实难度并没有很大,主要的工作量集中在语法的修改变更上,好在uni-app中可以同步去写vue2与vue3两种语法代码,这样有助于在引入之后陆续进行语法变更,另外迁移之后开发体验启动速度确实快了很多,接下来就可以腾出手针对uni-app-demo源码代码进行整体质量提升,敬请期待...

此次升级后的源码地址:github.com/easemob/web…

本网站是一个以CSS、JavaScript、Vue、HTML为核心的前端开发技术网站。我们致力于为广大前端开发者提供专业、全面、实用的前端开发知识和技术支持。 在本网站中,您可以学习到最新的前端开发技术,了解前端开发的最新趋势和最佳实践。我们提供丰富的教程和案例,让您可以快速掌握前端开发的核心技术和流程。 本网站还提供一系列实用的工具和插件,帮助您更加高效地进行前端开发工作。我们提供的工具和插件都经过精心设计和优化,可以帮助您节省时间和精力,提升开发效率。 除此之外,本网站还拥有一个活跃的社区,您可以在社区中与其他前端开发者交流技术、分享经验、解决问题。我们相信,社区的力量可以帮助您更好地成长和进步。 在本网站中,您可以找到您需要的一切前端开发资源,让您成为一名更加优秀的前端开发者。欢迎您加入我们的大家庭,一起探索前端开发的无限可能!