目录
[第一章 前言](#第一章 前言)
[第二章 实现](#第二章 实现)
[2.1 组件功能概览](#2.1 组件功能概览)
[2.2 实现思路](#2.2 实现思路)
[2.2.1 富文本核心:contenteditable](#2.2.1 富文本核心:contenteditable)
[2.2.2 标签解析与序列化](#2.2.2 标签解析与序列化)
[2.2.3 光标定位与弹窗跟随](#2.2.3 光标定位与弹窗跟随)
[2.3.4 中文输入法兼容处理](#2.3.4 中文输入法兼容处理)
[2.3.5 Teleport 解决层级问题](#2.3.5 Teleport 解决层级问题)
[2.3.6 双向绑定防死循环机制](#2.3.6 双向绑定防死循环机制)
[第三章 完整代码](#第三章 完整代码)
第一章 前言
在目前的很多AI系统,后台管理系统、表单模板配置、消息推送、问卷系统等业务中,我们经常需要实现一类通用需求:在输入框中输入 / 或者 @ 唤起变量列表 → 选择变量自动插入为标签 → 支持删除标签 → 数据双向绑定 → 支持回显与编辑。本文基于 Vue3 + 原生 DOM API,实现一套轻量、无第三方依赖。
第二章 实现
2.1 组件功能概览
本组件已实现的完整能力:
- v-model 双向绑定 ,数据格式为
{``{id|name}} - 输入
/自动触发变量选择面板 - 支持关键词模糊搜索过滤变量列表
- 光标实时定位,弹窗跟随光标位置
- 键盘 ↑↓ 选择、Enter 确认、ESC 关闭
- 插入不可编辑标签,标签支持删除
- 支持 placeholder、禁用状态、聚焦样式
- 粘贴自动过滤为纯文本,防止富文本污染
- 兼容中文输入法(composition 事件处理)
- 使用 Teleport 挂载弹窗到 body,解决层级遮挡
- 支持外部通过
ref.focus()主动聚焦
数据存储格式示例:
javascript
"测试描述{{22bb83a1e45b4f1ea0db456a87eb842e|机构性质}} {{436979cd47234041adbbb22285cb9b81|测试印章2}} {{8a5e203679a7486f99327ca7ef75a869|机构地址}} ?"
2.2 实现思路
2.2.1 富文本核心:contenteditable
- contenteditable 是 HTML 提供的一个非常强大的属性,它能瞬间把任何元素变成一个可编辑区域。借助它,我们可以轻松实现在线笔记、表格编辑、富文本编辑器等功能。
html
<div
ref="editorRef"
class="mention-textarea__editor"
:contenteditable="!disabled"
></div>
- contenteditable="true":让普通 div 变成浏览器原生可编辑区域
- 优点:轻量、可控、不依赖任何富文本库
- 难点:光标管理、DOM 序列化、数据同步
2.2.2 标签解析与序列化
这是整个功能最核心的逻辑,实现 字符串 ↔ DOM 互相转换
- 匹配格式:{{变量ID|变量名}};
javascript
const TAG_REGEX = /\{\{([^|]+)\|([^}]+)\}\}/g
- 字符串 → DOM 片段
javascript
function parseToSegments(str) {
// 拆分文本片段与标签片段
}
- DOM 片段 → 可存储字符串
javascript
function serializeFromDOM(el) {
// 遍历 DOM 节点,还原为 {{id|name}} 格式
}
- 作用:
- 编辑时:DOM 结构 → 字符串,用于提交后端
- 回显时:字符串 → DOM 结构,用于页面渲染
2.2.3 光标定位与弹窗跟随
javascript
function getCaretRect() {
const sel = window.getSelection()
const range = sel.getRangeAt(0)
return range.getBoundingClientRect()
}
- window.getSelection():获取用户选区对象
- getRangeAt(0):获取光标范围
- getClientRects():获取光标坐标
- 零宽字符 \u200b 兼容空行光标位置获取
2.3.4 中文输入法兼容处理
javascript
const isComposing = ref(false)
function handleCompositionStart() {
isComposing.value = true
}
function handleCompositionEnd() {
isComposing.value = false
handleInput()
}
- 监听 compositionstart / compositionend 避免中文输入
- 未上屏时触发搜索、校验、弹窗
2.3.5 Teleport 解决层级问题
javascript
<Teleport to="body">
<div class="mention-popup" :style="popupAbsStyle">
...
</div>
</Teleport>
2.3.6 双向绑定防死循环机制
javascript
let skipNextWatch = false
function emitValue() {
skipNextWatch = true
// ... 触发更新
}
watch(() => props.modelValue, (newVal) => {
if (skipNextWatch) {
skipNextWatch = false
return
}
// ... 重新渲染
})
- 自身触发更新时,跳过监听,避免无限循环
- 外部修改 modelValue 时正常响应并重新渲染
第三章 完整代码
javascript
<script setup>
import {
ref, computed, watch, nextTick,
onMounted, onUnmounted, throttle, debounce
} from 'vue'
const props = defineProps({
modelValue: {
type: String,
default: ''
},
mentionOptions: {
type: Array,
default: () => []
},
placeholder: {
type: String,
default: '请输入内容,输入 / 可插入变量'
},
disabled: {
type: Boolean,
default: false
}
})
const emit = defineEmits(['update:modelValue'])
const editorRef = ref(null)
const showPopup = ref(false)
const popupStyle = ref({ top: '0px', left: '0px' })
const searchText = ref('')
const selectedIndex = ref(0)
const slashInfo = ref(null)
const isFocused = ref(false)
const isComposing = ref(false)
// 用于清理标签关闭事件,防止内存泄漏
const tagClickHandlers = new WeakMap()
const filteredOptions = computed(() => {
if (!searchText.value) return props.mentionOptions
const keyword = searchText.value.toLowerCase()
return props.mentionOptions.filter(opt =>
opt.name.toLowerCase().includes(keyword)
)
})
watch(filteredOptions, () => {
selectedIndex.value = 0
})
const TAG_REGEX = /\{\{([^|]+)\|([^}]+)\}\}/g
// 字符串解析为片段
function parseToSegments(str) {
if (!str) return []
const segments = []
let lastIndex = 0
const re = new RegExp(TAG_REGEX.source, 'g')
let match
while ((match = re.exec(str)) !== null) {
if (match.index > lastIndex) {
segments.push({ type: 'text', value: str.slice(lastIndex, match.index) })
}
segments.push({ type: 'tag', id: match[1], name: match[2] })
lastIndex = re.lastIndex
}
if (lastIndex < str.length) {
segments.push({ type: 'text', value: str.slice(lastIndex) })
}
return segments
}
// DOM 序列化为存储字符串
function serializeFromDOM(el) {
if (!el) return ''
let result = ''
for (const node of el.childNodes) {
if (node.nodeType === Node.TEXT_NODE) {
result += node.textContent
} else if (node.nodeType === Node.ELEMENT_NODE) {
if (node.dataset?.tagId) {
result += `{{${node.dataset.tagId}|${node.dataset.tagName}}}`
} else {
result += node.textContent || ''
}
}
}
return result
}
// 创建标签 DOM
function createTagNode(id, name) {
const span = document.createElement('span')
span.className = 'mention-tag'
span.contentEditable = 'false'
span.dataset.tagId = id
span.dataset.tagName = name
const label = document.createElement('span')
label.className = 'mention-tag__label'
label.textContent = name
span.appendChild(label)
const close = document.createElement('span')
close.className = 'mention-tag__close'
close.textContent = '\u00d7'
const handler = (e) => {
e.preventDefault()
e.stopPropagation()
span.remove()
emitValue()
}
close.addEventListener('mousedown', handler)
tagClickHandlers.set(close, handler)
span.appendChild(close)
return span
}
// 渲染编辑器内容
function renderDOM() {
const el = editorRef.value
if (!el) return
const segments = parseToSegments(props.modelValue)
el.innerHTML = ''
segments.forEach(seg => {
if (seg.type === 'text') {
el.appendChild(document.createTextNode(seg.value))
} else {
el.appendChild(createTagNode(seg.id, seg.name))
}
})
}
// 获取光标位置
function getCaretRect() {
const sel = window.getSelection()
if (!sel || !sel.rangeCount) return null
const range = sel.getRangeAt(0).cloneRange()
range.collapse(true)
const rect = range.getClientRects()[0]
if (rect) return rect
// 兼容空光标位置
const span = document.createElement('span')
span.textContent = '\u200b'
range.insertNode(span)
const r = span.getBoundingClientRect()
span.remove()
sel.removeAllRanges()
sel.addRange(range)
return r
}
// 节流更新弹窗位置
const updatePopupPositionThrottle = throttle(() => {
const rect = getCaretRect()
const editorRect = editorRef.value?.getBoundingClientRect()
if (!rect || !editorRect) return
popupStyle.value = {
top: `${rect.bottom - editorRect.top + 4}px`,
left: `${rect.left - editorRect.left}px`
}
}, 80)
// 关闭弹窗
function closePopup() {
showPopup.value = false
searchText.value = ''
slashInfo.value = null
selectedIndex.value = 0
}
// 输入事件防抖
const handleInputDebounce = debounce(() => {
if (isComposing.value) return
checkSlashTrigger()
emitValue()
}, 180)
// 中文输入开始
function handleCompositionStart() {
isComposing.value = true
}
// 中文输入结束
function handleCompositionEnd() {
isComposing.value = false
handleInputDebounce()
}
// 检测 / 触发变量面板
function checkSlashTrigger() {
const sel = window.getSelection()
if (!sel || !sel.rangeCount) {
closePopup()
return
}
const range = sel.getRangeAt(0)
const node = range.startContainer
if (node.nodeType !== Node.TEXT_NODE) {
closePopup()
return
}
const text = node.textContent
const cursor = range.startOffset
const slashIdx = text.lastIndexOf('/', cursor)
if (slashIdx === -1 || slashIdx >= cursor) {
closePopup()
return
}
const between = text.slice(slashIdx + 1, cursor)
if (/\n/.test(between)) {
closePopup()
return
}
searchText.value = between
slashInfo.value = { node, slashOffset: slashIdx }
selectedIndex.value = 0
showPopup.value = true
nextTick(updatePopupPositionThrottle)
}
// 选择变量插入
function selectOption(opt) {
if (!slashInfo.value || !editorRef.value) return
const { node, slashOffset } = slashInfo.value
const sel = window.getSelection()
const text = node.textContent
const before = text.slice(0, slashOffset)
const after = text.slice(sel.getRangeAt(0).startOffset)
const parent = node.parentNode
parent.insertBefore(document.createTextNode(before), node)
parent.insertBefore(createTagNode(opt.id, opt.name), node)
parent.insertBefore(document.createTextNode(after || '\u00a0'), node)
parent.removeChild(node)
closePopup()
emitValue()
}
// 键盘上下选择
function handleKeydown(e) {
if (!showPopup.value) return
const opts = filteredOptions.value
if (e.key === 'ArrowDown') {
e.preventDefault()
selectedIndex.value = (selectedIndex.value + 1) % opts.length
} else if (e.key === 'ArrowUp') {
e.preventDefault()
selectedIndex.value = (selectedIndex.value - 1 + opts.length) % opts.length
} else if (e.key === 'Enter') {
e.preventDefault()
opts.length && selectOption(opts[selectedIndex.value])
} else if (e.key === 'Escape') {
e.preventDefault()
closePopup()
}
}
function handleFocus() {
isFocused.value = true
}
function handleBlur() {
isFocused.value = false
setTimeout(closePopup, 200)
}
// 粘贴纯文本(替换废弃 API)
function handlePaste(e) {
e.preventDefault()
const text = e.clipboardData.getData('text/plain')
const range = window.getSelection().getRangeAt(0)
range.deleteContents()
range.insertNode(document.createTextNode(text))
emitValue()
}
// 弹窗样式
const popupAbsStyle = computed(() => {
const rect = editorRef.value?.getBoundingClientRect()
if (!rect) return {}
return {
position: 'fixed',
top: `${rect.top + parseFloat(popupStyle.value.top || 0)}px`,
left: `${rect.left + parseFloat(popupStyle.value.left || 0)}px`,
zIndex: 9999
}
})
// 双向绑定防死循环
let skipNextWatch = false
function emitValue() {
skipNextWatch = true
const val = serializeFromDOM(editorRef.value)
if (val !== props.modelValue) {
emit('update:modelValue', val)
}
if (!val && editorRef.value) {
nextTick(() => {
editorRef.value.innerHTML = ''
})
}
}
watch(
() => props.modelValue,
(newVal) => {
if (skipNextWatch) {
skipNextWatch = false
return
}
const current = serializeFromDOM(editorRef.value)
if (current !== newVal) {
renderDOM()
}
}
)
onMounted(() => {
renderDOM()
})
// 销毁事件,避免内存泄漏
onUnmounted(() => {
tagClickHandlers.forEach((handler, el) => {
el.removeEventListener('mousedown', handler)
})
})
defineExpose({
focus: () => editorRef.value?.focus()
})
</script>
<template>
<div class="mention-textarea" :class="{ 'is-focused': isFocused, 'is-disabled': disabled }">
<div
ref="editorRef"
class="mention-textarea__editor"
:contenteditable="!disabled"
:data-placeholder="placeholder"
@input="handleInputDebounce"
@keydown="handleKeydown"
@focus="handleFocus"
@blur="handleBlur"
@paste="handlePaste"
@compositionstart="handleCompositionStart"
@compositionend="handleCompositionEnd"
></div>
<Teleport to="body">
<div v-if="showPopup && filteredOptions.length" class="mention-popup" :style="popupAbsStyle">
<div
v-for="(opt, idx) in filteredOptions"
:key="opt.id"
class="mention-popup__item"
:class="{ 'is-active': idx === selectedIndex }"
@mousedown.prevent="selectOption(opt)"
@mouseenter="selectedIndex = idx"
>
{{ opt.name }}
</div>
</div>
</Teleport>
</div>
</template>
<style lang="scss" scoped>
.mention-textarea {
position: relative;
border: 1px solid #dcdfe6;
border-radius: 4px;
background: #fff;
transition: border-color 0.2s;
width: 100%;
&.is-focused {
border-color: #605ce5;
}
&.is-disabled {
background: #f5f7fa;
cursor: not-allowed;
.mention-textarea__editor {
cursor: not-allowed;
color: #a8abb2;
}
}
&__editor {
min-height: 60px;
max-height: 200px;
overflow-y: auto;
padding: 5px 14px;
font-size: 14px;
line-height: 1.8;
color: #606266;
outline: none;
word-break: break-all;
white-space: pre-wrap;
&:empty::before {
content: attr(data-placeholder);
color: #c9c9c9;
pointer-events: none;
}
}
}
</style>
<style lang="scss">
.mention-tag {
display: inline-flex;
align-items: center;
gap: 2px;
padding: 0 8px;
margin: 0 2px;
height: 22px;
line-height: 22px;
background: rgba(96, 92, 229, 0.08);
border: 1px solid #605ce5;
border-radius: 4px;
color: #605ce5;
font-size: 12px;
vertical-align: middle;
user-select: none;
cursor: default;
&__label {
max-width: 120px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&__close {
cursor: pointer;
font-size: 14px;
line-height: 1;
margin-left: 2px;
color: #605ce5;
opacity: 0.6;
&:hover {
opacity: 1;
}
}
}
.mention-popup {
background: #fff;
border: 1px solid #e4e7ed;
border-radius: 4px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.12);
max-height: 200px;
overflow-y: auto;
min-width: 150px;
&__item {
padding: 8px 12px;
font-size: 14px;
color: #606266;
cursor: pointer;
transition: background 0.15s;
&:hover,
&.is-active {
background: #f5f4fe;
color: #605ce5;
}
}
}
</style>