前言
在现代Web应用中,@标签功能已经成为社交、协作类应用的标配。从微信@群成员、钉钉@同事,到各种评论系统的@用户功能,@标签输入体验直接影响用户的使用感受。本文将详细介绍如何基于Vue3 + TypeScript实现一个高性能、功能完善的@标签富文本编辑器组件。
目录
需求分析与技术选型
功能需求
通过对主流产品的分析,我们确定了以下核心需求:
- ✅ 基础@功能:输入@符号触发用户选择
- ✅ 多行支持:支持Enter换行,@符号仍然有效
- ✅ 智能定位:下拉框自动避开屏幕边界
- ✅ 键盘导航:上下键选择,Enter确认,Esc取消
- ✅ 中文输入:完美支持中文输入法
- ✅ 长度限制:可选的文本长度限制
- ✅ TypeScript:完整的类型支持
技术选型
基于Vue3生态,我们选择了以下技术栈:
// 核心依赖
{
"vue": "^3.3.4",
"typescript": "^5.0.2",
"vite": "^4.4.5"
}
// 为什么选择contenteditable?
// 1. 原生支持,无需额外依赖
// 2. 性能优秀,适合复杂文本编辑
// 3. 与DOM原生交互,控制力强
核心架构设计
组件结构
AtInputSelect/
├── index.vue # 主组件
├── AtDialog.vue # 下拉选择框
├── AtUtil.ts # 类型定义
└── README.md # 使用文档
数据模型设计
// 核心数据结构
interface IIndicatorNode {
type: 'text' | 'at' // 节点类型
value: string // 文本内容或@标签key
entity: string | IIndicatorItem // 关联实体
}
interface IIndicatorItem {
key: string | number // 唯一标识
name: string // 显示名称
demoValue: string // 演示值
}
状态管理
// 核心状态
const showAtDialog = ref<boolean>(false) // 下拉框显示状态
const position = ref<IPosition>({ x: 0, y: 0 }) // 下拉框位置
const queryString = ref<string>('') // 搜索字符串
const isComposited = ref<boolean>(false) // 中文输入状态
关键实现细节
1. @符号检测算法
这是整个组件的核心,需要准确检测用户输入的@符号:
const showAt = () => {
const selection = window.getSelection()
if (!selection || !selection.focusNode) return false
let content = ''
let cursorIndex = 0
// 处理不同类型的节点
if (selection.focusNode.nodeType === Node.TEXT_NODE) {
content = selection.focusNode.textContent || ''
cursorIndex = selection.focusOffset
} else if (selection.focusNode.nodeType === Node.ELEMENT_NODE) {
const range = selection.getRangeAt(0)
const preCaretRange = range.cloneRange()
preCaretRange.selectNodeContents(selection.focusNode)
preCaretRange.setEnd(range.endContainer, range.endOffset)
content = preCaretRange.toString()
cursorIndex = content.length
}
const beforeCursor = content.slice(0, cursorIndex)
const regx = /@([^@\s]*)$/
const match = regx.exec(beforeCursor)
return match && match.length === 2
}
技术要点:
- 使用
Range.cloneRange()获取光标前的所有文本 - 正则表达式
/@([^@\s]*)$/匹配@符号后的非空格字符 - 兼容文本节点和元素节点两种情况
2. 智能定位算法
下拉框位置计算是用户体验的关键:
const getRangeRect = () => {
const selection = window.getSelection()
const range = selection?.getRangeAt(0)
const rect = range?.getClientRects()[0]
const clientWidth = window.innerWidth
const clientHeight = window.innerHeight
const DIALOG_WIDTH = 224
const DIALOG_MAX_HEIGHT = 296
const MARGIN = 16
// 获取页面滚动位置
const scrollX = window.pageXOffset || document.documentElement.scrollLeft
const scrollY = window.pageYOffset || document.documentElement.scrollTop
// 计算光标在页面中的绝对位置(加上滚动偏移)
let x = (rect?.x || 0) + scrollX
let y = (rect?.y || 0) + scrollY + 30 // LINE_HEIGHT
// 防止超出右边界
if (x + DIALOG_WIDTH > clientWidth + scrollX) {
x = clientWidth + scrollX - DIALOG_WIDTH - MARGIN
}
// 防止超出左边界
if (x < MARGIN + scrollX) {
x = MARGIN + scrollX
}
// 智能上下定位
if (y + DIALOG_MAX_HEIGHT > clientHeight + scrollY) {
const aboveY = (rect?.y || 0) + scrollY - DIALOG_MAX_HEIGHT
if (aboveY >= MARGIN + scrollY) {
y = aboveY // 显示在上方
} else {
y = MARGIN + scrollY // 显示在顶部
}
}
return { x, y }
}
技术亮点:
- 使用
getClientRects()获取光标准确位置 - 四向边界检测,确保下拉框始终可见
- 智能上下定位,优先显示在下方,空间不足时显示在上方
- 新增:页面滚动偏移计算,确保下拉框位置跟随滚动
2.1 滚动跟随机制
为了实现下拉框跟随页面滚动,我们添加了滚动事件监听:
// 滚动事件处理函数
let scrollHandler: ((e: Event) => void) | null = null
// 显示下拉框时添加滚动监听
const handleShow = () => {
showAtDialog.value = true
if (!scrollHandler) {
scrollHandler = () => {
if (showAtDialog.value) {
position.value = getRangeRect()
}
}
window.addEventListener('scroll', scrollHandler, true)
}
}
// 隐藏下拉框时移除滚动监听
const handleHide = () => {
showAtDialog.value = false
if (scrollHandler) {
window.removeEventListener('scroll', scrollHandler, true)
scrollHandler = null
}
}
// 组件卸载时清理
onUnmounted(() => {
if (scrollHandler) {
window.removeEventListener('scroll', scrollHandler, true)
scrollHandler = null
}
})
关键技术点:
- 使用绝对定位(
position: absolute)替代固定定位 - 实时监听滚动事件,动态更新下拉框位置
- 组件卸载时正确清理事件监听器,避免内存泄漏
3. Teleport渲染优化与定位策略
使用Vue3的Teleport避免下拉框被遮挡,配合绝对定位实现滚动跟随:
<template>
<teleport to="body">
<div class="wrapper" :style="{
position: 'absolute',
top: position?.y + 'px',
left: position?.x + 'px',
zIndex: 9999
}">
<!-- 下拉框内容 -->
</div>
</teleport>
</template>
优势:
- 避免父元素
overflow: hidden的影响 - 脱离文档流,不影响原有布局
- 高层级z-index确保始终在最上层
- 新增:绝对定位配合滚动监听,实现完美的滚动跟随效果
定位策略对比:
position: fixed:相对于视口定位,滚动时位置固定不变position: absolute:相对于文档定位,滚动时跟随页面内容移动
为了实现滚动跟随,我们选择了position: absolute,这样下拉框会随着页面滚动而移动,始终保持与光标的相对位置不变。
4. 多行输入支持
通过CSS属性控制单行/多行模式:
<template>
<div
class="editor"
:style="{
whiteSpace: multiline ? 'pre-wrap' : 'nowrap',
overflowY: multiline ? 'auto' : 'hidden',
minHeight: multiline ? minHeight + 'px' : '32px',
maxHeight: multiline ? maxHeight + 'px' : '32px'
}"
></div>
</template>
Enter键处理:
const handleKeyDown = (e: any) => {
// 多行模式下允许换行,单行模式下阻止
if (e.code === 'Enter' && !showAtDialog.value) {
if (!props.multiline || props.isInput) {
e.preventDefault()
return false
}
}
}
5. 中文输入法支持
解决中文输入法与@符号检测的冲突:
const handleCompositionStart = (e: any) => {
isComposited.value = true
}
const handleCompositionEnd = (e: any) => {
isComposited.value = false
if (showAtDialog.value) {
queryString.value = getAtUser() || ''
}
}
const handleKeyUp = (e: any) => {
// 中文输入期间不处理@符号检测
if (isComposited.value) return false
// ... 其他逻辑
}
性能优化策略
1. 防抖搜索
let timer: any = null
watch(() => props.queryString, (searchName: string) => {
if (timer) clearTimeout(timer)
timer = setTimeout(() => {
refleshData(searchName)
}, 100)
})
2. 延迟数据保存
const handleKeyUp = (e: any) => {
// ... @符号检测逻辑
// 延迟调用onSaveingData,避免干扰@符号检测
setTimeout(() => {
onSaveingData()
}, 0)
}
3. 智能事件监听
onMounted(() => {
document.addEventListener('keyup', keyDownhandler, true)
document.addEventListener('click', checkClickHandler, true)
})
onUnmounted(() => {
document.removeEventListener('keyup', keyDownhandler, true)
document.removeEventListener('click', checkClickHandler, true)
})
遇到的坑与解决方案
坑1:输入@符号后无法继续输入
问题现象: 输入@符号后,整个输入框被锁定,无法继续输入。
根本原因:
// 错误的逻辑
onSaveingData()
if (maxlength.value >= props.limit) {
e.preventDefault() // 总是被阻止!
}
解决方案:
// 修复后的逻辑
if (isNeedLimit.value) {
onSaveingData()
if (maxlength.value >= props.limit) {
e.preventDefault()
}
}
经验总结:
- 边界条件要仔细测试,特别是0值的情况
- 限制逻辑应该只在设置了限制时才生效
- 防御性编程,避免意外的副作用
坑2:下拉框不跟随页面滚动
问题现象: 在页面滚动时,下拉框位置保持不变,与光标位置脱节。
根本原因:
使用position: fixed定位,下拉框相对于视口定位,不会跟随页面滚动。
解决方案:
// 1. 修改定位方式为absolute
position: 'absolute' // 替代 'fixed'
// 2. 计算滚动偏移
const scrollX = window.pageXOffset || document.documentElement.scrollLeft
const scrollY = window.pageYOffset || document.documentElement.scrollTop
// 3. 更新位置计算
let x = (rect?.x || 0) + scrollX
let y = (rect?.y || 0) + scrollY + LINE_HEIGHT
// 4. 添加滚动监听
scrollHandler = () => {
if (showAtDialog.value) {
position.value = getRangeRect()
}
}
window.addEventListener('scroll', scrollHandler, true)
坑3:中文输入法冲突
问题现象: 使用中文输入法输入@符号时,触发多次选择框。
解决方案:
使用compositionstart和compositionend事件跟踪输入法状态。
完整实现与使用指南
基础使用
<template>
<AtInputSelect
v-model="content"
place-holder="请输入内容,使用@提及他人"
:comp-width="500"
/>
</template>
<script setup>
import { ref } from 'vue'
import AtInputSelect from './components/AtInputSelect/index.vue'
const content = ref([])
</script>
高级配置
<template>
<AtInputSelect
v-model="content"
place-holder="支持多行输入,按Enter换行"
:multiline="true"
:min-height="100"
:max-height="200"
:limit="100"
:comp-width="600"
@update:modelValue="handleChange"
/>
</template>
API文档
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| modelValue | Array | [] | 绑定值 |
| placeHolder | String | '' | 占位符 |
| multiline | Boolean | false | 多行模式 |
| limit | Number | 0 | 长度限制 |
| minHeight | Number | 32 | 最小高度 |
| maxHeight | Number | 120 | 最大高度 |
总结与展望
技术收获
- 深入理解contenteditable:掌握了DOM操作和Selection API
- 性能优化实践:防抖、延迟、智能事件监听
- 边界条件处理:中文输入、屏幕边界、长度限制
- 组件设计思维:可配置、可扩展、易维护
未来优化方向
-
功能增强:
- 支持#话题标签
- 表情符号支持
- 富文本格式化
-
性能优化:
- 虚拟滚动(大量用户时)
- Web Worker处理搜索
- 懒加载用户数据
-
体验提升:
- 移动端适配
- 无障碍支持
- 主题定制
-
滚动跟随优化:
- 支持容器内滚动(如iframe、modal等)
- 平滑滚动动画
- 智能滚动边界检测
开源贡献
这个组件已经开源,欢迎贡献代码和提出建议:
# 项目地址
https://github.com/weisXX/AtSelect
# 安装使用
npm install @weisxx/at-select
结语
通过这个@标签富文本编辑器的实现,我们深入了解了Vue3组件开发的各种技巧和最佳实践。从需求分析到架构设计,从核心算法到性能优化,每一步都体现了前端工程师的专业素养。
特别值得一提的是滚动跟随功能的实现,这个看似简单的功能背后涉及到了DOM定位、事件监听、内存管理等多个技术点。通过将position: fixed改为position: absolute,配合滚动偏移计算和实时位置更新,我们完美解决了下拉框不跟随页面滚动的问题。
希望这篇文章能帮助到正在开发类似功能的开发者,也期待大家在使用过程中提出宝贵的意见和建议。让我们一起构建更好的用户体验!