前言
由于看到有部分的需求为在页面层,快速的引入一个包,并且以简单的配置,就可以快速实现一个聊天窗口,因此尝试以 Vue3 插件的形式开发一个轻量的聊天窗口。
这次简单分享一下此插件的实现思路,以及实现过程,并描述一下本次插件发布 npm 的过程。
📦 emchat-chatroom-widget
┣ 📂 build
┣ 📂 demo
┣ 📂 scripts
┣ 📂 src
┃ ┣ 📂 components
┃ ┣ 📂 container
┃ ┣ 📂 EaseIM
┃ ┣ 📂 utils
┃ ┣ 📜 index.ts
┃ ┗ 📜 install.ts
┣ 📜 package.json
┣ 📜 vite.config.ts
┗ 📜 README.md
...
首先确认本次插件实现的功能范围,从而围绕要实现的功能着手进行开发准备。
- Vue3 框架使用
- 轻量配置、仅配置少量参数即可立即使用聊天功能
- 页面大小自适应,给定容器宽高,插件内部宽高自适应。
- 仅聊天室类型消息支持基础文本,表情,图片。
暂时第一期仅支持这些功能范围。
pnpm create vite emchat-chatroom-widget
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 后缀。
目录下新建一个文件夹命名为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 代码由于篇幅关系,可以后面查看文末的源码地址。
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);
},
};
聊天插件入口组件主要用来接收插件使用者所传递进来的一些必要参数,比如登录用户 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"
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(">>>>>断开连接")
}
onMounted(() => {
loginIM()
if (props.chatroomId) {
setCurrentChatroomId(props.chatroomId)
}
})
return {
loginIM,
closeIM
}
},
render() {
return (
<>
<div class={"easemob_chatroom_container"}>
<MessageContainer />
<InputBarContainer />
</div>
</>
)
}
})
主要处理插件输入框功能,实现消息文本内容,图片内容的发送。
import { defineComponent, ref } from "vue"
import { EasemobChat } from "easemob-websdk"
import { EMClient } from "../EaseIM"
import { useManageChatroom } from "../EaseIM/mangeChatroom"
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>
</>
)
}
})
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>
</>
);
};
},
});
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
}
}
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"
createApp(App)
.use(EMChatroom, {
appKey: "easemob#XXX"
})
.mount("#app")
<EasemobChatroom
:username="'hfp'"
:password="'1'"
:chatroomId="'208712152186885'"
>
</EasemobChatroom>