实现不同富文本对比文字的vue组件

<!-- RichTextDiff.vue -->
<template>
  <div class="rich-text-diff" v-html="diffedHtml"></div>
</template>

<script>
// 引入字符级差异比对库,这是实现精确比对的基石[citation:2]
import { diffChars } from 'diff';

export default {
  name: 'RichTextDiff',
  props: {
    // 旧版本富文本HTML
    oldHtml: {
      type: String,
      default: ''
    },
    // 新版本富文本HTML
    newHtml: {
      type: String,
      default: ''
    },
    // 自定义高亮样式类名(可选)
    insClass: {
      type: String,
      default: 'diff-ins'
    },
    delClass: {
      type: String,
      default: 'diff-del'
    }
  },
  data() {
    return {
      diffedHtml: '' // 存储最终生成的、带有高亮标记的HTML
    };
  },
  watch: {
    // 当传入的富文本内容变化时,自动重新计算差异
    oldHtml: 'computeDiff',
    newHtml: 'computeDiff'
  },
  mounted() {
    this.computeDiff();
  },
  methods: {
    /**
     * 主入口:触发整个比对流程
     */
    computeDiff() {
      if (!this.oldHtml && !this.newHtml) {
        this.diffedHtml = '';
        return;
      }
      this.diffedHtml = this.generateDiffHtml(this.oldHtml, this.newHtml);
    },

    /**
     * 生成带差异高亮的最终HTML字符串
     * @param {String} oldHtml - 旧HTML
     * @param {String} newHtml - 新HTML
     * @returns {String} 结果HTML
     */
    generateDiffHtml(oldHtml, newHtml) {
      // 1. 将字符串解析为独立的DOM树
      const oldContainer = this.parseToContainer(oldHtml);
      const newContainer = this.parseToContainer(newHtml);

      // 2. 从根节点开始,递归执行“保留标签、仅比对文本”的核心算法
      const resultNode = this.diffNodeWithStructure(oldContainer, newContainer);

      // 3. 将处理后的DOM树转换回HTML字符串
      return this.containerToHtml(resultNode);
    },

    /**
     * 将HTML字符串解析为DOM元素(内存中操作,不影响页面)
     */
    parseToContainer(html) {
      const container = document.createElement('div');
      container.innerHTML = html || '';
      return container;
    },

    /**
     * 核心算法:递归比对两棵树,保留标签,只diff文本
     * @param {Node} oldNode - 旧树节点
     * @param {Node} newNode - 新树节点
     * @returns {Node} 比对后生成的新节点
     */
    diffNodeWithStructure(oldNode, newNode) {
      // **情况1: 两个都是文本节点 -> 进行字符级差异比对**
      if (this.isTextNode(oldNode) && this.isTextNode(newNode)) {
        return this.createTextDiffFragment(oldNode.textContent, newNode.textContent);
      }

      // **情况2: 两个都是元素节点,且标签名相同 -> 递归比对子节点**
      if (this.isElementNode(oldNode) && this.isElementNode(newNode) && oldNode.tagName === newNode.tagName) {
        // 克隆新节点的标签和所有属性,作为结果的基础结构
        const clonedNode = this.cloneElementWithAttributes(newNode);

        // 获取子节点数组,准备并行遍历
        const oldChildren = Array.from(oldNode.childNodes);
        const newChildren = Array.from(newNode.childNodes);
        const maxLen = Math.max(oldChildren.length, newChildren.length);

        for (let i = 0; i < maxLen; i++) {
          const oldChild = oldChildren[i] || null;
          const newChild = newChildren[i] || null;
          let diffChild = null;

          if (oldChild && newChild) {
            // 递归比对
            diffChild = this.diffNodeWithStructure(oldChild, newChild);
          } else if (newChild) {
            // 新节点独有:将整个新节点结构标记为“新增”
            diffChild = this.wrapNodeAsAdded(newChild);
          } else if (oldChild) {
            // 旧节点独有:将整个旧节点结构标记为“删除”
            diffChild = this.wrapNodeAsRemoved(oldChild);
          }

          if (diffChild) {
            clonedNode.appendChild(diffChild);
          }
        }
        return clonedNode; // 返回保留了完整标签结构的结果
      }

      // **情况3: 节点类型不同或标签不同 -> 视为完全不同的节点块**
      return this.createBlockDiffFragment(oldNode, newNode);
    },

    /**
     * 字符级文本比对:使用diffChars生成<ins>/<del>片段
     */
    createTextDiffFragment(oldText, newText) {
      if (oldText === newText) {
        return document.createTextNode(newText);
      }

      const diffResult = diffChars(oldText, newText);
      const fragment = document.createDocumentFragment();

      diffResult.forEach(part => {
        let node;
        if (part.added) {
          node = document.createElement('ins');
          node.className = this.insClass;
          node.textContent = part.value;
        } else if (part.removed) {
          node = document.createElement('del');
          node.className = this.delClass;
          node.textContent = part.value;
        } else {
          node = document.createTextNode(part.value);
        }
        fragment.appendChild(node);
      });

      return fragment;
    },

    /**
     * 克隆元素节点及其所有属性(保留样式、类名等)
     */
    cloneElementWithAttributes(elementNode) {
      const cloned = elementNode.cloneNode(false); // 浅克隆,只克隆标签本身
      // 确保保留原始元素的样式类
      cloned.classList.add('diff-tag-preserved');
      return cloned;
    },

    /**
     * 将节点包裹为“新增”状态
     */
    wrapNodeAsAdded(node) {
      const ins = document.createElement('ins');
      ins.className = `${this.insClass} diff-ins-block`;
      ins.appendChild(node.cloneNode(true)); // 深克隆整个子树
      return ins;
    },

    /**
     * 将节点包裹为“删除”状态
     */
    wrapNodeAsRemoved(node) {
      const del = document.createElement('del');
      del.className = `${this.delClass} diff-del-block`;
      del.appendChild(node.cloneNode(true)); // 深克隆整个子树
      return del;
    },

    /**
     * 处理完全不同节点(如<p> vs <div>),并排显示新旧块
     */
    createBlockDiffFragment(oldNode, newNode) {
      const fragment = document.createDocumentFragment();
      if (oldNode) {
        fragment.appendChild(this.wrapNodeAsRemoved(oldNode));
      }
      if (newNode) {
        fragment.appendChild(this.wrapNodeAsAdded(newNode));
      }
      return fragment;
    },

    /**
     * 工具函数:判断是否为文本节点
     */
    isTextNode(node) {
      return node && node.nodeType === Node.TEXT_NODE;
    },

    /**
     * 工具函数:判断是否为元素节点
     */
    isElementNode(node) {
      return node && node.nodeType === Node.ELEMENT_NODE;
    },

    /**
     * 将处理后的容器节点转换为HTML字符串
     */
    containerToHtml(containerNode) {
      const wrapper = document.createElement('div');
      wrapper.appendChild(containerNode);
      return wrapper.innerHTML;
    }
  }
};
</script>

<style scoped>
/* 基础容器样式 */
.rich-text-diff {
  font-family: inherit;
  line-height: 1.6;
}
/* 深度选择器,用于影响v-html内部元素的样式 */
.rich-text-diff ::v-deep .diff-ins {
  background-color: #e6ffed; /* 新增文本:浅绿色背景 */
  text-decoration: none;
  padding: 0 1px;
  border-radius: 2px;
}
.rich-text-diff ::v-deep .diff-del {
  background-color: #ffe6e6; /* 删除文本:浅红色背景 */
  color: #8e3434;
  text-decoration: line-through;
  padding: 0 1px;
  border-radius: 2px;
}
/* 当整个节点块(包含其内部标签)被添加或删除时的样式 */
.rich-text-diff ::v-deep .diff-ins-block {
  display: block;
  background-color: #f0fff0;
  border-left: 3px solid #52c41a;
  margin: 4px 0;
  padding: 2px 8px;
}
.rich-text-diff ::v-deep .diff-del-block {
  display: block;
  background-color: #fff0f0;
  border-left: 3px solid #ff4d4f;
  margin: 4px 0;
  padding: 2px 8px;
}
/* 为所有被保留的原始标签添加一个标识,可用于调试 */
.rich-text-diff ::v-deep .diff-tag-preserved {
  /* 标签本身被完整保留,无特殊样式 */
}
</style>
posted on 2026-01-12 13:35  jv_coder  阅读(1)  评论(0)    收藏  举报