在之前的文章当中,从需求分析、技术选型到具体实现,逐步完成了聊天室的基础功能,包括动态组件切换、消息展示和输入框交互。
上一篇:从零打造前沿Web聊天组件:从设计到交互作者现在正制作一款聊天室(青春版),本文主要对于Vue3聊天组件进行着重开发讲解 - 掘金
已完成:
- 界面UI:当前效果展示了聊天室列表的基本UI,实现了动态切换聊天室的功能,点击左侧聊天室可切换右侧内容。
- 动态组件模式 :选择动态组件模式(
groupChat
和privateChat
)以实现类型隔离、性能优化和可扩展性。 - 高复用性设计:通过组合式API和插槽组件分离通用逻辑与特殊逻辑。
- 组件创建 :创建了
groupChat.vue
、privateChat.vue
和BaseMessage.vue
三个组件文件 - 组件交互 :
- 使用模拟数据展示聊天记录和用户头像。
- 时间分割线:根据时间维度(日期变化、设定间隔)和活跃度维度判断是否显示分割线,并格式化时间显示。
- 消息列表布局和样式已实现。
- 消息输入框 :
- 高度调节:通过CSS和JS实现输入框高度的拖动调整(100px-200px)。
- 功能扩展区:基础功能包括表情和聊天记录按钮,预留插槽供后续扩展。
- 可编辑DIV输入框 :
- 支持Enter发送消息,Shift+Enter换行。
- 处理粘贴内容,纯文本去格式,图片自动缩小。
对于消息输入框 中,还需要添加 @成员提及 功能和 插入表情
@成员
成员提及功能就是指 @功能 ,此功能一般只在群聊中可用,所以需要通过props进行开放限制
js
chatType: {
type: String,
default: 'group'
}
键盘交互系统
按键 | 行为 | 方法 |
---|---|---|
↑/↓ | 导航成员列表 | navigate() |
←/→/Backspace | 关闭弹窗 | close() |
Enter | 插入选中成员或发送消息 | selectCurrentMember() |
最终效果:

这里的成员提及功能较为复杂,所以我将@成员列表提示弹窗独立封装成一个组件mention.vue,此组件作为@成员功能的专用组件,与输入框紧密配合,需要跟随输入光标位置。
弹窗列表组件
父组件调用:
html
<mention ref="mentionComp" :members="mentionList" @select-member="insertMemberName" ></mention>
用于显示成员提及列表的弹出式组件,主要功能分析如下:
- 模板部分 :
- 使用 v-if 控制显示/隐藏
- 通过 :style 动态设置弹出位置
- 遍历 members 列表显示成员信息
- 包含头像和名称显示区域
- 主要功能 :
- 显示/隐藏控制:通过 showMentionList 响应式变量,外部可通过调用
open
和close
方法进行列表显隐控制 - 位置控制:通过 position 响应式对象设置成员列表出现的位置
- 成员选择: - 点击选择: selectMember 方法 - 键盘导航: navigate 方法(上下箭头) - 回车确认: selectCurrentMember 方法
- 显示/隐藏控制:通过 showMentionList 响应式变量,外部可通过调用
- 与父组件交互 :
- 通过 defineEmits 定义 select-member 事件,子组件调用外部父组件方法
- 通过 defineExpose 暴露方法供父组件调用
- 通过 defineProps 接收成员列表数据
- 样式部分 :
- 绝对定位的弹出框样式
- 成员列表项样式和选中状态
- 最大高度和滚动条控制
说实话制作了弹窗组件后,只是完成一小部分的功能而已,更多的内容在外部输入框中处理
核心问题
- 判断输入 @ 时调用弹窗列表,并获取光标的位置设置弹窗
- 弹窗支持上下键移动选择用户,回车和鼠标点击选择用户时,选区转移处理
- 如何将 @用户名 判断为一个整体,而不是普通文本
- 保证删除时能够整体删除,不是逐字符删除
实现方案
1. 判断@
调用弹窗
创建一个handleInput 输入方法,将其绑定到div
消息输入框上

html
@input="handleInput"
此方法主要用来检测@符号输入并触发成员列表显示,并且支持成员名称的模糊匹配
- 使用 atIndexFlag 标记@输入状态
- 通过 window.getSelection() 获取当前光标位置
- 支持中文输入法特殊字符处理
- 仅在群聊模式下生效( chatType === 'group' )
方法代码:
js
let atIndexFlag = false;
const handleInput = () => {
// 监听@键触发
const selection = window.getSelection();
if (props.chatType === 'group') {
const range = selection.getRangeAt(0);
const text = range.startContainer.textContent;
const cursorPos = range.startOffset;
// 检查是否输入了@符号
if (text[cursorPos - 1] === '@') {
// 触发显示用户列表
showMentionList();
atIndexFlag = true;
} else if (atIndexFlag) {
// 匹配@符号后面和光标前的内容是否与mentionList中的name相同
let atIndex = text.lastIndexOf('@', cursorPos - 2);
const textBeforeCursor = text.substring(atIndex + 1, cursorPos);
// 主要处理中文输入
if (textBeforeCursor.includes(' ')) {
mentionComp.value.close();
return ;
}
// 匹配mentionList中的name
showMentionList(textBeforeCursor);
}
}
}
这里调用的showMentionList方法核心功能是显示用户列表,主要功能包括:
- 获取选区信息 :通过 window.getSelection() 获取当前光标位置和选区范围
- 克隆选区 :使用 cloneSelection() 保存当前选区状态,以便后续恢复
- 过滤成员列表 :调用 useHandleUserInfo 根据输入内容过滤可提及的成员
- 计算弹窗位置 :根据成员列表数量和光标位置计算弹窗显示位置
- 调用子组件 :通过 mentionComp.value.open() 打开成员列表弹窗
这样就能够根据输入框输入的@,弹出相应列表弹窗,并且位置在@后

2. 控制键盘事件
因为出现弹窗列表后,需要根据上下键移动选择用户,回车和鼠标点击选择用户时,选区转移处理
这里就需要添加 handleKeyDown 和 handleEnter 事件
handleKeyDown:仅在 @ 提及功能激活时处理特定按键,处理上下箭头键用于导航成员列表,处理左右箭头、退格和删除键用于关闭成员列表
handleEnter:阻止默认回车行为,判断当前是否显示成员列表弹窗 * 如果显示:获取当前选中成员并插入其名字 * 如果未显示:触发发送消息功能
方法代码:
js
// 在键盘事件中调用
const handleKeyDown = (e) => {
if (!atIndexFlag) return;
if (e.key === 'ArrowUp') {
e.preventDefault();
mentionComp.value.navigate('up');
} else if (e.key === 'ArrowDown') {
e.preventDefault();
mentionComp.value.navigate('down');
} else if (e.key === 'ArrowLeft' || e.key === 'ArrowRight' || e.key === 'Backspace' || e.key === 'Delete') {
mentionComp.value.close();
}
};
// 输入框回车事件处理
const handleEnter = (e) => {
e.preventDefault();
// 判断@成员列表弹窗是否显示
if (mentionComp.value.showMentionList) {
// 调用子组件的 selectCurrentMember 方法
let currentMember = mentionComp.value.selectCurrentMember();
// 如果有选中的成员,插入到光标位置
if (currentMember) {
insertMemberName(currentMember.name);
}
} else {
// 调用发送消息方法
sendMessage();
}
}
3. 列表选中用户,输入框添加@用户名
这里最重要的地方来了,如何让列表中选中的用户出现在输入框,并与普通文本作区别。
关键点:
- 使用
span
包裹,使其在 DOM 中成为一个独立节点 - 设置
contenteditable="false"
防止部分浏览器误操作 - 克隆当前选区和光标位置,恢复选区和光标位置
创建一个插入用户名方法insertMemberName,主要功能为:
- 选区恢复 :如果存在保存的选区(clonedRange )且flag 为false,则恢复之前保存的选区位置
- 获取当前选区 :通过**window.getSelection()**获取当前选区信息
- 查找 @ 符号位置 :在光标前查找最后一个 @ 符号的位置
- 删除@符号及后续内容 :设置选区范围从 @ 符号到光标位置,并删除这部分内容
- 创建并插入成员名字节点 :
- 创建一个带有特定样式的<span>元素
- 设置元素内容为 @ 成员名字
- 将元素插入到选区位置
- 移动光标 :创建新选区并将光标移动到插入的成员名字后面
insertMemberName代码:
js
const insertMemberName = (memberName, flag = true) => {
if (clonedRange && !flag) {
restoreSelection(clonedRange);
}
const selection = window.getSelection();
const range = selection.getRangeAt(0);
const text = range.startContainer.textContent;
const cursorPos = range.startOffset;
// 查找最后一个@符号的位置,进行删除
const atIndex = text.lastIndexOf('@', cursorPos - 1);
if (atIndex !== -1) {
// 设置选区范围从@符号到光标位置
range.setStart(range.startContainer, atIndex);
range.setEnd(range.startContainer, cursorPos);
// 删除选区内容
range.deleteContents();
}
// 插入@名字
const mentionNode = document.createElement('span');
mentionNode.className = 'insert-mention';
mentionNode.contentEditable = 'false';
mentionNode.style.color = '#007bff';
mentionNode.textContent = `@${memberName}`;
// 更新输入框内容
range.insertNode(mentionNode);
// 移动光标到名字后面
const newRange = document.createRange();
// 移动光标到 mention 后面
newRange.setStartAfter(mentionNode);
selection.removeAllRanges();
selection.addRange(newRange);
}
点击用户列表中的用户时会将选区从输入框中移出,这里需要定义两个方法用于恢复选区和光标
- cloneSelection 方法 :
- 获取当前选区对象
- 克隆选区范围
- 返回克隆后的选区对象
- 主要用于保存当前光标位置和选区状态
- restoreSelection 方法 :
- 接收一个克隆的选区对象作为参数
- 清除当前所有选区
- 将克隆的选区对象添加到当前选区中
- 主要用于恢复之前保存的光标位置和选区状态
这两个方法配合使用,在需要临时保存和恢复选区状态的场景下非常有用
方法代码:
js
/**
* 克隆当前选区和光标位置
* @returns {Range} 克隆后的选区对象
*/
const cloneSelection = () => {
const selection = window.getSelection();
const range = selection.getRangeAt(0);
const clonedRange = range.cloneRange();
return clonedRange;
}
/*
* 恢复之前的选区和光标位置
* @param {Range} clonedRange - 之前克隆的选区对象
*/
const restoreSelection = (clonedRange) => {
const selection = window.getSelection();
selection.removeAllRanges();
selection.addRange(clonedRange);
}
目前通过DOM操作和事件管理,实现聊天场景下的@成员提及功能,既保证用户体验流畅性,又为后续扩展留有设计空间。
表情包
表情包插入 的难度相比于 @成员 功能就低了很多,创建一个表情包的组件emojiPlane.vue
因为是简易版聊天室,表情包就不设计复杂的功能了,只有最基础的emoji
表情,这样做的好处是无图片依赖,可以直接使用Unicode字符。
表情数据 :(使用 flatMap 将所有表情展开为一维数组 allEmojis)

使用绝对定位的弹窗展示表情面板,因为触发可能比较频繁,所以通过 v-show 控制显示/隐藏,每个表情项都有悬停效果和点击事件。
功能 :
- selectEmoji 方法触发 select-emoji 事件并关闭面板
- 提供 open 和 close 方法供父组件调用
- 点击面板外部区域自动关闭
选择表情,添加到输入框
选区处理:
- 如果存在保存的选区( clonedRange ),则恢复选区
- 否则聚焦到输入框并创建新选区 光标定位:
- 插入后移动光标到表情后面
- 确保后续输入从正确位置开始
方法代码:
js
/**
* 插入表情包方法
* @param {*} emoji 表情对象,包含 symbol 和 name 属性
*/
const insertEmoji = (emoji) => {
// 根据记录的光标位置插入表情
if (clonedRange) {
restoreSelection(clonedRange);
} else {
// 如果没有选区,则聚焦到输入框并创建新选区
editableDiv.value.focus();
}
const selection = window.getSelection();
const range = selection.getRangeAt(0);
const aInsert = document.createElement('span');
aInsert.innerHTML = emoji.symbol;
aInsert.alt = emoji.name;
aInsert.className = 'insert-emoji';
range.insertNode(aInsert);
// 移动光标到表情后面
const newRange = document.createRange();
newRange.setStartAfter(aInsert);
selection.removeAllRanges();
selection.addRange(newRange);
}
表情包最终效果

两个弹窗组件的具体代码下边就不具体展示了,只放一下script脚本代码
mention.vue组件代码:
html
<!-- 成员列表组件 -->
<script setup>
import { ref, reactive, onMounted } from "vue";
const emit = defineEmits(['select-member']);
const props = defineProps({
members: {
type: Array,
required: true
}
})
// 是否显示
const showMentionList = ref(false);
// 显示位置
const position = reactive({ x: 0, y: 0 });
// 默认选中的成员
let selectedMember = ref("");
// 提供父组件调用的方法
defineExpose({
// 打开弹窗
open:(pos, name)=> {
position.x = pos.x;
position.y = pos.y;
showMentionList.value = true;
selectedMember.value = name;
},
// 关闭弹窗
close:()=> {
showMentionList.value = false;
},
// 选中成员,键盘导航逻辑
navigate: (direction)=> {
if (!showMentionList.value) return;
const currentIndex = props.members.findIndex(m => m.name === selectedMember.value);
if (direction == "up" && currentIndex > 0) {
selectedMember.value = props.members[currentIndex - 1].name;
} else if (direction == "down" && currentIndex < props.members.length - 1) {
selectedMember.value = props.members[currentIndex + 1].name;
}
},
// 回车键选择当前成员
selectCurrentMember: ()=> {
if (!showMentionList.value) return null;
const currentMember = props.members.find(m => m.name === selectedMember.value);
showMentionList.value = false;
return currentMember;
},
showMentionList,
selectedMember
})
// 选择成员
const selectMember = (name)=> {
showMentionList.value = false;
emit('select-member', name, false);
}
// 点击其他地方关闭
onMounted(()=>{
document.addEventListener('click', (e)=>{
if (!e.target.closest('.mention-popover')) {
showMentionList.value = false;
}
})
})
</script>
emojiPlane.vue组件代码:
html
<script setup >
import { ref, reactive, onMounted } from "vue";
// 表情数据
const emojis = [
{
name: '表情',
emojis: [
{ symbol: '😀', name: '笑脸' },
{ symbol: '😂', name: '笑哭' },
...
]
},
...
]
// 将所有表情展开成一维数组
const allEmojis = emojis.flatMap(emoji => emoji.emojis);
// 显示位置
const position = reactive({ x: 0, y: 0 });
// 是否显示
const showEmojiPanel = ref(false);
const emit = defineEmits(['select-emoji']);
// 选择表情
const selectEmoji = (emoji)=> {
emit('select-emoji', emoji);
showEmojiPanel.value = false;
}
// 提供父组件调用的方法
defineExpose({
open:(pos)=> {
position.x = pos.x;
position.y = pos.y;
showEmojiPanel.value = true;
},
close:()=> {
showEmojiPanel.value = false;
},
showEmojiPanel
})
onMounted(()=>{
document.addEventListener('click', (e)=>{
if (!e.target.closest('.emoji-panel') && !e.target.closest('.fun-emoji')) {
showEmojiPanel.value = false;
}
})
})
</script>