章
目
录
最近遇到个挺有意思的开发需求,今天来给大伙说说。在开发短信模板编辑器的时候,得实现一个变量插入功能,也就是在输入框里能插入变量,而且还得是富文本输入框,支持各种格式的编辑。这听起来有点复杂,不过咱们一步步拆解,其实也没那么难。
一、需求的由来
那天我正捣鼓一个日期选择器的bug呢,公司新来的前端小妹林小夕找我帮忙,说有个需求搞不定。我一看,是在短信模板编辑器里实现变量插入功能。嘿,这可太熟悉了,我刚入行那会,也被这需求折磨得够呛。想着这需求虽然看着简单,但里面弯弯绕绕还挺多的,就跟小夕说,咱们试试用contenteditable这个方案来实现吧。
实现效果
二、技术实现过程
(一)用contenteditable实现富文本编辑
这个需求的关键就是得有个能编辑的输入框,既能输入普通文本,还得能插入不可编辑的元素。但Vue 3本身没有直接绑定contenteditable的机制,所以得手动监听@input事件,再解析innerHTML,才能实现富文本编辑。
咱们先在模板里定义好这个输入框:
<div
ref="editorRef"
class="content flex-1"
contenteditable
@input="handleInput"
@paste="handlePaste"
@blur="saveSelection"
></div>
这里给div加上了contenteditable属性,让它变成可编辑的。同时监听了input、paste和blur这几个事件,每个事件都有不同的作用。
接着看看处理输入事件的代码:
// 更新字符统计
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();
};
在handleInput方法里,调用了updateCharCount函数更新输入框的字数统计,再通过emitUpdate把输入框的文本内容传递给父组件保存起来。
还有处理粘贴事件的代码也很重要,因为普通的粘贴可能会带来格式问题,甚至有XSS攻击的风险:
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);
};
在handlePaste方法里,先阻止了默认的粘贴行为,然后获取剪贴板里的纯文本内容,再用document.execCommand把纯文本插入到输入框里,这样就能保证插入的内容安全又规范。
(二)光标选区保存与恢复
小夕在测试的时候发现,每次点击插入按钮,变量老是出现在输入框末尾,这可不行。这时候就得靠Range和Selection这两个“神器”来解决问题了。
为了能把变量准确插入到用户想要的位置,得先保存当前的光标位置,也就是选区Range对象,等插入完变量再恢复它。
先看看保存光标的代码:
const saveCaretPosition = () => {
const selection = window.getSelection();
if (!selection?.rangeCount) return;
lastSelection = selection.getRangeAt(0).cloneRange();
editorRef.value?.focus();
};
在saveCaretPosition函数里,通过window.getSelection获取当前的选区,如果有选区,就克隆一份保存起来,方便后续恢复。
再看看插入变量的代码:
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();
});
};
在insertVariable函数里,先判断变量名是否存在,存在的话就获取选区。如果之前保存过选区,就先把当前选区清空,再恢复之前保存的选区,然后创建变量元素插入到选区里。插入完之后,还得重新设置选区,把光标定位到变量后面,最后再保存一下新的光标位置。
还有在输入框失焦的时候,也得保存选区状态,防止下次插入变量时光标位置不对:
// 常规光标位置保存(用于blur事件)
const saveSelection = () => {
const selection = window.getSelection();
if (selection?.rangeCount) {
const range = selection.getRangeAt(0);
if (editorRef.value?.contains(range.commonAncestorContainer)) {
lastSelection = range.cloneRange();
}
}
};
(三)变量元素DOM设计
为了让变量不能被编辑,还能整体删除,咱们创建一个span标签,把变量包起来,然后设置contentEditable=false。
const createVarElement = (varName: string) => {
const span = document.createElement("span");
span.className = "variable";
span.contentEditable = "false";
span.dataset.var = varName;
span.textContent = `${${varName}}`;
return span;
};
在这个createVarElement函数里,创建了一个span标签,给它加上了variable类名,设置不可编辑,还在dataset里存了变量名,最后把变量的显示内容设置好。
光有标签还不够,还得在CSS里给变量设置个独特的样式,和普通文本区分开:
.variable {
background-color: #f0f2f5;
border-radius: 3px;
padding: 0 4px;
color: #409eff;
display: inline-block;
}
这样,用户在编辑的时候就能很清楚地知道哪些是变量,哪些是普通文本了。
(四)双向数据绑定
在Vue组件里,为了能正确存储和展示变量,咱们得在组件挂载的时候解析modelValue,把变量转换成span元素。
const initHtml = (value: string) => {
return value.replace(/${(\w+)}/g, (_, varName) => {
return createVarElement(varName).outerHTML;
});
};
onMounted(() => {
editorRef.value!.innerHTML = initHtml(props.modelValue);
updateCharCount();
});
在initHtml函数里,用正则表达式把字符串里的变量替换成对应的span元素。在onMounted钩子函数里,把解析后的内容设置到输入框里,再更新一下字数统计。
除了初始化,还得监听modelValue的变化,保证输入框内容和外部数据始终一致:
watch(() => props.modelValue, (newVal) => {
const parsedHTML = initHtml(newVal);
if (parsedHTML !== editorRef.value?.innerHTML) {
editorRef.value!.innerHTML = parsedHTML;
updateCharCount();
}
});
在这个watch函数里,只要modelValue有变化,就重新解析内容,如果和输入框当前的innerHTML不一样,就更新输入框内容,同时更新字数统计。
(五)配置式参数传递
为了让这个组件用起来更灵活,咱们定义了一个variableList,用来配置变量选项,用户可以根据自己的需求自由设置。
const props = defineProps({
variableList: {
type: Array<{ label: string; value: string }>,
default: () => [],
},
});
这样,在使用这个组件的时候,只需要传递不同的variableList,就能轻松切换变量选项了。
三、总结
折腾了这么一大圈,这个短信模板编辑器的富文本输入框功能总算是实现了。这里面用到的几个技术点还挺关键的:
- 利用contenteditable实现富文本编辑:借助这个属性,再配合一些事件监听,咱们就打造出了一个比传统textarea功能更强大的富文本输入框,能满足各种编辑需求。
- 光标选区保存与恢复机制:靠Range和Selection对象,成功解决了变量插入位置不对的问题,大大提升了用户体验,编辑起来更顺手。
- 变量元素DOM设计:用不可编辑的span标签封装变量,再加上独特的CSS样式,让变量在编辑过程中既不会被误改,又能方便地整体删除。
- 双向数据绑定实现内容同步:通过Vue的响应式机制,不管是初始化还是数据更新,输入框内容和外部数据都能保持一致,后续处理和存储数据也更方便。
- 配置式参数传递实现组件灵活集成:把变量列表这些配置参数通过Props传递,组件复用性大大提高,以后遇到类似需求,稍微改改配置就能用,开发效率杠杠的!
大伙要是在开发中也遇到类似需求,不妨参考参考这篇文章,希望能帮到你们!