前言

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

前期准备

  • 【重要】阅读 uni-app 官网文档 Vue2 升级 Vue3 指南文档地址
  • 调研迁移到 Vue3 中原有的 Demo 中哪些三方库或者方法将不可用主要 uview UI 库不支持 Vue3)。
  • 下载并运行环信官网 uni-app 项目(原项目master分支)。Demo下载地址
  • 在 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 组件

image.png

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 那么则需要进行重写,最终经过改造以及剔除不再使用的组件以及音视频相关代码之后,结构如图:
image.png

有一点较为基础但是还是要强调注意的事项要提一下,在 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 源码代码进行整体质量提升,敬请期待…

此次升级后的源码地址:https://github.com/easemob/webim-uniapp-demo/tree/vue3

Logo

华为开发者空间,是为全球开发者打造的专属开发空间,汇聚了华为优质开发资源及工具,致力于让每一位开发者拥有一台云主机,基于华为根生态开发、创新。

更多推荐