環信 uni-app Demo升級改造計劃——Vue2遷移到Vue3(一)

環信發表於2023-04-18

前言

由於環信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

相關文章