Vue3 富文本输入框组件深度解析:从零到一的实现之路
在现代前端开发中,富文本编辑器是一个常见且复杂的需求。本文将深入解析一个基于 Vue3 的富文本输入框组件,该组件巧妙地结合了 contenteditable 属性和自定义标签系统,实现了一个功能强大且用户体验优秀的富文本编辑器。
效果预览

核心特性
- 标签化输入: 支持预定义标签的插入和编辑
- 占位符:占位符自由切换
- 控制逻辑:精确的删除、光标定位和移动逻辑
- 样式清理: 自动清除粘贴内容的多余样式
- 键盘导航: 支持 Tab 键在标签间快速切换
核心架构设计
数据模型设计
javascript
const command = reactive([
{ text: "帮我写一篇", type: "string" },
{ text: "主题", type: "label" },
{ text: "的文章,篇幅大概在", type: "string" },
{ text: "字数", type: "label" },
{ text: "字左右", type: "string" },
]);数据模型采用了类型化的设计思路:
string类型:普通文本内容label类型:可编辑的标签占位符
这种设计使得组件能够灵活处理不同类型的内容,为后续的渲染和交互逻辑提供了清晰的数据基础。
核心功能实现
1. 内容渲染机制
javascript
const setContent = (command) => {
const content = command
.map((item) => {
switch (item.type) {
case "string":
return `<span>${item.text}</span>`;
case "label":
return `<span class="rich-input-label" placeholder="【${item.text}】">\u200B</span><span>\u200B</span>`;
}
})
.join("");
inputWrapRef.value.innerHTML = content;
updateStatus([inputWrapRef.value]);
};关键技术点:
- 使用
\u200B(零宽空格)作为占位符,确保空标签仍然可以被光标定位 - 每个标签后添加额外的
<span>\u200B</span>,提供光标停靠点 - 通过
innerHTML直接设置内容,避免了复杂的虚拟 DOM 操作
2. 光标控制系统
基础光标操作
javascript
const moveCursor = (element, start = true) => {
if (!element) return;
const range = document.createRange();
const sel = window.getSelection();
range.selectNodeContents(element);
range.collapse(start);
sel.removeAllRanges();
sel.addRange(range);
element.focus();
};精确光标定位
javascript
const setCursorPosition = (textNode, offset = 1) => {
if (!textNode) return;
const selection = window.getSelection();
const newRange = document.createRange();
newRange.setStart(textNode, offset);
newRange.setEnd(textNode, offset);
selection.removeAllRanges();
selection.addRange(newRange);
};技术亮点:
- 封装了通用的光标操作方法,提高代码复用性
- 支持精确到字符级别的光标定位
- 通过
Range和SelectionAPI 实现跨浏览器兼容的光标控制
3. TreeWalker 遍历优化
javascript
const createWalker = (root, whatToShow, acceptNode) => {
return document.createTreeWalker(root, whatToShow, { acceptNode });
};
const findFirstTextNode = (element) => {
const walker = createWalker(
element,
NodeFilter.SHOW_TEXT,
() => NodeFilter.FILTER_ACCEPT
);
return walker.nextNode();
};
const findLastTextNode = (element) => {
const walker = createWalker(
element,
NodeFilter.SHOW_TEXT,
() => NodeFilter.FILTER_ACCEPT
);
let lastTextNode = null;
let node;
while ((node = walker.nextNode())) {
lastTextNode = node;
}
return lastTextNode;
};性能优化:
- 使用
TreeWalker替代递归遍历,提高大文档的遍历性能 - 封装通用的遍历逻辑,支持不同的节点类型筛选
- 针对文本节点的快速查找,优化光标定位的响应速度
4. 键盘事件处理
Enter 键处理
javascript
case 'Enter':
if (e.shiftKey) {
// Shift+Enter:插入换行
e.preventDefault()
document.execCommand('insertLineBreak')
} else {
e.preventDefault()
/* ... */
// 发送事件处理
}
breakBackspace 和 Delete 键的复杂逻辑
光标在标签外部左侧,向后删除:
- 标签有内容:光标向右移动一位,移动至标签内部开头零宽占位符后,正常删除标签内容
- 标签无内容:直接删除标签及标签外部右侧的 span 零宽占位符
光标在标签外部右侧,向前删除:
- 标签有内容:光标向左移动一位,移动至标签内部末尾,正常删除标签内容
- 标签无内容:直接删除标签及标签外部右侧的 span 零宽占位符
光标在标签内部开头,向前删除:
- 标签有内容:正常删除标签内容
- 标签无内容:光标向左移动一位,移动至标签外部左侧,正常删除文本内容
光标在标签内部末尾,向后删除:
- 标签有内容:正常删除标签内容
- 标签无内容:光标向右移动一位,移动至标签外部右侧,正常删除文本内容
javascript
case 'Backspace':
// 光标在标签内部
if (label) {
// 标签内容为空
if (!getText(label)) {
// 删除标签前内容
moveCursor(preElement, false)
}
} else {
// 光标在标签外部
if (!getText(element)) {
// 光标在标签后的占位span标签
if (!getText(preElement)) {
// 标签内容为空
e.preventDefault() // 阻止默认删除
setRange(preElement, element) // 选中标签和占位符
document.execCommand('forwardDelete')
} else {
// 标签内容不为空
moveCursor(preElement, false) // 移动至标签内部末尾
}
}
}
break
case 'Delete':
// 光标在标签内部
if (label) {
// 下一个元素是占位span标签
if (!getText(nextElement)) {
// 移动到占位span标签末尾
moveCursor(nextElement, false)
}
} else {
// 光标在标签外部左侧
if (nextLabel) {
// 标签内容不为空,移动光标至标签内部占位符后
if (getText(nextLabel)) {
const selection = window.getSelection()
// 找到下一个元素的第一个文本节点
const firstTextNode = findFirstTextNode(nextLabel)
// 设置光标位置到第一个文本节点的第二个字符位置
setCursorPosition(firstTextNode, 1)
} else {
// 标签内容为空
const sel = window.getSelection()
if (!sel || sel.rangeCount === 0) return false
const range = sel.getRangeAt(0).cloneRange()
// 找到当前元素的最后一个文本节点
const lastTextNode = findLastTextNode(element)
let atEnd
if (!lastTextNode) atEnd = false // 元素里没有任何文本节点
atEnd = range.startContainer === lastTextNode && range.startOffset === lastTextNode.nodeValue.length
if (atEnd) {
// 在当前元素的最后一个文本节点,一并删除标签和占位符
e.preventDefault() // 阻止默认删除
setRange(nextElement, nextNextElement) // 选中标签和占位符
document.execCommand('forwardDelete')
}
}
}
}
break设计思路:
- 根据光标位置和周围元素状态,实现智能的删除逻辑
- 保护标签结构的完整性,避免意外删除
- 提供直观的用户交互体验
Tab 键标签导航
javascript
case 'Tab':
e.preventDefault()
const labels = getAllLabels()
if (labels.length === 0) return
const currentLabelIndex = label ? labels.indexOf(label) : -1
if (currentLabelIndex === -1) return
else moveCursor(labels[(currentLabelIndex + 1) % labels.length], false)
break用户体验优化:
- 支持 Tab 键在标签间循环切换
- 提高表单填写效率
- 符合用户的操作习惯
5. 样式清理机制
javascript
const findStyledElements = (root) => {
const walker = createWalker(root, NodeFilter.SHOW_ELEMENT, (node) => {
const tag = node.nodeName.toLowerCase();
if (tag === "font") return NodeFilter.FILTER_ACCEPT;
if (tag === "span" && node.style && node.style.length) {
return NodeFilter.FILTER_ACCEPT;
}
return NodeFilter.FILTER_SKIP;
});
const elements = [];
while (walker.nextNode()) {
elements.push(walker.currentNode);
}
return elements;
};
function handleInput() {
// 1. 找到所有需要剥离的 <font> / <span style=...> 节点
const toUnwrap = findStyledElements(inputWrapRef.value);
// 2. 逐个拆包:把子节点前移,然后删除该标签
toUnwrap.forEach((el) => {
const parent = el.parentNode;
while (el.firstChild) parent.insertBefore(el.firstChild, el);
parent.removeChild(el);
});
}技术创新:
- 自动识别和清理粘贴内容中的多余样式
- 保持编辑器内容的一致性和纯净性
- 避免样式污染影响整体显示效果
样式设计与用户体验
CSS 架构
css
.input-wrap {
cursor: text;
line-height: 22px;
font-size: 14px;
font-weight: 400;
color: #222222e6;
}
.input-wrap::after {
content: attr(placeholder);
font-weight: 400;
color: rgba(100, 100, 100, 0.5);
position: absolute;
top: 0;
}
.rich-input-label {
background: rgba(58, 115, 255, 0.1);
position: relative;
display: inline-block;
margin: 2px 6px;
padding: 2px 6px;
border-radius: 4px;
width: auto;
min-width: fit-content;
color: #06f;
font-weight: 600;
}
.rich-input-label::after {
content: attr(placeholder);
font-weight: 400;
color: #3a73ff;
}设计亮点:
- 使用
::after伪元素实现占位符效果,避免 JavaScript 操作 - 标签采用半透明背景,提供视觉层次感
- 响应式的最小宽度设计,适应不同长度的标签内容
状态管理集成
javascript
const updateStatus = (doms) => {
// 更新placeholder
doms.forEach((dom, index) => {
if (dom)
dom.classList.toggle("hide-placeholder", !!getText(dom, index < 1));
});
// 更新输入内容
if (!getContent()) emptyContent.value = ["内容"];
else {
const labels = getAllLabels();
emptyContent.value = labels
.filter((el) => !getText(el))
.map((el) => el.getAttribute("placeHolder"));
}
};状态同步机制:
- 实时更新占位符显示状态
- 跟踪空标签,提供用户反馈
扩展性与维护性
组件接口设计
javascript
defineExpose({ getContent, emptyContent });API 设计原则:
- 提供简洁的外部接口
- 隐藏内部实现细节
- 支持父组件的数据获取需求
配置化支持
javascript
watch(commandJson, () => {
key.value++;
nextTick(() => {
setContent(commandJson.value);
});
});灵活性设计:
- 支持外部配置的动态更新
- 响应式的内容重新渲染
- 保持组件状态的一致性
总结与展望
这个富文本输入框组件展现了现代前端开发的多个最佳实践:
- 技术选型合理: Vue3 提供了优秀的开发体验
- 架构设计清晰: 分离关注点,职责明确
- 用户体验优秀: 智能的交互逻辑和直观的视觉反馈
- 性能表现良好: 合理的优化策略和高效的算法实现
- 可维护性强: 清晰的代码结构和完善的接口设计
这个组件的实现充分体现了前端工程化的思维,不仅解决了业务需求,更为类似项目提供了可参考的技术方案。通过深入理解其实现原理,我们可以更好地应对复杂的富文本编辑需求,构建出更加优秀的用户界面。