Skip to content

Vue3 富文本输入框组件深度解析:从零到一的实现之路

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

效果预览

核心特性

  1. 标签化输入: 支持预定义标签的插入和编辑
  2. 占位符:占位符自由切换
  3. 控制逻辑:精确的删除、光标定位和移动逻辑
  4. 样式清理: 自动清除粘贴内容的多余样式
  5. 键盘导航: 支持 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);
};

技术亮点:

  • 封装了通用的光标操作方法,提高代码复用性
  • 支持精确到字符级别的光标定位
  • 通过 RangeSelection API 实现跨浏览器兼容的光标控制

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()
    /* ... */
    // 发送事件处理
  }
  break

Backspace 和 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);
  });
});

灵活性设计:

  • 支持外部配置的动态更新
  • 响应式的内容重新渲染
  • 保持组件状态的一致性

总结与展望

这个富文本输入框组件展现了现代前端开发的多个最佳实践:

  1. 技术选型合理: Vue3 提供了优秀的开发体验
  2. 架构设计清晰: 分离关注点,职责明确
  3. 用户体验优秀: 智能的交互逻辑和直观的视觉反馈
  4. 性能表现良好: 合理的优化策略和高效的算法实现
  5. 可维护性强: 清晰的代码结构和完善的接口设计

这个组件的实现充分体现了前端工程化的思维,不仅解决了业务需求,更为类似项目提供了可参考的技术方案。通过深入理解其实现原理,我们可以更好地应对复杂的富文本编辑需求,构建出更加优秀的用户界面。

Released under the MIT License.