写在开头

Hello,各位UU们好呀!

今是 2026年2月1日 下午,距离上一次写文已经过去一个月了,时间飞快。

昨天,公司开了年会,可惜没有中大奖,还好有阳光普照奖,也不差哈。

然后,上上几个周末,全去爬山了,广州的"火凤线"、"白江湖"与"龙凤线"全去了一遍,主打一个运动健康,其中,"龙凤线" 稍微费劲一些,不过区区二十公里,随便"拿下",就是腿略抖,但问题不大。

回到正题,本次要分享的是如何在 Quill 富文本编辑器中实现一个 "自定义标签" 功能,支持插入不可编辑的标签块,并能以特定格式输出数据,效果如下,请诸君按需食用哈。

需求背景

最近,做业务中遇到个需要在输入框中插入一些动态变量(比如"用户姓名"、"订单号"等)的需求。这些变量在输入框里需要长得像一个"标签"一样,作为一个整体存在,不能被用户修改里面的文字,只能整体插入与删除。

该需求属于典型富文本场景,原生 input/textarea 基本无法实现。

最初尝试基于 contentEditable 手写组件,不料却深陷光标位置、删除逻辑、异常边界等细节问题折磨,后续测试同学又反馈大量交互 bug,最终果断弃用手写方案,改用 Quill 重构。

本次开发涉及 Quill 自定义 Blot、数据格式双向转换等核心逻辑,结合踩坑经验做一次简单分享。

实现过程 ️

第1️⃣步:自定义 Blot (mark.js)

认为 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",这样用户就没法光标点进去修改文字,只能把它当做一个整体来处理。

第2️⃣步:Vue 组件封装 (index.vue)

接下来就是重头戏了,咱们需要在 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,
});

数据转换

数据转换是核心环节,前端需要处理两种格式的互转:

  • Blot ↔ 字符串:编辑器内部使用 Blot 显示标签,但保存时需要转换为 {{#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#}} 替换为对应的实际值,然后后端收到的是已经转换好的完整数据。

第3️⃣步:细节优化

由于,富文本核心就是输出一个值的内容,所以通过 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 的小伙伴们一些灵感吧!





至此,本篇文章就写完啦,撒花撒花。

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

本站提供的所有下载资源均来自互联网,仅提供学习交流使用,版权归原作者所有。如需商业使用,请联系原作者获得授权。 如您发现有涉嫌侵权的内容,请联系我们 邮箱:alixiixcom@163.com