记录一个开发遇到需求:输入框不允许用户随意输入文本,只能插入特定的"标签"或"变量" ,并且这些标签需要支持删除操作。
普通的input或textarea 无法实现标签样式的嵌入,而富文本编辑器(插件)又过于厚重。所以想到利用 HTML 的 contenteditable 属性,手撸一个轻量级、不支持直接键入、但支持光标插入的标签编辑器。
🎯 核心功能与需求
- 禁止直接输入:用户按键盘字符键无效,不能输入普通文本。
- 支持标签插入:通过外部触发或特定逻辑插入可视化的标签(DOM 节点)。
- 光标控制:虽然不能打字,但光标需要能定位,以便在两个标签中间插入新标签。
- 删除功能:支持点击标签内的"×"号删除,或通过Backspace 删除(代码中主要演示点击删除)。
- 数据双向绑定:标签的变化需要同步更新到 Vue 的 model 数据中。
🤠 先上效果

🛠️ 实现思路
利用 div 的 contenteditable="true"。实现可以自定样式标签插入, 但为了控制输入,我们需要监听一系列事件:
html
<template>
<div
ref="inputBoxRef"
contenteditable="true"
id="editor-box"
placeholder="请插入"
@beforeInput="onBeforeInput"
@keydown="handleKeydown"
@compositionstart="onCompositionStart"
@compositionend="onCompositionEnd"
@paste.prevent></div>
</template>
如何"禁止输入"但"允许插入"?
🚫 拦截常规输入
我们通过 onBeforeInput 和 handleKeydown 阻止默认行为:
typescript
// 禁止直接输入文本
const onBeforeInput = (e: InputEvent) => {
e.preventDefault();
};
// 阻止回车换行等键盘事件
const handleKeydown = (e: KeyboardEvent) => {
e.preventDefault();
};
🚫 拦截组合输入
通过onCompositionStart 和 onCompositionEnd 阻止输入法软件键入问题;
typescript
const onCompositionStart = () => {
isComposing.value = true;
};
// 处理组合输入结束
const onCompositionEnd = () => {
isComposing.value = false;
if (!inputBoxRef.value) return;
clearValue();
$matchList.value.forEach((i) => insertMatch(i.matchLabel));
};
✨ 魔法:零宽空格 (ZWSP)
既然禁止了输入,光标在两个标签 之间往往会"无法停留"或者被浏览器怪异的行为吞噬。为了解决这个问题,引入了 零宽空格 (\u200B)。
在插入标签时,采用 ZWSP + 标签 + ZWSP 的三明治结构:
typescript
// 插入零宽空格的辅助函数
const createZwsp = () => document.createTextNode('\u200B');
- 前置 ZWSP:确保标签前面有一个可以放置光标的文本节点。
- 后置 ZWSP:确保标签后面有一个可以放置光标的文本节点,且插入完成后,光标能跳到这个位置。
核心插入函数
typescript
const insertMatch = (character: string) => {
const inputBox = inputBoxRef.value;
if (!inputBox) return;
const selection = window.getSelection();
// 如果没有选区,或者选区不在输入框内,则强制聚焦到末尾
...
...
... 省略
let range = selection.getRangeAt(0);
range.deleteContents(); // 如果有选中文字,先删除
// 1. 准备"三明治"结构
const zwspBefore = createZwsp();
const zwspAfter = createZwsp();
// 2. 创建标签 DOM
const matchDiv = document.createElement('span');
matchDiv.className = 'matchItem';
matchDiv.contentEditable = 'false'; // 关键:标签本身作为一个整体,不可编辑内部文字
matchDiv.dataset.character = character;
matchDiv.innerHTML = `${character}<span class="remove-btn">×</span>`;
// 3. 绑定删除事件(闭包处理)
matchDiv.querySelector('.remove-btn')?.addEventListener('click', (e) => {
e.stopPropagation();
// 更新数据源逻辑...
});
// 4. 使用 DocumentFragment 插入 DOM
const frag = document.createDocumentFragment();
frag.appendChild(zwspBefore);
frag.appendChild(matchDiv);
frag.appendChild(zwspAfter);
range.insertNode(frag);
// 5. 修正光标位置:移动到后置 ZWSP 后面
range.setStartAfter(zwspAfter);
range.setEndAfter(zwspAfter);
selection.removeAllRanges();
selection.addRange(range);
// 滚动到底部
inputBox.scrollTop = inputBox.scrollHeight;
};
样式处理 (SCSS)
为了让标签看起来像"标签",并且解决 contenteditable 的一些默认怪异样式,需调整 CSS。
- user-select: none: 防止用户不小心选中标签内的文字(这通常会导致删除时只删了一半标签)。
- vertical-align: middle: 保证标签和零宽空格(虽然看不见)在垂直方向居中。
- :empty::before: 纯 CSS 实现 placeholder 效果。
css
#editor-box {
white-space: pre-wrap; // 保留空白符
word-break: break-all;
line-height: 2; // 给标签留足垂直空间
// 纯 CSS 实现 Placeholder
&:empty::before {
content: attr(placeholder);
color: #aaa;
pointer-events: none;
display: block;
}
}
.matchItem {
display: inline-flex; // 使用 flex 布局方便对齐
user-select: none; // 禁止选中内部文本
contenteditable: false; // 再次确保不可编辑
// ...其他美化样式
}
⚠️ 遇到的坑与注意事项
1、中文输入法 (IME):
代码中监听了 compositionstart 和 compositionend 。这是为了处理用户如果不小心触发了输入法,在输入法选词结束时(compositionend),代码逻辑选择了清空当前输入并重新渲染列表。这是为了防止输入法残留文字破坏 DOM 结构。
2、光标丢失:
如果你直接操作 innerHTML(例如 inputBox.innerHTML += ...) ,光标会重置到最前面。这也是为什么我们必须使用 Range API 来插入节点,它能在修改 DOM 后保留光标位置。
3、数据同步:
由于这是一个非受控组件(DOM 操作为主),我们需要手动维护 matchList 数组。当点击标签内的 × 时,需要根据 dataset 找到对应的数据并剔除
完整源码
javascript
<template>
<div
ref="inputBoxRef"
contenteditable="true"
id="editor-box"
placeholder="请插入"
class="dark:!border-dark-border"
@beforeInput="onBeforeInput"
@keydown="handleKeydown"
@compositionstart="onCompositionStart"
@compositionend="onCompositionEnd"
@paste.prevent></div>
</template>
<script lang="ts" setup>
import { ref } from 'vue';
const emit = defineEmits(['updateMatchList']);
const $matchList = defineModel<{ matchLabel: string; id?: string | number }[]>('matchList', {
default: [],
});
const inputBoxRef = ref<HTMLElement | null>(null);
// 组合输入(处理中文输入法键入)
const isComposing = ref(false);
const onCompositionStart = () => {
isComposing.value = true;
};
// 处理组合输入结束
const onCompositionEnd = () => {
isComposing.value = false;
if (!inputBoxRef.value) return;
clearValue();
$matchList.value.forEach((i) => insertMatch(i.matchLabel));
};
// 禁止直接输入文本
const onBeforeInput = (e: InputEvent) => {
e.preventDefault();
};
// 处理键盘事件 阻止手动换行
const handleKeydown = (e: KeyboardEvent) => {
e.preventDefault();
};
// 插入零宽空格
const createZwsp = () => document.createTextNode('\u200B');
const insertMatch = (character: string) => {
const inputBox = inputBoxRef.value;
if (!inputBox) return;
const selection = window.getSelection();
if (!selection || selection.rangeCount === 0) return;
let range = selection.getRangeAt(0);
// 确保 range 在输入框内
if (!inputBox.contains(range.commonAncestorContainer)) {
inputBox.focus();
range = document.createRange();
range.selectNodeContents(inputBox);
range.collapse(false); // 移动到末尾
}
// 删除选区内容(如果有选中)
range.deleteContents();
// 前置 ZWSP 确保前面有光标位
const zwspBefore = createZwsp();
// 后置 ZWSP 确保后面有光标位
const zwspAfter = createZwsp();
// 创建标签 DOM
const matchDiv = document.createElement('span');
matchDiv.className = 'matchItem';
matchDiv.contentEditable = 'false'; // 禁止光标进入内部
matchDiv.dataset.character = character; // 自定属性存储匹配项label
matchDiv.innerHTML = `${character}<span class="remove-btn">×</span>`;
// 绑定删除事件
matchDiv.querySelector('.remove-btn')?.addEventListener('click', (e) => {
e.stopPropagation();
const matchItemText = matchDiv.dataset.character;
// 对应删除匹配项列表数据 (注意 实际删除考虑使用唯一属性判断索引)
const index = $matchList.value.findIndex((i) => i.matchLabel === matchItemText);
if (index !== -1) $matchList.value.splice(index, 1);
emit('updateMatchList');
});
// 插入 DOM
const frag = document.createDocumentFragment();
frag.appendChild(zwspBefore);
frag.appendChild(matchDiv);
frag.appendChild(zwspAfter);
range.insertNode(frag);
// 修正光标位置:移动到后置 ZWSP 后面
range.setStartAfter(zwspAfter);
range.setEndAfter(zwspAfter);
selection.removeAllRanges();
selection.addRange(range);
inputBox.scrollTop = inputBox.scrollHeight;
};
// 清空
const clearValue = () => {
if (inputBoxRef.value) inputBoxRef.value.innerHTML = '';
};
// 暴露方法
defineExpose({
insertMatch,
clearValue,
});
</script>
<style lang="scss" scoped>
#editor-box {
display: block;
padding: 8px 10px;
min-height: 100px;
max-height: 160px;
overflow-y: auto;
white-space: pre-wrap;
word-break: break-all;
line-height: 2; /* 增加行高,防止标签垂直重叠太挤 */
font-size: 14px;
border: 1px solid #e0e5eb;
border-radius: 4px;
color: #333;
caret-color: #000;
&:focus {
outline: none;
}
&:empty::before {
content: attr(placeholder);
color: #aaa;
pointer-events: none;
display: block; /* 确保 placeholder 占位 */
}
}
</style>
<style lang="scss">
.matchItem {
display: inline-flex;
align-items: center;
vertical-align: middle;
margin: 0 4px 4px;
padding: 2px 8px;
background-color: #f0f7ff;
border: 1px solid #cce0ff;
border-radius: 4px;
color: #337ecc;
font-size: 12px;
user-select: none; /*禁止光标进入*/
-webkit-user-select: none;
.remove-btn {
display: inline-flex;
align-items: center;
justify-content: center;
margin-left: 6px;
width: 14px;
height: 14px;
border-radius: 50%;
cursor: pointer;
color: #fff;
background-color: #aaccff;
font-size: 12px;
line-height: 1;
&:hover {
background-color: #ff6b6b;
}
}
}
</style>
如有更好思路请告诉我😎