You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

Vue.js更新v-html时打字输入光标焦点异常问题求助

Vue中contenteditable div实现链接自动高亮并解决光标跳转问题

问题根源

你遇到的光标跳转问题核心原因是:每次更新v-html绑定的html变量时,Vue会重新渲染整个contenteditable节点,原有选区/光标位置会被清空重置,所以出现光标跳转到节点开头/末尾的异常。

可行解决方案

核心思路是:更新HTML前先保存当前光标位置,HTML更新完成后手动还原光标位置,同时补充链接自动高亮的逻辑。

实现步骤

  1. 用原生Range、SelectionAPI实现光标位置的读写能力
  2. 在updateHtml方法中补充「保存光标→处理内容匹配链接→更新HTML→还原光标」的完整逻辑
  3. 新增链接匹配规则,将纯文本链接转为带样式的蓝色超链接

完整代码实现

<div id="app">
  <div class="flex">
    <div 
      class="message" 
      @input="updateHtml" 
      v-html="html" 
      contenteditable="true"
      style="border:1px solid #ccc; min-height: 40px; padding: 8px;"
    ></div>
    <br>
    <div class="message">{{ html }}</div>
  </div>
</div>
<style>
/* 高亮链接样式 */
.message a {
  color: #1677ff;
  text-decoration: underline;
}
</style>
<script>
let app = new Vue({
  el: '#app',
  data: {
    html: 'some text',
  },
  methods: {
    // 获取当前光标在contenteditable节点中的偏移量
    getCaretPosition(editableDiv) {
      let caretOffset = 0;
      const doc = editableDiv.ownerDocument || document;
      const win = doc.defaultView || window;
      const sel = win.getSelection();
      if (sel.rangeCount > 0) {
        const range = sel.getRangeAt(0);
        const preCaretRange = range.cloneRange();
        preCaretRange.selectNodeContents(editableDiv);
        preCaretRange.setEnd(range.endContainer, range.endOffset);
        caretOffset = preCaretRange.toString().length;
      }
      return caretOffset;
    },
    // 设置光标到指定偏移量位置
    setCaretPosition(editableDiv, offset) {
      const doc = editableDiv.ownerDocument || document;
      const win = doc.defaultView || window;
      const sel = win.getSelection();
      const range = doc.createRange();
      range.selectNodeContents(editableDiv);
      
      let currentOffset = 0;
      let nodeStack = [editableDiv];
      let node, foundStart = false, stop = false;
      
      while (!stop && (node = nodeStack.pop())) {
        if (node.nodeType === Node.TEXT_NODE) {
          const nextOffset = currentOffset + node.length;
          if (!foundStart && offset >= currentOffset && offset <= nextOffset) {
            range.setStart(node, offset - currentOffset);
            foundStart = true;
          }
          if (foundStart && offset >= currentOffset && offset <= nextOffset) {
            range.setEnd(node, offset - currentOffset);
            stop = true;
          }
          currentOffset = nextOffset;
        } else {
          let i = node.childNodes.length;
          while (i--) {
            nodeStack.push(node.childNodes[i]);
          }
        }
      }
      
      range.collapse(true);
      sel.removeAllRanges();
      sel.addRange(range);
    },
    // 处理文本中的链接,转为高亮a标签
    parseLinks(text) {
      // 匹配http/https开头的链接,排除已经包裹在a标签内的内容
      const urlRegex = /(?<!<a[^>]*>)(https?:\/\/[^\s<]+)/g;
      return text.replace(urlRegex, '<a href="$1" target="_blank">$1</a>');
    },
    updateHtml: function(e) {
      const editableDiv = e.target;
      // 1. 保存当前光标位置
      const caretPos = this.getCaretPosition(editableDiv);
      // 2. 获取原始内容并处理链接高亮
      const rawHtml = editableDiv.innerHTML;
      const processedHtml = this.parseLinks(rawHtml);
      // 3. 只有内容变化时才更新,减少不必要重渲
      if (this.html !== processedHtml) {
        this.html = processedHtml;
        // 4. 等Vue完成DOM更新后还原光标位置
        this.$nextTick(() => {
          this.setCaretPosition(editableDiv, caretPos);
        });
      }
    },
    renderHtml: function(){
      this.html += '<img src="https://cdn-images-1.medium.com/max/853/1*FH12a2fX61aHOn39pff9vA.jpeg" alt="" width=200px>';
    }
  }
});
</script>

额外优化建议

  • 可以给updateHtml方法加100-300ms的防抖,减少频繁重渲带来的性能损耗,同时不影响用户输入体验
  • 如果需要兼容低版本浏览器(如IE),可以替换正则中的零宽断言逻辑,改用DOM遍历的方式处理链接,避免匹配到已有的a标签
  • 如果需要支持更复杂的富文本能力,建议直接引入成熟的富文本编辑器组件,避免重复造轮子
  • 链接匹配正则可以根据业务需求调整,比如支持不带http前缀的域名、邮箱等内容的高亮

内容的提问来源于stack exchange,提问作者Soju Varughese

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.09.25 05:24:06