从零打造前沿Web聊天组件:从设计到交互

作者现在制作一款网页端聊天室(青春版),之前一直有这个想法,现在总算是迈出了第一步开始制作了...... 雄关漫道真如铁,而今迈步从头越!

启程

当前已经完成左侧聊天室列表显示,通过http://localhost:10086/chatRoom/chatRoomList接口进行获取,可选传入当前用户id,这样将返回所有存在此用户的聊天室数据。

数据从node + express 后端获取,存储数据库为mongoDB

  • 获取到的数据:
  • 当前效果:

组件设计

在聊天室项目里,右侧的聊天内容区域组件设计要慎重,毕竟属于项目当中的重中之重。

因为聊天一般分为群聊和私聊,所以我准备开发两种复用组件groupChat(群聊)和privateChat(私聊)。

关于组件的引入,有两种方案可选:动态组件模式单一组件条件渲染

这里我选择使用 动态组件模式,其优点:

  1. 类型隔离:群聊/私聊逻辑完全解耦
  2. 性能优化key 保证切换时完全重建实例,可以进行异步加载
  3. 可扩展性:后续想到新的聊天类型,添加起来也非常方便

组件通信规范: - 父组件 → 子组件:Props - 子组件 → 父组件:Emit - 跨层级通信:Provide/Inject

这两种聊天组件当中也具有相同的结构,实现私聊和群聊组件的高复用性设计,关键在于将通用逻辑特殊逻辑分离。

对于设计思路,准备使用组合式API + 插槽组件的方式。

动手:组件创建

创建相应的组件文件,groupChat.vueprivateChat.vueBaseMessage.vue三个组件文件

BaseMessage.vue中主要由三部分组成,这也是群聊和私聊的共同点,三部分分别是:

  1. 顶部:群聊显示聊天室名称,私聊显示对方用户名
  2. 内容:消息的滚动条列表,用户头像、消息气泡、消息时间,间隔时间较久的消息间显示时间分割线
  3. 底部:消息输入框,可输入文字、图片、表情包

可以先添加相应插槽进入每一块元素,这样可以增强组件的可扩展性。

接下来在群聊和私聊组件当中引入基础插件,传入聊天室名称

将群聊组件和私聊组件引入聊天室当中,使用动态切换的方式进行加载。

js 复制代码
<div class="chat-main">
	<!-- 动态组件模式 -->
	<component :is="curChatRoom.key" :key="curChatRoom.key" :chatRoom="curChatRoom" />
</div>
...
<script>
	import privateChat from "@/components/privateChat.vue";
    import groupChat from "@/components/groupChat.vue";
	// 定义组件选项,这样下面可以直接用字符串名称代表组件
    defineOptions({
        components:{
            groupChat,
            privateChat
        }
    })
    // 定义当前聊天的聊天室,动态组件的key,用于强制组件重新渲染
    const curChatRoom = reactive({
        roomId: "",
        key: "groupChat",
        roomName: "",
        roomType: "public"
    });
	// 切换聊天室,设置当前聊天室和组件
    const switchChatRoom = (room) => {
        curChatRoom.roomId = room.roomId;
        curChatRoom.key = room.roomType == "private" ? "privateChat" : "groupChat";
        curChatRoom.roomName = room.roomName;
    }
</script>

当前效果可做到点击切换左侧聊天室,右侧群聊的名称相应改变

现在已经生成了可复用的群聊组件。

组件交互

在最外层数据传入群聊groupChat和私聊private组件后,组件都需要根据变化的聊天室roomId去获取此聊天室的聊天记录并展示,因为当前我尚未在后端编写相应接口,先用模拟数据代替。

这里用户头像数据不放在聊天数据一起,这能够减轻聊天数据负担。

js 复制代码
// 聊天数据
const mockChatHistory = [
	{
		username: '张三',
		time: new Date('2024-07-01 10:00:00').toLocaleString(),
		content: '大家好,今天天气不错!'
	},
	{
		username: '李四',
		time: new Date('2024-07-01 10:05:00').toLocaleString(),
		content: '是的,适合出去走走。'
	},
	{
		username: '王五',
		time: new Date('2024-07-01 10:10:00').toLocaleString(),
		content: '有没有推荐的地方?'
	}
];

// 对应的头像
mockChatUserAvatar = {
	"张三": 'icon-animal-4',
	"李四": 'icon-animal-9',
	"王五": 'icon-animal-1'
}

得到这些数据后,可以将数据传入BaseMessage.vue基础组件当中,当然外部切换聊天室需要内部对roomId进行监听。

js 复制代码
	// 假设这里有一个外部动态变化的响应式变量 props
    watch(() => props.chatRoom?.roomId, async (newRoomId, oldRoomId) => {
      if (newRoomId) {
            await getChatUserAvatar();
            await getChatHistory();
      }
    });

时间分割线

在聊天窗中判断是否添加时间切割线,通常可参考时间维度聊天活跃度维度标准:

  1. 时间维度
  • 日期变化 :当新消息与上一条消息不在同一天时,添加时间切割线,这是最常见的判断方式,能清晰区分不同日期的聊天记录。
  • 设定时间间隔 :根据预设的时间间隔(如每 5 分钟、每小时等)来判断。当两条消息的时间间隔超过设定值,就添加时间切割线,方便用户按特定时间范围查看聊天记录 。
  1. 聊天活跃度维度
  • 消息密集程度 :若一段时间内聊天信息较为密集,即使在同一天,也可能添加多个时间切割线,以区分不同活跃时段的聊天内容;反之,若聊天信息比较稀疏,即使跨越几天,也可能不添加切割线。

根据传入的时间判断时间分割线是否显示:

js 复制代码
// 判断时间分隔线是否显示
const shouldShowTimeDivider = (index) => {
	if (index === 0) return true; // 第一个消息显示分隔线
	const currentTime = new Date(props.mockChatHistory[index].time);
	const previousTime = new Date(props.mockChatHistory[index - 1].time);
	if (currentTime - previousTime > 5 * 60 * 1000) { // 超过5分钟显示分隔线
		return true;
	} else {
		return false;
	}
}
// 时间分割线时间显示形式
const formatTime = (time) => {
	const date = new Date(time);
	const now = new Date();
	const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
	const yesterday = new Date(today);
	yesterday.setDate(yesterday.getDate() - 1);
	const dayBeforeYesterday = new Date(today);
	dayBeforeYesterday.setDate(dayBeforeYesterday.getDate() - 2);

	// 今天:显示小时:分钟
	if (date >= today) {
		return date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' });
	} 
	// 昨天:显示昨天 小时:分钟
	else if (date >= yesterday) {
		return `昨天 ${date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })}`;
	}
	// 前天:显示前天 小时:分钟
	else if (date >= dayBeforeYesterday) {
		return `前天 ${date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })}`;
	}
	// 今年:显示月份-日期
	else if (date.getFullYear() === now.getFullYear()) {
		return `${date.getMonth() + 1}月${date.getDate()}日 ${date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })}`;
	}
	// 往年:显示xxxx年
	else {
		return `${date.getFullYear()}年${date.getMonth() + 1}月${date.getDate()}日 ${date.toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' })}`;
	}
}

对于时间分割线上时间内容显示:

  • 当日时间:hh:mm
  • 昨天/前天: 昨天/前天 hh:mm
  • 今年内: 月份日期 hh:mm
  • 以往年份:年份月份日期 hh:mm

PS:这里对聊天内容界面的元素布局和样式等内容就不详细说明了,都算比较基础的了


消息输入框

聊天消息输入框需要兼顾用户体验、功能完备性和技术实现优雅性,当然现在我只进行基础的设计

需要考虑的内容

  • 输入框高度调节
  • 功能扩展区
  • @成员提及
  • 表情插入

高度调节

给底部chat-footer元素添加伪类,高度3px,设置ns-resize双向调整大小光标

CSS样式:

css 复制代码
&::before {
	content: '';
	position: absolute;
	top: 0px;
	left: 0;
	right: 0;
	height: 3px;
	background-color: #ccc;
	cursor: ns-resize;
	z-index: 1;
}

效果:

现在光设置css样式还不能拖动边线,需要添加相应的Js方法:(这里在onDragMove移动方法中,设置可移动的最大200px最小100px,chat-content是底部输入框上面的聊天内容区域)

js 复制代码
// 底部footer输入框拖动方法
const footerRef = ref(null);
const isDragging = ref(false);
const startY = ref(0);
const startHeight = ref(0);
// 底部输入框顶部边拖动 鼠标按下事件
const onDragStart = (e) => {
	e.preventDefault(); // 阻止默认行为
	isDragging.value = true;
	startY.value = e.clientY;
	startHeight.value = footerRef.value.offsetHeight;
	document.addEventListener('mousemove', onDragMove);
	document.addEventListener('mouseup', onDragEnd);
	document.body.style.userSelect = 'none'; // 禁用文本选择
};
// 底部输入框顶部边拖动 鼠标移动事件
const onDragMove = (e) => {
	if (!isDragging.value) return;
	const dy = e.clientY - startY.value;
	const newHeight = startHeight.value - dy;
	if (newHeight >= 100 && newHeight <= 200) {
		footerRef.value.style.height = `${newHeight}px`;
		document.querySelector('.chat-content').style.height = `calc(100% - 50px - ${newHeight}px)`;
	}
};
// 底部输入框顶部边拖动 鼠标松开事件
const onDragEnd = () => {
	isDragging.value = false;
	document.removeEventListener('mousemove', onDragMove);
	document.removeEventListener('mouseup', onDragEnd);
	document.body.style.userSelect = ''; // 恢复文本选择
};

onMounted(() => {
	footerRef.value = document.querySelector('.chat-footer');
	// 直接在footer元素上监听mousedown事件,通过event.target判断是否点击了顶部边框
	footerRef.value.addEventListener('mousedown', (e) => {
		if (e.offsetY <= 3) { // 判断是否点击了顶部3px区域
			onDragStart(e);
		}
	});
});

功能扩展区

有最基础的表情和聊天记录icon按钮,如果后续有需要可以继续添加扩展功能,考虑到后续可能群里和私聊功能不同,添加了slot扩展

html 复制代码
<!-- 功能列表 -->
<div class="input-function-list">
	<div class="input-function-item" v-for="funItem in inputFunctionList" :key="funItem.name" :title="funItem.text">
		<svg class="icon" aria-hidden="true">
			<use :xlink:href="'#' + funItem.icon"></use>
		</svg>
	</div>
	<slot name="input-extra-function"></slot>
</div>

基础输入框功能:

js 复制代码
// 输入框功能列表
const inputFunctionList = [
	{ name: 'emoji', icon: 'icon-biaoqing', text: '表情', event: () => { console.log('点击了表情') }   },
	{ name: 'record', icon: 'icon-liaotianjilu', text: '聊天记录', event: () => { console.log('点击了聊天记录')} }
];

可编辑DIV输入框

使用div的contenteditable去制作一个可输入消息框,让div元素可编辑

html 复制代码
<div class="input-area">
	<!-- 可扩展的输入区域 -->
	<slot name="input-area-solt">
		<div ref="editableDiv" class="editable-area" 
			contenteditable 
			@input="handleInput"
			@keydown.enter.exact.prevent="sendMessage"
			@paste="handlePaste">
		</div>
	</slot>
</div>

这里设计:

  • enter:发送消息
  • shift + enter:换行

通过@keydown.enter.exact.prevent已经让回车键和发送消息方法关联起来了,现在设置shift + enter 配置换行。在div中配置 @keydown.shift.enter.prevent="handleShiftEnter"

handleShiftEnter方法用于获取当前选区,在输入框文本对象末尾添加换行节点br,并重新设置光标位置

js 复制代码
// 输入框换行事件处理
const handleShiftEnter = (e) => {
	e.preventDefault();
	const selection = window.getSelection();
	// 检查选区是否在可编辑区域内
	if (!selection.containsNode(editableDiv.value, true)) {
		return;
	}
	const range = selection.getRangeAt(0);
	// 创建并插入换行节点
	const br = document.createElement('br');
	range.insertNode(br);
	// 创建新范围并设置光标位置
	const newRange = document.createRange();
	newRange.setStartAfter(br);  // 在新创建的 <br> 元素后设置光标位置
	newRange.collapse(true);
	// 更新选区
	selection.removeAllRanges();
	selection.addRange(newRange);
}

效果:

当前的复制会将颜色复制过来,所以需要设置handlePaste复制方法,只复制文本内容

这里对于图片数据复制时,也需要进行在输入框自动缩小图片尺寸,主要修改包括:

  • 检测剪贴板中的图片数据
  • 使用 FileReader 读取原始图片数据(event.target.result)创建 img 元素
  • 保持原有的 maxWidth 样式设置将图片缩小到最大宽度100px
  • 保持图片比例不变
  • 将缩小后的图片插入到可编辑区域
  • 仍然保留原有的纯文本粘贴功能
js 复制代码
// 输入框复制事件处理
const handlePaste = (e) => {
	e.preventDefault();
	// 检查是否由图片数据
	const clipboardData = e.clipboardData;
	if (clipboardData.files && clipboardData.files.length > 0) {
		const file = clipboardData.files[0];
		if (file.type.startsWith('image/')) {
			const reader = new FileReader();
			reader.onload = (event) => {
				const range = window.getSelection().getRangeAt(0);
				range.deleteContents();
				const imgElement = document.createElement('img');
				imgElement.src = event.target.result;
				imgElement.style.maxWidth = '100px';
				range.insertNode(imgElement);
			};
			reader.readAsDataURL(file);
			return;
		}
	}

	// 处理纯文本粘贴
	const text = e.clipboardData.getData('text/plain');
	document.execCommand('insertHTML', false, text);
}

to be continued

让作者先歇歇吧,成员提及和表情包插入功能尚未开发,后续还要加入最重要WebSocket发送机制

这些都将再下篇文章中出现,先到此这里吧

相关推荐
陈_杨1 分钟前
鸿蒙5开发宝藏案例分享---切面编程实战揭秘
前端
喵手8 分钟前
CSS3 渐变、阴影和遮罩的使用
前端·css·css3
顽强d石头10 分钟前
bug:undefined is not iterable (cannot read property Symbol(Symbol.iterator))
前端·bug
烛阴19 分钟前
模块/命名空间/全局类型如何共存?TS声明空间终极生存指南
前端·javascript·typescript
火车叼位23 分钟前
Git 精准移植代码:cherry-pick 简单说明
前端·git
江城开朗的豌豆26 分钟前
JavaScript篇:移动端点击的300ms魔咒:你以为用户手抖?其实是浏览器在搞事情!
前端·javascript·面试
华洛33 分钟前
聊聊我们公司的AI应用工程师每天都干啥?
前端·javascript·vue.js
江城开朗的豌豆33 分钟前
JavaScript篇:你以为事件循环都一样?浏览器和Node的差别让我栽了跟头!
前端·javascript·面试
gyx_这个杀手不太冷静36 分钟前
Vue3 响应式系统探秘:watch 如何成为你的数据侦探
前端·vue.js·架构
晴殇i42 分钟前
🌐 CDN跨域原理深度解析:浏览器安全策略的智慧设计
前端·面试·程序员