【開箱即用】開發了一個基於環信IM聊天室的Vue3外掛,從而快速實現仿直播間聊天窗功能

發表於2023-09-19

前言

由於看到有部分的需求為在頁面層,快速的引入一個包,並且以簡單的配置,就可以快速實現一個聊天視窗,因此嘗試以 Vue3 外掛的形式開發一個輕量的聊天視窗。

這次簡單分享一下此外掛的實現思路,以及實現過程,並描述一下本次外掛釋出 npm 的過程。

技術棧

  • Vue3
  • pnpm
  • Typescript
  • Vite

外掛核心目錄設計

? emchat-chatroom-widget
┣ ? build // 外掛打包輸出的目錄
┣ ? demo // 驗證外掛demo相關目錄
┣ ? scripts // 打包指令碼目錄
┣ ? src // 外掛原始碼
┃   ┣ ? components // 元件目錄
┃   ┣ ? container // 容器元件目錄
┃   ┣ ? EaseIM // 環信IM相關目錄
┃   ┣ ? utils // 工具相關目錄
┃   ┣ ? index.ts // 外掛入口檔案
┃   ┗ ? install.ts // 外掛初始化檔案
┣ ? package.json // 專案配置檔案
┣ ? vite.config.ts // vite配置檔案
┗ ? README.md // 專案說明文件
...

實現過程

確認功能範圍

首先確認本次外掛實現的功能範圍,從而圍繞要實現的功能著手進行開發準備。

  1. Vue3 框架使用
  2. 輕量配置、僅配置少量引數即可立即使用聊天功能
  3. 頁面大小自適應,給定容器寬高,外掛內部寬高自適應。
  4. 僅聊天室型別訊息支援基礎文字,表情,圖片。
    暫時第一期僅支援這些功能範圍。

著手開發

1、建立空白專案

pnpm create vite emchat-chatroom-widget --template vue-ts

2、配置eslint pretter 等程式碼校驗、以及程式碼風格工具。

pnpm i eslint eslint-plugin-vue @typescript-eslint/eslint-plugin @typescript-eslint/parser -D
pnpm i prettier eslint-config-prettier eslint-plugin-prettier -D

同時也不要忘了建立對應的 .eslintrc.cjs.prettierrc.cjs

這裡遇到了一個問題:

這幾個檔案以 cjs 結尾是因為 package.json 建立時設定了"type": "module" 後你的所有 js 檔案預設使用 ESM 模組規範,不支援 commonjs 規範,所以必須顯式的宣告成 xxx.cjs 才能標識這個是用 commonjs 規範的,把你的配置都改成.cjs 字尾。

3、配置 scripts 打包指令碼

目錄下新建一個資料夾命名為scripts,新加一個 build.js 或者為.ts 檔案。

在該檔案中引入vite進行打包時的配置。由於本次外掛編寫時使用了jsx語法進行編寫,因此 vite 打包時也需要引入 jsx 打包外掛。
安裝@vitejs/plugin-vue-jsx外掛。

const BASE_VITE_CONFIG = defineConfig({
  publicDir: false, //暫不需要打包靜態資源到public資料夾
  plugins: [
    vue(),
    vueJSX(),
    // visualizer({
    //   emitFile: true,
    //   filename: "stats.html"
    // }),
    dts({
      outputDir: './build/types',
      insertTypesEntry: true, // 插入TS 入口
      copyDtsFiles: true, // 是否將原始碼裡的 .d.ts 檔案複製到 outputDir
    }),
  ],
});

package.json中增加 build 指令碼執行命令,

  "scripts": {
    "dev": "vite",
    "build": "vue-tsc && vite build",
    "preview": "vite preview",
    "lint": "eslint src --fix",
    "build:widget": "node ./scripts/build.js"
  },

整體 build.js 程式碼由於篇幅關係,可以後面檢視文末的原始碼地址。

4、 編寫 Vue3 外掛入口函式

import type { App } from 'vue';
import EasemobChatroom from './container';
import { initEMClient } from './EaseIM';
export interface IEeasemobOptions {
  appKey: string;
}

export default {
  install: (app: App, options: IEeasemobOptions) => {
    // 在這裡編寫外掛程式碼
    console.log(app);
    console.log('options', options);
    if (options && options?.appKey) {
      initEMClient(options.appKey);
    } else {
      throw console.error('appKey不能為空');
    }
    app.component(EasemobChatroom.name, EasemobChatroom);
  },
};

5、聊天外掛入口程式碼

聊天外掛入口元件主要用來接收外掛使用者所傳遞進來的一些必要引數,比如登入使用者 id、密碼、token、聊天室 id,以及針對初始化外掛的初始狀態。

import { defineComponent, onMounted } from "vue"
import { EMClient } from "../EaseIM"
import { useManageChatroom } from "../EaseIM/mangeChatroom"
import { manageEasemobApis } from "../EaseIM/imApis"
import "./style/index.css"
/* components */
import MessageContainer from "./message"
import InputBarContainer from "./inputbar"
console.log("EMClient", EMClient)
export default defineComponent({
  name: "EasemobChatroom",
  props: {
    username: {
      type: String,
      default: "",
      required: true
    },
    password: {
      type: String,
      default: ""
    },
    accessToken: {
      type: String,
      default: ""
    },
    chatroomId: {
      type: String,
      default: "",
      required: true
    }
  },
  setup(props) {
    const { setCurrentChatroomId } = useManageChatroom()
    const { loginIMWithPassword, loginIMWithAccessToken } = manageEasemobApis()
    const loginIM = async (): Promise<void> => {
      if (!EMClient) return
      try {
        if (props.accessToken) {
          await loginIMWithAccessToken(props.username, props.accessToken)
        } else {
          await loginIMWithPassword(props.username, props.password)
        }
      } catch (error: any) {
        throw `${error.data.message}`
      }
    }
    const closeIM = async (): Promise<void> => {
      console.log(">>>>>斷開連線")
      //   EMClient.close()
    }
    onMounted(() => {
      loginIM()
      if (props.chatroomId) {
        setCurrentChatroomId(props.chatroomId)
      }
    })
    return {
      loginIM,
      closeIM
    }
  },
  render() {
    return (
      <>
        <div class={"easemob_chatroom_container"}>
          <MessageContainer />
          <InputBarContainer />
        </div>
      </>
    )
  }
})

6、輸入框元件程式碼

主要處理外掛輸入框功能,實現訊息文字內容,圖片內容的傳送。

import { defineComponent, ref } from "vue"
import { EasemobChat } from "easemob-websdk"
import { EMClient } from "../EaseIM"
import { useManageChatroom } from "../EaseIM/mangeChatroom"
/* compoents */
import InputEmojiComponent from "../components/InputEmojiComponent"
import UploadImageComponent from "../components/UploadImageComponent"
import "./style/inputbar.css"
export enum PLACE_HOLDER_TEXT {
  TEXT = "Enter 傳送輸入的內容..."
}
export default defineComponent({
  name: "InputBarContainer",
  setup() {
    //基礎文字傳送
    const inputContent = ref("")
    const setInputContent = (event: Event) => {
      inputContent.value = (event.target as HTMLInputElement).value
    }
    const { currentChatroomId, loginUserInfo, sendDisplayMessage } =
      useManageChatroom()
    const sendMessage = async (event: KeyboardEvent) => {
      if (inputContent.value.match(/^\s*$/)) return
      if (event.code === "Enter" && !event.shiftKey) {
        event.preventDefault()
        console.log(">>>>>>呼叫傳送方法")
        const param: EasemobChat.CreateTextMsgParameters = {
          chatType: "chatRoom",
          type: "txt",
          to: currentChatroomId.value,
          msg: inputContent.value,
          from: EMClient.user,
          ext: {
            nickname: loginUserInfo.nickname
          }
        }
        try {
          await sendDisplayMessage(param)
          inputContent.value = ""
        } catch (error) {
          console.log(">>>>>訊息傳送失敗", error)
        }
      }
    }
    const appendEmojitoInput = (emoji: string) => {
      inputContent.value = inputContent.value + emoji
    }
    return () => (
      <>
        <div class={"input_bar_container"}>
          <div class={"control_strip_container"}>
            <InputEmojiComponent onAppendEmojitoInput={appendEmojitoInput} />
            <UploadImageComponent />
          </div>

          <div class={"message_content_input_box"}>
            <input
              class={"message_content_input"}
              type="text"
              value={inputContent.value}
              onInput={setInputContent}
              placeholder={PLACE_HOLDER_TEXT.TEXT}
              onKeyup={sendMessage}
            />
          </div>
        </div>
      </>
    )
  }
})

7、訊息列表元件程式碼

渲染聊天室內收發的訊息程式碼,以及列表滾動。

import { defineComponent, nextTick, watch } from 'vue';
import { useManageChatroom } from '../EaseIM/mangeChatroom';
import { scrollBottom } from '../utils';
import './style/message.css';
import { EasemobChat } from 'easemob-websdk';
const { messageCollect } = useManageChatroom();

const MessageList = () => {
  const downloadSourceImage = (message: EasemobChat.MessageBody) => {
    if (message.type === 'img') {
      window.open(message.url);
    }
  };
  return (
    <>
      {messageCollect.length > 0 &&
        messageCollect.map((msgItem) => {
          return (
            <div class={'message_item_box'} key={msgItem.id}>
              <div class={'message_item_nickname'}>
                {msgItem?.ext?.nickname || msgItem.from}
              </div>
              {msgItem.type === 'txt' && (
                <p class={'message_item_textmsg'}>{msgItem.msg}</p>
              )}
              {msgItem.type === 'img' && (
                <img
                  style={'cursor: pointer;'}
                  onClick={() => {
                    downloadSourceImage(msgItem);
                  }}
                  src={msgItem.thumb}
                />
              )}
            </div>
          );
        })}
    </>
  );
};
export default defineComponent({
  name: 'MessageContainer',
  setup() {
    watch(messageCollect, () => {
      console.log('>>>>>>監聽到訊息列表改變');
      nextTick(() => {
        const messageContainer = document.querySelector('.message_container');
        setTimeout(() => {
          messageContainer && scrollBottom(messageContainer);
        }, 300);
      });
    });

    return () => {
      return (
        <>
          <div class='message_container'>
            <MessageList />
          </div>
        </>
      );
    };
  },
});

8、聊天室核心心方法

聊天室內部分狀態管理

import { EasemobChat } from "easemob-websdk"
import { reactive, ref } from "vue"
import { DisplayMessageType, ILoginUserInfo } from "../types/index"
import { manageEasemobApis } from "../imApis/"
const messageCollect = reactive<DisplayMessageType[]>([])
const loginUserInfo: ILoginUserInfo = {
  loginUserId: "",
  nickname: ""
}
const currentChatroomId = ref("")
export const useManageChatroom = () => {
  const setCurrentChatroomId = (roomId: string) => {
    currentChatroomId.value = roomId
  }
  const setLoginUserInfo = async (loginUserId: string) => {
    const { fetchLoginUserNickname } = manageEasemobApis()
    loginUserInfo.loginUserId = loginUserId
    try {
      const res = await fetchLoginUserNickname(loginUserId)
      loginUserInfo.nickname = res[loginUserId].nickname
      console.log(">>>>>>獲取到使用者屬性", loginUserInfo.nickname)
    } catch (error) {
      console.log(">>>>>>獲取失敗")
    }
  }
  const pushMessageToList = (message: DisplayMessageType) => {
    messageCollect.push(message)
  }
  const sendDisplayMessage = async (payload: EasemobChat.CreateMsgType) => {
    const { sendTextMessage, sendImageMessage } = manageEasemobApis()
    return new Promise((resolve, reject) => {
      if (payload.type === "txt") {
        sendTextMessage(payload)
          .then(res => {
            messageCollect.push(res as unknown as EasemobChat.TextMsgBody)
            resolve(res)
          })
          .catch(err => {
            reject(err)
          })
      }
      if (payload.type === "img") {
        sendImageMessage(payload)
          .then(res => {
            messageCollect.push(res as unknown as EasemobChat.ImgMsgBody)
            resolve(res)
          })
          .catch(err => {
            reject(err)
          })
      }
    })
  }

  return {
    messageCollect,
    currentChatroomId,
    loginUserInfo,
    setCurrentChatroomId,
    sendDisplayMessage,
    pushMessageToList,
    setLoginUserInfo
  }
}

例項化 IM SDK

import EaseSDK, { EasemobChat } from "easemob-websdk"
import { mountEaseIMListener } from "./listener"
export let EMClient = {} as EasemobChat.Connection
export const EMCreateMessage = EaseSDK.message.create
export const initEMClient = (appKey: string) => {
  EMClient = new EaseSDK.connection({
    appKey: appKey
  })
  mountEaseIMListener(EMClient)
  return EMClient
}

掛載聊天室相關監聽監聽

import { EasemobChat } from 'easemob-websdk';
import { useManageChatroom } from '../mangeChatroom';
import { manageEasemobApis } from '../imApis';
export const mountEaseIMListener = (EMClient: EasemobChat.Connection) => {
  const { pushMessageToList, setLoginUserInfo, currentChatroomId } =
    useManageChatroom();
  const { joinChatroom } = manageEasemobApis();
  console.log('>>>mountEaseIMListener');
  EMClient.addEventHandler('connection', {
    onConnected: () => {
      console.log('>>>>>onConnected');
      joinChatroom();
      setLoginUserInfo(EMClient.user);
    },
    onDisconnected: () => {
      console.log('>>>>>Disconnected');
    },
    onError: (error: any) => {
      console.log('>>>>>>Error', error);
    },
  });
  EMClient.addEventHandler('message', {
    onTextMessage(msg) {
      if (msg.chatType === 'chatRoom' && msg.to === currentChatroomId.value) {
        pushMessageToList(msg);
      }
    },
    onImageMessage(msg) {
      if (msg.chatType === 'chatRoom' && msg.to === currentChatroomId.value) {
        pushMessageToList(msg);
      }
    },
  });
  EMClient.addEventHandler('chatroomEvent', {
    onChatroomEvent(eventData) {
      console.log('>>>>chatroomEvent', eventData);
    },
  });
};

使用方式

npm install emchat-chatroom-widget
import EMChatroom from "emchat-chatroom-widget/emchat-chatroom-widget.esm.js"
//引入外掛內部樣式
import "emchat-chatroom-widget/style.css"
//appKey 需從環信申請
createApp(App)
  .use(EMChatroom, {
    appKey: "easemob#XXX"
  })
  .mount("#app")

  //模版元件內使用
  /**
   * @param {username} string
   * @param {password} string
   * @param {accessToken} string
   * @param {chatroomId} string
   */
    <EasemobChatroom
      :username="'hfp'"
      :password="'1'"
      :chatroomId="'208712152186885'"
    >
    </EasemobChatroom>

最終效果

image.png

相關程式碼

Github 原始碼地址

npm 相關包地址

參考資料

註冊環信

環信官方 Web 端相關文件

【前端工程化-元件庫】從 0-1 構建 Vue3 元件庫(元件開發)

使用 TSX 編寫 Vue3 元件

相關文章