实际效果
缘起:那个蝉鸣午后的求助
"前辈...能帮我看个需求吗?"软糯的声音从身后传来时,我正在调试一个诡异的日期选择器。转身看见新来的前端小妹林小夕,她的睫毛在阳光中扑闪,像极了代码中跳动的光标。
需求文档上写着:"在短信模板编辑器中实现变量插入功能"。这让我想起三年前刚入行时,也被这个看似简单实则暗藏玄机的需求折磨得死去活来。
"我们试试用contenteditable方案吧?"我拖动椅子靠近她的工位,空气中飘来淡淡的栀子花香。小夕如释重负,眼神里透出一丝期待。
技术突围:五个维度的攻防战
2.1 使用contenteditable实现富文本编辑
需求的核心是一个可编辑的输入框,支持普通文本输入,同时允许插入不可编辑的元素。
Vue 3 本身没有提供直接的 contenteditable
绑定机制,因此我们需要手动监听 @input
事件,并解析 innerHTML
以支持富文本。
ini
<div
ref="editorRef"
class="content flex-1"
contenteditable
@input="handleInput"
@paste="handlePaste"
@blur="saveSelection"
></div>
handleInput
方法更新输入框字数和同步内容,并在emitUpdate
中传递文本内容给父组件保存。
ini
// 更新字符统计
const updateCharCount = () => {
const text = editorRef.value?.textContent || '';
currentCharCount.value = text.length;
};
// 发送更新事件
const emitUpdate = () => {
const content = editorRef.value?.textContent?.replace(/\n/g, '') || '';
emit("update:modelValue", content);
};
// 处理输入事件
const handleInput = () => {
updateCharCount();
emitUpdate();
};
"传统的textarea只能处理纯文本,而contenteditable允许我们创建富文本结构。但这也意味着..."我故意停顿,小夕眉宇微蹙,马上补充道:"需要处理更多边界情况!比如粘贴内容时会携带格式...",我们相视一笑,在handlePaste方法中写下防御代码,防止xss攻击,:
typescript
const handlePaste = (event: { preventDefault: () => void; clipboardData: any; }) => {
// 阻止默认行为,即不允许粘贴任何格式的内容
event.preventDefault();
// 获取剪贴板数据
const text = event.clipboardData?.getData("text/plain") || "";
// 使用document.execCommand('insertText', false, text)插入纯文本
document.execCommand("insertText", false, text);
};
2.2 光标选区保存与恢复:保障插入位置准确性
当小夕第三次点击插入按钮后变量总是出现在末尾时,她的鼻尖沁出了细小的汗珠。我知道是时候祭出Range和Selection这两个上古神器了。
为了支持变量插入,必须先保存当前的光标位置(即选区 Range
对象),在插入后恢复它,以确保变量能够被正确插入到用户想要的位置。
我们使用 window.getSelection()
获取选区,并在 插入变量
按钮点击时手动恢复它:
ini
const saveCaretPosition = () => {
const selection = window.getSelection();
if (!selection?.rangeCount) return;
lastSelection = selection.getRangeAt(0).cloneRange();
editorRef.value?.focus();
};
然后,在用户选择变量后,我们需要恢复选区,并插入变量:
ini
const insertVariable = (varName: string) => {
if (!varName) return;
editorRef.value?.focus();
const selection = window.getSelection();
if (lastSelection && editorRef.value?.contains(lastSelection.startContainer)) {
selection!.removeAllRanges();
selection!.addRange(lastSelection);
}
const varElement = createVarElement(varName);
const range = selection!.getRangeAt(0);
range.insertNode(varElement);
requestAnimationFrame(() => {
const newRange = document.createRange();
newRange.setStartAfter(varElement);
newRange.collapse(true);
selection!.removeAllRanges();
selection!.addRange(newRange);
saveCaretPosition();
});
};
"这样,每次用户点击变量选项后,变量就会被正确地插入到光标所在的位置,而不是默认地出现在输入框的最后。"我语重心长地解释道。
小夕的眸子突然亮起来:"所以blur事件里也要保存选区状态,防止光标意外丢失!" saveSelection
方法是在输入框失焦时保存选区,当插入变量时可以恢复选区
ini
// 常规光标位置保存(用于blur事件)
const saveSelection = () => {
const selection = window.getSelection();
if (selection?.rangeCount) {
const range = selection.getRangeAt(0);
if (editorRef.value?.contains(range.commonAncestorContainer)) {
lastSelection = range.cloneRange();
}
}
};
2.3 变量元素DOM设计:不可编辑区块封装
为了让变量不可编辑,同时保证它可以整体删除,我们创建 span
标签,并设置 contentEditable=false
。
ini
const createVarElement = (varName: string) => {
const span = document.createElement("span");
span.className = "variable";
span.contentEditable = "false";
span.dataset.var = varName;
span.textContent = `${${varName}}`;
return span;
};
在 CSS
层面,我们为变量设置了独特的样式,让它与普通文本明显区分开来:
scss
复制编辑
css
.variable {
background-color: #f0f2f5;
border-radius: 3px;
padding: 0 4px;
color: #409eff;
display: inline-block;
}
这样,用户就能清晰地区分哪些内容是变量,哪些是普通文本。
2.4 双向数据绑定:实现内容同步
为了在 Vue
组件中正确存储和展示变量,我们需要在 onMounted
时解析 modelValue
,并将 ${变量}
转换成变量 span
元素。
scss
const initHtml = (value: string) => {
return value.replace(/${(\w+)}/g, (_, varName) => {
return createVarElement(varName).outerHTML;
});
};
onMounted(() => {
editorRef.value!.innerHTML = initHtml(props.modelValue);
updateCharCount();
});
同时,我们监听 modelValue
的变化,确保内容一致:
ini
watch(() => props.modelValue, (newVal) => {
const parsedHTML = initHtml(newVal);
if (parsedHTML !== editorRef.value?.innerHTML) {
editorRef.value!.innerHTML = parsedHTML;
updateCharCount();
}
});
这样,无论是用户手动输入,还是外部数据更新,文本内容都会保持一致。
2.5 配置式参数传递:组件灵活集成
为了让组件更加灵活,我们定义了 variableList
作为变量选项的来源,用户可以自由配置:
php
const props = defineProps({
variableList: {
type: Array<{ label: string; value: string }>,
default: () => [],
},
});
"这样我们只需要传递variableList..."她的声音带着疲惫的雀跃。我注意到她的马克杯边缘印着淡淡的唇印,咖啡早已凉透。小夕看着最终完成的组件,脸上露出了惊喜的笑容。她说道:"谢谢前辈,真的太厉害了!"
技术总结
整个短信模板编辑器的实现,正是建立在以下几个关键技术点上的:
- contenteditable 实现富文本编辑
利用原生contenteditable
属性,结合@input
和@paste
等事件,实现了一个灵活而安全的富文本输入框,超越了传统 textarea 的局限。 - 光标选区保存与恢复机制
通过Range
和Selection
对象,我们不仅保存了用户的光标位置,还能在插入变量时恢复选区,确保变量精确地嵌入到用户期望的位置。这一机制极大提升了用户体验,避免了频繁的编辑失误。 - 变量元素 DOM 设计
采用不可编辑的span
标签封装变量,并通过 CSS 样式做视觉区分,使得变量在编辑过程中既能保持整体性,又能被整体删除。这种设计兼顾了用户交互和数据完整性。 - 双向数据绑定实现内容同步
借助 Vue 的响应式机制,编辑器的内容和外部数据始终保持同步,无论是初始化还是动态更新,都能确保数据的一致性,方便后续处理与存储。 - 配置式参数传递实现组件灵活集成
将变量列表等配置参数作为 Props 传递,极大地提高了组件的灵活性和复用性。开发者可以根据具体需求对组件进行定制,而不必改动底层实现。