跳绳鸭
67.76M · 2026-02-04
Hello,各位UU们好呀!
今是 2026年2月1日 下午,距离上一次写文已经过去一个月了,时间飞快。
昨天,公司开了年会,可惜没有中大奖,还好有阳光普照奖,也不差哈。
然后,上上几个周末,全去爬山了,广州的"火凤线"、"白江湖"与"龙凤线"全去了一遍,主打一个运动健康,其中,"龙凤线" 稍微费劲一些,不过区区二十公里,随便"拿下",就是腿略抖,但问题不大。
回到正题,本次要分享的是如何在 Quill 富文本编辑器中实现一个 "自定义标签" 功能,支持插入不可编辑的标签块,并能以特定格式输出数据,效果如下,请诸君按需食用哈。
最近,做业务中遇到个需要在输入框中插入一些动态变量(比如"用户姓名"、"订单号"等)的需求。这些变量在输入框里需要长得像一个"标签"一样,作为一个整体存在,不能被用户修改里面的文字,只能整体插入与删除。
该需求属于典型富文本场景,原生 input/textarea 基本无法实现。
最初尝试基于 contentEditable 手写组件,不料却深陷光标位置、删除逻辑、异常边界等细节问题折磨,后续测试同学又反馈大量交互 bug,最终果断弃用手写方案,改用 Quill 重构。
本次开发涉及 Quill 自定义 Blot、数据格式双向转换等核心逻辑,结合踩坑经验做一次简单分享。
认为 Quill 的强大之处就在于它的扩展性。要实现一个 "标签",咱们可以继承一下 blots/embed Blot。这个 Blot 负责定义标签长什么样,以及它的一些行为。
我们创建一个 mark.js:
import Quill from "quill";
const BlockEmbed = Quill.import("blots/embed");
export default class MarkBlot extends BlockEmbed {
static blotName = "mark-blot"; // Blot 的名称
static tagName = "span"; // 对应的 DOM 标签
static className = "mark-blot-class"; // 对应的类名
static create(value) {
const node = super.create();
const id = typeof value === "object" ? value.id : value;
const label = typeof value === "object" ? value.label : value;
// 存储 ID,用于后续获取数据
node.setAttribute("data-value", id);
// 关键!设置为不可编辑
node.contentEditable = "false";
// 1. 标签文本
const textSpan = document.createElement("span");
textSpan.className = "mark-blot-text";
textSpan.innerText = label;
node.appendChild(textSpan);
// 2. 删除按钮 (小叉叉)
const closeBtn = document.createElement("span");
closeBtn.className = "close-btn";
closeBtn.innerHTML = `...SVG代码...`; // 这里省略 SVG 代码哈,的图标是使用svg形式,你可以根据你自己的需要调整
// 点击删除按钮时,通知外部删除
closeBtn.addEventListener("click", (e) => {
e.stopPropagation();
// 派发自定义事件,方便 Vue 组件
const event = new CustomEvent("mark-remove", {
detail: { markId: id },
bubbles: true,
composed: true,
});
node.dispatchEvent(event);
// 直接在 DOM 上移除自己
const blot = Quill.find(node);
if (blot) blot.remove();
});
node.appendChild(closeBtn);
return node;
}
static value(node) {
// 获取 Blot 的值,用于 getContents()
const id = node.getAttribute("data-value");
const textSpan = node.querySelector("span");
const label = textSpan ? textSpan.innerText : id;
return { id, label };
}
}
这里有个小细节,我们在 create 方法里给节点加了 contentEditable="false",这样用户就没法光标点进去修改文字,只能把它当做一个整体来处理。
接下来就是重头戏了,咱们需要在 Vue 组件里初始化 Quill,并处理数据转换。
import MarkBlot from "./customBlot/mark.js";
// 注册自定义 Blot
if (!Quill.imports["formats/mark-blot"]) {
Quill.register(MarkBlot);
}
// 初始化 Quill
quillInstance = new Quill(editorRef.value, {
modules: { toolbar: false }, // 咱们这里不需要默认的工具栏
placeholder: props.placeholder,
});
数据转换是核心环节,前端需要处理两种格式的互转:
{{#id#}} 格式的字符串{{#id#}} 替换为实际内容1. 字符串转 Blot(回显逻辑)
当获取到包含 {{#id#}} 格式的数据时,需要将其转换为 Blot 显示在富文本中:
/**
* @name 转换文本,将特定内容转为blot
* @param {string} text 原始文本
* @return {Object[]} delta 操作数组
*/
function textToDelta(text) {
const delta = [];
const { start, end } = getDelimiters(); // 解析标签模板,如 {{##}} -> {start: "{{#", end: "#}}"}
const escapeRegExp = (string) => string.replace(/[.*+?^${}()|[]\]/g, "\$&");
const regex = new RegExp(`${escapeRegExp(start)}(.*?)${escapeRegExp(end)}`, "g");
let lastIndex = 0;
let match;
while ((match = regex.exec(text)) !== null) {
// 1. 添加匹配前的普通文本
if (match.index > lastIndex) {
delta.push({ insert: text.substring(lastIndex, match.index) });
}
// 2. 添加标签 Blot
const id = match[1]; // 提取 id
const label = props.getMarkLabel(id); // 通过 id 获取显示文本,就是进行id映射,如 {1: 小明, 2: 小红, 3: 小黑}
delta.push({ insert: { "mark-blot": { id, label } } });
lastIndex = regex.lastIndex;
}
// 3. 添加剩余文本
if (lastIndex < text.length) {
delta.push({ insert: text.substring(lastIndex) });
}
// 确保至少有一个 insert,否则 Quill 可能报错
if (delta.length === 0) {
delta.push({ insert: "" });
}
return delta;
}
/**
* @name 解析标签模版
* @return {{start: string, end: string}}
*/
function getDelimiters() {
const start = props.specialBracket.slice(0, props.specialBracket.length / 2);
const end = props.specialBracket.slice(props.specialBracket.length / 2);
// props.specialBracket 等于 {{##}} ,单独定义,方便维护
return { start, end };
}
2. Blot 转字符串(保存逻辑)
用户编辑完成后,需要将 Blot 转换回 {{#id#}} 格式的字符串:
/**
* @name 统一获取富文本内容
*/
function getContent() {
if (!quillInstance) return "";
const delta = quillInstance.getContents();
let text = "";
const { start, end } = getDelimiters();
// 遍历 delta 操作,构建文本内容
if (delta && delta.ops) {
delta.ops.forEach((op) => {
if (typeof op.insert === "string") {
text += op.insert;
} else if (op.insert && op.insert["mark-blot"]) {
// 遇到 mark-blot,拼接成 {{#id#}}
const val = op.insert["mark-blot"];
const id = typeof val === "object" ? val.id : val;
text += `${start}${id}${end}`;
}
});
}
// Quill 总是会在末尾添加一个 n,通常我们需要移除它以获取实际输入内容
return text.replace(/n$/, "");
}
3. 提交时的变量替换
{{#id#}} 是前端规定的特定格式,用于在编辑器中标识动态变量。但在提交给后端时,需要将这些占位符替换为实际值。
在的业务场景中,是单独写了一个 replaceVariableTags 函数来处理这个事情:
/**
* 替换对象中字符串属性的 {{#xxx#}} 标签为指定值(包裹【】)
* @param {Object} targetObj - 待处理的目标对象(支持多层嵌套)
* @param {Array} replaceList - 替换映射数组(结构:[{id: 'xxx', ...}, ...])
* @param {Object} [options] - 可选配置项
* @param {string} [options.idKey='id'] - replaceList 中 id 对应的字段名(支持嵌套路径,如 'data.id')
* @param {string} [options.valueKey='properties.taskResult'] - replaceList 中值对应的字段名(支持嵌套路径)
* @returns {Object} 处理后的新对象(原对象不变)
*/
export function replaceVariableTags(targetObj, replaceList, options = {}) {
// 默认配置:id字段为根层级id,值字段为properties.taskResult
const { idKey = "id", valueKey = "content" } = options;
// 工具函数:根据嵌套路径获取对象属性值
const getNestedValue = (obj, path) => {
if (typeof obj !== "object" || obj === null) return undefined;
return path.split(".").reduce((current, key) => {
return current?.[key]; // 路径不存在时返回undefined
}, obj);
};
// 1. 构建 id -> 值 的映射表(支持嵌套字段解析)
const replaceMap = new Map();
replaceList.forEach((item) => {
const id = getNestedValue(item, idKey);
const value = getNestedValue(item, valueKey);
if (id && value) {
// 过滤id/值为空的无效项
replaceMap.set(id, value);
}
});
// 2. 正则:匹配 {{#xxx#}} 标签,捕获中间的 nodeId
const tagRegex = /{{#([^#]+)#}}/g;
// 3. 递归处理字符串/对象的核心函数
const processValue = (value) => {
// 字符串类型:替换标签
if (typeof value === "string") {
return value.replace(tagRegex, (match, nodeId) => {
const replaceValue = replaceMap.get(nodeId);
return replaceValue ? `${replaceValue}` : match; // 未匹配则保留原标签
});
}
// 对象/数组类型:递归处理
if (typeof value === "object" && value !== null) {
return Array.isArray(value)
? value.map((item) => processValue(item)) // 数组遍历
: Object.fromEntries(
// 普通对象:保留原属性,递归处理值
Object.entries(value).map(([key, val]) => [key, processValue(val)]),
);
}
// 非字符串/对象类型(数字、布尔等):直接返回原值
return value;
};
// 4. 返回新对象(避免修改原对象)
return processValue(targetObj);
}
这个函数会递归遍历对象,将所有字符串属性中的 {{#id#}} 替换为对应的实际值,然后后端收到的是已经转换好的完整数据。
由于,富文本核心就是输出一个值的内容,所以通过 v-model 形式来使用组件的数据绑定。
但是,在实现 v-model 双向绑定时,最头疼的就是光标跳动问题。
每次值变化时都会重新 setContents,光标就会跑回开头,为此咱们需要做一些判断:
// modelValue 变化
watch(() => props.modelValue, (newValue) => {
// 如果新值等于上一次emit的值,说明是回显,无需更新
if (newValue === lastEmittedValue) return;
lastEmittedValue = newValue;
if (quillInstance && newValue !== getContent()) {
// 1. 记录光标位置
const selection = quillInstance.getSelection();
// 2. 更新内容
setFormattedContent(newValue);
// 3. 恢复光标位置
if (selection && isInputFocused.value) {
const currentLength = quillInstance.getLength();
const index = Math.min(selection.index, currentLength - 1);
quillInstance.setSelection(Math.max(0, index), 0);
}
}
});
// Quill 内容变化
quillInstance.on("text-change", (_delta, _oldDelta, source) => {
if (source === "user") {
const content = getContent();
inputUpdate(content); // emit update:modelValue
}
});
还有一个细节,当用户手动输入 {{#id#}} 格式的文本时,需要实时检测并转换为 Blot。这可以在 text-change 事件中处理:
quillInstance.on("text-change", (_delta, _oldDelta, source) => {
if (source === "user") {
// 检查并替换 {{#...#}} 模式
const text = quillInstance.getText();
const { start, end } = getDelimiters();
const regex = new RegExp(`${escapeRegExp(start)}(.*?)${escapeRegExp(end)}`, "g");
const matches = [...text.matchAll(regex)];
// 如果有匹配项,从后往前处理,避免索引变化影响前面的匹配
if (matches.length > 0) {
for (let i = matches.length - 1; i >= 0; i--) {
const match = matches[i];
const index = match.index;
const fullMatch = match[0];
const content = match[1];
const label = props.getMarkLabel(content);
// 替换文本为 Blot
quillInstance.deleteText(index, fullMatch.length);
quillInstance.insertEmbed(index, "mark-blot", { id: content, label });
quillInstance.insertText(index + 1, "u00A0"); // 插入不换行空格
}
}
const content = getContent();
inputUpdate(content);
}
});
传送门
其实,富文本编辑器开发中,认为最难的往往不是 API 的使用,而是如何处理数据格式的转换以及各种边界情况,最后希望这篇文章能给正在折腾 Quill 的小伙伴们一些灵感吧!
至此,本篇文章就写完啦,撒花撒花。

希望本文对你有所帮助,如有任何疑问,期待你的留言哦。
老样子,点攒+评论=你会了,收藏=你精通了。